From e59affcfcb5bc6f362ea59202409485455659f10 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Sun, 15 Nov 2026 04:50:25 +0900 Subject: [PATCH 1/3] feat(presets): preset gallery window and 'filee presets check' Part B of the preset gallery (#1530). - Filee.Core/Gallery: an explicit allow-list of the fields a shared preset.json may have (unknown fields fail, fields that carry the sharer's folders, passwords, picture paths, clip music, metadata edits or the AI model may only be empty), the bundle and folder checks (pictures by header, sizes, no animation), index.json reading with relative paths and the counted download link, and an installer that verifies SHA-256 and the rules, then adds new presets and a donut under new ids without replacing anything. - filee presets check [--json]: what the gallery's CI runs, with SkiaSharp decoding every picture; codes, messages and derived meta fields. - App: "Preset gallery" window from the Presets page (Hot/Best/New, filters, search, details, Install, Comments), contacting filee.sh only while open, through the proxy setting with time and size limits and a picture cache. Texts in all 23 languages, labels matching filee.sh. - Export writes settingsSchema into bundles. Refs #1530 Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 21 + src/Filee.App/Assets/i18n/ar.json | 64 +- src/Filee.App/Assets/i18n/bn-BD.json | 64 +- src/Filee.App/Assets/i18n/de.json | 64 +- src/Filee.App/Assets/i18n/en.json | 64 +- src/Filee.App/Assets/i18n/es.json | 64 +- src/Filee.App/Assets/i18n/fa.json | 64 +- src/Filee.App/Assets/i18n/fr.json | 64 +- src/Filee.App/Assets/i18n/hi.json | 64 +- src/Filee.App/Assets/i18n/id.json | 64 +- src/Filee.App/Assets/i18n/it.json | 64 +- src/Filee.App/Assets/i18n/ja.json | 64 +- src/Filee.App/Assets/i18n/ko.json | 64 +- src/Filee.App/Assets/i18n/mn.json | 64 +- src/Filee.App/Assets/i18n/ne.json | 64 +- src/Filee.App/Assets/i18n/pl.json | 64 +- src/Filee.App/Assets/i18n/pt-BR.json | 64 +- src/Filee.App/Assets/i18n/pt-PT.json | 64 +- src/Filee.App/Assets/i18n/ru.json | 64 +- src/Filee.App/Assets/i18n/th.json | 64 +- src/Filee.App/Assets/i18n/tr.json | 64 +- src/Filee.App/Assets/i18n/vi.json | 64 +- src/Filee.App/Assets/i18n/zh-CN.json | 64 +- src/Filee.App/Assets/i18n/zh-TW.json | 64 +- src/Filee.App/Services/PresetGalleryClient.cs | 203 ++++++ src/Filee.App/Services/WindowService.cs | 46 ++ src/Filee.App/ViewModels/GallerySummaries.cs | 129 ++++ .../ViewModels/Pages/PresetsPageViewModel.cs | 8 + .../ViewModels/PresetGalleryViewModel.cs | 594 ++++++++++++++++++ src/Filee.App/Views/Pages/PresetsPage.axaml | 8 + src/Filee.App/Views/PresetGalleryWindow.axaml | 251 ++++++++ .../Views/PresetGalleryWindow.axaml.cs | 31 + src/Filee.Cli/Cli.cs | 12 +- src/Filee.Cli/CliOptions.cs | 4 + src/Filee.Cli/Help.cs | 27 +- src/Filee.Cli/PresetsCheckCommand.cs | 111 ++++ src/Filee.Core/Gallery/GalleryImageHeader.cs | 151 +++++ src/Filee.Core/Gallery/GalleryIndex.cs | 221 +++++++ src/Filee.Core/Gallery/GalleryInstaller.cs | 113 ++++ src/Filee.Core/Gallery/PresetCheckResult.cs | 71 +++ src/Filee.Core/Gallery/SharedBundleCheck.cs | 310 +++++++++ .../Gallery/SharedPresetFolderCheck.cs | 281 +++++++++ src/Filee.Core/Gallery/SharedPresetRules.cs | 331 ++++++++++ src/Filee.Core/Gallery/SharedText.cs | 149 +++++ src/Filee.Core/Settings/FileeJsonContext.cs | 7 + src/Filee.Core/Settings/UserDataStore.cs | 8 +- tests/Filee.App.Tests/PresetGalleryTests.cs | 529 ++++++++++++++++ tests/Filee.Cli.Tests/PresetsCheckTests.cs | 156 +++++ tests/Filee.Core.Tests/GalleryFiles.cs | 131 ++++ .../PresetGalleryCheckTests.cs | 427 +++++++++++++ .../PresetGalleryFolderTests.cs | 233 +++++++ .../PresetGalleryIndexTests.cs | 188 ++++++ .../PresetGalleryInstallTests.cs | 165 +++++ 53 files changed, 6357 insertions(+), 31 deletions(-) create mode 100644 src/Filee.App/Services/PresetGalleryClient.cs create mode 100644 src/Filee.App/ViewModels/GallerySummaries.cs create mode 100644 src/Filee.App/ViewModels/PresetGalleryViewModel.cs create mode 100644 src/Filee.App/Views/PresetGalleryWindow.axaml create mode 100644 src/Filee.App/Views/PresetGalleryWindow.axaml.cs create mode 100644 src/Filee.Cli/PresetsCheckCommand.cs create mode 100644 src/Filee.Core/Gallery/GalleryImageHeader.cs create mode 100644 src/Filee.Core/Gallery/GalleryIndex.cs create mode 100644 src/Filee.Core/Gallery/GalleryInstaller.cs create mode 100644 src/Filee.Core/Gallery/PresetCheckResult.cs create mode 100644 src/Filee.Core/Gallery/SharedBundleCheck.cs create mode 100644 src/Filee.Core/Gallery/SharedPresetFolderCheck.cs create mode 100644 src/Filee.Core/Gallery/SharedPresetRules.cs create mode 100644 src/Filee.Core/Gallery/SharedText.cs create mode 100644 tests/Filee.App.Tests/PresetGalleryTests.cs create mode 100644 tests/Filee.Cli.Tests/PresetsCheckTests.cs create mode 100644 tests/Filee.Core.Tests/GalleryFiles.cs create mode 100644 tests/Filee.Core.Tests/PresetGalleryCheckTests.cs create mode 100644 tests/Filee.Core.Tests/PresetGalleryFolderTests.cs create mode 100644 tests/Filee.Core.Tests/PresetGalleryIndexTests.cs create mode 100644 tests/Filee.Core.Tests/PresetGalleryInstallTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0412d726b..68fd8e500 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -613,6 +613,27 @@ installed yet; the slides introduce the installed one. The demo animations are r The tray app runs one per rule (`WatchFolderService`, Settings → Watch folders, conversions in the toast and history); `filee watch` runs one on its own, with a stable `cli` rule identity across command restarts. +## Preset gallery + +Presets shared by users live in the `KnifeLemon/Filee-Presets` repository and on filee.sh (#1530); the app reads +them, it never uploads. + +- `Filee.Core/Gallery/SharedPresetRules` is the allow-list: every field a shared preset.json (a `PresetBundle`) may + have, with its range, and the fields left out (folders, passwords, a picture's path, a clip's music, metadata edits, + the AI model), which may only be empty. A field added to `Preset` fails the check until it is listed there + (`PresetGalleryCheckTests` compares the list with the model). +- `SharedBundleCheck` checks a preset.json against it, as JSON first (unknown fields, types, ranges, links, personal + paths) and then as presets (kinds, tools, targets, references), and derives what meta.json needs. + `SharedPresetFolderCheck` checks a whole submission folder, pictures included (`GalleryImageHeader` reads their + header before anything decodes them). `filee presets check [--json]` runs it, with SkiaSharp decoding each + picture; the gallery's CI calls it. +- `GalleryInstaller` checks the downloaded file's SHA-256 and the rules again, then adds its presets and donut under + new ids (" (2)" on a name the library has); nothing is ever replaced, unlike Settings → General → Import. +- The app's `PresetGalleryWindow` (from the Presets page) reads `index.json` through `PresetGalleryClient`, only while + it is open, with the proxy setting, time and size limits, and falls back to the repository on GitHub when filee.sh + can't be reached. Paths in the index are relative to the URL it came from; Install uses the entry's counted + `download` link. Pictures are cached in the work folder's `gallery/` (64 MB, 30 days). + ## Platform code Everything OS-specific goes through `IPlatformServices` (`Filee.Core/Platform`). Windows lives in diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 5df39016d..1780a541a 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -1850,5 +1850,67 @@ "connect.pin_label": "رمز PIN:", "connect.firewall_hint_generic": "ملاحظة: إذا لم يتمكن هاتفك من الاتصال، اسمح لـ Filee في جدار الحماية الخاص بالنظام.", "connect.no_network_message": "تعذر بدء Filee Connect. يرجى التأكد من اتصال هذا الجهاز بشبكة Wi-Fi أو شبكة إيثرنت محلية.", - "connect.save_as": "حفظ باسم…" + "connect.save_as": "حفظ باسم…", + "gallery.title": "معرض الإعدادات المسبقة", + "gallery.open": "معرض الإعدادات المسبقة", + "gallery.open_tip": "إعدادات مسبقة شاركها آخرون على filee.sh", + "gallery.lead": "إعدادات مسبقة شاركها آخرون على filee.sh. عند تثبيتها تُضاف كإعدادات مسبقة جديدة وتبقى إعداداتك كما هي. لا يتصل Filee بـ filee.sh إلا أثناء فتح هذه النافذة.", + "gallery.sort": "الترتيب", + "gallery.sort.Hot": "الرائج", + "gallery.sort.Best": "الأفضل", + "gallery.sort.New": "الأحدث", + "gallery.period": "الفترة", + "gallery.period.day": "اليوم", + "gallery.period.week": "هذا الأسبوع", + "gallery.period.month": "هذا الشهر", + "gallery.period.year": "هذا العام", + "gallery.period.all": "كل الأوقات", + "gallery.filter.kind": "النوع", + "gallery.kind.all": "كل الأنواع", + "gallery.kind.convert": "التحويلات", + "gallery.kind.tool": "الأدوات", + "gallery.kind.toolbar": "الأشرطة الدائرية", + "gallery.filter.format": "الصيغة", + "gallery.format.all": "كل الصيغ", + "gallery.filter.tag": "الوسم", + "gallery.tag.all": "كل الوسوم", + "gallery.filter.language": "اللغة", + "gallery.language.all": "كل اللغات", + "gallery.loading": "جارٍ تحميل الإعدادات المسبقة من filee.sh…", + "gallery.failed": "تعذّر الوصول إلى filee.sh. تحقّق من الاتصال بالإنترنت ومن الوكيل في الإعدادات ← عام، ثم أعد المحاولة.", + "gallery.empty": "لا يوجد إعداد مسبق يطابق البحث وعوامل التصفية.", + "gallery.none": "لم يشارك أحد إعدادات مسبقة بعد.", + "gallery.newer": "{0} أخرى تحتاج إلى إصدار أحدث من Filee.", + "gallery.by": "من {0}", + "gallery.likes": "{0} إعجاب", + "gallery.downloads": "{0} تنزيل", + "gallery.select": "اختر إعدادًا مسبقًا لترى صوره وما يفعله.", + "gallery.image": "الصورة {0} من {1}", + "gallery.what": "ما سيُضاف", + "gallery.what_group": "مجموعة: {0}", + "gallery.loading_details": "جارٍ التحميل…", + "gallery.donut_new": "يضيف الشريط الدائري «{0}» لـ {1}. لا يُفتح إلا للملفات التي لا يتولاها أي من أشرطتك الدائرية؛ يمكنك تغيير ذلك من الإعدادات ← شريط الأدوات الدائري.", + "gallery.donut_mixed": "الملفات المختلطة", + "gallery.donut_none": "لا يوضع على أي شريط دائري: أضفه إلى أحدها من الإعدادات ← شريط الأدوات الدائري.", + "gallery.license": "شاركه {0} بموجب CC0: حرّ الاستخدام والتعديل والمشاركة.", + "gallery.comments": "التعليقات", + "gallery.install": "تثبيت", + "gallery.installing": "جارٍ التثبيت…", + "gallery.installed": "أُضيف {0} من الإعدادات المسبقة. تجدها في قائمة الإعدادات المسبقة.", + "gallery.installed_donut": "أُضيف {0} من الإعدادات المسبقة والشريط الدائري «{1}».", + "gallery.install_failed": "تعذّر التثبيت: {0}", + "gallery.not_installable": "لا يمكن تثبيت هذا الإعداد المسبق: {0}", + "gallery.download_failed": "تعذّر تنزيل الإعداد المسبق. تحقّق من الاتصال وأعد المحاولة.", + "gallery.mismatch": "الملف الذي نُزّل ليس الملف الذي يذكره المعرض، لذا لم يُثبَّت شيء.", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "{0} ميغابايت كحد أقصى", + "gallery.option.no_metadata": "يزيل البيانات الوصفية", + "gallery.option.merge": "يدمج في ملف واحد", + "gallery.option.pages": "الصفحات {0}", + "gallery.option.max_height": "حتى {0}p", + "gallery.option.translate": "يترجم إلى {0}", + "gallery.option.ask_compression": "يسأل عن درجة الضغط في كل مرة", + "gallery.option.subfolder": "يحفظ في المجلد «{0}»", + "gallery.option.file_name": "اسم الملف: {0}" } diff --git a/src/Filee.App/Assets/i18n/bn-BD.json b/src/Filee.App/Assets/i18n/bn-BD.json index d99bc260f..a18c41f11 100644 --- a/src/Filee.App/Assets/i18n/bn-BD.json +++ b/src/Filee.App/Assets/i18n/bn-BD.json @@ -1847,5 +1847,67 @@ "connect.pin_label": "পিন:", "connect.firewall_hint_generic": "নোট: আপনার ফোন সংযোগ করতে না পারলে, সিস্টেম ফায়ারওয়ালে Filee অনুমতি দিন।", "connect.no_network_message": "Filee Connect শুরু করা যায়নি। অনুগ্রহ করে নিশ্চিত করুন যে এই ডিভাইসটি একটি স্থানীয় Wi-Fi বা ইথারনেট নেটওয়ার্কের সাথে সংযুক্ত রয়েছে।", - "connect.save_as": "সংরক্ষণ করুন…" + "connect.save_as": "সংরক্ষণ করুন…", + "gallery.title": "প্রিসেট গ্যালারি", + "gallery.open": "প্রিসেট গ্যালারি", + "gallery.open_tip": "অন্যরা filee.sh-এ যে প্রিসেট শেয়ার করেছেন", + "gallery.lead": "অন্যরা filee.sh-এ যে প্রিসেট শেয়ার করেছেন। ইনস্টল করলে এগুলো নতুন প্রিসেট হিসেবে যোগ হয়, আপনার প্রিসেটগুলো যেমন আছে তেমনই থাকে। এই উইন্ডো খোলা থাকলেই কেবল Filee filee.sh-এর সঙ্গে যোগাযোগ করে।", + "gallery.sort": "সাজান", + "gallery.sort.Hot": "জনপ্রিয়", + "gallery.sort.Best": "সেরা", + "gallery.sort.New": "নতুন", + "gallery.period": "সময়কাল", + "gallery.period.day": "আজ", + "gallery.period.week": "এই সপ্তাহ", + "gallery.period.month": "এই মাস", + "gallery.period.year": "এই বছর", + "gallery.period.all": "সব সময়", + "gallery.filter.kind": "ধরন", + "gallery.kind.all": "সব ধরন", + "gallery.kind.convert": "রূপান্তর", + "gallery.kind.tool": "টুল", + "gallery.kind.toolbar": "ডোনাট টুলবার", + "gallery.filter.format": "ফরম্যাট", + "gallery.format.all": "সব ফরম্যাট", + "gallery.filter.tag": "ট্যাগ", + "gallery.tag.all": "সব ট্যাগ", + "gallery.filter.language": "ভাষা", + "gallery.language.all": "সব ভাষা", + "gallery.loading": "filee.sh থেকে প্রিসেট লোড হচ্ছে…", + "gallery.failed": "filee.sh-এ সংযোগ করা যায়নি। ইন্টারনেট সংযোগ এবং সেটিংস → সাধারণ-এর প্রক্সি পরীক্ষা করে আবার চেষ্টা করুন।", + "gallery.empty": "অনুসন্ধান ও ফিল্টারের সঙ্গে মেলে এমন কোনো প্রিসেট নেই।", + "gallery.none": "এখনো কোনো প্রিসেট শেয়ার করা হয়নি।", + "gallery.newer": "আরও {0}টির জন্য Filee-এর নতুন সংস্করণ লাগবে।", + "gallery.by": "{0}-এর", + "gallery.likes": "{0} লাইক", + "gallery.downloads": "{0} ডাউনলোড", + "gallery.select": "একটি প্রিসেট বেছে নিন, তার ছবি আর সেটি কী করে তা দেখতে।", + "gallery.image": "ছবি {0} / {1}", + "gallery.what": "যা যোগ হবে", + "gallery.what_group": "গ্রুপ: {0}", + "gallery.loading_details": "লোড হচ্ছে…", + "gallery.donut_new": "{1}-এর জন্য ডোনাট টুলবার “{0}” যোগ করে। আপনার কোনো ডোনাট যে ফাইল নেয় না, কেবল সেগুলোর জন্যই এটি খোলে; সেটিংস → ডোনাট টুলবারে এটি বদলাতে পারেন।", + "gallery.donut_mixed": "মিশ্র ফাইল", + "gallery.donut_none": "কোনো ডোনাটে নেই: সেটিংস → ডোনাট টুলবারে কোনো একটিতে যোগ করুন।", + "gallery.license": "{0} CC0-এর অধীনে শেয়ার করেছেন: ব্যবহার, পরিবর্তন ও শেয়ার করা সম্পূর্ণ মুক্ত।", + "gallery.comments": "মন্তব্য", + "gallery.install": "ইনস্টল করুন", + "gallery.installing": "ইনস্টল হচ্ছে…", + "gallery.installed": "{0}টি প্রিসেট যোগ হয়েছে। প্রিসেট তালিকায় সেগুলো পাবেন।", + "gallery.installed_donut": "{0}টি প্রিসেট ও ডোনাট টুলবার “{1}” যোগ হয়েছে।", + "gallery.install_failed": "ইনস্টল করা যায়নি: {0}", + "gallery.not_installable": "এই প্রিসেট ইনস্টল করা যাবে না: {0}", + "gallery.download_failed": "প্রিসেট ডাউনলোড করা যায়নি। সংযোগ পরীক্ষা করে আবার চেষ্টা করুন।", + "gallery.mismatch": "ডাউনলোড করা ফাইলটি গ্যালারির তালিকার ফাইল নয়, তাই কিছুই ইনস্টল করা হয়নি।", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "সর্বোচ্চ {0} MB", + "gallery.option.no_metadata": "মেটাডেটা সরায়", + "gallery.option.merge": "একটি ফাইলে জোড়া লাগায়", + "gallery.option.pages": "পৃষ্ঠা {0}", + "gallery.option.max_height": "সর্বোচ্চ {0}p", + "gallery.option.translate": "{0}-এ অনুবাদ করে", + "gallery.option.ask_compression": "প্রতিবার কমপ্রেশন জিজ্ঞেস করে", + "gallery.option.subfolder": "“{0}” ফোল্ডারে সেভ করে", + "gallery.option.file_name": "ফাইলের নাম: {0}" } diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index ab9cf21bf..3e9d72197 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -1849,5 +1849,67 @@ "connect.pin_label": "PIN:", "connect.firewall_hint_generic": "Hinweis: Wenn dein Smartphone keine Verbindung herstellen kann, erlaube Filee in der System-Firewall.", "connect.no_network_message": "Filee Connect konnte nicht gestartet werden. Bitte stelle sicher, dass dieses Gerät mit einem lokalen WLAN- oder Ethernet-Netzwerk verbunden ist.", - "connect.save_as": "Speichern unter…" + "connect.save_as": "Speichern unter…", + "gallery.title": "Vorlagengalerie", + "gallery.open": "Vorlagengalerie", + "gallery.open_tip": "Vorlagen, die andere auf filee.sh geteilt haben", + "gallery.lead": "Vorlagen, die andere auf filee.sh geteilt haben. Beim Installieren kommen sie als neue Vorlagen hinzu, deine eigenen bleiben, wie sie sind. Filee verbindet sich nur mit filee.sh, solange dieses Fenster offen ist.", + "gallery.sort": "Sortieren", + "gallery.sort.Hot": "Angesagt", + "gallery.sort.Best": "Beste", + "gallery.sort.New": "Neu", + "gallery.period": "Zeitraum", + "gallery.period.day": "Heute", + "gallery.period.week": "Diese Woche", + "gallery.period.month": "Diesen Monat", + "gallery.period.year": "Dieses Jahr", + "gallery.period.all": "Gesamt", + "gallery.filter.kind": "Art", + "gallery.kind.all": "Alle Arten", + "gallery.kind.convert": "Umwandlungen", + "gallery.kind.tool": "Werkzeuge", + "gallery.kind.toolbar": "Ringmenüs", + "gallery.filter.format": "Format", + "gallery.format.all": "Alle Formate", + "gallery.filter.tag": "Schlagwort", + "gallery.tag.all": "Alle Schlagwörter", + "gallery.filter.language": "Sprache", + "gallery.language.all": "Alle Sprachen", + "gallery.loading": "Vorlagen werden von filee.sh geladen …", + "gallery.failed": "filee.sh ist nicht erreichbar. Prüfe die Internetverbindung und den Proxy unter Einstellungen → Allgemein und versuche es dann erneut.", + "gallery.empty": "Keine Vorlage passt zur Suche und zu den Filtern.", + "gallery.none": "Bisher wurden keine Vorlagen geteilt.", + "gallery.newer": "{0} weitere brauchen eine neuere Version von Filee.", + "gallery.by": "von {0}", + "gallery.likes": "{0} Likes", + "gallery.downloads": "{0} Downloads", + "gallery.select": "Wähle eine Vorlage, um ihre Bilder zu sehen und was sie tut.", + "gallery.image": "Bild {0} von {1}", + "gallery.what": "Was hinzukommt", + "gallery.what_group": "Gruppe: {0}", + "gallery.loading_details": "Wird geladen …", + "gallery.donut_new": "Fügt das Ringmenü „{0}“ für {1} hinzu. Es öffnet sich nur für Dateien, die keines deiner Ringmenüs übernimmt; ändern kannst du das unter Einstellungen → Ringmenü.", + "gallery.donut_mixed": "gemischte Dateien", + "gallery.donut_none": "Kommt in kein Ringmenü: Füge sie unter Einstellungen → Ringmenü einem hinzu.", + "gallery.license": "Von {0} unter CC0 geteilt: frei zu nutzen, zu ändern und weiterzugeben.", + "gallery.comments": "Kommentare", + "gallery.install": "Installieren", + "gallery.installing": "Wird installiert …", + "gallery.installed": "{0} Vorlage(n) hinzugefügt. Du findest sie in der Liste der Vorlagen.", + "gallery.installed_donut": "{0} Vorlage(n) und das Ringmenü „{1}“ hinzugefügt.", + "gallery.install_failed": "Installieren fehlgeschlagen: {0}", + "gallery.not_installable": "Diese Vorlage kann nicht installiert werden: {0}", + "gallery.download_failed": "Die Vorlage konnte nicht heruntergeladen werden. Prüfe die Verbindung und versuche es erneut.", + "gallery.mismatch": "Die heruntergeladene Datei ist nicht die, die die Galerie angibt; es wurde nichts installiert.", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "Höchstens {0} MB", + "gallery.option.no_metadata": "Entfernt Metadaten", + "gallery.option.merge": "Fügt zu einer Datei zusammen", + "gallery.option.pages": "Seiten {0}", + "gallery.option.max_height": "Bis {0}p", + "gallery.option.translate": "Übersetzt in: {0}", + "gallery.option.ask_compression": "Fragt jedes Mal nach der Komprimierung", + "gallery.option.subfolder": "Speichert im Ordner „{0}“", + "gallery.option.file_name": "Dateiname: {0}" } diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index b9eebb3f2..4ab829fe4 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -1871,5 +1871,67 @@ "connect.pin_label": "PIN:", "connect.firewall_hint_generic": "Note: If your phone cannot connect, allow Filee in your system firewall.", "connect.no_network_message": "Could not start Filee Connect. Please ensure this device is connected to a local Wi-Fi or Ethernet network.", - "connect.save_as": "Save as…" + "connect.save_as": "Save as…", + "gallery.title": "Preset gallery", + "gallery.open": "Preset gallery", + "gallery.open_tip": "Presets other people shared on filee.sh", + "gallery.lead": "Presets other people shared on filee.sh. Installing adds them as new presets and leaves yours as they are. Filee contacts filee.sh only while this window is open.", + "gallery.sort": "Sort", + "gallery.sort.Hot": "Hot", + "gallery.sort.Best": "Best", + "gallery.sort.New": "New", + "gallery.period": "Period", + "gallery.period.day": "Today", + "gallery.period.week": "This week", + "gallery.period.month": "This month", + "gallery.period.year": "This year", + "gallery.period.all": "All time", + "gallery.filter.kind": "Kind", + "gallery.kind.all": "All kinds", + "gallery.kind.convert": "Conversions", + "gallery.kind.tool": "Tools", + "gallery.kind.toolbar": "Donuts", + "gallery.filter.format": "Format", + "gallery.format.all": "All formats", + "gallery.filter.tag": "Tag", + "gallery.tag.all": "All tags", + "gallery.filter.language": "Language", + "gallery.language.all": "All languages", + "gallery.loading": "Loading presets from filee.sh…", + "gallery.failed": "Couldn't reach filee.sh. Check the internet connection and the proxy in Settings → General, then try again.", + "gallery.empty": "No preset matches the search and filters.", + "gallery.none": "No presets have been shared yet.", + "gallery.newer": "{0} more need a newer version of Filee.", + "gallery.by": "by {0}", + "gallery.likes": "{0} likes", + "gallery.downloads": "{0} downloads", + "gallery.select": "Choose a preset to see its pictures and what it does.", + "gallery.image": "Picture {0} of {1}", + "gallery.what": "What it adds", + "gallery.what_group": "Group: {0}", + "gallery.loading_details": "Loading…", + "gallery.donut_new": "Adds the donut “{0}” for {1}. It opens only for files none of your donuts takes; change that in Settings → Donut toolbar.", + "gallery.donut_mixed": "mixed files", + "gallery.donut_none": "Goes on no donut: add it to one in Settings → Donut toolbar.", + "gallery.license": "Shared by {0} under CC0: free to use, change and share.", + "gallery.comments": "Comments", + "gallery.install": "Install", + "gallery.installing": "Installing…", + "gallery.installed": "Added {0} preset(s). Find them in the Presets list.", + "gallery.installed_donut": "Added {0} preset(s) and the donut “{1}”.", + "gallery.install_failed": "Couldn't install it: {0}", + "gallery.not_installable": "This preset can't be installed: {0}", + "gallery.download_failed": "Couldn't download the preset. Check the connection and try again.", + "gallery.mismatch": "The downloaded file isn't the one the gallery lists, so nothing was installed.", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "At most {0} MB", + "gallery.option.no_metadata": "Removes metadata", + "gallery.option.merge": "Merges into one file", + "gallery.option.pages": "Pages {0}", + "gallery.option.max_height": "Up to {0}p", + "gallery.option.translate": "Translates into {0}", + "gallery.option.ask_compression": "Asks for the compression each time", + "gallery.option.subfolder": "Saves in the folder “{0}”", + "gallery.option.file_name": "File name: {0}" } diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index f496216e4..ff079c039 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -1851,5 +1851,67 @@ "connect.pin_label": "PIN:", "connect.firewall_hint_generic": "Nota: Si tu teléfono no puede conectarse, permite Filee en el cortafuegos del sistema.", "connect.no_network_message": "No se pudo iniciar Filee Connect. Asegúrate de que este dispositivo esté conectado a una red Wi-Fi o Ethernet local.", - "connect.save_as": "Guardar como…" + "connect.save_as": "Guardar como…", + "gallery.title": "Galería de ajustes preestablecidos", + "gallery.open": "Galería de ajustes preestablecidos", + "gallery.open_tip": "Ajustes preestablecidos que otras personas compartieron en filee.sh", + "gallery.lead": "Ajustes preestablecidos que otras personas compartieron en filee.sh. Al instalarlos se añaden como ajustes nuevos y los tuyos quedan como están. Filee solo se conecta a filee.sh mientras esta ventana está abierta.", + "gallery.sort": "Ordenar", + "gallery.sort.Hot": "Populares", + "gallery.sort.Best": "Mejores", + "gallery.sort.New": "Nuevos", + "gallery.period": "Período", + "gallery.period.day": "Hoy", + "gallery.period.week": "Esta semana", + "gallery.period.month": "Este mes", + "gallery.period.year": "Este año", + "gallery.period.all": "Desde siempre", + "gallery.filter.kind": "Tipo", + "gallery.kind.all": "Todos los tipos", + "gallery.kind.convert": "Conversiones", + "gallery.kind.tool": "Herramientas", + "gallery.kind.toolbar": "Menús circulares", + "gallery.filter.format": "Formato", + "gallery.format.all": "Todos los formatos", + "gallery.filter.tag": "Etiqueta", + "gallery.tag.all": "Todas las etiquetas", + "gallery.filter.language": "Idioma", + "gallery.language.all": "Todos los idiomas", + "gallery.loading": "Cargando ajustes desde filee.sh…", + "gallery.failed": "No se pudo conectar con filee.sh. Revisa la conexión a internet y el proxy en Ajustes → General, y vuelve a intentarlo.", + "gallery.empty": "Ningún ajuste coincide con la búsqueda y los filtros.", + "gallery.none": "Todavía no se ha compartido ningún ajuste.", + "gallery.newer": "{0} más necesitan una versión más nueva de Filee.", + "gallery.by": "de {0}", + "gallery.likes": "{0} me gusta", + "gallery.downloads": "{0} descargas", + "gallery.select": "Elige un ajuste para ver sus imágenes y lo que hace.", + "gallery.image": "Imagen {0} de {1}", + "gallery.what": "Lo que se añade", + "gallery.what_group": "Grupo: {0}", + "gallery.loading_details": "Cargando…", + "gallery.donut_new": "Añade el menú circular «{0}» para {1}. Solo se abre con archivos que ninguno de tus menús circulares toma; puedes cambiarlo en Ajustes → Menú circular.", + "gallery.donut_mixed": "archivos mezclados", + "gallery.donut_none": "No va en ningún menú circular: añádelo a uno en Ajustes → Menú circular.", + "gallery.license": "Compartido por {0} bajo CC0: libre para usar, modificar y compartir.", + "gallery.comments": "Comentarios", + "gallery.install": "Instalar", + "gallery.installing": "Instalando…", + "gallery.installed": "Se añadieron {0} ajuste(s). Están en la lista de ajustes preestablecidos.", + "gallery.installed_donut": "Se añadieron {0} ajuste(s) y el menú circular «{1}».", + "gallery.install_failed": "No se pudo instalar: {0}", + "gallery.not_installable": "Este ajuste no se puede instalar: {0}", + "gallery.download_failed": "No se pudo descargar el ajuste. Revisa la conexión y vuelve a intentarlo.", + "gallery.mismatch": "El archivo descargado no es el que indica la galería, así que no se instaló nada.", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "Como máximo {0} MB", + "gallery.option.no_metadata": "Quita los metadatos", + "gallery.option.merge": "Une en un solo archivo", + "gallery.option.pages": "Páginas {0}", + "gallery.option.max_height": "Hasta {0}p", + "gallery.option.translate": "Traduce a {0}", + "gallery.option.ask_compression": "Pregunta la compresión cada vez", + "gallery.option.subfolder": "Guarda en la carpeta «{0}»", + "gallery.option.file_name": "Nombre de archivo: {0}" } diff --git a/src/Filee.App/Assets/i18n/fa.json b/src/Filee.App/Assets/i18n/fa.json index ddf929c3d..67246df9d 100644 --- a/src/Filee.App/Assets/i18n/fa.json +++ b/src/Filee.App/Assets/i18n/fa.json @@ -1847,5 +1847,67 @@ "connect.pin_label": "پین:", "connect.firewall_hint_generic": "توجه: اگر تلفن شما متصل نمی‌شود، به Filee در فایروال سیستم اجازه دسترسی دهید.", "connect.no_network_message": "Filee Connect راه‌اندازی نشد. لطفاً مطمئن شوید این دستگاه به شبکه Wi-Fi یا اترنت محلی متصل است.", - "connect.save_as": "ذخیره به عنوان…" + "connect.save_as": "ذخیره به عنوان…", + "gallery.title": "گالری پیش‌تنظیم‌ها", + "gallery.open": "گالری پیش‌تنظیم‌ها", + "gallery.open_tip": "پیش‌تنظیم‌هایی که دیگران در filee.sh هم‌رسانی کرده‌اند", + "gallery.lead": "پیش‌تنظیم‌هایی که دیگران در filee.sh هم‌رسانی کرده‌اند. با نصب، به‌عنوان پیش‌تنظیم‌های جدید اضافه می‌شوند و پیش‌تنظیم‌های خودتان دست‌نخورده می‌مانند. Filee فقط وقتی این پنجره باز است به filee.sh وصل می‌شود.", + "gallery.sort": "مرتب‌سازی", + "gallery.sort.Hot": "داغ", + "gallery.sort.Best": "بهترین", + "gallery.sort.New": "جدید", + "gallery.period": "بازه", + "gallery.period.day": "امروز", + "gallery.period.week": "این هفته", + "gallery.period.month": "این ماه", + "gallery.period.year": "امسال", + "gallery.period.all": "همه زمان‌ها", + "gallery.filter.kind": "نوع", + "gallery.kind.all": "همه انواع", + "gallery.kind.convert": "تبدیل‌ها", + "gallery.kind.tool": "ابزارها", + "gallery.kind.toolbar": "نوارهای ابزار دونات", + "gallery.filter.format": "فرمت", + "gallery.format.all": "همه فرمت‌ها", + "gallery.filter.tag": "برچسب", + "gallery.tag.all": "همه برچسب‌ها", + "gallery.filter.language": "زبان", + "gallery.language.all": "همه زبان‌ها", + "gallery.loading": "در حال بارگیری پیش‌تنظیم‌ها از filee.sh…", + "gallery.failed": "اتصال به filee.sh ممکن نشد. اتصال اینترنت و پراکسی را در تنظیمات ← عمومی بررسی کنید و دوباره تلاش کنید.", + "gallery.empty": "هیچ پیش‌تنظیمی با جست‌وجو و فیلترها جور نیست.", + "gallery.none": "هنوز کسی پیش‌تنظیمی هم‌رسانی نکرده است.", + "gallery.newer": "{0} مورد دیگر به نسخه جدیدتری از Filee نیاز دارند.", + "gallery.by": "از {0}", + "gallery.likes": "{0} پسند", + "gallery.downloads": "{0} بارگیری", + "gallery.select": "یک پیش‌تنظیم انتخاب کنید تا تصاویر و کارش را ببینید.", + "gallery.image": "تصویر {0} از {1}", + "gallery.what": "آنچه اضافه می‌شود", + "gallery.what_group": "گروه: {0}", + "gallery.loading_details": "در حال بارگیری…", + "gallery.donut_new": "نوار ابزار دونات «{0}» را برای {1} اضافه می‌کند. فقط برای فایل‌هایی باز می‌شود که هیچ‌یک از دونات‌های شما آن‌ها را نمی‌گیرد؛ این را می‌توانید در تنظیمات ← نوار ابزار دونات تغییر دهید.", + "gallery.donut_mixed": "فایل‌های ترکیبی", + "gallery.donut_none": "روی هیچ دوناتی نیست: آن را در تنظیمات ← نوار ابزار دونات به یکی اضافه کنید.", + "gallery.license": "هم‌رسانی‌شده توسط {0} تحت CC0: استفاده، تغییر و هم‌رسانی آزاد است.", + "gallery.comments": "نظرها", + "gallery.install": "نصب", + "gallery.installing": "در حال نصب…", + "gallery.installed": "{0} پیش‌تنظیم اضافه شد. آن‌ها را در فهرست پیش‌تنظیم‌ها پیدا می‌کنید.", + "gallery.installed_donut": "{0} پیش‌تنظیم و نوار ابزار دونات «{1}» اضافه شد.", + "gallery.install_failed": "نصب ممکن نشد: {0}", + "gallery.not_installable": "این پیش‌تنظیم را نمی‌توان نصب کرد: {0}", + "gallery.download_failed": "بارگیری پیش‌تنظیم ممکن نشد. اتصال را بررسی کنید و دوباره تلاش کنید.", + "gallery.mismatch": "فایل بارگیری‌شده همان فایلی نیست که گالری فهرست کرده است، پس چیزی نصب نشد.", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "حداکثر {0} MB", + "gallery.option.no_metadata": "فراداده را حذف می‌کند", + "gallery.option.merge": "در یک فایل ادغام می‌کند", + "gallery.option.pages": "صفحه‌های {0}", + "gallery.option.max_height": "تا {0}p", + "gallery.option.translate": "به {0} ترجمه می‌کند", + "gallery.option.ask_compression": "هر بار درباره فشرده‌سازی می‌پرسد", + "gallery.option.subfolder": "در پوشه «{0}» ذخیره می‌کند", + "gallery.option.file_name": "نام فایل: {0}" } diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index b08536140..1c6a47c72 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -1871,5 +1871,67 @@ "connect.pin_label": "Code PIN :", "connect.firewall_hint_generic": "Remarque : Si votre téléphone ne peut pas se connecter, autorisez Filee dans le pare-feu du système.", "connect.no_network_message": "Impossible de démarrer Filee Connect. Veuillez vous assurer que cet appareil est connecté à un réseau Wi-Fi ou Ethernet local.", - "connect.save_as": "Enregistrer sous…" + "connect.save_as": "Enregistrer sous…", + "gallery.title": "Galerie de préréglages", + "gallery.open": "Galerie de préréglages", + "gallery.open_tip": "Préréglages partagés par d'autres sur filee.sh", + "gallery.lead": "Des préréglages partagés par d'autres sur filee.sh. Les installer les ajoute comme nouveaux préréglages, sans toucher aux vôtres. Filee ne contacte filee.sh que tant que cette fenêtre est ouverte.", + "gallery.sort": "Trier", + "gallery.sort.Hot": "Tendances", + "gallery.sort.Best": "Meilleurs", + "gallery.sort.New": "Nouveaux", + "gallery.period": "Période", + "gallery.period.day": "Aujourd'hui", + "gallery.period.week": "Cette semaine", + "gallery.period.month": "Ce mois-ci", + "gallery.period.year": "Cette année", + "gallery.period.all": "Depuis toujours", + "gallery.filter.kind": "Type", + "gallery.kind.all": "Tous les types", + "gallery.kind.convert": "Conversions", + "gallery.kind.tool": "Outils", + "gallery.kind.toolbar": "Menus circulaires", + "gallery.filter.format": "Format", + "gallery.format.all": "Tous les formats", + "gallery.filter.tag": "Étiquette", + "gallery.tag.all": "Toutes les étiquettes", + "gallery.filter.language": "Langue", + "gallery.language.all": "Toutes les langues", + "gallery.loading": "Chargement des préréglages depuis filee.sh…", + "gallery.failed": "Impossible de joindre filee.sh. Vérifiez la connexion Internet et le proxy dans Paramètres → Général, puis réessayez.", + "gallery.empty": "Aucun préréglage ne correspond à la recherche et aux filtres.", + "gallery.none": "Aucun préréglage n'a encore été partagé.", + "gallery.newer": "{0} autres demandent une version plus récente de Filee.", + "gallery.by": "par {0}", + "gallery.likes": "{0} j'aime", + "gallery.downloads": "{0} téléchargements", + "gallery.select": "Choisissez un préréglage pour voir ses images et ce qu'il fait.", + "gallery.image": "Image {0} sur {1}", + "gallery.what": "Ce qui est ajouté", + "gallery.what_group": "Groupe : {0}", + "gallery.loading_details": "Chargement…", + "gallery.donut_new": "Ajoute le menu circulaire « {0} » pour {1}. Il ne s'ouvre que pour les fichiers qu'aucun de vos menus ne prend ; vous pouvez changer cela dans Paramètres → Menu circulaire.", + "gallery.donut_mixed": "les fichiers mélangés", + "gallery.donut_none": "N'est sur aucun menu circulaire : ajoutez-le à l'un d'eux dans Paramètres → Menu circulaire.", + "gallery.license": "Partagé par {0} sous CC0 : libre d'utilisation, de modification et de partage.", + "gallery.comments": "Commentaires", + "gallery.install": "Installer", + "gallery.installing": "Installation…", + "gallery.installed": "{0} préréglage(s) ajouté(s). Ils sont dans la liste des préréglages.", + "gallery.installed_donut": "{0} préréglage(s) et le menu circulaire « {1} » ajoutés.", + "gallery.install_failed": "Installation impossible : {0}", + "gallery.not_installable": "Ce préréglage ne peut pas être installé : {0}", + "gallery.download_failed": "Impossible de télécharger le préréglage. Vérifiez la connexion et réessayez.", + "gallery.mismatch": "Le fichier téléchargé n'est pas celui indiqué par la galerie : rien n'a été installé.", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "{0} Mo au plus", + "gallery.option.no_metadata": "Supprime les métadonnées", + "gallery.option.merge": "Fusionne en un seul fichier", + "gallery.option.pages": "Pages {0}", + "gallery.option.max_height": "Jusqu'à {0}p", + "gallery.option.translate": "Traduit en {0}", + "gallery.option.ask_compression": "Demande la compression à chaque fois", + "gallery.option.subfolder": "Enregistre dans le dossier « {0} »", + "gallery.option.file_name": "Nom de fichier : {0}" } diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 4a38f4f8c..d83d42a9e 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -1851,5 +1851,67 @@ "connect.pin_label": "पिन:", "connect.firewall_hint_generic": "नोट: यदि आपका फ़ोन कनेक्ट नहीं हो पा रहा है, तो सिस्टम फ़ायरवॉल में Filee को अनुमति दें।", "connect.no_network_message": "Filee Connect प्रारंभ नहीं किया जा सका। कृपया सुनिश्चित करें कि यह डिवाइस स्थानीय वाई-फ़ाई या ईथरनेट नेटवर्क से कनेक्ट है।", - "connect.save_as": "इस रूप में सहेजें…" + "connect.save_as": "इस रूप में सहेजें…", + "gallery.title": "प्रीसेट गैलरी", + "gallery.open": "प्रीसेट गैलरी", + "gallery.open_tip": "दूसरों द्वारा filee.sh पर साझा किए गए प्रीसेट", + "gallery.lead": "दूसरों द्वारा filee.sh पर साझा किए गए प्रीसेट। इंस्टॉल करने पर ये नए प्रीसेट के रूप में जुड़ते हैं और आपके प्रीसेट जैसे हैं वैसे ही रहते हैं। Filee केवल तभी filee.sh से जुड़ता है जब यह विंडो खुली हो।", + "gallery.sort": "क्रम", + "gallery.sort.Hot": "लोकप्रिय", + "gallery.sort.Best": "सर्वश्रेष्ठ", + "gallery.sort.New": "नए", + "gallery.period": "अवधि", + "gallery.period.day": "आज", + "gallery.period.week": "इस सप्ताह", + "gallery.period.month": "इस महीने", + "gallery.period.year": "इस साल", + "gallery.period.all": "अब तक", + "gallery.filter.kind": "प्रकार", + "gallery.kind.all": "सभी प्रकार", + "gallery.kind.convert": "रूपांतरण", + "gallery.kind.tool": "टूल", + "gallery.kind.toolbar": "डोनट टूलबार", + "gallery.filter.format": "फ़ॉर्मैट", + "gallery.format.all": "सभी फ़ॉर्मैट", + "gallery.filter.tag": "टैग", + "gallery.tag.all": "सभी टैग", + "gallery.filter.language": "भाषा", + "gallery.language.all": "सभी भाषाएँ", + "gallery.loading": "filee.sh से प्रीसेट लोड हो रहे हैं…", + "gallery.failed": "filee.sh से कनेक्ट नहीं हो सका। इंटरनेट कनेक्शन और सेटिंग्स → सामान्य में प्रॉक्सी जाँचें, फिर दोबारा कोशिश करें।", + "gallery.empty": "खोज और फ़िल्टर से मेल खाने वाला कोई प्रीसेट नहीं है।", + "gallery.none": "अभी तक कोई प्रीसेट साझा नहीं किया गया है।", + "gallery.newer": "{0} और प्रीसेट के लिए Filee का नया संस्करण चाहिए।", + "gallery.by": "{0} द्वारा", + "gallery.likes": "{0} पसंद", + "gallery.downloads": "{0} डाउनलोड", + "gallery.select": "प्रीसेट चुनें, ताकि उसकी तस्वीरें और वह क्या करता है, दिख सके।", + "gallery.image": "तस्वीर {0} / {1}", + "gallery.what": "क्या जुड़ेगा", + "gallery.what_group": "समूह: {0}", + "gallery.loading_details": "लोड हो रहा है…", + "gallery.donut_new": "{1} के लिए डोनट टूलबार “{0}” जोड़ता है। यह केवल उन फ़ाइलों के लिए खुलता है जिन्हें आपका कोई डोनट नहीं लेता; इसे सेटिंग्स → डोनट टूलबार में बदलें।", + "gallery.donut_mixed": "मिश्रित फ़ाइलों", + "gallery.donut_none": "किसी डोनट पर नहीं है: इसे सेटिंग्स → डोनट टूलबार में किसी एक में जोड़ें।", + "gallery.license": "{0} द्वारा CC0 के तहत साझा किया गया: उपयोग, बदलाव और साझा करने के लिए मुक्त।", + "gallery.comments": "टिप्पणियाँ", + "gallery.install": "इंस्टॉल करें", + "gallery.installing": "इंस्टॉल हो रहा है…", + "gallery.installed": "{0} प्रीसेट जोड़े गए। ये प्रीसेट सूची में हैं।", + "gallery.installed_donut": "{0} प्रीसेट और डोनट टूलबार “{1}” जोड़े गए।", + "gallery.install_failed": "इंस्टॉल नहीं हो सका: {0}", + "gallery.not_installable": "यह प्रीसेट इंस्टॉल नहीं किया जा सकता: {0}", + "gallery.download_failed": "प्रीसेट डाउनलोड नहीं हो सका। कनेक्शन जाँचकर दोबारा कोशिश करें।", + "gallery.mismatch": "डाउनलोड की गई फ़ाइल वह नहीं है जो गैलरी में दी गई है, इसलिए कुछ भी इंस्टॉल नहीं किया गया।", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "अधिकतम {0} MB", + "gallery.option.no_metadata": "मेटाडेटा हटाता है", + "gallery.option.merge": "एक फ़ाइल में जोड़ता है", + "gallery.option.pages": "पेज {0}", + "gallery.option.max_height": "{0}p तक", + "gallery.option.translate": "{0} में अनुवाद करता है", + "gallery.option.ask_compression": "हर बार कंप्रेशन पूछता है", + "gallery.option.subfolder": "फ़ोल्डर “{0}” में सहेजता है", + "gallery.option.file_name": "फ़ाइल नाम: {0}" } diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index 96d8c0066..ec9c2a872 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -1849,5 +1849,67 @@ "connect.pin_label": "PIN:", "connect.firewall_hint_generic": "Catatan: Jika ponsel Anda tidak dapat terhubung, izinkan Filee di firewall sistem.", "connect.no_network_message": "Tidak dapat memulai Filee Connect. Pastikan perangkat ini terhubung ke jaringan Wi-Fi atau Ethernet lokal.", - "connect.save_as": "Simpan sebagai…" + "connect.save_as": "Simpan sebagai…", + "gallery.title": "Galeri preset", + "gallery.open": "Galeri preset", + "gallery.open_tip": "Preset yang dibagikan orang lain di filee.sh", + "gallery.lead": "Preset yang dibagikan orang lain di filee.sh. Saat dipasang, preset ditambahkan sebagai preset baru dan milik Anda tetap seperti semula. Filee hanya terhubung ke filee.sh selama jendela ini terbuka.", + "gallery.sort": "Urutkan", + "gallery.sort.Hot": "Populer", + "gallery.sort.Best": "Terbaik", + "gallery.sort.New": "Terbaru", + "gallery.period": "Periode", + "gallery.period.day": "Hari ini", + "gallery.period.week": "Minggu ini", + "gallery.period.month": "Bulan ini", + "gallery.period.year": "Tahun ini", + "gallery.period.all": "Sepanjang waktu", + "gallery.filter.kind": "Jenis", + "gallery.kind.all": "Semua jenis", + "gallery.kind.convert": "Konversi", + "gallery.kind.tool": "Alat", + "gallery.kind.toolbar": "Toolbar donut", + "gallery.filter.format": "Format", + "gallery.format.all": "Semua format", + "gallery.filter.tag": "Tag", + "gallery.tag.all": "Semua tag", + "gallery.filter.language": "Bahasa", + "gallery.language.all": "Semua bahasa", + "gallery.loading": "Memuat preset dari filee.sh…", + "gallery.failed": "Tidak dapat menghubungi filee.sh. Periksa koneksi internet dan proxy di Pengaturan → Umum, lalu coba lagi.", + "gallery.empty": "Tidak ada preset yang cocok dengan pencarian dan filter.", + "gallery.none": "Belum ada preset yang dibagikan.", + "gallery.newer": "{0} lainnya memerlukan versi Filee yang lebih baru.", + "gallery.by": "oleh {0}", + "gallery.likes": "{0} suka", + "gallery.downloads": "{0} unduhan", + "gallery.select": "Pilih preset untuk melihat gambar dan fungsinya.", + "gallery.image": "Gambar {0} dari {1}", + "gallery.what": "Yang ditambahkan", + "gallery.what_group": "Grup: {0}", + "gallery.loading_details": "Memuat…", + "gallery.donut_new": "Menambahkan toolbar donut “{0}” untuk {1}. Toolbar ini hanya terbuka untuk file yang tidak ditangani toolbar donut Anda yang lain; ubah di Pengaturan → Toolbar donut.", + "gallery.donut_mixed": "file campuran", + "gallery.donut_none": "Tidak ada di toolbar donut mana pun: tambahkan ke salah satunya di Pengaturan → Toolbar donut.", + "gallery.license": "Dibagikan oleh {0} dengan CC0: bebas digunakan, diubah, dan dibagikan.", + "gallery.comments": "Komentar", + "gallery.install": "Pasang", + "gallery.installing": "Memasang…", + "gallery.installed": "{0} preset ditambahkan. Anda dapat menemukannya di daftar Preset.", + "gallery.installed_donut": "{0} preset dan toolbar donut “{1}” ditambahkan.", + "gallery.install_failed": "Tidak dapat memasang: {0}", + "gallery.not_installable": "Preset ini tidak dapat dipasang: {0}", + "gallery.download_failed": "Tidak dapat mengunduh preset. Periksa koneksi lalu coba lagi.", + "gallery.mismatch": "File yang diunduh bukan file yang tercantum di galeri, jadi tidak ada yang dipasang.", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "Paling besar {0} MB", + "gallery.option.no_metadata": "Menghapus metadata", + "gallery.option.merge": "Menggabungkan menjadi satu file", + "gallery.option.pages": "Halaman {0}", + "gallery.option.max_height": "Hingga {0}p", + "gallery.option.translate": "Menerjemahkan ke {0}", + "gallery.option.ask_compression": "Menanyakan kompresi setiap kali", + "gallery.option.subfolder": "Menyimpan di folder “{0}”", + "gallery.option.file_name": "Nama file: {0}" } diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index c3c54a568..7f1b26363 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -1848,5 +1848,67 @@ "connect.pin_label": "PIN:", "connect.firewall_hint_generic": "Nota: se il telefono non si connette, autorizza Filee nel firewall di sistema.", "connect.no_network_message": "Impossibile avviare Filee Connect. Assicurati che questo dispositivo sia connesso a una rete Wi-Fi o Ethernet locale.", - "connect.save_as": "Salva con nome…" + "connect.save_as": "Salva con nome…", + "gallery.title": "Galleria dei preset", + "gallery.open": "Galleria dei preset", + "gallery.open_tip": "Preset condivisi da altri su filee.sh", + "gallery.lead": "Preset condivisi da altre persone su filee.sh. Installandoli si aggiungono come nuovi preset e i tuoi restano come sono. Filee si collega a filee.sh solo mentre questa finestra è aperta.", + "gallery.sort": "Ordina", + "gallery.sort.Hot": "Di tendenza", + "gallery.sort.Best": "Migliori", + "gallery.sort.New": "Nuovi", + "gallery.period": "Periodo", + "gallery.period.day": "Oggi", + "gallery.period.week": "Questa settimana", + "gallery.period.month": "Questo mese", + "gallery.period.year": "Quest'anno", + "gallery.period.all": "Di sempre", + "gallery.filter.kind": "Tipo", + "gallery.kind.all": "Tutti i tipi", + "gallery.kind.convert": "Conversioni", + "gallery.kind.tool": "Strumenti", + "gallery.kind.toolbar": "Barre donut", + "gallery.filter.format": "Formato", + "gallery.format.all": "Tutti i formati", + "gallery.filter.tag": "Tag", + "gallery.tag.all": "Tutti i tag", + "gallery.filter.language": "Lingua", + "gallery.language.all": "Tutte le lingue", + "gallery.loading": "Caricamento dei preset da filee.sh…", + "gallery.failed": "Impossibile raggiungere filee.sh. Controlla la connessione a Internet e il proxy in Impostazioni → Generali, poi riprova.", + "gallery.empty": "Nessun preset corrisponde alla ricerca e ai filtri.", + "gallery.none": "Non è stato ancora condiviso nessun preset.", + "gallery.newer": "Altri {0} richiedono una versione più recente di Filee.", + "gallery.by": "di {0}", + "gallery.likes": "{0} Mi piace", + "gallery.downloads": "{0} download", + "gallery.select": "Scegli un preset per vederne le immagini e cosa fa.", + "gallery.image": "Immagine {0} di {1}", + "gallery.what": "Cosa aggiunge", + "gallery.what_group": "Gruppo: {0}", + "gallery.loading_details": "Caricamento…", + "gallery.donut_new": "Aggiunge la barra donut «{0}» per {1}. Si apre solo per i file che nessuna delle tue barre prende; puoi cambiarlo in Impostazioni → Barra donut.", + "gallery.donut_mixed": "file misti", + "gallery.donut_none": "Non va su nessuna barra donut: aggiungilo a una in Impostazioni → Barra donut.", + "gallery.license": "Condiviso da {0} con licenza CC0: libero da usare, modificare e condividere.", + "gallery.comments": "Commenti", + "gallery.install": "Installa", + "gallery.installing": "Installazione…", + "gallery.installed": "Aggiunti {0} preset. Li trovi nell'elenco dei preset.", + "gallery.installed_donut": "Aggiunti {0} preset e la barra donut «{1}».", + "gallery.install_failed": "Impossibile installare: {0}", + "gallery.not_installable": "Questo preset non può essere installato: {0}", + "gallery.download_failed": "Impossibile scaricare il preset. Controlla la connessione e riprova.", + "gallery.mismatch": "Il file scaricato non è quello indicato dalla galleria, quindi non è stato installato nulla.", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "Al massimo {0} MB", + "gallery.option.no_metadata": "Rimuove i metadati", + "gallery.option.merge": "Unisce in un solo file", + "gallery.option.pages": "Pagine {0}", + "gallery.option.max_height": "Fino a {0}p", + "gallery.option.translate": "Traduce in {0}", + "gallery.option.ask_compression": "Chiede ogni volta la compressione", + "gallery.option.subfolder": "Salva nella cartella «{0}»", + "gallery.option.file_name": "Nome file: {0}" } diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index b4dfcfc96..dfcfd36c0 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -1851,5 +1851,67 @@ "connect.pin_label": "PIN:", "connect.firewall_hint_generic": "注: スマートフォンが接続できない場合は、システムファイアウォールでFileeを許可してください。", "connect.no_network_message": "Filee Connectを開始できませんでした。このデバイスがローカルのWi-Fiまたは有線LANネットワークに接続されていることを確認してください。", - "connect.save_as": "名前を付けて保存…" + "connect.save_as": "名前を付けて保存…", + "gallery.title": "プリセットギャラリー", + "gallery.open": "プリセットギャラリー", + "gallery.open_tip": "ほかの人が filee.sh で共有したプリセット", + "gallery.lead": "ほかの人が filee.sh で共有したプリセットです。インストールすると新しいプリセットとして追加され、自分のプリセットはそのまま残ります。filee.sh に接続するのは、このウィンドウを開いている間だけです。", + "gallery.sort": "並べ替え", + "gallery.sort.Hot": "人気", + "gallery.sort.Best": "ベスト", + "gallery.sort.New": "新着", + "gallery.period": "期間", + "gallery.period.day": "今日", + "gallery.period.week": "今週", + "gallery.period.month": "今月", + "gallery.period.year": "今年", + "gallery.period.all": "全期間", + "gallery.filter.kind": "種類", + "gallery.kind.all": "すべての種類", + "gallery.kind.convert": "変換", + "gallery.kind.tool": "ツール", + "gallery.kind.toolbar": "ドーナツメニュー", + "gallery.filter.format": "形式", + "gallery.format.all": "すべての形式", + "gallery.filter.tag": "タグ", + "gallery.tag.all": "すべてのタグ", + "gallery.filter.language": "言語", + "gallery.language.all": "すべての言語", + "gallery.loading": "filee.sh からプリセットを読み込んでいます…", + "gallery.failed": "filee.sh に接続できません。インターネット接続と「設定」→「一般」のプロキシを確認してから、もう一度お試しください。", + "gallery.empty": "検索とフィルターに合うプリセットはありません。", + "gallery.none": "共有されたプリセットはまだありません。", + "gallery.newer": "ほかに {0} 件は、より新しい Filee が必要です。", + "gallery.by": "{0} さん", + "gallery.likes": "いいね {0}", + "gallery.downloads": "ダウンロード {0}", + "gallery.select": "プリセットを選ぶと、画像と内容が表示されます。", + "gallery.image": "画像 {0}/{1}", + "gallery.what": "追加されるもの", + "gallery.what_group": "グループ: {0}", + "gallery.loading_details": "読み込み中…", + "gallery.donut_new": "ドーナツメニュー「{0}」を {1} 用に追加します。自分のどのドーナツメニューも扱わないファイルでだけ開きます。「設定」→「ドーナツメニュー」で変更できます。", + "gallery.donut_mixed": "複数の種類のファイル", + "gallery.donut_none": "どのドーナツメニューにも入りません。「設定」→「ドーナツメニュー」で追加できます。", + "gallery.license": "{0} さんが CC0 で共有しました。自由に使用、変更、共有できます。", + "gallery.comments": "コメント", + "gallery.install": "インストール", + "gallery.installing": "インストールしています…", + "gallery.installed": "{0} 個のプリセットを追加しました。プリセットの一覧にあります。", + "gallery.installed_donut": "{0} 個のプリセットとドーナツメニュー「{1}」を追加しました。", + "gallery.install_failed": "インストールできませんでした: {0}", + "gallery.not_installable": "このプリセットはインストールできません: {0}", + "gallery.download_failed": "プリセットをダウンロードできませんでした。接続を確認して、もう一度お試しください。", + "gallery.mismatch": "ダウンロードしたファイルがギャラリーに載っているものと違うため、何もインストールしませんでした。", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "最大 {0} MB", + "gallery.option.no_metadata": "メタデータを削除", + "gallery.option.merge": "1 つのファイルに結合", + "gallery.option.pages": "ページ {0}", + "gallery.option.max_height": "最大 {0}p", + "gallery.option.translate": "{0}に翻訳", + "gallery.option.ask_compression": "毎回圧縮率を確認", + "gallery.option.subfolder": "フォルダー「{0}」に保存", + "gallery.option.file_name": "ファイル名: {0}" } diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index c1fb69441..0cbdc3368 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -1871,5 +1871,67 @@ "connect.pin_label": "PIN:", "connect.firewall_hint_generic": "참고: 휴대폰이 연결되지 않으면 시스템 방화벽에서 Filee를 허용하세요.", "connect.no_network_message": "Filee Connect를 시작할 수 없습니다. 이 기기가 로컬 Wi-Fi 또는 이더넷 네트워크에 연결되어 있는지 확인하세요.", - "connect.save_as": "다른 이름으로 저장…" + "connect.save_as": "다른 이름으로 저장…", + "gallery.title": "프리셋 갤러리", + "gallery.open": "프리셋 갤러리", + "gallery.open_tip": "다른 사람들이 filee.sh에 공유한 프리셋", + "gallery.lead": "다른 사람들이 filee.sh에 공유한 프리셋이에요. 설치하면 새 프리셋으로 추가되고, 내 프리셋은 그대로 남아요. 이 창이 열려 있는 동안에만 filee.sh에 연결해요.", + "gallery.sort": "정렬", + "gallery.sort.Hot": "인기", + "gallery.sort.Best": "베스트", + "gallery.sort.New": "최신", + "gallery.period": "기간", + "gallery.period.day": "오늘", + "gallery.period.week": "이번 주", + "gallery.period.month": "이번 달", + "gallery.period.year": "올해", + "gallery.period.all": "전체 기간", + "gallery.filter.kind": "종류", + "gallery.kind.all": "모든 종류", + "gallery.kind.convert": "변환", + "gallery.kind.tool": "도구", + "gallery.kind.toolbar": "원형 툴바", + "gallery.filter.format": "형식", + "gallery.format.all": "모든 형식", + "gallery.filter.tag": "태그", + "gallery.tag.all": "모든 태그", + "gallery.filter.language": "언어", + "gallery.language.all": "모든 언어", + "gallery.loading": "filee.sh에서 프리셋을 불러오는 중…", + "gallery.failed": "filee.sh에 연결할 수 없어요. 인터넷 연결과 설정 → 일반의 프록시를 확인한 뒤 다시 시도해 주세요.", + "gallery.empty": "검색어와 필터에 맞는 프리셋이 없어요.", + "gallery.none": "아직 공유된 프리셋이 없어요.", + "gallery.newer": "{0}개는 더 새로운 Filee가 필요해요.", + "gallery.by": "{0} 님", + "gallery.likes": "좋아요 {0}", + "gallery.downloads": "다운로드 {0}", + "gallery.select": "프리셋을 고르면 그림과 하는 일을 볼 수 있어요.", + "gallery.image": "그림 {0}/{1}", + "gallery.what": "추가되는 것", + "gallery.what_group": "묶음: {0}", + "gallery.loading_details": "불러오는 중…", + "gallery.donut_new": "원형 툴바 “{0}”를 {1}용으로 추가해요. 내 원형 툴바가 맡지 않는 파일에서만 열리고, 설정 → 원형 툴바에서 바꿀 수 있어요.", + "gallery.donut_mixed": "여러 종류의 파일", + "gallery.donut_none": "원형 툴바에는 올라가지 않아요. 설정 → 원형 툴바에서 넣을 수 있어요.", + "gallery.license": "{0} 님이 CC0로 공유했어요. 누구나 자유롭게 쓰고 고치고 나눌 수 있어요.", + "gallery.comments": "댓글", + "gallery.install": "설치", + "gallery.installing": "설치하는 중…", + "gallery.installed": "프리셋 {0}개를 추가했어요. 프리셋 목록에서 찾을 수 있어요.", + "gallery.installed_donut": "프리셋 {0}개와 원형 툴바 “{1}”를 추가했어요.", + "gallery.install_failed": "설치하지 못했어요: {0}", + "gallery.not_installable": "이 프리셋은 설치할 수 없어요: {0}", + "gallery.download_failed": "프리셋을 내려받지 못했어요. 연결을 확인하고 다시 시도해 주세요.", + "gallery.mismatch": "내려받은 파일이 갤러리에 올라온 파일과 달라서 아무것도 설치하지 않았어요.", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "최대 {0} MB", + "gallery.option.no_metadata": "메타데이터 제거", + "gallery.option.merge": "파일 하나로 합치기", + "gallery.option.pages": "{0} 페이지", + "gallery.option.max_height": "최대 {0}p", + "gallery.option.translate": "{0}(으)로 번역", + "gallery.option.ask_compression": "매번 압축 정도를 물어봄", + "gallery.option.subfolder": "“{0}” 폴더에 저장", + "gallery.option.file_name": "파일 이름: {0}" } diff --git a/src/Filee.App/Assets/i18n/mn.json b/src/Filee.App/Assets/i18n/mn.json index fa528de85..44329b032 100644 --- a/src/Filee.App/Assets/i18n/mn.json +++ b/src/Filee.App/Assets/i18n/mn.json @@ -1825,5 +1825,67 @@ "connect.pin_label": "ПИН:", "connect.firewall_hint_generic": "Тэмдэглэл: Хэрэв таны утас холбогдохгүй бол системийн галт хана (firewall) дотор Filee-г зөвшөөрнө үү.", "connect.no_network_message": "Filee Connect-ийг эхлүүлж чадсангүй. Энэ төхөөрөмж дотоод Wi-Fi эсвэл Ethernet сүлжээнд холбогдсон эсэхийг шалгана уу.", - "connect.save_as": "Өөрөөр хадгалах…" + "connect.save_as": "Өөрөөр хадгалах…", + "gallery.title": "Урьдчилсан тохиргооны цомог", + "gallery.open": "Урьдчилсан тохиргооны цомог", + "gallery.open_tip": "Бусдын filee.sh дээр хуваалцсан урьдчилсан тохиргоонууд", + "gallery.lead": "Бусдын filee.sh дээр хуваалцсан урьдчилсан тохиргоонууд. Суулгахад шинэ урьдчилсан тохиргоо болж нэмэгдэх бөгөөд таны өөрийнх хэвээрээ үлдэнэ. Filee зөвхөн энэ цонх нээлттэй байх үед filee.sh-тэй холбогдоно.", + "gallery.sort": "Эрэмбэлэх", + "gallery.sort.Hot": "Эрэлттэй", + "gallery.sort.Best": "Шилдэг", + "gallery.sort.New": "Шинэ", + "gallery.period": "Хугацаа", + "gallery.period.day": "Өнөөдөр", + "gallery.period.week": "Энэ долоо хоног", + "gallery.period.month": "Энэ сар", + "gallery.period.year": "Энэ жил", + "gallery.period.all": "Бүх цаг үе", + "gallery.filter.kind": "Төрөл", + "gallery.kind.all": "Бүх төрөл", + "gallery.kind.convert": "Хөрвүүлэлт", + "gallery.kind.tool": "Хэрэгсэл", + "gallery.kind.toolbar": "Цагираг самбар", + "gallery.filter.format": "Формат", + "gallery.format.all": "Бүх формат", + "gallery.filter.tag": "Шошго", + "gallery.tag.all": "Бүх шошго", + "gallery.filter.language": "Хэл", + "gallery.language.all": "Бүх хэл", + "gallery.loading": "filee.sh-ээс урьдчилсан тохиргоонуудыг ачаалж байна…", + "gallery.failed": "filee.sh-тэй холбогдож чадсангүй. Интернэт холболт болон Тохиргоо → Ерөнхий хэсгийн проксиг шалгаад дахин оролдоно уу.", + "gallery.empty": "Хайлт болон шүүлтүүрт тохирох урьдчилсан тохиргоо алга.", + "gallery.none": "Одоогоор хуваалцсан урьдчилсан тохиргоо алга.", + "gallery.newer": "Өөр {0} нь Filee-гийн шинэ хувилбар шаардана.", + "gallery.by": "Нийтэлсэн: {0}", + "gallery.likes": "{0} таалагдсан", + "gallery.downloads": "{0} татсан", + "gallery.select": "Зураг болон юу хийдгийг нь харахын тулд урьдчилсан тохиргоо сонгоно уу.", + "gallery.image": "Зураг {0}/{1}", + "gallery.what": "Нэмэгдэх зүйл", + "gallery.what_group": "Бүлэг: {0}", + "gallery.loading_details": "Ачаалж байна…", + "gallery.donut_new": "{1}-д зориулсан “{0}” цагираг самбарыг нэмнэ. Таны аль ч цагираг самбар авдаггүй файлд л нээгдэнэ; үүнийг Тохиргоо → Цагираг самбар хэсгээс өөрчилж болно.", + "gallery.donut_mixed": "холимог файл", + "gallery.donut_none": "Аль ч цагираг самбарт ороогүй: Тохиргоо → Цагираг самбар хэсгээс аль нэгэнд нь нэмнэ үү.", + "gallery.license": "{0} CC0 лицензээр хуваалцсан: чөлөөтэй ашиглаж, өөрчилж, түгээж болно.", + "gallery.comments": "Сэтгэгдэл", + "gallery.install": "Суулгах", + "gallery.installing": "Суулгаж байна…", + "gallery.installed": "{0} урьдчилсан тохиргоо нэмэгдлээ. Урьдчилсан тохиргооны жагсаалтаас олно уу.", + "gallery.installed_donut": "{0} урьдчилсан тохиргоо болон “{1}” цагираг самбар нэмэгдлээ.", + "gallery.install_failed": "Суулгаж чадсангүй: {0}", + "gallery.not_installable": "Энэ урьдчилсан тохиргоог суулгах боломжгүй: {0}", + "gallery.download_failed": "Урьдчилсан тохиргоог татаж чадсангүй. Холболтоо шалгаад дахин оролдоно уу.", + "gallery.mismatch": "Татсан файл нь цомогт заасан файл биш тул юу ч суулгаагүй.", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "Дээд тал нь {0} MB", + "gallery.option.no_metadata": "Мета өгөгдлийг устгана", + "gallery.option.merge": "Нэг файл болгон нэгтгэнэ", + "gallery.option.pages": "Хуудас {0}", + "gallery.option.max_height": "{0}p хүртэл", + "gallery.option.translate": "{0} хэл рүү орчуулна", + "gallery.option.ask_compression": "Шахалтыг тухай бүр асууна", + "gallery.option.subfolder": "“{0}” хавтаст хадгална", + "gallery.option.file_name": "Файлын нэр: {0}" } diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 1037c1caf..dd6b1899a 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -1851,5 +1851,67 @@ "connect.pin_label": "पिन:", "connect.firewall_hint_generic": "नोट: यदि तपाईंको फोन जडान हुन सकेन भने, प्रणाली फायरवालमा Filee लाई अनुमति दिनुहोस्।", "connect.no_network_message": "Filee Connect सुरु गर्न सकिएन। कृपया यो यन्त्र स्थानीय Wi-Fi वा इथरनेट नेटवर्कमा जडान भएको निश्चित गर्नुहोस्।", - "connect.save_as": "यस रूपमा बचत गर्नुहोस्…" + "connect.save_as": "यस रूपमा बचत गर्नुहोस्…", + "gallery.title": "प्रिसेट ग्यालरी", + "gallery.open": "प्रिसेट ग्यालरी", + "gallery.open_tip": "अरूले filee.sh मा साझा गरेका प्रिसेटहरू", + "gallery.lead": "अरूले filee.sh मा साझा गरेका प्रिसेटहरू। स्थापना गर्दा यी नयाँ प्रिसेटका रूपमा थपिन्छन् र तपाईंका प्रिसेटहरू जस्ताको तस्तै रहन्छन्। यो विन्डो खुला हुँदा मात्र Filee ले filee.sh सँग जडान गर्छ।", + "gallery.sort": "क्रम", + "gallery.sort.Hot": "लोकप्रिय", + "gallery.sort.Best": "उत्कृष्ट", + "gallery.sort.New": "नयाँ", + "gallery.period": "अवधि", + "gallery.period.day": "आज", + "gallery.period.week": "यो हप्ता", + "gallery.period.month": "यो महिना", + "gallery.period.year": "यो वर्ष", + "gallery.period.all": "सबै समय", + "gallery.filter.kind": "प्रकार", + "gallery.kind.all": "सबै प्रकार", + "gallery.kind.convert": "रूपान्तरणहरू", + "gallery.kind.tool": "उपकरणहरू", + "gallery.kind.toolbar": "डोनट टुलबारहरू", + "gallery.filter.format": "ढाँचा", + "gallery.format.all": "सबै ढाँचा", + "gallery.filter.tag": "ट्याग", + "gallery.tag.all": "सबै ट्याग", + "gallery.filter.language": "भाषा", + "gallery.language.all": "सबै भाषा", + "gallery.loading": "filee.sh बाट प्रिसेटहरू लोड हुँदैछन्…", + "gallery.failed": "filee.sh सँग जडान हुन सकेन। इन्टरनेट जडान र सेटिङहरू → सामान्यमा प्रोक्सी जाँच गरेर फेरि प्रयास गर्नुहोस्।", + "gallery.empty": "खोज र फिल्टरसँग मिल्ने कुनै प्रिसेट छैन।", + "gallery.none": "अहिलेसम्म कुनै प्रिसेट साझा गरिएको छैन।", + "gallery.newer": "थप {0} लाई Filee को नयाँ संस्करण चाहिन्छ।", + "gallery.by": "{0} द्वारा", + "gallery.likes": "{0} मन पराइएको", + "gallery.downloads": "{0} डाउनलोड", + "gallery.select": "प्रिसेट छान्नुहोस्; त्यसका तस्बिर र त्यसले के गर्छ भन्ने देखिन्छ।", + "gallery.image": "तस्बिर {0} / {1}", + "gallery.what": "के थपिन्छ", + "gallery.what_group": "समूह: {0}", + "gallery.loading_details": "लोड हुँदैछ…", + "gallery.donut_new": "{1} का लागि डोनट टुलबार “{0}” थप्छ। यो तपाईंको कुनै पनि डोनटले नलिने फाइलहरूमा मात्र खुल्छ; यसलाई सेटिङहरू → डोनट टुलबारमा परिवर्तन गर्नुहोस्।", + "gallery.donut_mixed": "मिश्रित फाइलहरू", + "gallery.donut_none": "कुनै डोनटमा छैन: सेटिङहरू → डोनट टुलबारमा कुनै एकमा थप्नुहोस्।", + "gallery.license": "{0} ले CC0 अन्तर्गत साझा गर्नुभएको: प्रयोग, परिवर्तन र साझा गर्न स्वतन्त्र।", + "gallery.comments": "टिप्पणीहरू", + "gallery.install": "स्थापना गर्नुहोस्", + "gallery.installing": "स्थापना हुँदैछ…", + "gallery.installed": "{0} प्रिसेट थपियो। तिनीहरू प्रिसेटहरूको सूचीमा छन्।", + "gallery.installed_donut": "{0} प्रिसेट र डोनट टुलबार “{1}” थपियो।", + "gallery.install_failed": "स्थापना गर्न सकिएन: {0}", + "gallery.not_installable": "यो प्रिसेट स्थापना गर्न सकिँदैन: {0}", + "gallery.download_failed": "प्रिसेट डाउनलोड गर्न सकिएन। जडान जाँचेर फेरि प्रयास गर्नुहोस्।", + "gallery.mismatch": "डाउनलोड गरिएको फाइल ग्यालरीले देखाएको फाइल होइन, त्यसैले केही पनि स्थापना गरिएन।", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "बढीमा {0} MB", + "gallery.option.no_metadata": "मेटाडाटा हटाउँछ", + "gallery.option.merge": "एउटै फाइलमा जोड्छ", + "gallery.option.pages": "पृष्ठ {0}", + "gallery.option.max_height": "{0}p सम्म", + "gallery.option.translate": "{0} मा अनुवाद गर्छ", + "gallery.option.ask_compression": "हरेक पटक कम्प्रेसन सोध्छ", + "gallery.option.subfolder": "“{0}” फोल्डरमा सेभ गर्छ", + "gallery.option.file_name": "फाइल नाम: {0}" } diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index bb64aceae..49b0cdb7c 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -1871,5 +1871,67 @@ "connect.pin_label": "PIN:", "connect.firewall_hint_generic": "Uwaga: jeśli telefon nie może się połączyć, zezwól na Filee w zaporze systemowej.", "connect.no_network_message": "Nie można uruchomić Filee Connect. Upewnij się, że to urządzenie jest połączone z lokalną siecią Wi-Fi lub Ethernet.", - "connect.save_as": "Zapisz jako…" + "connect.save_as": "Zapisz jako…", + "gallery.title": "Galeria ustawień wstępnych", + "gallery.open": "Galeria ustawień wstępnych", + "gallery.open_tip": "Ustawienia wstępne udostępnione przez innych na filee.sh", + "gallery.lead": "Ustawienia wstępne, które inni udostępnili na filee.sh. Instalacja dodaje je jako nowe ustawienia, a Twoje zostają bez zmian. Filee łączy się z filee.sh tylko wtedy, gdy to okno jest otwarte.", + "gallery.sort": "Sortuj", + "gallery.sort.Hot": "Popularne", + "gallery.sort.Best": "Najlepsze", + "gallery.sort.New": "Nowe", + "gallery.period": "Okres", + "gallery.period.day": "Dziś", + "gallery.period.week": "Ten tydzień", + "gallery.period.month": "Ten miesiąc", + "gallery.period.year": "Ten rok", + "gallery.period.all": "Cały czas", + "gallery.filter.kind": "Rodzaj", + "gallery.kind.all": "Wszystkie rodzaje", + "gallery.kind.convert": "Konwersje", + "gallery.kind.tool": "Narzędzia", + "gallery.kind.toolbar": "Paski-pierścienie", + "gallery.filter.format": "Format", + "gallery.format.all": "Wszystkie formaty", + "gallery.filter.tag": "Tag", + "gallery.tag.all": "Wszystkie tagi", + "gallery.filter.language": "Język", + "gallery.language.all": "Wszystkie języki", + "gallery.loading": "Wczytywanie ustawień z filee.sh…", + "gallery.failed": "Nie można połączyć się z filee.sh. Sprawdź połączenie z internetem i serwer proxy w Ustawienia → Ogólne, a potem spróbuj ponownie.", + "gallery.empty": "Żadne ustawienie nie pasuje do wyszukiwania i filtrów.", + "gallery.none": "Nikt jeszcze nie udostępnił ustawień.", + "gallery.newer": "Jeszcze {0} wymaga nowszej wersji Filee.", + "gallery.by": "autor: {0}", + "gallery.likes": "Polubienia: {0}", + "gallery.downloads": "Pobrania: {0}", + "gallery.select": "Wybierz ustawienie, aby zobaczyć jego obrazy i to, co robi.", + "gallery.image": "Obraz {0} z {1}", + "gallery.what": "Co zostanie dodane", + "gallery.what_group": "Grupa: {0}", + "gallery.loading_details": "Wczytywanie…", + "gallery.donut_new": "Dodaje pasek-pierścień „{0}” dla {1}. Otwiera się tylko dla plików, których nie obsługuje żaden z Twoich pasków; zmienisz to w Ustawienia → Pasek-pierścień.", + "gallery.donut_mixed": "plików mieszanych", + "gallery.donut_none": "Nie trafia na żaden pasek-pierścień: dodaj je do któregoś w Ustawienia → Pasek-pierścień.", + "gallery.license": "Udostępnione przez {0} na licencji CC0: można swobodnie używać, zmieniać i udostępniać.", + "gallery.comments": "Komentarze", + "gallery.install": "Zainstaluj", + "gallery.installing": "Instalowanie…", + "gallery.installed": "Dodano ustawienia: {0}. Znajdziesz je na liście ustawień.", + "gallery.installed_donut": "Dodano ustawienia: {0} oraz pasek-pierścień „{1}”.", + "gallery.install_failed": "Nie udało się zainstalować: {0}", + "gallery.not_installable": "Tego ustawienia nie można zainstalować: {0}", + "gallery.download_failed": "Nie udało się pobrać ustawienia. Sprawdź połączenie i spróbuj ponownie.", + "gallery.mismatch": "Pobrany plik nie jest tym, który podaje galeria, więc nic nie zainstalowano.", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "Najwyżej {0} MB", + "gallery.option.no_metadata": "Usuwa metadane", + "gallery.option.merge": "Łączy w jeden plik", + "gallery.option.pages": "Strony {0}", + "gallery.option.max_height": "Do {0}p", + "gallery.option.translate": "Tłumaczy na: {0}", + "gallery.option.ask_compression": "Za każdym razem pyta o kompresję", + "gallery.option.subfolder": "Zapisuje w folderze „{0}”", + "gallery.option.file_name": "Nazwa pliku: {0}" } diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index cbec29537..7dddc080a 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -1871,5 +1871,67 @@ "connect.pin_label": "PIN:", "connect.firewall_hint_generic": "Observação: se o seu celular não conseguir se conectar, permita o Filee no firewall do sistema.", "connect.no_network_message": "Não foi possível iniciar o Filee Connect. Certifique-se de que este dispositivo esteja conectado a uma rede Wi-Fi ou Ethernet local.", - "connect.save_as": "Salvar como…" + "connect.save_as": "Salvar como…", + "gallery.title": "Galeria de predefinições", + "gallery.open": "Galeria de predefinições", + "gallery.open_tip": "Predefinições que outras pessoas compartilharam no filee.sh", + "gallery.lead": "Predefinições que outras pessoas compartilharam no filee.sh. Ao instalar, elas entram como novas predefinições e as suas ficam como estão. O Filee só se conecta ao filee.sh enquanto esta janela está aberta.", + "gallery.sort": "Ordenar", + "gallery.sort.Hot": "Em alta", + "gallery.sort.Best": "Melhores", + "gallery.sort.New": "Novas", + "gallery.period": "Período", + "gallery.period.day": "Hoje", + "gallery.period.week": "Esta semana", + "gallery.period.month": "Este mês", + "gallery.period.year": "Este ano", + "gallery.period.all": "Desde sempre", + "gallery.filter.kind": "Tipo", + "gallery.kind.all": "Todos os tipos", + "gallery.kind.convert": "Conversões", + "gallery.kind.tool": "Ferramentas", + "gallery.kind.toolbar": "Menus circulares", + "gallery.filter.format": "Formato", + "gallery.format.all": "Todos os formatos", + "gallery.filter.tag": "Tag", + "gallery.tag.all": "Todas as tags", + "gallery.filter.language": "Idioma", + "gallery.language.all": "Todos os idiomas", + "gallery.loading": "Carregando predefinições do filee.sh…", + "gallery.failed": "Não foi possível acessar o filee.sh. Verifique a conexão com a internet e o proxy em Configurações → Geral e tente de novo.", + "gallery.empty": "Nenhuma predefinição corresponde à busca e aos filtros.", + "gallery.none": "Ninguém compartilhou predefinições ainda.", + "gallery.newer": "Mais {0} precisam de uma versão mais nova do Filee.", + "gallery.by": "por {0}", + "gallery.likes": "{0} curtidas", + "gallery.downloads": "{0} downloads", + "gallery.select": "Escolha uma predefinição para ver as imagens e o que ela faz.", + "gallery.image": "Imagem {0} de {1}", + "gallery.what": "O que é adicionado", + "gallery.what_group": "Grupo: {0}", + "gallery.loading_details": "Carregando…", + "gallery.donut_new": "Adiciona o menu circular “{0}” para {1}. Ele só abre para arquivos que nenhum dos seus menus pega; mude isso em Configurações → Menu circular.", + "gallery.donut_mixed": "arquivos mistos", + "gallery.donut_none": "Não fica em nenhum menu circular: adicione a um em Configurações → Menu circular.", + "gallery.license": "Compartilhado por {0} sob CC0: livre para usar, alterar e compartilhar.", + "gallery.comments": "Comentários", + "gallery.install": "Instalar", + "gallery.installing": "Instalando…", + "gallery.installed": "{0} predefinição(ões) adicionada(s). Estão na lista de predefinições.", + "gallery.installed_donut": "{0} predefinição(ões) e o menu circular “{1}” adicionados.", + "gallery.install_failed": "Não foi possível instalar: {0}", + "gallery.not_installable": "Esta predefinição não pode ser instalada: {0}", + "gallery.download_failed": "Não foi possível baixar a predefinição. Verifique a conexão e tente de novo.", + "gallery.mismatch": "O arquivo baixado não é o que a galeria indica, então nada foi instalado.", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "No máximo {0} MB", + "gallery.option.no_metadata": "Remove os metadados", + "gallery.option.merge": "Junta em um só arquivo", + "gallery.option.pages": "Páginas {0}", + "gallery.option.max_height": "Até {0}p", + "gallery.option.translate": "Traduz para {0}", + "gallery.option.ask_compression": "Pergunta a compressão toda vez", + "gallery.option.subfolder": "Salva na pasta “{0}”", + "gallery.option.file_name": "Nome do arquivo: {0}" } diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index a7c27a580..d026a919e 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -1871,5 +1871,67 @@ "connect.pin_label": "PIN:", "connect.firewall_hint_generic": "Nota: se o seu telemóvel não conseguir ligar-se, permita o Filee na firewall do sistema.", "connect.no_network_message": "Não foi possível iniciar o Filee Connect. Certifique-se de que este dispositivo está ligado a uma rede Wi-Fi ou Ethernet local.", - "connect.save_as": "Guardar como…" + "connect.save_as": "Guardar como…", + "gallery.title": "Galeria de predefinições", + "gallery.open": "Galeria de predefinições", + "gallery.open_tip": "Predefinições que outras pessoas partilharam no filee.sh", + "gallery.lead": "Predefinições que outras pessoas partilharam no filee.sh. Ao instalar, são adicionadas como novas predefinições e as tuas ficam como estão. O Filee só contacta o filee.sh enquanto esta janela está aberta.", + "gallery.sort": "Ordenar", + "gallery.sort.Hot": "Em destaque", + "gallery.sort.Best": "Melhores", + "gallery.sort.New": "Novas", + "gallery.period": "Período", + "gallery.period.day": "Hoje", + "gallery.period.week": "Esta semana", + "gallery.period.month": "Este mês", + "gallery.period.year": "Este ano", + "gallery.period.all": "Desde sempre", + "gallery.filter.kind": "Tipo", + "gallery.kind.all": "Todos os tipos", + "gallery.kind.convert": "Conversões", + "gallery.kind.tool": "Ferramentas", + "gallery.kind.toolbar": "Barras circulares", + "gallery.filter.format": "Formato", + "gallery.format.all": "Todos os formatos", + "gallery.filter.tag": "Etiqueta", + "gallery.tag.all": "Todas as etiquetas", + "gallery.filter.language": "Idioma", + "gallery.language.all": "Todos os idiomas", + "gallery.loading": "A carregar predefinições do filee.sh…", + "gallery.failed": "Não foi possível contactar o filee.sh. Verifica a ligação à Internet e o proxy em Definições → Geral e tenta novamente.", + "gallery.empty": "Nenhuma predefinição corresponde à pesquisa e aos filtros.", + "gallery.none": "Ainda ninguém partilhou predefinições.", + "gallery.newer": "Mais {0} precisam de uma versão mais recente do Filee.", + "gallery.by": "por {0}", + "gallery.likes": "{0} gostos", + "gallery.downloads": "{0} transferências", + "gallery.select": "Escolhe uma predefinição para veres as imagens e o que faz.", + "gallery.image": "Imagem {0} de {1}", + "gallery.what": "O que é adicionado", + "gallery.what_group": "Grupo: {0}", + "gallery.loading_details": "A carregar…", + "gallery.donut_new": "Adiciona a barra circular “{0}” para {1}. Só abre para ficheiros que nenhuma das tuas barras trata; podes mudar isso em Definições → Barra circular.", + "gallery.donut_mixed": "ficheiros mistos", + "gallery.donut_none": "Não fica em nenhuma barra circular: adiciona-a a uma em Definições → Barra circular.", + "gallery.license": "Partilhado por {0} sob CC0: livre para usar, alterar e partilhar.", + "gallery.comments": "Comentários", + "gallery.install": "Instalar", + "gallery.installing": "A instalar…", + "gallery.installed": "{0} predefinição(ões) adicionada(s). Estão na lista de predefinições.", + "gallery.installed_donut": "{0} predefinição(ões) e a barra circular “{1}” adicionadas.", + "gallery.install_failed": "Não foi possível instalar: {0}", + "gallery.not_installable": "Esta predefinição não pode ser instalada: {0}", + "gallery.download_failed": "Não foi possível transferir a predefinição. Verifica a ligação e tenta novamente.", + "gallery.mismatch": "O ficheiro transferido não é o que a galeria indica, por isso nada foi instalado.", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "No máximo {0} MB", + "gallery.option.no_metadata": "Remove os metadados", + "gallery.option.merge": "Junta num só ficheiro", + "gallery.option.pages": "Páginas {0}", + "gallery.option.max_height": "Até {0}p", + "gallery.option.translate": "Traduz para {0}", + "gallery.option.ask_compression": "Pergunta a compressão de cada vez", + "gallery.option.subfolder": "Guarda na pasta “{0}”", + "gallery.option.file_name": "Nome do ficheiro: {0}" } diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 7fb769f62..014ed00f4 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -1871,5 +1871,67 @@ "connect.pin_label": "PIN:", "connect.firewall_hint_generic": "Примечание: если телефон не подключается, разрешите Filee в системном брандмауэре.", "connect.no_network_message": "Не удалось запустить Filee Connect. Убедитесь, что это устройство подключено к локальной сети Wi-Fi или Ethernet.", - "connect.save_as": "Сохранить как…" + "connect.save_as": "Сохранить как…", + "gallery.title": "Галерея пресетов", + "gallery.open": "Галерея пресетов", + "gallery.open_tip": "Пресеты, которыми другие поделились на filee.sh", + "gallery.lead": "Пресеты, которыми другие поделились на filee.sh. При установке они добавляются как новые пресеты, а ваши остаются как есть. Filee обращается к filee.sh, только пока открыто это окно.", + "gallery.sort": "Сортировка", + "gallery.sort.Hot": "Популярные", + "gallery.sort.Best": "Лучшие", + "gallery.sort.New": "Новые", + "gallery.period": "Период", + "gallery.period.day": "Сегодня", + "gallery.period.week": "За неделю", + "gallery.period.month": "За месяц", + "gallery.period.year": "За год", + "gallery.period.all": "За всё время", + "gallery.filter.kind": "Тип", + "gallery.kind.all": "Все типы", + "gallery.kind.convert": "Преобразования", + "gallery.kind.tool": "Инструменты", + "gallery.kind.toolbar": "Круговые меню", + "gallery.filter.format": "Формат", + "gallery.format.all": "Все форматы", + "gallery.filter.tag": "Тег", + "gallery.tag.all": "Все теги", + "gallery.filter.language": "Язык", + "gallery.language.all": "Все языки", + "gallery.loading": "Загрузка пресетов с filee.sh…", + "gallery.failed": "Не удалось подключиться к filee.sh. Проверьте подключение к интернету и прокси в разделе «Настройки → Общие» и повторите попытку.", + "gallery.empty": "Нет пресетов, подходящих под поиск и фильтры.", + "gallery.none": "Пока никто не поделился пресетами.", + "gallery.newer": "Ещё {0} требуют более новой версии Filee.", + "gallery.by": "автор: {0}", + "gallery.likes": "Нравится: {0}", + "gallery.downloads": "Загрузок: {0}", + "gallery.select": "Выберите пресет, чтобы увидеть его изображения и что он делает.", + "gallery.image": "Изображение {0} из {1}", + "gallery.what": "Что добавится", + "gallery.what_group": "Группа: {0}", + "gallery.loading_details": "Загрузка…", + "gallery.donut_new": "Добавляет круговое меню «{0}» для {1}. Оно открывается только для файлов, которые не берёт ни одно из ваших меню; изменить это можно в разделе «Настройки → Круговое меню».", + "gallery.donut_mixed": "смешанных файлов", + "gallery.donut_none": "Не входит ни в одно круговое меню: добавьте его в разделе «Настройки → Круговое меню».", + "gallery.license": "Опубликовал {0} под CC0: можно свободно использовать, изменять и распространять.", + "gallery.comments": "Комментарии", + "gallery.install": "Установить", + "gallery.installing": "Установка…", + "gallery.installed": "Добавлено пресетов: {0}. Они в списке пресетов.", + "gallery.installed_donut": "Добавлено пресетов: {0} и круговое меню «{1}».", + "gallery.install_failed": "Не удалось установить: {0}", + "gallery.not_installable": "Этот пресет нельзя установить: {0}", + "gallery.download_failed": "Не удалось скачать пресет. Проверьте подключение и повторите попытку.", + "gallery.mismatch": "Скачанный файл не совпадает с указанным в галерее, поэтому ничего не установлено.", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "Не больше {0} МБ", + "gallery.option.no_metadata": "Удаляет метаданные", + "gallery.option.merge": "Объединяет в один файл", + "gallery.option.pages": "Страницы {0}", + "gallery.option.max_height": "До {0}p", + "gallery.option.translate": "Переводит на язык: {0}", + "gallery.option.ask_compression": "Каждый раз спрашивает степень сжатия", + "gallery.option.subfolder": "Сохраняет в папку «{0}»", + "gallery.option.file_name": "Имя файла: {0}" } diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 4ee2f8993..f105ff768 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -1851,5 +1851,67 @@ "connect.pin_label": "PIN:", "connect.firewall_hint_generic": "หมายเหตุ: หากโทรศัพท์ของคุณไม่สามารถเชื่อมต่อได้ ให้เปิดการอนุญาต Filee ในไฟร์วอลล์ของระบบ", "connect.no_network_message": "ไม่สามารถเริ่ม Filee Connect ได้ โปรดตรวจสอบให้แน่ใจว่าอุปกรณ์นี้เชื่อมต่อกับเครือข่าย Wi-Fi หรืออีเทอร์เน็ตในเครื่อง", - "connect.save_as": "บันทึกเป็น…" + "connect.save_as": "บันทึกเป็น…", + "gallery.title": "แกลเลอรีพรีเซ็ต", + "gallery.open": "แกลเลอรีพรีเซ็ต", + "gallery.open_tip": "พรีเซ็ตที่ผู้อื่นแชร์ไว้บน filee.sh", + "gallery.lead": "พรีเซ็ตที่ผู้อื่นแชร์ไว้บน filee.sh เมื่อติดตั้ง พรีเซ็ตจะถูกเพิ่มเป็นพรีเซ็ตใหม่ และพรีเซ็ตของคุณยังคงเหมือนเดิม Filee จะเชื่อมต่อกับ filee.sh เฉพาะขณะที่หน้าต่างนี้เปิดอยู่", + "gallery.sort": "เรียงตาม", + "gallery.sort.Hot": "มาแรง", + "gallery.sort.Best": "ยอดนิยม", + "gallery.sort.New": "ใหม่ล่าสุด", + "gallery.period": "ช่วงเวลา", + "gallery.period.day": "วันนี้", + "gallery.period.week": "สัปดาห์นี้", + "gallery.period.month": "เดือนนี้", + "gallery.period.year": "ปีนี้", + "gallery.period.all": "ตลอดเวลา", + "gallery.filter.kind": "ชนิด", + "gallery.kind.all": "ทุกชนิด", + "gallery.kind.convert": "การแปลง", + "gallery.kind.tool": "เครื่องมือ", + "gallery.kind.toolbar": "แถบโดนัท", + "gallery.filter.format": "ฟอร์แมต", + "gallery.format.all": "ทุกฟอร์แมต", + "gallery.filter.tag": "แท็ก", + "gallery.tag.all": "ทุกแท็ก", + "gallery.filter.language": "ภาษา", + "gallery.language.all": "ทุกภาษา", + "gallery.loading": "กำลังโหลดพรีเซ็ตจาก filee.sh…", + "gallery.failed": "เชื่อมต่อ filee.sh ไม่ได้ ตรวจสอบการเชื่อมต่ออินเทอร์เน็ตและพร็อกซีใน การตั้งค่า → ทั่วไป แล้วลองอีกครั้ง", + "gallery.empty": "ไม่มีพรีเซ็ตที่ตรงกับการค้นหาและตัวกรอง", + "gallery.none": "ยังไม่มีใครแชร์พรีเซ็ต", + "gallery.newer": "อีก {0} รายการต้องใช้ Filee เวอร์ชันใหม่กว่านี้", + "gallery.by": "โดย {0}", + "gallery.likes": "ถูกใจ {0}", + "gallery.downloads": "ดาวน์โหลด {0}", + "gallery.select": "เลือกพรีเซ็ตเพื่อดูรูปภาพและสิ่งที่พรีเซ็ตทำ", + "gallery.image": "รูปที่ {0} จาก {1}", + "gallery.what": "สิ่งที่จะเพิ่ม", + "gallery.what_group": "กลุ่ม: {0}", + "gallery.loading_details": "กำลังโหลด…", + "gallery.donut_new": "เพิ่มแถบโดนัท “{0}” สำหรับ {1} ซึ่งจะเปิดเฉพาะกับไฟล์ที่ไม่มีแถบโดนัทของคุณรับอยู่ เปลี่ยนได้ใน การตั้งค่า → แถบโดนัท", + "gallery.donut_mixed": "ไฟล์หลายชนิด", + "gallery.donut_none": "ไม่ได้อยู่บนแถบโดนัทใด เพิ่มลงในแถบใดแถบหนึ่งได้ใน การตั้งค่า → แถบโดนัท", + "gallery.license": "{0} แชร์ไว้ภายใต้ CC0: ใช้ แก้ไข และแชร์ต่อได้อย่างอิสระ", + "gallery.comments": "ความคิดเห็น", + "gallery.install": "ติดตั้ง", + "gallery.installing": "กำลังติดตั้ง…", + "gallery.installed": "เพิ่มพรีเซ็ต {0} รายการแล้ว ดูได้ในรายการพรีเซ็ต", + "gallery.installed_donut": "เพิ่มพรีเซ็ต {0} รายการและแถบโดนัท “{1}” แล้ว", + "gallery.install_failed": "ติดตั้งไม่ได้: {0}", + "gallery.not_installable": "ติดตั้งพรีเซ็ตนี้ไม่ได้: {0}", + "gallery.download_failed": "ดาวน์โหลดพรีเซ็ตไม่ได้ ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง", + "gallery.mismatch": "ไฟล์ที่ดาวน์โหลดไม่ใช่ไฟล์ที่แกลเลอรีระบุไว้ จึงไม่ได้ติดตั้งอะไรเลย", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "ไม่เกิน {0} MB", + "gallery.option.no_metadata": "ลบข้อมูลเมตา", + "gallery.option.merge": "รวมเป็นไฟล์เดียว", + "gallery.option.pages": "หน้า {0}", + "gallery.option.max_height": "สูงสุด {0}p", + "gallery.option.translate": "แปลเป็น{0}", + "gallery.option.ask_compression": "ถามระดับการบีบอัดทุกครั้ง", + "gallery.option.subfolder": "บันทึกในโฟลเดอร์ “{0}”", + "gallery.option.file_name": "ชื่อไฟล์: {0}" } diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 39612b88b..fefb7a08c 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -1849,5 +1849,67 @@ "connect.pin_label": "PIN:", "connect.firewall_hint_generic": "Not: Telefonunuz bağlanamıyorsa, sistem güvenlik duvarında Filee'ye izin verin.", "connect.no_network_message": "Filee Connect başlatılamadı. Lütfen bu cihazın yerel bir Wi-Fi veya Ethernet ağına bağlı olduğundan emin olun.", - "connect.save_as": "Farklı kaydet…" + "connect.save_as": "Farklı kaydet…", + "gallery.title": "Ön ayar galerisi", + "gallery.open": "Ön ayar galerisi", + "gallery.open_tip": "Başkalarının filee.sh'de paylaştığı ön ayarlar", + "gallery.lead": "Başkalarının filee.sh'de paylaştığı ön ayarlar. Kurduğunuzda yeni ön ayarlar olarak eklenir, sizinkiler olduğu gibi kalır. Filee, filee.sh'ye yalnızca bu pencere açıkken bağlanır.", + "gallery.sort": "Sırala", + "gallery.sort.Hot": "Popüler", + "gallery.sort.Best": "En iyi", + "gallery.sort.New": "Yeni", + "gallery.period": "Dönem", + "gallery.period.day": "Bugün", + "gallery.period.week": "Bu hafta", + "gallery.period.month": "Bu ay", + "gallery.period.year": "Bu yıl", + "gallery.period.all": "Tüm zamanlar", + "gallery.filter.kind": "Tür", + "gallery.kind.all": "Tüm türler", + "gallery.kind.convert": "Dönüştürmeler", + "gallery.kind.tool": "Araçlar", + "gallery.kind.toolbar": "Halka araç çubukları", + "gallery.filter.format": "Biçim", + "gallery.format.all": "Tüm biçimler", + "gallery.filter.tag": "Etiket", + "gallery.tag.all": "Tüm etiketler", + "gallery.filter.language": "Dil", + "gallery.language.all": "Tüm diller", + "gallery.loading": "Ön ayarlar filee.sh'den yükleniyor…", + "gallery.failed": "filee.sh'ye ulaşılamadı. İnternet bağlantısını ve Ayarlar → Genel'deki proxy'yi kontrol edip yeniden deneyin.", + "gallery.empty": "Aramaya ve filtrelere uyan ön ayar yok.", + "gallery.none": "Henüz paylaşılmış ön ayar yok.", + "gallery.newer": "{0} tanesi daha Filee'nin daha yeni bir sürümünü gerektiriyor.", + "gallery.by": "{0} tarafından", + "gallery.likes": "{0} beğeni", + "gallery.downloads": "{0} indirme", + "gallery.select": "Görsellerini ve ne yaptığını görmek için bir ön ayar seçin.", + "gallery.image": "Görsel {0}/{1}", + "gallery.what": "Eklenecekler", + "gallery.what_group": "Grup: {0}", + "gallery.loading_details": "Yükleniyor…", + "gallery.donut_new": "{1} için “{0}” halka araç çubuğunu ekler. Yalnızca hiçbir halka araç çubuğunuzun almadığı dosyalarda açılır; bunu Ayarlar → Halka araç çubuğu bölümünden değiştirebilirsiniz.", + "gallery.donut_mixed": "karışık dosyalar", + "gallery.donut_none": "Hiçbir halka araç çubuğunda yer almaz: Ayarlar → Halka araç çubuğu bölümünden birine ekleyin.", + "gallery.license": "{0} tarafından CC0 ile paylaşıldı: özgürce kullanılabilir, değiştirilebilir ve paylaşılabilir.", + "gallery.comments": "Yorumlar", + "gallery.install": "Kur", + "gallery.installing": "Kuruluyor…", + "gallery.installed": "{0} ön ayar eklendi. Ön ayarlar listesinde bulabilirsiniz.", + "gallery.installed_donut": "{0} ön ayar ve “{1}” halka araç çubuğu eklendi.", + "gallery.install_failed": "Kurulamadı: {0}", + "gallery.not_installable": "Bu ön ayar kurulamaz: {0}", + "gallery.download_failed": "Ön ayar indirilemedi. Bağlantıyı kontrol edip yeniden deneyin.", + "gallery.mismatch": "İndirilen dosya galerinin listelediği dosya değil, bu yüzden hiçbir şey kurulmadı.", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "En fazla {0} MB", + "gallery.option.no_metadata": "Meta verileri kaldırır", + "gallery.option.merge": "Tek dosyada birleştirir", + "gallery.option.pages": "Sayfalar {0}", + "gallery.option.max_height": "{0}p'ye kadar", + "gallery.option.translate": "Çevirir: {0}", + "gallery.option.ask_compression": "Sıkıştırmayı her seferinde sorar", + "gallery.option.subfolder": "“{0}” klasörüne kaydeder", + "gallery.option.file_name": "Dosya adı: {0}" } diff --git a/src/Filee.App/Assets/i18n/vi.json b/src/Filee.App/Assets/i18n/vi.json index 79ed7ebbe..e1ed763d6 100644 --- a/src/Filee.App/Assets/i18n/vi.json +++ b/src/Filee.App/Assets/i18n/vi.json @@ -1871,5 +1871,67 @@ "connect.pin_label": "PIN:", "connect.firewall_hint_generic": "Lưu ý: Nếu điện thoại không thể kết nối, hãy cho phép Filee trong tường lửa hệ thống.", "connect.no_network_message": "Không thể khởi động Filee Connect. Vui lòng đảm bảo thiết bị này được kết nối với mạng Wi-Fi hoặc Ethernet cục bộ.", - "connect.save_as": "Lưu dưới dạng…" + "connect.save_as": "Lưu dưới dạng…", + "gallery.title": "Thư viện cấu hình sẵn", + "gallery.open": "Thư viện cấu hình sẵn", + "gallery.open_tip": "Cấu hình sẵn do người khác chia sẻ trên filee.sh", + "gallery.lead": "Cấu hình sẵn do người khác chia sẻ trên filee.sh. Khi cài đặt, chúng được thêm thành cấu hình sẵn mới, còn của bạn vẫn giữ nguyên. Filee chỉ kết nối với filee.sh khi cửa sổ này đang mở.", + "gallery.sort": "Sắp xếp", + "gallery.sort.Hot": "Thịnh hành", + "gallery.sort.Best": "Hay nhất", + "gallery.sort.New": "Mới", + "gallery.period": "Khoảng thời gian", + "gallery.period.day": "Hôm nay", + "gallery.period.week": "Tuần này", + "gallery.period.month": "Tháng này", + "gallery.period.year": "Năm nay", + "gallery.period.all": "Mọi lúc", + "gallery.filter.kind": "Loại", + "gallery.kind.all": "Tất cả các loại", + "gallery.kind.convert": "Chuyển đổi", + "gallery.kind.tool": "Công cụ", + "gallery.kind.toolbar": "Thanh công cụ vòng tròn", + "gallery.filter.format": "Định dạng", + "gallery.format.all": "Tất cả định dạng", + "gallery.filter.tag": "Thẻ", + "gallery.tag.all": "Tất cả thẻ", + "gallery.filter.language": "Ngôn ngữ", + "gallery.language.all": "Tất cả ngôn ngữ", + "gallery.loading": "Đang tải cấu hình sẵn từ filee.sh…", + "gallery.failed": "Không thể kết nối với filee.sh. Hãy kiểm tra kết nối Internet và proxy trong Cài đặt → Chung, rồi thử lại.", + "gallery.empty": "Không có cấu hình sẵn nào khớp với tìm kiếm và bộ lọc.", + "gallery.none": "Chưa có ai chia sẻ cấu hình sẵn.", + "gallery.newer": "Còn {0} mục cần phiên bản Filee mới hơn.", + "gallery.by": "của {0}", + "gallery.likes": "{0} lượt thích", + "gallery.downloads": "{0} lượt tải", + "gallery.select": "Chọn một cấu hình sẵn để xem hình ảnh và chức năng của nó.", + "gallery.image": "Hình {0} / {1}", + "gallery.what": "Những gì được thêm", + "gallery.what_group": "Nhóm: {0}", + "gallery.loading_details": "Đang tải…", + "gallery.donut_new": "Thêm thanh công cụ vòng tròn “{0}” cho {1}. Nó chỉ mở với các tệp mà không thanh nào của bạn đảm nhận; bạn có thể đổi điều này trong Cài đặt → Thanh công cụ vòng tròn.", + "gallery.donut_mixed": "tệp hỗn hợp", + "gallery.donut_none": "Không nằm trên thanh công cụ vòng tròn nào: hãy thêm vào một thanh trong Cài đặt → Thanh công cụ vòng tròn.", + "gallery.license": "Được {0} chia sẻ theo CC0: tự do sử dụng, chỉnh sửa và chia sẻ.", + "gallery.comments": "Bình luận", + "gallery.install": "Cài đặt", + "gallery.installing": "Đang cài đặt…", + "gallery.installed": "Đã thêm {0} cấu hình sẵn. Bạn có thể tìm thấy chúng trong danh sách cấu hình sẵn.", + "gallery.installed_donut": "Đã thêm {0} cấu hình sẵn và thanh công cụ vòng tròn “{1}”.", + "gallery.install_failed": "Không thể cài đặt: {0}", + "gallery.not_installable": "Không thể cài đặt cấu hình sẵn này: {0}", + "gallery.download_failed": "Không thể tải cấu hình sẵn xuống. Hãy kiểm tra kết nối rồi thử lại.", + "gallery.mismatch": "Tệp đã tải không phải tệp mà thư viện liệt kê, nên không có gì được cài đặt.", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "Tối đa {0} MB", + "gallery.option.no_metadata": "Xóa siêu dữ liệu", + "gallery.option.merge": "Gộp thành một tệp", + "gallery.option.pages": "Trang {0}", + "gallery.option.max_height": "Tối đa {0}p", + "gallery.option.translate": "Dịch sang {0}", + "gallery.option.ask_compression": "Hỏi mức nén mỗi lần", + "gallery.option.subfolder": "Lưu vào thư mục “{0}”", + "gallery.option.file_name": "Tên tệp: {0}" } diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index e3b1176ef..32d7c8bda 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -1871,5 +1871,67 @@ "connect.pin_label": "PIN 码:", "connect.firewall_hint_generic": "注意:如果您的手机无法连接,请在系统防火墙中允许 Filee。", "connect.no_network_message": "无法启动 Filee Connect。请确保此设备已连接到本地 Wi-Fi 或有线以太网。", - "connect.save_as": "另存为…" + "connect.save_as": "另存为…", + "gallery.title": "预设广场", + "gallery.open": "预设广场", + "gallery.open_tip": "其他人在 filee.sh 上分享的预设", + "gallery.lead": "其他人在 filee.sh 上分享的预设。安装后会作为新预设添加,你自己的预设保持不变。只有在此窗口打开时,Filee 才会连接 filee.sh。", + "gallery.sort": "排序", + "gallery.sort.Hot": "热门", + "gallery.sort.Best": "最佳", + "gallery.sort.New": "最新", + "gallery.period": "时间段", + "gallery.period.day": "今天", + "gallery.period.week": "本周", + "gallery.period.month": "本月", + "gallery.period.year": "今年", + "gallery.period.all": "全部时间", + "gallery.filter.kind": "类型", + "gallery.kind.all": "所有类型", + "gallery.kind.convert": "转换", + "gallery.kind.tool": "工具", + "gallery.kind.toolbar": "环形工具栏", + "gallery.filter.format": "格式", + "gallery.format.all": "所有格式", + "gallery.filter.tag": "标签", + "gallery.tag.all": "所有标签", + "gallery.filter.language": "语言", + "gallery.language.all": "所有语言", + "gallery.loading": "正在从 filee.sh 加载预设…", + "gallery.failed": "无法连接 filee.sh。请检查网络连接以及 设置 → 常规 中的代理,然后重试。", + "gallery.empty": "没有符合搜索和筛选条件的预设。", + "gallery.none": "还没有人分享预设。", + "gallery.newer": "另有 {0} 个需要更新版本的 Filee。", + "gallery.by": "作者:{0}", + "gallery.likes": "{0} 个赞", + "gallery.downloads": "{0} 次下载", + "gallery.select": "选择一个预设,查看它的图片和作用。", + "gallery.image": "第 {0} 张,共 {1} 张", + "gallery.what": "将添加的内容", + "gallery.what_group": "组:{0}", + "gallery.loading_details": "正在加载…", + "gallery.donut_new": "添加用于 {1} 的环形工具栏“{0}”。它只会在你的环形工具栏都不处理的文件上打开;可在“设置 → 环形工具栏”中更改。", + "gallery.donut_mixed": "混合文件", + "gallery.donut_none": "不在任何环形工具栏上:可在“设置 → 环形工具栏”中添加。", + "gallery.license": "由 {0} 以 CC0 分享:可自由使用、修改和分享。", + "gallery.comments": "评论", + "gallery.install": "安装", + "gallery.installing": "正在安装…", + "gallery.installed": "已添加 {0} 个预设,可在预设列表中找到。", + "gallery.installed_donut": "已添加 {0} 个预设和环形工具栏“{1}”。", + "gallery.install_failed": "无法安装:{0}", + "gallery.not_installable": "无法安装此预设:{0}", + "gallery.download_failed": "无法下载预设。请检查网络连接后重试。", + "gallery.mismatch": "下载的文件与预设广场列出的不一致,因此没有安装任何内容。", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "最大 {0} MB", + "gallery.option.no_metadata": "删除元数据", + "gallery.option.merge": "合并为一个文件", + "gallery.option.pages": "页面 {0}", + "gallery.option.max_height": "最高 {0}p", + "gallery.option.translate": "翻译为{0}", + "gallery.option.ask_compression": "每次询问压缩程度", + "gallery.option.subfolder": "保存到文件夹“{0}”", + "gallery.option.file_name": "文件名:{0}" } diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index c7b54bec2..58c6fa2a5 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -1871,5 +1871,67 @@ "connect.pin_label": "PIN 碼:", "connect.firewall_hint_generic": "注意:如果您的手機無法連線,請在系統防火牆中允許 Filee。", "connect.no_network_message": "無法啟動 Filee Connect。請確認此裝置已連線至本機 Wi-Fi 或乙太網路。", - "connect.save_as": "另存為…" + "connect.save_as": "另存為…", + "gallery.title": "預設廣場", + "gallery.open": "預設廣場", + "gallery.open_tip": "其他人在 filee.sh 上分享的預設", + "gallery.lead": "其他人在 filee.sh 上分享的預設。安裝後會新增為新的預設,你自己的預設維持不變。只有在此視窗開啟時,Filee 才會連線到 filee.sh。", + "gallery.sort": "排序", + "gallery.sort.Hot": "熱門", + "gallery.sort.Best": "最佳", + "gallery.sort.New": "最新", + "gallery.period": "期間", + "gallery.period.day": "今天", + "gallery.period.week": "本週", + "gallery.period.month": "本月", + "gallery.period.year": "今年", + "gallery.period.all": "所有時間", + "gallery.filter.kind": "類型", + "gallery.kind.all": "所有類型", + "gallery.kind.convert": "轉換", + "gallery.kind.tool": "工具", + "gallery.kind.toolbar": "環形工具列", + "gallery.filter.format": "格式", + "gallery.format.all": "所有格式", + "gallery.filter.tag": "標籤", + "gallery.tag.all": "所有標籤", + "gallery.filter.language": "語言", + "gallery.language.all": "所有語言", + "gallery.loading": "正在從 filee.sh 載入預設…", + "gallery.failed": "無法連線到 filee.sh。請檢查網路連線以及 設定 → 一般 中的 Proxy,然後再試一次。", + "gallery.empty": "沒有符合搜尋與篩選條件的預設。", + "gallery.none": "還沒有人分享預設。", + "gallery.newer": "另有 {0} 個需要較新版本的 Filee。", + "gallery.by": "作者:{0}", + "gallery.likes": "{0} 個讚", + "gallery.downloads": "{0} 次下載", + "gallery.select": "選擇一個預設,即可查看它的圖片與用途。", + "gallery.image": "第 {0} 張,共 {1} 張", + "gallery.what": "會新增的項目", + "gallery.what_group": "群組:{0}", + "gallery.loading_details": "正在載入…", + "gallery.donut_new": "新增用於 {1} 的環形工具列「{0}」。只有在你的環形工具列都不處理的檔案上才會開啟;可在「設定 → 環形工具列」中變更。", + "gallery.donut_mixed": "混合檔案", + "gallery.donut_none": "不在任何環形工具列上:可在「設定 → 環形工具列」中加入。", + "gallery.license": "由 {0} 以 CC0 分享:可自由使用、修改與分享。", + "gallery.comments": "留言", + "gallery.install": "安裝", + "gallery.installing": "正在安裝…", + "gallery.installed": "已新增 {0} 個預設,可在預設清單中找到。", + "gallery.installed_donut": "已新增 {0} 個預設與環形工具列「{1}」。", + "gallery.install_failed": "無法安裝:{0}", + "gallery.not_installable": "無法安裝此預設:{0}", + "gallery.download_failed": "無法下載預設。請檢查網路連線後再試一次。", + "gallery.mismatch": "下載的檔案與預設廣場列出的不同,因此沒有安裝任何項目。", + "gallery.option.size": "{0} × {1} px", + "gallery.option.pixels": "{0} px", + "gallery.option.max_size": "最大 {0} MB", + "gallery.option.no_metadata": "移除中繼資料", + "gallery.option.merge": "合併成一個檔案", + "gallery.option.pages": "頁面 {0}", + "gallery.option.max_height": "最高 {0}p", + "gallery.option.translate": "翻譯成{0}", + "gallery.option.ask_compression": "每次詢問壓縮程度", + "gallery.option.subfolder": "儲存到資料夾「{0}」", + "gallery.option.file_name": "檔名:{0}" } diff --git a/src/Filee.App/Services/PresetGalleryClient.cs b/src/Filee.App/Services/PresetGalleryClient.cs new file mode 100644 index 000000000..836a85f00 --- /dev/null +++ b/src/Filee.App/Services/PresetGalleryClient.cs @@ -0,0 +1,203 @@ +// Talks to the preset gallery on filee.sh (#1530), and only while its window is open: index.json, the pictures, and a +// preset's preset.json (to show what it does, and through the download link to install it, which the site counts). +// Every path in index.json is relative to the address it came from, so the same code reads filee.sh's index and the +// repository's. +// Nothing else is sent. Requests go through the proxy of Settings → General (HttpClient's default proxy, see +// ProxySetting), each with a time limit and a size limit, and only to filee.sh or, when filee.sh can't be reached, +// to the gallery's repository on GitHub. Pictures are kept in the work folder (gallery/) up to a size, so the window +// opens quickly the next time. + +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using Filee.Core.Gallery; +using Filee.Core.Platform; + +namespace Filee.App.Services; + +public sealed class PresetGalleryClient : IDisposable +{ + /// The list the app and the site show. + public const string IndexUrl = "https://filee.sh/presets/index.json"; + + /// The gallery repository's own index.json, read when filee.sh can't be reached. + public const string RepositoryIndexUrl = "https://raw.githubusercontent.com/KnifeLemon/Filee-Presets/main/index.json"; + + /// A shared preset's comments and likes. + public const string DiscussionsUrl = "https://github.com/KnifeLemon/Filee-Presets/discussions/"; + + /// How long one request may take, from asking to the last byte. + public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(20); + + /// Largest thumbnail and picture read. + public const int MaxThumbBytes = SharedPresetFolderCheck.MaxThumbBytes, MaxImageBytes = SharedPresetFolderCheck.MaxImageBytes; + + /// How much the picture cache may hold before the oldest pictures go, and how long one is kept. + public const long MaxCacheBytes = 64L * 1024 * 1024; + public static readonly TimeSpan MaxCacheAge = TimeSpan.FromDays(30); + + private readonly HttpClient _http; + private readonly string? _cache; + private readonly Lock _cacheGate = new(); + private Uri _index = new(IndexUrl); + private bool _fromRepository; + + /// The HTTP handler (tests pass one that answers in place of the network); the default + /// follows the proxy setting. + /// Where pictures are kept; the work folder's gallery/ when null, none when empty. + public PresetGalleryClient(HttpMessageHandler? handler = null, string? cacheFolder = null) + { + _http = new HttpClient(handler ?? new HttpClientHandler(), disposeHandler: true) { Timeout = RequestTimeout + TimeSpan.FromSeconds(5) }; + _http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Filee", UpdateService.CurrentVersion)); + _cache = cacheFolder ?? Path.Combine(WorkFolders.Root, "gallery"); + if (_cache.Length == 0) + _cache = null; + } + + /// The index came from the repository on GitHub: filee.sh couldn't be reached (no counts then). + public bool FromRepository => _fromRepository; + + /// The list of shared presets, from filee.sh or else from the repository. + public async Task GetIndexAsync(CancellationToken cancellationToken) + { + try + { + var index = GalleryIndex.Parse(await GetBytesAsync(new Uri(IndexUrl), GalleryIndex.MaxBytes, cancellationToken)); + (_index, _fromRepository) = (new Uri(IndexUrl), false); + return index; + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException or InvalidDataException or System.Text.Json.JsonException + && !cancellationToken.IsCancellationRequested) + { + var index = GalleryIndex.Parse(await GetBytesAsync(new Uri(RepositoryIndexUrl), GalleryIndex.MaxBytes, cancellationToken)); + (_index, _fromRepository) = (new Uri(RepositoryIndexUrl), true); + return index; + } + } + + /// + /// A picture of (one of its or its thumbnail), from the + /// cache when it has it for this version of the preset. + /// + public async Task GetImageAsync(GalleryEntry entry, string path, CancellationToken cancellationToken) + { + if (path != entry.Thumb && !entry.Images.Contains(path)) + throw new ArgumentException("Not one of the preset's pictures.", nameof(path)); + var max = path == entry.Thumb ? MaxThumbBytes : MaxImageBytes; + var key = CacheKey(entry, path); + if (ReadCache(key, max) is { } cached) + return cached; + var data = await GetBytesAsync(FileUri(path), max, cancellationToken); + WriteCache(key, data); + return data; + } + + /// The preset's preset.json as the gallery has it, to show what it does (not counted as a download). + public Task GetPresetAsync(GalleryEntry entry, CancellationToken cancellationToken) => + GetBytesAsync(FileUri(entry.Preset), SharedBundleCheck.MaxBytes, cancellationToken); + + /// + /// The preset's preset.json through its install link, which filee.sh counts; from the repository (which has none) + /// the file itself. Either way the installer checks it against the SHA-256 the index lists. + /// + public Task DownloadPresetAsync(GalleryEntry entry, CancellationToken cancellationToken) => + GetBytesAsync(entry.Download is { } link && OnIndexHost(new Uri(_index, link)) is { } download ? download : FileUri(entry.Preset), + SharedBundleCheck.MaxBytes, cancellationToken); + + /// The page of a shared preset's discussion. + public static Uri DiscussionUri(int number) => new(DiscussionsUrl + number.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + // index.json's paths were checked to be plain relative paths into the preset's folder (GalleryIndex), so joining them + // to the index's address stays below it; the host is checked once more all the same. + private Uri FileUri(string path) => + OnIndexHost(new Uri(_index, path)) ?? throw new InvalidDataException("A gallery path leaves the gallery's address."); + + private Uri? OnIndexHost(Uri uri) => + uri.Scheme == Uri.UriSchemeHttps && string.Equals(uri.Host, _index.Host, StringComparison.OrdinalIgnoreCase) ? uri : null; + + private async Task GetBytesAsync(Uri uri, int maxBytes, CancellationToken cancellationToken) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(RequestTimeout); + using var response = await _http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, timeout.Token); + response.EnsureSuccessStatusCode(); + // A redirect (the download link's) must stay on HTTPS. + if (response.RequestMessage?.RequestUri is { Scheme: not "https" }) + throw new HttpRequestException("The gallery redirected to an address that isn't HTTPS."); + if (response.Content.Headers.ContentLength > maxBytes) + throw new InvalidDataException($"{uri.AbsolutePath} is larger than {maxBytes:N0} bytes."); + await using var stream = await response.Content.ReadAsStreamAsync(timeout.Token); + using var buffer = new MemoryStream(); + var chunk = new byte[81920]; + int read; + while ((read = await stream.ReadAsync(chunk, timeout.Token)) > 0) + { + if (buffer.Length + read > maxBytes) + throw new InvalidDataException($"{uri.AbsolutePath} is larger than {maxBytes:N0} bytes."); + buffer.Write(chunk, 0, read); + } + return buffer.ToArray(); + } + + /// One file per picture and version of the preset (its ). + private static string CacheKey(GalleryEntry entry, string path) => + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes($"{path}|{entry.Updated.ToUnixTimeSeconds()}")))[..32] + ".img"; + + private byte[]? ReadCache(string key, int maxBytes) + { + if (_cache is null) + return null; + try + { + var file = new FileInfo(Path.Combine(_cache, key)); + if (!file.Exists || file.Length > maxBytes || DateTime.UtcNow - file.LastWriteTimeUtc > MaxCacheAge) + return null; + var data = File.ReadAllBytes(file.FullName); + file.LastAccessTimeUtc = DateTime.UtcNow; + return data; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } + + private void WriteCache(string key, byte[] data) + { + if (_cache is null) + return; + lock (_cacheGate) + { + try + { + Directory.CreateDirectory(_cache); + var path = Path.Combine(_cache, key); + var temp = path + ".tmp"; + File.WriteAllBytes(temp, data); + File.Move(temp, path, overwrite: true); + Trim(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // A cache that can't be written only means the pictures are downloaded again. + } + } + } + + /// Deletes pictures older than , then the least recently used beyond the limit. + private void Trim() + { + var files = new DirectoryInfo(_cache!).GetFiles("*.img") + .OrderByDescending(f => f.LastAccessTimeUtc > f.LastWriteTimeUtc ? f.LastAccessTimeUtc : f.LastWriteTimeUtc) + .ToList(); + long total = 0; + foreach (var file in files) + { + total += file.Length; + if (total > MaxCacheBytes || DateTime.UtcNow - file.LastWriteTimeUtc > MaxCacheAge) + file.Delete(); + } + } + + public void Dispose() => _http.Dispose(); +} diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index 8c02df794..c5aa29d0c 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -852,6 +852,52 @@ public void ShowWhatsNew(Controls.SlideDeck deck) internal WhatsNewWindow? WhatsNew => _whatsNew; + private PresetGalleryWindow? _gallery; + + internal PresetGalleryWindow? Gallery => _gallery; + + /// + /// Opens the preset gallery (#1530), centred on the main window; a second call brings the open one to the front. + /// filee.sh is contacted from now until it closes. is told after each install. + /// + public void ShowPresetGallery(Action? installed = null) + { + if (_gallery is not null) + { + _gallery.Activate(); + return; + } + var client = new PresetGalleryClient(); + PresetGalleryWindow? window = null; + var vm = new PresetGalleryViewModel(client, services.GetRequiredService(), loc, PresetGalleryViewModel.CurrentVersion, + async uri => + { + if (window is not null) + await window.Launcher.LaunchUriAsync(uri); + }, + installed); + window = new PresetGalleryWindow { DataContext = vm }; + PrepareWindow(window); + window.Closed += (_, _) => + { + if (_gallery == window) + _gallery = null; + vm.Dispose(); + client.Dispose(); + trimmer.Request(); + }; + _gallery = window; + if (_main is { IsVisible: true } main && main.WindowState != WindowState.Minimized) + window.Show(main); + else + { + window.WindowStartupLocation = WindowStartupLocation.CenterScreen; + window.Show(); + } + window.Activate(); + _ = vm.LoadAsync(); + } + /// Opens the optional-engine choice (first run); a second call brings the open window to front. /// /// Shows the engine choice. With (packages picked in the installer) the downloads start diff --git a/src/Filee.App/ViewModels/GallerySummaries.cs b/src/Filee.App/ViewModels/GallerySummaries.cs new file mode 100644 index 000000000..b683d2695 --- /dev/null +++ b/src/Filee.App/ViewModels/GallerySummaries.cs @@ -0,0 +1,129 @@ +// What a shared preset does, in a line or two for the preset gallery's detail view (#1530): its target or tool and the +// options it changes from the defaults, in the words of the preset editor where it has them. + +using System.Globalization; +using Filee.Core.Formats; +using Filee.Core.Localization; +using Filee.Core.Presets; + +namespace Filee.App.ViewModels; + +internal static class GallerySummaries +{ + /// Most options listed for one preset. + private const int MaxOptions = 8; + + private static readonly IReadOnlyDictionary ToolNames = new Dictionary + { + [Preset.CropTool] = "preset.crop", + [Preset.ClipTool] = "preset.clip", + [Preset.ImageEditTool] = "preset.image_edit", + [Preset.HideTool] = "preset.hide", + [Preset.AnnotateTool] = "preset.annotate", + [Preset.BackgroundTool] = "preset.background", + [Preset.UpscaleTool] = "preset.upscale", + }; + + private static readonly HashSet LossyPictures = ["jpg", "webp", "avif", "heic", "jxl"]; + + /// The name, what does and its main options; names a group's. + public static PresetGallerySummary Describe(Preset preset, IReadOnlyList all, ILocalizer loc) + { + var what = preset.Kind switch + { + PresetKind.Group => loc.Format("gallery.what_group", string.Join(", ", + preset.SubPresetIds.Select(id => all.FirstOrDefault(p => p.Id == id)).OfType().Select(loc.DisplayName))), + PresetKind.Tool => loc[ToolNames.GetValueOrDefault(preset.Tool ?? Preset.CropTool, "preset.crop")], + _ => preset.TargetFormat == BuiltInData.SameAsSource + ? loc["presets.same_as_source"] + : "→ " + (FormatLabels.NameOf(loc, preset.TargetFormat) ?? preset.TargetFormat.ToUpperInvariant()), + }; + var options = preset.IsGroup ? [] : Options(preset, loc).Take(MaxOptions); + return new PresetGallerySummary(loc.DisplayName(preset), what, string.Join(" · ", options)); + } + + private static IEnumerable Options(Preset preset, ILocalizer loc) + { + var category = FormatRegistry.FindById(preset.TargetFormat)?.Category; + var pictures = category == FormatCategory.Image || preset.TargetFormat == BuiltInData.SameAsSource || preset.IsTool; + var image = preset.Image; + if (pictures) + { + if (LossyPictures.Contains(preset.TargetFormat) && image.Quality != new ImageOptions().Quality) + yield return $"{loc["presets.quality"]} {image.Quality}"; + if (image.CustomSize is { } size) + yield return $"{loc["presets.resize.Custom"]} {loc.Format("gallery.option.size", size.ClampedWidth, size.ClampedHeight)}"; + else if (image.ClampedMinShortEdge is { } edge) + yield return $"{loc["presets.resize.MinShortEdge"]} {loc.Format("gallery.option.pixels", edge)}"; + else if (image.Resize == ResizeMode.Percent) + yield return $"{loc["presets.resize.Percent"]} {image.ResizePercent}%"; + else if (image.Resize == ResizeMode.LongEdge) + yield return $"{loc["presets.resize.LongEdge"]} {loc.Format("gallery.option.pixels", image.LongEdge)}"; + else if (image.Resize == ResizeMode.Width) + yield return $"{loc["presets.resize.Width"]} {loc.Format("gallery.option.pixels", image.Width)}"; + if (image.Rotation != ImageRotation.None) + yield return loc[$"presets.rotation.{image.Rotation}"]; + if (image.Flip != ImageFlip.None) + yield return loc[$"presets.flip.{image.Flip}"]; + if (image.Grayscale) + yield return loc["presets.grayscale"]; + if (image.RemoveBackground) + yield return loc["presets.remove_background"]; + if (image.MaxSizeMb > 0) + yield return loc.Format("gallery.option.max_size", image.MaxSizeMb.ToString("0.##", CultureInfo.CurrentCulture)); + if (!image.KeepMetadata) + yield return loc["gallery.option.no_metadata"]; + } + + var pdf = preset.Pdf; + if (pdf.MergeIntoSingle || image.JoinIntoOne || preset.Media.JoinIntoOne || preset.Archive.CombineIntoOne) + yield return loc["gallery.option.merge"]; + if (preset.TargetFormat == "pdf") + { + if (pdf.Compression != PdfCompression.None) + yield return loc[$"presets.pdf_compression.{pdf.Compression}"]; + if (pdf.MakesSheets) + yield return $"{loc["presets.pages_per_sheet"]} {pdf.PagesPerSheet}"; + if (pdf.SplitPages) + yield return loc["presets.split"]; + if (pdf.Ocr) + yield return loc["presets.ocr"]; + if (pdf.Watermark.Length > 0) + yield return $"{loc["presets.watermark"]}: “{pdf.Watermark}”"; + } + if (pdf.PageRange.Length > 0) + yield return loc.Format("gallery.option.pages", pdf.PageRange); + + if (category is FormatCategory.Video or FormatCategory.Audio) + { + var media = preset.Media; + if (media.Quality != MediaQuality.Balanced) + yield return $"{loc["presets.media_quality"]}: {loc[$"presets.media_quality.{media.Quality}"]}"; + if (media.MaxHeight > 0) + yield return loc.Format("gallery.option.max_height", media.MaxHeight); + if (media.RemoveAudio) + yield return loc["presets.remove_audio"]; + } + + if (preset.Translate.Enabled) + yield return loc.Format("gallery.option.translate", LanguageName(preset.Translate.TargetLanguage)); + if (preset.AskCompression) + yield return loc["gallery.option.ask_compression"]; + if (preset.Output.Location == OutputLocation.Subfolder) + yield return loc.Format("gallery.option.subfolder", preset.Output.SubfolderName); + if (preset.Output.FileNamePattern is { Length: > 0 } pattern && pattern != "{name}") + yield return loc.Format("gallery.option.file_name", pattern); + } + + private static string LanguageName(string code) + { + try + { + return CultureInfo.GetCultureInfo(code).NativeName; + } + catch (CultureNotFoundException) + { + return code; + } + } +} diff --git a/src/Filee.App/ViewModels/Pages/PresetsPageViewModel.cs b/src/Filee.App/ViewModels/Pages/PresetsPageViewModel.cs index 34435aa45..a5630b76a 100644 --- a/src/Filee.App/ViewModels/Pages/PresetsPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/PresetsPageViewModel.cs @@ -129,6 +129,14 @@ private async Task Delete() SelectedPreset = Presets.Count == 0 ? null : Presets[Math.Clamp(index, 0, Presets.Count - 1)]; } + /// Opens the preset gallery; what it installs joins the list. + [RelayCommand] + private void OpenGallery() => _windows.ShowPresetGallery(result => + { + foreach (var preset in result.Presets.Where(p => !p.IsGroup && (!p.IsTool || p.IsUpscaleTool))) + Presets.Add(new PresetListItem(preset, _loc)); + }); + private void AddPreset(Preset preset) { _store.Presets.Add(preset); diff --git a/src/Filee.App/ViewModels/PresetGalleryViewModel.cs b/src/Filee.App/ViewModels/PresetGalleryViewModel.cs new file mode 100644 index 000000000..e5fed571d --- /dev/null +++ b/src/Filee.App/ViewModels/PresetGalleryViewModel.cs @@ -0,0 +1,594 @@ +// The "Preset gallery" window (#1530): presets other people shared on filee.sh, as cards with their cover, sorted by +// Hot, Best (of a day, week, month, year or all time) or New and filtered by kind, format, tag, language and text. The +// one chosen shows its pictures, its description and what each of its presets does, and installs as new presets (see +// GalleryInstaller). Presets that need a newer Filee are left out and counted. filee.sh is contacted only while the +// window is open (PresetGalleryClient). + +using System.Collections.ObjectModel; +using System.Globalization; +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Filee.App.Services; +using Filee.Core.Gallery; +using Filee.Core.Localization; +using Filee.Core.Settings; + +namespace Filee.App.ViewModels; + +/// The orders the gallery offers. +public enum GallerySort +{ + /// Likes and downloads of late count most (the site's score). + Hot, + + /// The most liked and downloaded of a period. + Best, + + /// The newest first. + New, +} + +/// Where the list is: still loading, shown, or filee.sh couldn't be reached. +public enum GalleryState +{ + Loading, + Ready, + Failed, +} + +public sealed partial class PresetGalleryViewModel : ObservableObject, IDisposable +{ + /// The filter value meaning "any". + public const string Any = ""; + + /// Thumbnails loading at the same time. + private const int ThumbsAtOnce = 4; + + private readonly PresetGalleryClient _client; + private readonly UserDataStore _store; + private readonly ILocalizer _loc; + private readonly Version _appVersion; + private readonly Func _open; + private readonly Action? _installed; + private readonly CancellationTokenSource _closing = new(); + private readonly List _all = []; + private bool _refreshing; + + /// This Filee's version: presets that need a newer one are left out. + /// Opens a page in the browser (the comments). + /// Told after an install added presets, so the Presets page lists them. + public PresetGalleryViewModel(PresetGalleryClient client, UserDataStore store, ILocalizer loc, Version appVersion, + Func open, Action? installed = null) + { + _client = client; + _store = store; + _loc = loc; + _appVersion = appVersion; + _open = open; + _installed = installed; + Sorts = [.. Enum.GetValues().Select(s => new Choice(s, loc[$"gallery.sort.{s}"]))]; + _sort = Sorts[0]; + Periods = [.. GalleryStats.Periods.Select(p => new Choice(p, loc[$"gallery.period.{p}"]))]; + _period = Periods[1]; + Kinds = [new(Any, loc["gallery.kind.all"]), .. SharedPresetFolderCheck.Kinds.Select(k => new Choice(k, loc[$"gallery.kind.{k}"]))]; + _kind = Kinds[0]; + Formats = [new(Any, loc["gallery.format.all"])]; + _format = Formats[0]; + Tags = [new(Any, loc["gallery.tag.all"])]; + _tag = Tags[0]; + Languages = [new(Any, loc["gallery.language.all"])]; + _language = Languages[0]; + } + + /// This Filee's version, as the update check reads it (a pre-release counts as its release). + public static Version CurrentVersion => + Version.TryParse(UpdateService.CurrentVersion.Split('-')[0], out var version) ? version : new Version(0, 0, 0); + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsLoading), nameof(IsFailed), nameof(IsReady), nameof(IsEmpty), nameof(EmptyText))] + private GalleryState _state = GalleryState.Loading; + + public bool IsLoading => State == GalleryState.Loading; + public bool IsFailed => State == GalleryState.Failed; + public bool IsReady => State == GalleryState.Ready; + + /// The list is shown but nothing is in it (none shared yet, or none matches the filters). + public bool IsEmpty => IsReady && Cards.Count == 0; + + public string EmptyText => _all.Count == 0 ? _loc["gallery.none"] : _loc["gallery.empty"]; + + /// The cards that pass the filters, in the chosen order. + public ObservableCollection Cards { get; } = []; + + public IReadOnlyList> Sorts { get; } + public IReadOnlyList> Periods { get; } + public IReadOnlyList> Kinds { get; } + public ObservableCollection> Formats { get; } + public ObservableCollection> Tags { get; } + public ObservableCollection> Languages { get; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowPeriods))] + private Choice _sort; + + /// The period of Best; the list only shows it with that order. + [ObservableProperty] private Choice _period; + + public bool ShowPeriods => Sort.Value == GallerySort.Best; + + [ObservableProperty] private Choice _kind; + [ObservableProperty] private Choice _format; + [ObservableProperty] private Choice _tag; + [ObservableProperty] private Choice _language; + [ObservableProperty] private string _search = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasSelection))] + private PresetGalleryCardViewModel? _selected; + + /// What the chosen preset is and does, with Install and Comments. + [ObservableProperty] private PresetGalleryDetailViewModel? _detail; + + public bool HasSelection => Selected is not null; + + /// "3 more need a newer Filee", or null when none does. + [ObservableProperty] private string? _newerText; + + partial void OnSortChanged(Choice value) => Refresh(); + partial void OnPeriodChanged(Choice value) => Refresh(); + partial void OnKindChanged(Choice value) => Refresh(); + partial void OnFormatChanged(Choice value) => Refresh(); + partial void OnTagChanged(Choice value) => Refresh(); + partial void OnLanguageChanged(Choice value) => Refresh(); + partial void OnSearchChanged(string value) => Refresh(); + + partial void OnSelectedChanged(PresetGalleryCardViewModel? value) + { + // The list refills while it filters: the preset stays chosen when it is still in it. + if (_refreshing) + return; + Detail?.Dispose(); + Detail = value is null ? null : new PresetGalleryDetailViewModel(value.Entry, _client, _store, _loc, _open, Installed, _closing.Token); + _ = Detail?.LoadAsync(); + } + + private void Installed(GalleryInstallResult result) => _installed?.Invoke(result); + + /// Loads (or loads again) the list from filee.sh. + [RelayCommand] + public async Task LoadAsync() + { + State = GalleryState.Loading; + GalleryIndex index; + try + { + index = await _client.GetIndexAsync(_closing.Token); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException or InvalidDataException or System.Text.Json.JsonException) + { + if (!_closing.IsCancellationRequested) + State = GalleryState.Failed; + return; + } + + foreach (var card in _all) + card.Dispose(); + _all.Clear(); + var newer = 0; + foreach (var entry in index.Entries) + { + if (entry.MinVersion > _appVersion) + newer++; + else + _all.Add(new PresetGalleryCardViewModel(entry, _loc)); + } + NewerText = newer == 0 ? null : _loc.Format("gallery.newer", newer); + Fill(Formats, _all.SelectMany(c => c.Entry.FormatsFrom.Concat(c.Entry.FormatsTo)), id => FormatLabels.NameOf(_loc, id) ?? id.ToUpperInvariant()); + Fill(Tags, _all.SelectMany(c => c.Entry.Tags), tag => tag); + Fill(Languages, _all.SelectMany(c => c.Entry.Languages), LanguageName); + State = GalleryState.Ready; + Refresh(); + _ = LoadThumbsAsync(); + } + + /// Fills a filter's list after its "any" item with the values the presets have, by their names. + private static void Fill(ObservableCollection> list, IEnumerable values, Func label) + { + while (list.Count > 1) + list.RemoveAt(list.Count - 1); + foreach (var value in values.Distinct(StringComparer.Ordinal).Select(v => new Choice(v, label(v))).OrderBy(c => c.Label, StringComparer.CurrentCultureIgnoreCase)) + list.Add(value); + } + + private static string LanguageName(string code) + { + try + { + var culture = CultureInfo.GetCultureInfo(code); + return culture.EnglishName == code || culture.NativeName.Length == 0 ? code : culture.NativeName; + } + catch (CultureNotFoundException) + { + return code; + } + } + + /// Applies the filters and the order to the list. + private void Refresh() + { + if (State != GalleryState.Ready) + return; + var query = Search.Trim(); + var cards = _all.Where(c => + (Kind.Value == Any || c.Entry.Kinds.Contains(Kind.Value)) + && (Format.Value == Any || c.Entry.FormatsFrom.Contains(Format.Value) || c.Entry.FormatsTo.Contains(Format.Value)) + && (Tag.Value == Any || c.Entry.Tags.Contains(Tag.Value)) + && (Language.Value == Any || c.Entry.Languages.Contains(Language.Value)) + && (query.Length == 0 || c.Matches(query))); + var sorted = Sort.Value switch + { + GallerySort.Hot => cards.OrderByDescending(c => c.Entry.Stats.Hot), + GallerySort.Best => cards.OrderByDescending(c => c.Entry.Stats.BestOf(Period.Value)).ThenByDescending(c => c.Entry.Stats.Likes), + _ => cards.OrderByDescending(c => c.Entry.Created), + }; + var ordered = sorted.ThenByDescending(c => c.Entry.Created).ThenBy(c => c.Title, StringComparer.CurrentCultureIgnoreCase).ToList(); + + var selected = Selected; + _refreshing = true; + try + { + Cards.Clear(); + foreach (var card in ordered) + Cards.Add(card); + Selected = selected is not null && ordered.Contains(selected) ? selected : null; + } + finally + { + _refreshing = false; + } + if (Selected != selected) + OnSelectedChanged(Selected); + OnPropertyChanged(nameof(IsEmpty)); + OnPropertyChanged(nameof(EmptyText)); + } + + /// Loads the cards' covers in the order they are listed, a few at a time. + private async Task LoadThumbsAsync() + { + using var gate = new SemaphoreSlim(ThumbsAtOnce); + var tasks = Cards.ToList().Concat(_all).Distinct().Select(async card => + { + await gate.WaitAsync(_closing.Token); + try + { + await card.LoadThumbAsync(_client, _closing.Token); + } + finally + { + gate.Release(); + } + }).ToList(); + try + { + await Task.WhenAll(tasks); + } + catch (OperationCanceledException) + { + } + } + + public void Dispose() + { + _closing.Cancel(); + Detail?.Dispose(); + foreach (var card in _all) + card.Dispose(); + _closing.Dispose(); + } +} + +/// One shared preset in the list: its cover, title, author, likes, downloads and tags. +public sealed partial class PresetGalleryCardViewModel : ObservableObject, IDisposable +{ + /// The cover's size in the list, in pixels of its long edge. + public const int ThumbEdge = 480; + + private bool _loading; + + public PresetGalleryCardViewModel(GalleryEntry entry, ILocalizer loc) + { + Entry = entry; + Byline = entry.Author.Length == 0 ? "" : loc.Format("gallery.by", entry.Author); + Likes = loc.Format("gallery.likes", entry.Stats.Likes.ToString("N0", CultureInfo.CurrentCulture)); + Downloads = loc.Format("gallery.downloads", entry.Stats.Downloads.ToString("N0", CultureInfo.CurrentCulture)); + AutomationName = string.Join(", ", new[] { entry.Title, Byline, entry.HasStats ? Likes : "", entry.HasStats ? Downloads : "" }.Where(s => s.Length > 0)); + } + + public GalleryEntry Entry { get; } + public string Title => Entry.Title; + public string Byline { get; } + public IReadOnlyList Tags => Entry.Tags; + public bool HasStats => Entry.HasStats; + public string Likes { get; } + public string Downloads { get; } + + /// What a screen reader says for the card. + public string AutomationName { get; } + + [ObservableProperty] private Bitmap? _thumb; + + /// True when the query is in the title, description, author or a tag. + public bool Matches(string query) => + Entry.Title.Contains(query, StringComparison.CurrentCultureIgnoreCase) + || Entry.Description.Contains(query, StringComparison.CurrentCultureIgnoreCase) + || Entry.Author.Contains(query, StringComparison.CurrentCultureIgnoreCase) + || Entry.Tags.Any(t => t.Contains(query, StringComparison.CurrentCultureIgnoreCase)); + + /// Loads the cover's thumbnail (the cover itself when there is none); a failure leaves the placeholder. + public async Task LoadThumbAsync(PresetGalleryClient client, CancellationToken cancellationToken) + { + if (Thumb is not null || _loading || (Entry.Thumb ?? Entry.Images.FirstOrDefault()) is not { } path) + return; + _loading = true; + try + { + var data = await client.GetImageAsync(Entry, path, cancellationToken); + var bitmap = await Task.Run(() => GalleryBitmaps.Decode(data, ThumbEdge), cancellationToken); + if (cancellationToken.IsCancellationRequested) + bitmap?.Dispose(); + else + Thumb = bitmap; + } + catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException or IOException or InvalidDataException or ArgumentException) + { + } + finally + { + _loading = false; + } + } + + public void Dispose() + { + Thumb?.Dispose(); + Thumb = null; + } +} + +/// A picture of the chosen preset, with what a screen reader says for it. +public sealed partial class PresetGalleryImageViewModel(string name) : ObservableObject +{ + public string Name { get; } = name; + + [ObservableProperty] private Bitmap? _bitmap; +} + +/// One preset of a shared preset.json: its name, what it does, its main options. +public sealed record PresetGallerySummary(string Name, string What, string Options) +{ + public bool HasOptions => Options.Length > 0; +} + +/// Whether the chosen preset is being installed, was, or couldn't be. +public enum GalleryInstallState +{ + Idle, + Installing, + Installed, + Failed, +} + +/// The chosen preset: its pictures, description, what it adds, Install and Comments. +public sealed partial class PresetGalleryDetailViewModel : ObservableObject, IDisposable +{ + /// The pictures' size in the window, in pixels of the long edge. + public const int ImageEdge = 1280; + + private readonly PresetGalleryClient _client; + private readonly UserDataStore _store; + private readonly ILocalizer _loc; + private readonly Func _open; + private readonly Action _installed; + private readonly CancellationTokenSource _cancel; + + internal PresetGalleryDetailViewModel(GalleryEntry entry, PresetGalleryClient client, UserDataStore store, ILocalizer loc, + Func open, Action installed, CancellationToken closing) + { + Entry = entry; + _client = client; + _store = store; + _loc = loc; + _open = open; + _installed = installed; + _cancel = CancellationTokenSource.CreateLinkedTokenSource(closing); + Byline = entry.Author.Length == 0 ? "" : loc.Format("gallery.by", entry.Author); + License = loc.Format("gallery.license", entry.Author.Length == 0 ? "?" : entry.Author); + Stats = entry.HasStats + ? string.Join(" · ", loc.Format("gallery.likes", entry.Stats.Likes.ToString("N0", CultureInfo.CurrentCulture)), + loc.Format("gallery.downloads", entry.Stats.Downloads.ToString("N0", CultureInfo.CurrentCulture))) + : ""; + for (var i = 0; i < entry.Images.Count; i++) + Images.Add(new PresetGalleryImageViewModel(loc.Format("gallery.image", i + 1, entry.Images.Count))); + _shownImage = Images.FirstOrDefault(); + } + + public GalleryEntry Entry { get; } + public string Title => Entry.Title; + public string Byline { get; } + public string Description => Entry.Description; + public IReadOnlyList Tags => Entry.Tags; + public string Stats { get; } + public bool HasStats => Stats.Length > 0; + + /// The CC0 note, with who shared it. + public string License { get; } + + public ObservableCollection Images { get; } = []; + + /// The picture shown large; the others are thumbnails below it. + [ObservableProperty] private PresetGalleryImageViewModel? _shownImage; + + public bool HasSeveralImages => Images.Count > 1; + + /// What each preset in it does. + public ObservableCollection Presets { get; } = []; + + /// Which donut it goes on: the one it brings, or none. + [ObservableProperty] private string? _donut; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(InstallCommand))] + private bool _installable; + + /// Loading what it does, or why it can't be installed here. + [ObservableProperty] private string? _summaryNote; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(InstallCommand))] + [NotifyPropertyChangedFor(nameof(IsInstalling))] + private GalleryInstallState _installState; + + public bool IsInstalling => InstallState == GalleryInstallState.Installing; + + /// What the install did, or why it didn't. + [ObservableProperty] private string? _installMessage; + + public bool HasComments => Entry.Discussion is not null; + + /// Loads the pictures and what the preset does. + public async Task LoadAsync() + { + SummaryNote = _loc["gallery.loading_details"]; + var token = _cancel.Token; + await LoadSummaryAsync(token); + for (var i = 0; i < Entry.Images.Count && !token.IsCancellationRequested; i++) + { + try + { + var data = await _client.GetImageAsync(Entry, Entry.Images[i], token); + var bitmap = await Task.Run(() => GalleryBitmaps.Decode(data, ImageEdge), token); + if (token.IsCancellationRequested) + bitmap?.Dispose(); + else + Images[i].Bitmap = bitmap; + } + catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException or IOException or InvalidDataException or ArgumentException) + { + } + } + } + + private async Task LoadSummaryAsync(CancellationToken token) + { + byte[] json; + try + { + json = await _client.GetPresetAsync(Entry, token); + } + catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException or IOException or InvalidDataException) + { + SummaryNote = token.IsCancellationRequested ? null : _loc["gallery.download_failed"]; + return; + } + if (!string.Equals(Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(json)), Entry.Sha256, StringComparison.Ordinal)) + { + SummaryNote = _loc["gallery.mismatch"]; + return; + } + var check = SharedBundleCheck.Check(json); + if (check.Bundle is not { } bundle) + { + SummaryNote = _loc.Format("gallery.not_installable", check.Errors.FirstOrDefault()?.ToString() ?? ""); + return; + } + foreach (var preset in bundle.Presets) + Presets.Add(GallerySummaries.Describe(preset, bundle.Presets, _loc)); + Donut = bundle.Profiles.FirstOrDefault() is { } profile + ? _loc.Format("gallery.donut_new", _loc.DisplayName(profile), + profile.Extensions.Count == 0 ? _loc["gallery.donut_mixed"] : string.Join(", ", profile.Extensions.Take(8).Select(e => e.ToUpperInvariant())) + (profile.Extensions.Count > 8 ? " …" : "")) + : _loc["gallery.donut_none"]; + SummaryNote = null; + Installable = true; + } + + private bool CanInstall() => Installable && InstallState is not (GalleryInstallState.Installing or GalleryInstallState.Installed); + + /// Downloads it through the install link and adds it to the library as new presets. + [RelayCommand(CanExecute = nameof(CanInstall))] + private async Task InstallAsync() + { + InstallState = GalleryInstallState.Installing; + InstallMessage = _loc["gallery.installing"]; + byte[] json; + try + { + json = await _client.DownloadPresetAsync(Entry, _cancel.Token); + } + catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException or IOException or InvalidDataException) + { + InstallState = GalleryInstallState.Failed; + InstallMessage = _loc["gallery.download_failed"]; + return; + } + var result = GalleryInstaller.Install(_store, json, Entry.Sha256, _loc.DisplayName, _loc.DisplayName); + if (!result.Ok) + { + InstallState = GalleryInstallState.Failed; + InstallMessage = result.Errors[0].Code == "install.sha256" + ? _loc["gallery.mismatch"] + : _loc.Format("gallery.install_failed", result.Errors[0].ToString()); + return; + } + InstallState = GalleryInstallState.Installed; + InstallMessage = result.Profile is { } donut + ? _loc.Format("gallery.installed_donut", result.Presets.Count, _loc.DisplayName(donut)) + : _loc.Format("gallery.installed", result.Presets.Count); + _installed(result); + } + + /// Opens the preset's discussion (comments and likes) in the browser. + [RelayCommand] + private Task OpenCommentsAsync() => + Entry.Discussion is { } number ? _open(PresetGalleryClient.DiscussionUri(number)) : Task.CompletedTask; + + [RelayCommand] + private void ShowImage(PresetGalleryImageViewModel image) => ShownImage = image; + + public void Dispose() + { + _cancel.Cancel(); + _cancel.Dispose(); + foreach (var image in Images) + { + image.Bitmap?.Dispose(); + image.Bitmap = null; + } + } +} + +/// Turns the gallery's pictures into bitmaps, checking their header before anything decodes them. +internal static class GalleryBitmaps +{ + /// + /// A still PNG, JPEG or WebP of at most pixels, scaled to at most + /// on its long edge; null for anything else. + /// + public static Bitmap? Decode(byte[] data, int edge) + { + if (GalleryImageHeader.Read(data) is not { Animated: false } info + || Math.Max(info.Width, info.Height) > SharedPresetFolderCheck.MaxLongEdge) + return null; + try + { + using var stream = new MemoryStream(data, writable: false); + return info.Width >= info.Height + ? Bitmap.DecodeToWidth(stream, Math.Min(edge, info.Width), BitmapInterpolationMode.HighQuality) + : Bitmap.DecodeToHeight(stream, Math.Min(edge, info.Height), BitmapInterpolationMode.HighQuality); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or IOException or NullReferenceException) + { + return null; + } + } +} diff --git a/src/Filee.App/Views/Pages/PresetsPage.axaml b/src/Filee.App/Views/Pages/PresetsPage.axaml index 730257ea2..3fa146a2b 100644 --- a/src/Filee.App/Views/Pages/PresetsPage.axaml +++ b/src/Filee.App/Views/Pages/PresetsPage.axaml @@ -35,6 +35,14 @@ ToolTip.Tip="{DynamicResource presets.delete}" AutomationProperties.Name="{DynamicResource presets.delete}"> + + diff --git a/src/Filee.App/Views/PresetGalleryWindow.axaml b/src/Filee.App/Views/PresetGalleryWindow.axaml new file mode 100644 index 000000000..e2b292edc --- /dev/null +++ b/src/Filee.App/Views/PresetGalleryWindow.axaml @@ -0,0 +1,251 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Filee.App/Views/PresetGalleryWindow.axaml.cs b/src/Filee.App/Views/PresetGalleryWindow.axaml.cs new file mode 100644 index 000000000..8c0faf429 --- /dev/null +++ b/src/Filee.App/Views/PresetGalleryWindow.axaml.cs @@ -0,0 +1,31 @@ +// The preset gallery's window (#1530). Escape closes it; the search box has the keys when it opens, Tab goes on to the +// filters and the list. + +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Threading; +using Filee.App.Services; + +namespace Filee.App.Views; + +public partial class PresetGalleryWindow : Window +{ + public PresetGalleryWindow() + { + InitializeComponent(); + Motion.Track(this); + UiZoom.Track(this); + Opened += (_, _) => Dispatcher.UIThread.Post(() => SearchBox.Focus(), DispatcherPriority.Background); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + if (e.Key == Key.Escape && !e.Handled) + { + Close(); + e.Handled = true; + return; + } + base.OnKeyDown(e); + } +} diff --git a/src/Filee.Cli/Cli.cs b/src/Filee.Cli/Cli.cs index 430653089..b35b77283 100644 --- a/src/Filee.Cli/Cli.cs +++ b/src/Filee.Cli/Cli.cs @@ -1,7 +1,8 @@ -// The "filee" commands: convert, watch, formats, presets, engines. Writes results for people (one line per file) or, with -// --json, one JSON document for other programs; while it runs, convert --json writes events to stderr and watch --json -// to stdout, one JSON object per line. Exit codes: 0 nothing failed (skipped files included), 1 some failed or were -// cancelled, 2 wrong arguments or a missing input, 3 nothing to convert. +// The "filee" commands: convert, watch, formats, presets ('presets check' for the preset gallery), engines. Writes +// results for people (one line per file) or, with --json, one JSON document for other programs; while it runs, +// convert --json writes events to stderr and watch --json to stdout, one JSON object per line. Exit codes: 0 nothing +// failed (skipped files included), 1 some failed or were cancelled, 2 wrong arguments or a missing input, 3 nothing to +// convert. using System.Text.Json; using Filee.Core.Conversion; @@ -40,6 +41,9 @@ public static async Task RunAsync(IReadOnlyList args, TextWriter ou case "version": output.WriteLine($"filee {Help.Version}"); return Success; + // Checks files only: no settings, no engines. + case "presets" when options.Inputs.Count > 0: + return PresetsCheckCommand.Run(options, output, errors); } await using var host = (createHost ?? (() => CliHost.Create()))(); diff --git a/src/Filee.Cli/CliOptions.cs b/src/Filee.Cli/CliOptions.cs index f89efd20d..506eef6db 100644 --- a/src/Filee.Cli/CliOptions.cs +++ b/src/Filee.Cli/CliOptions.cs @@ -209,6 +209,10 @@ public static CliOptions Parse(IReadOnlyList args) "Use either --split-every or --split-parts.", "watch" when options.Inputs.Count != 1 => "Give exactly one folder to watch.", "watch" when options.Sort is not null => "--sort only works with 'filee convert'.", + "presets" when options.Inputs.Count > 0 && options.Inputs[0].ToLowerInvariant() != "check" => + $"Unknown presets command '{options.Inputs[0]}'. Use 'filee presets' or 'filee presets check '.", + "presets" when options.Inputs.Count == 1 => "Give the folder of the shared preset to check, e.g. 'filee presets check presets/my-preset'.", + "presets" when options.Inputs.Count > 2 => "Give one folder at a time.", "engines" when options.Inputs.Count > 0 && options.Inputs[0].ToLowerInvariant() is not ("install" or "remove" or "download") => $"Unknown engines command '{options.Inputs[0]}'. Use 'filee engines', 'filee engines install ', 'filee engines download -o ' or 'filee engines remove '.", "engines" when options.Inputs.Count == 1 => $"Give the engine to {options.Inputs[0].ToLowerInvariant()}, e.g. 'filee engines {options.Inputs[0].ToLowerInvariant()} pandoc'.", diff --git a/src/Filee.Cli/Help.cs b/src/Filee.Cli/Help.cs index 3108aca69..0116da169 100644 --- a/src/Filee.Cli/Help.cs +++ b/src/Filee.Cli/Help.cs @@ -16,8 +16,7 @@ internal static class Help "watch" => Watch, "formats" => "Usage: filee formats [] [--json]\n\nLists every format, or what one format converts to.\n" + "With --json, prints the same as JSON (for scripts and other programs).\n", - "presets" => "Usage: filee presets [--json]\n\nLists the presets saved in Filee (built-in and your own).\n" + - "With --json, prints the same as JSON (for scripts and other programs).\n", + "presets" => Presets, "engines" => Engines, _ => General, }; @@ -31,6 +30,7 @@ filee convert --preset [options] filee watch --to [options] filee formats [] [--json] filee presets [--json] + filee presets check [--json] filee engines [--json] filee engines (install | remove) [--json] filee engines download -o [--json] @@ -50,6 +50,29 @@ filee engines install pandoc """; + private const string Presets = """ + Usage: filee presets [--json] + filee presets check [--json] [--quiet] + + Lists the presets saved in Filee (built-in and your own). With --json, prints the same as JSON (for scripts + and other programs). + + 'check' checks a shared preset for the preset gallery: a folder with preset.json (what Settings → General → + Export writes, with at most 20 presets and one donut), meta.json (optional) and 1 to 4 pictures in images/ + (1.png … 4.webp; PNG, JPEG or WebP, 640 to 2,560 pixels on the long edge, at most 2 MB, not animated). + Only fields on the gallery's list may be in preset.json, and nothing that saves outside the source's folder, + runs a program, or carries a path, a password or a link. It reads nothing else and changes nothing. + Exit codes: 0 the preset may be shared, 1 it has problems, 2 the folder doesn't exist. + + Options: + --json Print the result as JSON: "ok", "errors" (each with "code", "file", "path" and + "message") and "derived" (what the preset is: "kinds", "formats" with "from" and + "to", "minVersion", the number of "presets" and "profiles", and the "sha256" of + preset.json) + --quiet Print nothing when the preset passes + + """; + private const string Engines = """ Usage: filee engines [--json] filee engines install [--from ...] [--json] [--quiet] diff --git a/src/Filee.Cli/PresetsCheckCommand.cs b/src/Filee.Cli/PresetsCheckCommand.cs new file mode 100644 index 000000000..9c7508827 --- /dev/null +++ b/src/Filee.Cli/PresetsCheckCommand.cs @@ -0,0 +1,111 @@ +// "filee presets check ": checks one shared preset's folder for the preset gallery (#1530) with the rules the +// app installs by (SharedPresetFolderCheck), and decodes every picture with SkiaSharp, the library Filee draws and +// converts pictures with. The gallery's CI runs it on each submission and comments what it prints; with --json it +// gets the problems (code, file, path, message) and what the preset is, for meta.json. It reads no settings and +// writes nothing. + +using System.Text.Json; +using Filee.Core.Gallery; +using SkiaSharp; + +namespace Filee.Cli; + +internal static class PresetsCheckCommand +{ + public static int Run(CliOptions options, TextWriter output, TextWriter errors) + { + var folder = options.Inputs[1]; + if (!Directory.Exists(folder)) + { + errors.WriteLine($"filee: not a folder: {folder}"); + return Cli.UsageError; + } + var result = SharedPresetFolderCheck.Check(folder, new SkiaImageDecoder()); + if (options.Json) + WriteJson(result, output); + else if (!options.Quiet || !result.Ok) + WriteText(result, output); + return result.Ok ? Cli.Success : Cli.SomeFailed; + } + + private static void WriteText(PresetCheckResult result, TextWriter output) + { + foreach (var error in result.Errors) + output.WriteLine(error); + if (!result.Ok) + { + output.WriteLine($"Found {result.Errors.Count} problem(s)."); + return; + } + var derived = result.Derived!; + output.WriteLine($"OK: {derived.Presets} preset(s), {derived.Profiles} donut(s). Kinds: {string.Join(", ", derived.Kinds)}. " + + $"Formats: {(derived.From.Count == 0 ? "any" : string.Join(", ", derived.From))} → {(derived.To.Count == 0 ? "-" : string.Join(", ", derived.To))}. " + + $"Needs Filee {derived.MinVersion} or later."); + } + + private static void WriteJson(PresetCheckResult result, TextWriter output) => Cli.WriteJsonDocument(output, json => + { + json.WriteStartObject(); + json.WriteBoolean("ok", result.Ok); + json.WriteStartArray("errors"); + foreach (var error in result.Errors) + { + json.WriteStartObject(); + json.WriteString("code", error.Code); + json.WriteString("file", error.File); + json.WriteString("path", error.Path); + json.WriteString("message", error.Message); + json.WriteEndObject(); + } + json.WriteEndArray(); + if (result.Derived is { } derived) + { + json.WriteStartObject("derived"); + WriteList(json, "kinds", derived.Kinds); + json.WriteStartObject("formats"); + WriteList(json, "from", derived.From); + WriteList(json, "to", derived.To); + json.WriteEndObject(); + json.WriteString("minVersion", derived.MinVersion); + json.WriteNumber("presets", derived.Presets); + json.WriteNumber("profiles", derived.Profiles); + json.WriteString("sha256", derived.Sha256); + json.WriteEndObject(); + } + else + json.WriteNull("derived"); + json.WriteEndObject(); + }); + + private static void WriteList(Utf8JsonWriter json, string name, IEnumerable values) + { + json.WriteStartArray(name); + foreach (var value in values) + json.WriteStringValue(value); + json.WriteEndArray(); + } +} + +/// +/// Decodes a picture with SkiaSharp, every pixel, so a file that is broken past its header fails. Only called for +/// pictures whose header said 2560 pixels or less on the long edge. +/// +internal sealed class SkiaImageDecoder : IGalleryImageDecoder +{ + public GalleryImageDecode Decode(byte[] data) + { + using var stream = new SKMemoryStream(data); + using var codec = SKCodec.Create(stream, out var opened); + if (codec is null) + return new GalleryImageDecode(0, 0, 0, $"not a picture SkiaSharp reads ({opened})"); + var size = codec.Info; + if (Math.Max(size.Width, size.Height) > SharedPresetFolderCheck.MaxLongEdge) + return new GalleryImageDecode(size.Width, size.Height, 1, $"{size.Width} x {size.Height} is larger than its header said"); + var frames = Math.Max(1, codec.FrameCount); + using var bitmap = new SKBitmap(new SKImageInfo(size.Width, size.Height, SKColorType.Rgba8888, SKAlphaType.Premul)); + var decoded = codec.GetPixels(bitmap.Info, bitmap.GetPixels()); + return decoded == SKCodecResult.Success + ? new GalleryImageDecode(size.Width, size.Height, frames, null) + : new GalleryImageDecode(size.Width, size.Height, frames, decoded.ToString()); + } +} diff --git a/src/Filee.Core/Gallery/GalleryImageHeader.cs b/src/Filee.Core/Gallery/GalleryImageHeader.cs new file mode 100644 index 000000000..2bf9e4fa4 --- /dev/null +++ b/src/Filee.Core/Gallery/GalleryImageHeader.cs @@ -0,0 +1,151 @@ +// Reads what a picture of a shared preset is from its first bytes: PNG, JPEG or WebP, its size, and whether it moves +// (APNG, animated WebP). Read before anything decodes the picture, so a file claiming 50,000 x 50,000 pixels is refused +// without being decoded. Every offset is checked against the length: the file comes from anyone. + +using System.Buffers.Binary; + +namespace Filee.Core.Gallery; + +/// What found: the format ("png", "jpeg", "webp"), size and motion. +public readonly record struct GalleryImageInfo(string Format, int Width, int Height, bool Animated); + +/// Reads the header of a PNG, JPEG or WebP picture. +public static class GalleryImageHeader +{ + /// The picture's format, size and whether it is animated; null when it is none of the three or broken. + public static GalleryImageInfo? Read(ReadOnlySpan data) + { + if (data.Length >= 24 && data[..8].SequenceEqual((ReadOnlySpan)[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])) + return Png(data); + if (data.Length >= 4 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) + return Jpeg(data); + if (data.Length >= 30 && data[..4].SequenceEqual("RIFF"u8) && data.Slice(8, 4).SequenceEqual("WEBP"u8)) + return WebP(data); + return null; + } + + private static GalleryImageInfo? Png(ReadOnlySpan data) + { + if (!data.Slice(12, 4).SequenceEqual("IHDR"u8)) + return null; + var width = BinaryPrimitives.ReadUInt32BigEndian(data.Slice(16, 4)); + var height = BinaryPrimitives.ReadUInt32BigEndian(data.Slice(20, 4)); + if (width is 0 or > int.MaxValue || height is 0 or > int.MaxValue) + return null; + // An APNG says so with an acTL chunk before its first IDAT. + var animated = false; + var at = 8; + while (at + 8 <= data.Length) + { + var length = BinaryPrimitives.ReadUInt32BigEndian(data.Slice(at, 4)); + var type = data.Slice(at + 4, 4); + if (type.SequenceEqual("acTL"u8)) + { + animated = true; + break; + } + if (type.SequenceEqual("IDAT"u8) || length > int.MaxValue - 12) + break; + at += 12 + (int)length; + } + return new GalleryImageInfo("png", (int)width, (int)height, animated); + } + + private static GalleryImageInfo? Jpeg(ReadOnlySpan data) + { + var at = 2; + while (at + 4 <= data.Length) + { + if (data[at] != 0xFF) + return null; + var marker = data[at + 1]; + if (marker == 0xFF) + { + at++; // fill byte + continue; + } + if (marker is 0xD8 or 0x01 or (>= 0xD0 and <= 0xD7)) + { + at += 2; + continue; + } + var length = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(at + 2, 2)); + if (length < 2) + return null; + // Start of frame: every SOFn but DHT (C4), JPG (C8) and DAC (CC). + if (marker is >= 0xC0 and <= 0xCF and not 0xC4 and not 0xC8 and not 0xCC) + { + if (at + 9 > data.Length) + return null; + var height = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(at + 5, 2)); + var width = BinaryPrimitives.ReadUInt16BigEndian(data.Slice(at + 7, 2)); + return width == 0 || height == 0 ? null : new GalleryImageInfo("jpeg", width, height, false); + } + if (marker is 0xD9 or 0xDA) + return null; // the end, or the image data, before any frame header + at += 2 + length; + } + return null; + } + + private static GalleryImageInfo? WebP(ReadOnlySpan data) + { + int? width = null, height = null; + var animated = false; + var at = 12; + while (at + 8 <= data.Length) + { + var type = data.Slice(at, 4); + var length = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(at + 4, 4)); + var body = at + 8; + if (length > int.MaxValue - 9) + return null; + var available = Math.Min((int)length, data.Length - body); + var chunk = data.Slice(body, available); + if (type.SequenceEqual("VP8X"u8) && chunk.Length >= 10) + { + animated |= (chunk[0] & 0x02) != 0; + width = 1 + (chunk[4] | chunk[5] << 8 | chunk[6] << 16); + height = 1 + (chunk[7] | chunk[8] << 8 | chunk[9] << 16); + } + else if (type.SequenceEqual("ANIM"u8) || type.SequenceEqual("ANMF"u8)) + { + animated = true; + } + else if (type.SequenceEqual("VP8 "u8) && width is null && chunk.Length >= 10) + { + // Frame tag (3 bytes), start code 9D 01 2A, then 14-bit width and height. + if (chunk[3] != 0x9D || chunk[4] != 0x01 || chunk[5] != 0x2A) + return null; + width = BinaryPrimitives.ReadUInt16LittleEndian(chunk.Slice(6, 2)) & 0x3FFF; + height = BinaryPrimitives.ReadUInt16LittleEndian(chunk.Slice(8, 2)) & 0x3FFF; + } + else if (type.SequenceEqual("VP8L"u8) && width is null && chunk.Length >= 5) + { + if (chunk[0] != 0x2F) + return null; + var bits = BinaryPrimitives.ReadUInt32LittleEndian(chunk.Slice(1, 4)); + width = 1 + (int)(bits & 0x3FFF); + height = 1 + (int)((bits >> 14) & 0x3FFF); + } + // Chunks are padded to an even length. + at = body + (int)length + (int)(length & 1); + } + return width is > 0 && height is > 0 ? new GalleryImageInfo("webp", width.Value, height.Value, animated) : null; + } +} + +/// What decoding a picture fully gave (). +public readonly record struct GalleryImageDecode(int Width, int Height, int Frames, string? Error) +{ + public bool Ok => Error is null; +} + +/// +/// Decodes a picture completely, to tell that it isn't broken past its header. The command line gives one built on +/// the image library Filee converts with; without one the folder check reads the headers only. +/// +public interface IGalleryImageDecoder +{ + GalleryImageDecode Decode(byte[] data); +} diff --git a/src/Filee.Core/Gallery/GalleryIndex.cs b/src/Filee.Core/Gallery/GalleryIndex.cs new file mode 100644 index 000000000..e17d14c05 --- /dev/null +++ b/src/Filee.Core/Gallery/GalleryIndex.cs @@ -0,0 +1,221 @@ +// index.json of the preset gallery (#1530): the list of shared presets the app and filee.sh show. It comes from the +// network, so it is read field by field with limits: an entry that isn't as the contract says is left out, every text +// is cut to a length, and the files it names are paths relative to index.json that end in the preset's own folder +// (filee.sh's index has files/presets//…, the repository's presets//…), never a URL or "..". + +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Filee.Core.Gallery; + +/// Likes, comments and downloads of a shared preset, and its scores for the Hot and Best orders. +public sealed record GalleryStats(int Likes, int Comments, int Downloads, double Hot, IReadOnlyDictionary Best) +{ + public static readonly GalleryStats None = new(0, 0, 0, 0, new Dictionary()); + + /// The periods of the Best order: day, week, month, year and all time. + public static readonly IReadOnlyList Periods = ["day", "week", "month", "year", "all"]; + + public double BestOf(string period) => Best.GetValueOrDefault(period); +} + +/// One shared preset in index.json. +public sealed record GalleryEntry +{ + public required string Id { get; init; } + public required string Title { get; init; } + public string Description { get; init; } = ""; + public string Author { get; init; } = ""; + public IReadOnlyList Tags { get; init; } = []; + public IReadOnlyList Languages { get; init; } = []; + public IReadOnlyList Kinds { get; init; } = []; + public IReadOnlyList FormatsFrom { get; init; } = []; + public IReadOnlyList FormatsTo { get; init; } = []; + + /// The lowest Filee that can install it. + public Version MinVersion { get; init; } = new(0, 0, 0); + + public DateTimeOffset Created { get; init; } + public DateTimeOffset Updated { get; init; } + + /// Its discussion in the gallery's repository (likes and comments), when it has one. + public int? Discussion { get; init; } + + public string License { get; init; } = SharedPresetFolderCheck.License; + + /// Its pictures (1 to 4, the cover first), as paths relative to index.json. + public IReadOnlyList Images { get; init; } = []; + + /// The cover's thumbnail; null without one (the cover is shown instead). + public string? Thumb { get; init; } + + /// Its preset.json, as a path relative to index.json. + public required string Preset { get; init; } + + /// + /// The install link filee.sh counts (https://filee.sh/presets/download/<id>, or relative to index.json), + /// when the index has one; the repository's index has none. + /// + public string? Download { get; init; } + + /// The SHA-256 of its preset.json (lowercase hex): what is installed must be exactly that file. + public required string Sha256 { get; init; } + + public GalleryStats Stats { get; init; } = GalleryStats.None; + + /// True when index.json had stats for it (the site adds them; the repository's own index has none). + public bool HasStats { get; init; } +} + +/// The gallery's list, as read by . +public sealed partial record GalleryIndex(int Version, DateTimeOffset Generated, IReadOnlyList Entries) +{ + /// Largest index.json the app reads, in bytes. + public const int MaxBytes = 8 * 1024 * 1024; + + /// Most entries read; the rest are left out. + public const int MaxEntries = 5000; + + /// Reads index.json. Throws when it isn't an index at all. + public static GalleryIndex Parse(ReadOnlySpan json) + { + if (json.Length > MaxBytes) + throw new JsonException("index.json is too large."); + using var document = JsonDocument.Parse(json.ToArray(), new JsonDocumentOptions { MaxDepth = 16 }); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("presets", out var presets) || presets.ValueKind != JsonValueKind.Array) + throw new JsonException("index.json has no presets list."); + var version = root.TryGetProperty("version", out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var number) ? number : 0; + var entries = new List(); + var ids = new HashSet(StringComparer.Ordinal); + foreach (var item in presets.EnumerateArray()) + { + if (entries.Count >= MaxEntries) + break; + if (Entry(item) is { } entry && ids.Add(entry.Id)) + entries.Add(entry); + } + return new GalleryIndex(version, Date(root, "generated"), entries); + } + + /// One entry, or null when it lacks what an install needs or names a file outside its folder. + private static GalleryEntry? Entry(JsonElement item) + { + if (item.ValueKind != JsonValueKind.Object) + return null; + var id = Text(item, "id", 48); + if (!SharedPresetFolderCheck.IsSlug(id)) + return null; + var preset = Text(item, "preset", 200); + var sha256 = Text(item, "sha256", 64).ToLowerInvariant(); + var title = Text(item, "title", 60).Trim(); + if (InFolder(preset, id) != "preset.json" || !Sha256Pattern().IsMatch(sha256) || title.Length == 0) + return null; + var images = List(item, "images", 4, 200).Where(path => InFolder(path, id) is { } file && ImageName().IsMatch(file)).ToList(); + var thumb = Text(item, "thumb", 200); + var download = Text(item, "download", 200); + var formats = item.TryGetProperty("formats", out var f) && f.ValueKind == JsonValueKind.Object ? f : default; + var stats = item.TryGetProperty("stats", out var s) && s.ValueKind == JsonValueKind.Object ? s : default; + return new GalleryEntry + { + Id = id, + Title = title, + Description = Text(item, "description", 600), + Author = Text(item, "author", 39), + Tags = List(item, "tags", 6, 24), + Languages = List(item, "languages", 10, 16), + Kinds = List(item, "kinds", 3, 16).Where(SharedPresetFolderCheck.Kinds.Contains).ToList(), + FormatsFrom = formats.ValueKind == JsonValueKind.Object ? List(formats, "from", 1000, 32) : [], + FormatsTo = formats.ValueKind == JsonValueKind.Object ? List(formats, "to", 1000, 32) : [], + MinVersion = System.Version.TryParse(Text(item, "minVersion", 20), out var min) ? min : new System.Version(0, 0, 0), + Created = Date(item, "created"), + Updated = Date(item, "updated"), + Discussion = item.TryGetProperty("discussion", out var d) && d.ValueKind == JsonValueKind.Number && d.TryGetInt32(out var n) && n > 0 ? n : null, + License = Text(item, "license", 16) is { Length: > 0 } license ? license : SharedPresetFolderCheck.License, + Images = images, + Thumb = InFolder(thumb, id) is { } thumbFile && ThumbName().IsMatch(thumbFile) ? thumb : null, + Preset = preset, + Download = IsDownload(download, id) ? download : null, + Sha256 = sha256, + HasStats = stats.ValueKind == JsonValueKind.Object, + Stats = stats.ValueKind == JsonValueKind.Object ? Stats(stats) : GalleryStats.None, + }; + } + + private static GalleryStats Stats(JsonElement stats) + { + var best = new Dictionary(StringComparer.Ordinal); + if (stats.TryGetProperty("best", out var b) && b.ValueKind == JsonValueKind.Object) + foreach (var period in GalleryStats.Periods) + best[period] = Number(b, period); + return new GalleryStats((int)Number(stats, "likes"), (int)Number(stats, "comments"), (int)Number(stats, "downloads"), + Number(stats, "hot"), best); + } + + /// + /// The part of inside the preset's folder (preset.json, images/1.webp), when it is + /// a relative path to that folder (files/presets/<id>/…, presets/<id>/…); null otherwise. + /// + private static string? InFolder(string path, string id) + { + var match = RelativePath().Match(path); + return match.Success && match.Groups["id"].Value == id ? match.Groups["file"].Value : null; + } + + // Up to three plain folders before presets// (no "..", no scheme, no leading slash), then a file of the folder. + [GeneratedRegex(@"^(?:[a-z0-9][a-z0-9_\-]*/){0,3}presets/(?[a-z0-9\-]{3,48})/(?preset\.json|images/[1-4](?:-thumb)?\.[a-z]{3,4})$", RegexOptions.CultureInvariant)] + private static partial Regex RelativePath(); + + [GeneratedRegex(@"^images/[1-4]\.(webp|png|jpg|jpeg)$", RegexOptions.CultureInvariant)] + private static partial Regex ImageName(); + + [GeneratedRegex(@"^images/[1-4]-thumb\.webp$", RegexOptions.CultureInvariant)] + private static partial Regex ThumbName(); + + /// + /// An install link of the preset's: https://filee.sh/presets/download/<id> or relative to index.json + /// (download/<id>). The app also checks that it stays on the host index.json came from. + /// + private static bool IsDownload(string link, string id) => + link == "https://filee.sh/presets/download/" + id || DownloadPath().Match(link) is { Success: true } m && m.Groups["id"].Value == id; + + [GeneratedRegex(@"^(?:[a-z0-9][a-z0-9_\-]*/){0,3}download/(?[a-z0-9\-]{3,48})$", RegexOptions.CultureInvariant)] + private static partial Regex DownloadPath(); + + [GeneratedRegex(@"^[0-9a-f]{64}$", RegexOptions.CultureInvariant)] + private static partial Regex Sha256Pattern(); + + private static string Text(JsonElement item, string name, int max) + { + if (!item.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.String) + return ""; + var text = value.GetString() ?? ""; + // No control or direction-changing characters in what the window shows. + text = new string([.. text.Where(c => !char.IsControl(c) || c == '\n').Where(c => c is not (>= '‪' and <= '‮') and not (>= '⁦' and <= '⁩'))]); + return text.Length <= max ? text : text[..max]; + } + + private static List List(JsonElement item, string name, int maxCount, int maxLength) + { + if (!item.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Array) + return []; + return [.. value.EnumerateArray() + .Where(e => e.ValueKind == JsonValueKind.String) + .Select(e => e.GetString() ?? "") + .Where(text => text.Length is > 0 && text.Length <= maxLength && !text.Any(char.IsControl)) + .Distinct(StringComparer.Ordinal) + .Take(maxCount)]; + } + + private static double Number(JsonElement item, string name) => + item.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var d) && double.IsFinite(d) && d >= 0 + ? Math.Min(d, int.MaxValue) + : 0; + + private static DateTimeOffset Date(JsonElement item, string name) => + item.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + && DateTimeOffset.TryParse(value.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date) + ? date + : DateTimeOffset.MinValue; +} diff --git a/src/Filee.Core/Gallery/GalleryInstaller.cs b/src/Filee.Core/Gallery/GalleryInstaller.cs new file mode 100644 index 000000000..eb5f8fe7e --- /dev/null +++ b/src/Filee.Core/Gallery/GalleryInstaller.cs @@ -0,0 +1,113 @@ +// Installs a preset from the preset gallery (#1530). Unlike Settings → General → Import, which replaces presets with +// the same id, an install only adds: every preset and the donut get new ids, and a name the library already has gets +// " (2)". The file is checked again here, with the same rules the gallery's CI used, and must be exactly the file +// index.json lists (its SHA-256): the app trusts neither the server nor the network in between. + +using System.Security.Cryptography; +using Filee.Core.Presets; +using Filee.Core.Profiles; +using Filee.Core.Settings; + +namespace Filee.Core.Gallery; + +/// What an install added, or why nothing was. +public sealed record GalleryInstallResult( + IReadOnlyList Presets, + ToolbarProfile? Profile, + IReadOnlyList Errors) +{ + public bool Ok => Errors.Count == 0; +} + +/// Adds a shared preset.json to the library as new presets (and a new donut). +public static class GalleryInstaller +{ + /// + /// Checks against and the gallery's rules, then adds what it holds + /// to and saves the library. and + /// give the names the user sees (a built-in name in their language), to tell a clash. + /// + public static GalleryInstallResult Install(UserDataStore store, ReadOnlySpan json, string sha256, + Func presetName, Func profileName) + { + var actual = Convert.ToHexStringLower(SHA256.HashData(json)); + if (!string.Equals(actual, sha256.Trim(), StringComparison.OrdinalIgnoreCase)) + return Failed("install.sha256", "is not the file the gallery lists (its SHA-256 differs)"); + + var check = SharedBundleCheck.Check(json); + if (!check.Ok || check.Bundle is not { } bundle) + return new GalleryInstallResult([], null, check.Errors); + + // As Import: no path to a picture of someone else's, and the back faces a bundle from before them lacks. + foreach (var preset in bundle.Presets) + preset.Image.ForgetBackdropPictures(); + SettingsMigrations.AddBackFaces(bundle.Presets, bundle.Profiles); + + var taken = store.Presets.Select(p => p.Id).ToHashSet(StringComparer.Ordinal); + var ids = new Dictionary(StringComparer.Ordinal); + foreach (var preset in bundle.Presets) + { + string id; + do + id = Preset.NewId(); + while (!taken.Add(id)); + ids[preset.Id] = id; + preset.Id = id; + } + string Map(string id) => ids.GetValueOrDefault(id, id); + + var names = store.Presets.Select(presetName).ToHashSet(StringComparer.CurrentCultureIgnoreCase); + foreach (var preset in bundle.Presets) + { + preset.SubPresetIds = [.. preset.SubPresetIds.Select(Map)]; + var name = Unique(presetName(preset), names); + if (name != presetName(preset)) + { + preset.Name = name; + preset.NameKey = null; + } + names.Add(name); + } + + var profile = bundle.Profiles.FirstOrDefault(); + if (profile is not null) + { + var profileIds = store.Profiles.Select(p => p.Id).ToHashSet(StringComparer.Ordinal); + do + profile.Id = Guid.NewGuid().ToString("N")[..12]; + while (profileIds.Contains(profile.Id)); + profile.PresetIds = [.. profile.PresetIds.Select(Map)]; + profile.BackPresetIds = [.. (profile.BackPresetIds ?? []).Select(Map)]; + var name = Unique(profileName(profile), store.Profiles.Select(profileName).ToHashSet(StringComparer.CurrentCultureIgnoreCase)); + if (name != profileName(profile)) + { + profile.Name = name; + profile.NameKey = null; + } + } + + store.Presets.AddRange(bundle.Presets); + // After every donut of the user's: a file type one of theirs takes keeps opening theirs (the first that has + // it), so the new donut changes nothing until the user gives it a file type (Settings → Toolbar). + if (profile is not null) + store.Profiles.Add(profile); + store.SaveLibrary(); + return new GalleryInstallResult(bundle.Presets, profile, []); + } + + /// , or with " (2)", " (3)" … when the library has it already. + internal static string Unique(string name, IReadOnlySet taken) + { + if (!taken.Contains(name)) + return name; + for (var n = 2; ; n++) + { + var candidate = $"{name} ({n})"; + if (!taken.Contains(candidate)) + return candidate; + } + } + + private static GalleryInstallResult Failed(string code, string message) => + new([], null, [new PresetCheckError(code, SharedBundleCheck.FileName, "", message)]); +} diff --git a/src/Filee.Core/Gallery/PresetCheckResult.cs b/src/Filee.Core/Gallery/PresetCheckResult.cs new file mode 100644 index 000000000..8342be31e --- /dev/null +++ b/src/Filee.Core/Gallery/PresetCheckResult.cs @@ -0,0 +1,71 @@ +// What checking a shared preset found (#1530): the problems, each with a stable code for the gallery's CI, and what +// the preset is (its kinds, formats and the lowest Filee that runs it), which the CI writes into meta.json. + +using Filee.Core.Settings; + +namespace Filee.Core.Gallery; + +/// +/// One problem: is stable (scripts match it), the file in the preset's folder +/// (preset.json, images/2.png), where in it (presets[0].output.customFolder, +/// empty for the whole file) and what is wrong, in English, for people. +/// +public sealed record PresetCheckError(string Code, string File, string Path, string Message) +{ + public override string ToString() => + Path.Length == 0 ? $"{File}: {Message} ({Code})" : $"{File}: {Path}: {Message} ({Code})"; +} + +/// +/// What a preset.json is, derived from it: (convert, tool, toolbar), the +/// formats it reads (: its donut's extensions) and writes (), the lowest Filee that +/// can install it, and the SHA-256 of the file (lowercase hex) that index.json lists. +/// +public sealed record PresetCheckDerived( + IReadOnlyList Kinds, + IReadOnlyList From, + IReadOnlyList To, + string MinVersion, + int Presets, + int Profiles, + string Sha256); + +/// The outcome of or . +public sealed class PresetCheckResult +{ + /// Most problems reported; a file with more gets one last "too many problems". + public const int MaxErrors = 200; + + private readonly List _errors = []; + + public IReadOnlyList Errors => _errors; + + /// True when nothing is wrong. + public bool Ok => _errors.Count == 0; + + /// What the preset.json is; null when it couldn't be read as a preset bundle. + public PresetCheckDerived? Derived { get; internal set; } + + /// + /// The bundle as the app reads it, when preset.json passed (what an install adds). Each check reads it anew, so it + /// may be changed. + /// + public PresetBundle? Bundle { get; internal set; } + + /// True once problems were found: further checks stop. + internal bool Full => _errors.Count > MaxErrors; + + internal void Add(string code, string file, string path, string message) + { + if (_errors.Count < MaxErrors) + _errors.Add(new PresetCheckError(code, file, path, message)); + else if (_errors.Count == MaxErrors) + _errors.Add(new PresetCheckError("check.too_many_errors", file, "", $"more than {MaxErrors} problems; the rest aren't listed")); + } + + internal void AddRange(PresetCheckResult other) + { + foreach (var error in other._errors) + Add(error.Code, error.File, error.Path, error.Message); + } +} diff --git a/src/Filee.Core/Gallery/SharedBundleCheck.cs b/src/Filee.Core/Gallery/SharedBundleCheck.cs new file mode 100644 index 000000000..aff8ab44c --- /dev/null +++ b/src/Filee.Core/Gallery/SharedBundleCheck.cs @@ -0,0 +1,310 @@ +// Checks a preset.json shared through the preset gallery (#1530) against the allow-list (SharedPresetRules), first as +// JSON (every field known, every value in range: nothing the app would read is left unchecked) and then as presets +// (kinds, tools, target formats, references between presets and the donut). The gallery's CI runs it through +// 'filee presets check', and the app runs it again before it installs anything, without trusting the server. + +using System.Security.Cryptography; +using System.Text.Json; +using Filee.Core.Formats; +using Filee.Core.Presets; +using Filee.Core.Settings; + +namespace Filee.Core.Gallery; + +/// Checks one preset.json (a ) for the preset gallery. +public static class SharedBundleCheck +{ + /// The file name problems in a bundle are reported against. + public const string FileName = "preset.json"; + + /// + /// The lowest Filee that can install a shared preset: the first with the gallery. A field added to the allow-list + /// later raises it for the presets that use it. + /// + public const string BaselineVersion = "2.5.0"; + + /// Largest preset.json, in bytes. + public const int MaxBytes = SharedPresetRules.MaxBundleBytes; + + /// Checks , the bytes of a preset.json. + public static PresetCheckResult Check(ReadOnlySpan json) => Check(json, SharedPresetRules.MaxPresets); + + internal static PresetCheckResult Check(ReadOnlySpan json, int maxPresets) + { + var result = new PresetCheckResult(); + if (json.Length > SharedPresetRules.MaxBundleBytes) + { + result.Add("bundle.too_large", FileName, "", $"is {json.Length:N0} bytes; a shared preset.json may have at most {SharedPresetRules.MaxBundleBytes:N0}"); + return result; + } + var sha256 = Convert.ToHexStringLower(SHA256.HashData(json)); + // An editor's byte order mark is the only thing tolerated before the JSON. + if (json.StartsWith((ReadOnlySpan)[0xEF, 0xBB, 0xBF])) + json = json[3..]; + + JsonDocument document; + try + { + document = JsonDocument.Parse(json.ToArray(), new JsonDocumentOptions + { + MaxDepth = SharedPresetRules.MaxDepth, + CommentHandling = JsonCommentHandling.Disallow, + AllowTrailingCommas = false, + }); + } + catch (JsonException ex) + { + var deep = ex.Message.Contains("maximum configured depth", StringComparison.OrdinalIgnoreCase); + result.Add(deep ? "bundle.too_deep" : "bundle.invalid_json", FileName, "", + deep ? $"nests deeper than {SharedPresetRules.MaxDepth} levels" : "is not valid JSON: " + FirstLine(ex.Message)); + return result; + } + + using (document) + { + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + result.Add("bundle.not_object", FileName, "", "must be a JSON object with \"presets\" (as Settings → General → Export writes it)"); + return result; + } + var rules = maxPresets == SharedPresetRules.MaxPresets + ? SharedPresetRules.BundleFields + : SharedPresetRules.BundleFields with + { + Fields = new Dictionary(SharedPresetRules.BundleFields.Fields) + { + ["presets"] = new ListRule(SharedPresetRules.PresetFields, maxPresets), + }, + }; + new Walker(result, FileName).Check(root, rules, ""); + if (!root.TryGetProperty("presets", out var presets) || presets.ValueKind != JsonValueKind.Array || presets.GetArrayLength() == 0) + result.Add("bundle.no_presets", FileName, "presets", "has no presets"); + if (!result.Ok) + return result; + } + + PresetBundle bundle; + try + { + bundle = JsonSerializer.Deserialize(json, FileeJsonContext.Default.PresetBundle) + ?? throw new JsonException("null"); + } + catch (Exception ex) when (ex is JsonException or NotSupportedException or InvalidOperationException) + { + result.Add("bundle.invalid_json", FileName, "", "can't be read as a preset bundle: " + FirstLine(ex.Message)); + return result; + } + + CheckPresets(bundle, result); + if (result.Ok) + result.Bundle = bundle; + result.Derived = Derive(bundle, sha256); + return result; + } + + /// The rules that span fields: kinds, tools, targets and the ids presets and the donut refer to. + private static void CheckPresets(PresetBundle bundle, PresetCheckResult result) + { + var ids = new HashSet(StringComparer.Ordinal); + for (var i = 0; i < bundle.Presets.Count; i++) + { + var preset = bundle.Presets[i]; + var at = $"presets[{i}]"; + if (!ids.Add(preset.Id)) + result.Add("preset.duplicate_id", FileName, at + ".id", $"the id \"{preset.Id}\" is used by another preset in the file"); + if (string.IsNullOrWhiteSpace(preset.Name) && string.IsNullOrEmpty(preset.NameKey)) + result.Add("preset.no_name", FileName, at + ".name", "needs a name"); + + switch (preset.Kind) + { + case PresetKind.Tool when !SharedPresetRules.Tools.Contains(preset.Tool): + result.Add("preset.tool_not_allowed", FileName, at + ".tool", + $"the tool \"{preset.Tool}\" can't be shared (allowed: {string.Join(", ", SharedPresetRules.Tools.Select(t => t ?? Preset.CropTool).Distinct())})"); + break; + case not PresetKind.Tool when preset.Tool is not null: + result.Add("preset.tool_not_allowed", FileName, at + ".tool", "only a preset of the kind Tool has a tool"); + break; + } + + if (preset.IsGroup) + { + if (preset.TargetFormat != Preset.GroupTarget) + result.Add("preset.target_unknown", FileName, at + ".targetFormat", $"a group's target is \"{Preset.GroupTarget}\""); + } + else if (preset.TargetFormat != BuiltInData.SameAsSource + && (FormatRegistry.FindById(preset.TargetFormat) is null || preset.TargetFormat == Preset.CopyTarget)) + { + result.Add("preset.target_unknown", FileName, at + ".targetFormat", $"\"{preset.TargetFormat}\" is not a format Filee writes"); + } + + for (var r = 0; r < preset.Output.Renames.Count; r++) + { + var step = preset.Output.Renames[r]; + if (step.Kind == RenameKind.Regex && OutputPathResolver.RenameError(step.Find) is { } why) + result.Add("output.rename_regex", FileName, $"{at}.output.renames[{r}].find", "is not a valid regular expression: " + FirstLine(why)); + } + } + + var actions = bundle.Presets.Where(p => !p.IsGroup).Select(p => p.Id).ToHashSet(StringComparer.Ordinal); + for (var i = 0; i < bundle.Presets.Count; i++) + { + var preset = bundle.Presets[i]; + if (!preset.IsGroup && preset.SubPresetIds.Count > 0) + result.Add("preset.reference", FileName, $"presets[{i}].subPresetIds", "only a group fans out other presets"); + if (!preset.IsGroup) + continue; + foreach (var id in preset.SubPresetIds.Where(id => !actions.Contains(id))) + result.Add("preset.reference", FileName, $"presets[{i}].subPresetIds", $"\"{id}\" is not a conversion or tool in this file"); + } + + for (var i = 0; i < bundle.Profiles.Count; i++) + { + var profile = bundle.Profiles[i]; + var at = $"profiles[{i}]"; + if (string.IsNullOrWhiteSpace(profile.Name) && string.IsNullOrEmpty(profile.NameKey)) + result.Add("profile.no_name", FileName, at + ".name", "needs a name"); + foreach (var id in profile.PresetIds.Concat(profile.BackPresetIds ?? []).Where(id => !ids.Contains(id)).Distinct()) + result.Add("profile.reference", FileName, at + ".presetIds", $"\"{id}\" is not a preset in this file"); + foreach (var extension in profile.Extensions.Where(e => FormatRegistry.FindByExtension(e) is null)) + result.Add("profile.extension_unknown", FileName, at + ".extensions", $"\"{extension}\" is not an extension Filee knows"); + } + } + + /// What the bundle is, for meta.json (see ). + private static PresetCheckDerived Derive(PresetBundle bundle, string sha256) + { + var kinds = new List(); + if (bundle.Presets.Any(p => p.Kind is PresetKind.Convert or PresetKind.Group)) + kinds.Add("convert"); + if (bundle.Presets.Any(p => p.Kind == PresetKind.Tool)) + kinds.Add("tool"); + if (bundle.Profiles.Count > 0) + kinds.Add("toolbar"); + var from = bundle.Profiles.SelectMany(p => p.Extensions) + .Select(SharedPresetRules.FormatOfExtension).OfType() + .Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList(); + var to = bundle.Presets.Where(p => p.Kind == PresetKind.Convert && p.TargetFormat != BuiltInData.SameAsSource) + .Select(p => p.TargetFormat).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList(); + return new PresetCheckDerived(kinds, from, to, BaselineVersion, bundle.Presets.Count, bundle.Profiles.Count, sha256); + } + + private static string FirstLine(string text) + { + var end = text.IndexOfAny(['\r', '\n']); + return end < 0 ? text : text[..end]; + } + + /// Walks JSON along an , reporting every value the rules don't allow. + internal sealed class Walker(PresetCheckResult result, string file) + { + public void Check(JsonElement value, ValueRule rule, string path) + { + if (result.Full) + return; + if (rule is LeftOutRule leftOut) + { + if (!IsEmpty(value)) + result.Add("field.left_out", file, path, $"must be empty: {leftOut.Why}"); + return; + } + if (value.ValueKind == JsonValueKind.Null) + { + if (!rule.Nullable) + result.Add("field.type", file, path, "may not be null"); + return; + } + switch (rule) + { + case ObjectRule obj: + if (value.ValueKind != JsonValueKind.Object) + { + result.Add("field.type", file, path, "must be an object"); + return; + } + var seen = new HashSet(StringComparer.Ordinal); + foreach (var property in value.EnumerateObject()) + { + var at = path.Length == 0 ? property.Name : $"{path}.{property.Name}"; + if (!seen.Add(property.Name)) + result.Add("field.duplicate", file, at, "appears twice"); + else if (!obj.Fields.TryGetValue(property.Name, out var field)) + result.Add("field.unknown", file, at, "is not a field a shared preset may have"); + else + Check(property.Value, field, at); + } + break; + case BoolRule flag: + if (value.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + result.Add("field.type", file, path, "must be true or false"); + else if (flag.Only is { } only && value.GetBoolean() != only) + result.Add("field.not_allowed", file, path, $"must be {(only ? "true" : "false")} in a shared preset"); + break; + case IntRule number: + if (value.ValueKind != JsonValueKind.Number || !value.TryGetInt64(out var whole)) + result.Add("field.type", file, path, "must be a whole number"); + else if (whole < number.Min || whole > number.Max || number.Allowed?.Contains(whole) == false) + result.Add("field.range", file, path, number.Allowed is { } allowed + ? $"must be one of {string.Join(", ", allowed)}" + : $"must be from {number.Min} to {number.Max}"); + break; + case NumberRule real: + if (value.ValueKind != JsonValueKind.Number || !value.TryGetDouble(out var d) || !double.IsFinite(d)) + result.Add("field.type", file, path, "must be a number"); + else if (d < real.Min || d > real.Max) + result.Add("field.range", file, path, FormattableString.Invariant($"must be from {real.Min} to {real.Max}")); + break; + case EnumRule choice: + if (value.ValueKind != JsonValueKind.String) + result.Add("field.type", file, path, "must be a name, as a string"); + else if (!choice.Names.Contains(value.GetString(), StringComparer.OrdinalIgnoreCase)) + result.Add("field.value", file, path, $"\"{Shorten(value.GetString())}\" is not allowed (allowed: {string.Join(", ", choice.Names)})"); + break; + case StringRule text: + if (value.ValueKind != JsonValueKind.String) + { + result.Add("field.type", file, path, "must be a string"); + break; + } + var s = value.GetString() ?? ""; + if (s.Length > text.MaxLength) + result.Add("text.length", file, path, $"is {s.Length} characters long; at most {text.MaxLength}"); + else if (SharedText.Common(s) is var (code, message)) + result.Add(code, file, path, message); + else if (text.Check?.Invoke(s) is { } why) + result.Add("field.value", file, path, $"\"{Shorten(s)}\" {why}"); + break; + case ListRule list: + if (value.ValueKind != JsonValueKind.Array) + { + result.Add("field.type", file, path, "must be a list"); + break; + } + var count = value.GetArrayLength(); + if (count > list.MaxCount) + { + result.Add("field.range", file, path, $"has {count} items; at most {list.MaxCount}"); + break; + } + var index = 0; + foreach (var item in value.EnumerateArray()) + Check(item, list.Item, $"{path}[{index++}]"); + break; + } + } + + /// Null, "", false, 0, [] or {}: what an export writes for a field nobody set. + private static bool IsEmpty(JsonElement value) => value.ValueKind switch + { + JsonValueKind.Null or JsonValueKind.False or JsonValueKind.Undefined => true, + JsonValueKind.String => value.GetString()!.Length == 0, + JsonValueKind.Number => value.TryGetDouble(out var d) && d == 0, + JsonValueKind.Array => value.GetArrayLength() == 0, + JsonValueKind.Object => !value.EnumerateObject().Any(), + _ => false, + }; + + private static string Shorten(string? text) => + text is null ? "" : text.Length <= 40 ? text : text[..40] + "…"; + } +} diff --git a/src/Filee.Core/Gallery/SharedPresetFolderCheck.cs b/src/Filee.Core/Gallery/SharedPresetFolderCheck.cs new file mode 100644 index 000000000..268c4288c --- /dev/null +++ b/src/Filee.Core/Gallery/SharedPresetFolderCheck.cs @@ -0,0 +1,281 @@ +// Checks one folder of the preset gallery's repository (#1530): presets// with preset.json, an optional +// meta.json and 1 to 4 pictures in images/ (1 is the cover), the thumbnails the gallery's CI makes next to them. This +// is what 'filee presets check ' runs. Nothing else may be in the folder, and nothing in it may be a link. + +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Filee.Core.Gallery; + +/// Checks a shared preset's folder: its preset.json, meta.json and pictures. +public static partial class SharedPresetFolderCheck +{ + public const string MetaName = "meta.json"; + public const string ImagesFolder = "images"; + + /// Fewest and most pictures a shared preset has. + public const int MinImages = 1, MaxImages = 4; + + /// Largest picture, in bytes. + public const int MaxImageBytes = 2 * 1024 * 1024; + + /// Shortest and longest long edge of a picture, in pixels. + public const int MinLongEdge = 640, MaxLongEdge = 2560; + + /// Longest long edge of a thumbnail the CI made, and its largest size. + public const int MaxThumbEdge = 480, MaxThumbBytes = 512 * 1024; + + /// Largest meta.json, in bytes. + public const int MaxMetaBytes = 16 * 1024; + + /// The licence every shared preset and picture is published under. + public const string License = "CC0-1.0"; + + /// Ids the site's own pages use (filee.sh/presets/files/, …/download/, …/index.json): no preset has them. + public static readonly IReadOnlyList ReservedSlugs = ["files", "download", "index"]; + + /// A preset's id: 3 to 48 of a-z, 0-9 and '-', not starting or ending with '-', and not reserved. + public static bool IsSlug(string text) => Slug().IsMatch(text) && !ReservedSlugs.Contains(text); + + /// The kinds meta.json may list (see ). + public static readonly IReadOnlyList Kinds = ["convert", "tool", "toolbar"]; + + /// + /// Checks . decodes each picture fully; without it only the + /// headers are read. + /// + public static PresetCheckResult Check(string folder, IGalleryImageDecoder? decoder = null) + { + var result = new PresetCheckResult(); + var root = new DirectoryInfo(folder); + if (!root.Exists) + { + result.Add("folder.missing", folder, "", "is not a folder"); + return result; + } + + var hasImages = false; + foreach (var entry in root.EnumerateFileSystemInfos()) + { + if (entry.Attributes.HasFlag(FileAttributes.ReparsePoint)) + { + result.Add("folder.link", entry.Name, "", "is a link; the folder may only hold real files"); + continue; + } + switch (entry) + { + case FileInfo { Name: SharedBundleCheck.FileName or MetaName }: + break; + case DirectoryInfo { Name: ImagesFolder }: + hasImages = true; + break; + default: + result.Add("folder.unexpected_file", entry.Name, "", $"doesn't belong in a shared preset's folder (only {SharedBundleCheck.FileName}, {MetaName} and {ImagesFolder}/)"); + break; + } + } + + var bundleFile = new FileInfo(Path.Combine(root.FullName, SharedBundleCheck.FileName)); + if (!bundleFile.Exists || bundleFile.Attributes.HasFlag(FileAttributes.ReparsePoint)) + result.Add("folder.no_preset", SharedBundleCheck.FileName, "", "is missing"); + else if (bundleFile.Length > SharedPresetRules.MaxBundleBytes) + result.Add("bundle.too_large", SharedBundleCheck.FileName, "", $"is {bundleFile.Length:N0} bytes; a shared preset.json may have at most {SharedPresetRules.MaxBundleBytes:N0}"); + else + { + var bundle = SharedBundleCheck.Check(File.ReadAllBytes(bundleFile.FullName)); + result.AddRange(bundle); + result.Derived = bundle.Derived; + } + + var metaFile = new FileInfo(Path.Combine(root.FullName, MetaName)); + if (metaFile.Exists && !metaFile.Attributes.HasFlag(FileAttributes.ReparsePoint)) + CheckMeta(metaFile, result.Derived, result); + + if (!hasImages) + result.Add("images.missing", ImagesFolder + "/", "", $"is missing: a shared preset needs {MinImages} to {MaxImages} pictures (1 is the cover)"); + else + CheckImages(new DirectoryInfo(Path.Combine(root.FullName, ImagesFolder)), decoder, result); + return result; + } + + /// The pictures: 1.png … 4.webp (1 to 4, no gaps), each with an optional 1-thumb.webp. + private static void CheckImages(DirectoryInfo folder, IGalleryImageDecoder? decoder, PresetCheckResult result) + { + var numbers = new SortedSet(); + var thumbs = new List<(int Number, FileInfo File)>(); + foreach (var entry in folder.EnumerateFileSystemInfos()) + { + var name = $"{ImagesFolder}/{entry.Name}"; + if (entry.Attributes.HasFlag(FileAttributes.ReparsePoint)) + { + result.Add("folder.link", name, "", "is a link; the folder may only hold real files"); + continue; + } + if (entry is not FileInfo file) + { + result.Add("images.unexpected_file", name, "", "doesn't belong in images/ (only 1.png … 4.webp)"); + continue; + } + var match = ImageName().Match(file.Name); + if (!match.Success) + { + result.Add("images.name", name, "", "must be named 1 to 4 with .png, .jpg, .jpeg or .webp (1 is the cover)"); + continue; + } + var number = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + if (match.Groups[2].Success) + { + thumbs.Add((number, file)); + continue; + } + if (!numbers.Add(number)) + { + result.Add("images.name", name, "", $"is a second picture {number}"); + continue; + } + CheckImage(file, name, match.Groups[3].Value.ToLowerInvariant(), thumb: false, decoder, result); + } + + if (numbers.Count < MinImages) + result.Add("images.missing", ImagesFolder + "/", "", $"has no pictures: a shared preset needs {MinImages} to {MaxImages} (1 is the cover)"); + else if (numbers.Max != numbers.Count) + result.Add("images.name", ImagesFolder + "/", "", "the pictures must be numbered from 1 without gaps (1 is the cover)"); + foreach (var (number, file) in thumbs) + { + var name = $"{ImagesFolder}/{file.Name}"; + if (!numbers.Contains(number)) + result.Add("images.name", name, "", $"is a thumbnail of picture {number}, which isn't there"); + else + CheckImage(file, name, "webp", thumb: true, decoder, result); + } + } + + [GeneratedRegex(@"^([1-4])(?:(-thumb)\.webp|\.(png|jpg|jpeg|webp))$", RegexOptions.CultureInvariant)] + private static partial Regex ImageName(); + + private static void CheckImage(FileInfo file, string name, string extension, bool thumb, IGalleryImageDecoder? decoder, PresetCheckResult result) + { + var limit = thumb ? MaxThumbBytes : MaxImageBytes; + if (file.Length > limit) + { + result.Add("images.too_large", name, "", $"is {file.Length:N0} bytes; at most {limit:N0}"); + return; + } + var data = File.ReadAllBytes(file.FullName); + if (GalleryImageHeader.Read(data) is not { } info) + { + result.Add("images.format", name, "", "is not a PNG, JPEG or WebP picture"); + return; + } + if (info.Format != (extension is "jpg" or "jpeg" ? "jpeg" : extension)) + { + result.Add("images.format", name, "", $"is a {info.Format.ToUpperInvariant()} picture with the extension .{extension}"); + return; + } + if (info.Animated) + { + result.Add("images.animated", name, "", "is animated; only still pictures are allowed"); + return; + } + var longEdge = Math.Max(info.Width, info.Height); + if (thumb ? longEdge > MaxThumbEdge : longEdge is < MinLongEdge or > MaxLongEdge) + { + result.Add("images.size", name, "", thumb + ? $"is {info.Width} x {info.Height} pixels; a thumbnail's long edge is at most {MaxThumbEdge}" + : $"is {info.Width} x {info.Height} pixels; the long edge must be {MinLongEdge} to {MaxLongEdge}"); + return; + } + if (decoder is null) + return; + var decoded = decoder.Decode(data); + if (!decoded.Ok) + result.Add("images.decode", name, "", "can't be decoded: " + decoded.Error); + else if (decoded.Frames > 1) + result.Add("images.animated", name, "", "is animated; only still pictures are allowed"); + else if (decoded.Width != info.Width || decoded.Height != info.Height) + result.Add("images.decode", name, "", $"decodes as {decoded.Width} x {decoded.Height}, not the {info.Width} x {info.Height} its header says"); + } + + /// The fields of meta.json (see the gallery's SPEC), each optional here: the CI fills some in. + private static readonly ObjectRule MetaFields = new(new Dictionary(StringComparer.Ordinal) + { + ["id"] = new StringRule(48, text => !Slug().IsMatch(text) ? "must be 3 to 48 of a-z, 0-9 and '-'" + : ReservedSlugs.Contains(text) ? "is reserved for the site's own pages" : null), + ["title"] = new StringRule(60, text => text.Trim().Length >= 3 ? null : "must be 3 to 60 characters long"), + ["description"] = new StringRule(600, text => text.Trim().Length >= 10 ? null : "must be 10 to 600 characters long"), + ["author"] = new StringRule(39, text => Login().IsMatch(text) ? null : "is not a GitHub login"), + ["tags"] = new ListRule(new StringRule(24, text => Tag().IsMatch(text) ? null : "must be lowercase a-z, 0-9 and '-'"), 6), + ["languages"] = new ListRule(new StringRule(16, SharedText.LanguageCode), 10), + ["kinds"] = new ListRule(new EnumRule(Kinds), Kinds.Count), + ["formats"] = new ObjectRule(new Dictionary(StringComparer.Ordinal) + { + ["from"] = new ListRule(new StringRule(32, SharedText.Id), 1000), + ["to"] = new ListRule(new StringRule(32, SharedText.Id), 1000), + }), + ["minVersion"] = new StringRule(20, text => Version.TryParse(text, out var v) && v.Build >= 0 && v.Revision < 0 ? null : "must be a version like 2.5.0"), + ["created"] = new StringRule(40, Timestamp), + ["updated"] = new StringRule(40, Timestamp), + ["discussion"] = new IntRule(1, int.MaxValue) { Nullable = true }, + ["license"] = new StringRule(16, text => text == License ? null : $"must be {License}"), + }); + + private static string? Timestamp(string text) => + DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out _) ? null : "is not a date and time like 2026-11-15T00:00:00Z"; + + [GeneratedRegex(@"^[a-z0-9][a-z0-9\-]{1,46}[a-z0-9]$", RegexOptions.CultureInvariant)] + private static partial Regex Slug(); + + [GeneratedRegex(@"^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$", RegexOptions.CultureInvariant)] + private static partial Regex Login(); + + [GeneratedRegex(@"^[a-z0-9][a-z0-9\-]{0,23}$", RegexOptions.CultureInvariant)] + private static partial Regex Tag(); + + private static void CheckMeta(FileInfo file, PresetCheckDerived? derived, PresetCheckResult result) + { + if (file.Length > MaxMetaBytes) + { + result.Add("meta.too_large", MetaName, "", $"is {file.Length:N0} bytes; at most {MaxMetaBytes:N0}"); + return; + } + JsonDocument document; + try + { + document = JsonDocument.Parse(File.ReadAllBytes(file.FullName), new JsonDocumentOptions { MaxDepth = 8 }); + } + catch (JsonException ex) + { + result.Add("meta.invalid_json", MetaName, "", "is not valid JSON: " + ex.Message.Split('\n')[0]); + return; + } + using (document) + { + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + result.Add("meta.invalid_json", MetaName, "", "must be a JSON object"); + return; + } + var before = result.Errors.Count; + new SharedBundleCheck.Walker(result, MetaName).Check(root, MetaFields, ""); + // What the CI derived from preset.json must still be what preset.json is. + if (derived is null || result.Errors.Count != before) + return; + if (root.TryGetProperty("kinds", out var kinds) && !SameSet(kinds, derived.Kinds)) + result.Add("meta.derived_mismatch", MetaName, "kinds", $"must be [{string.Join(", ", derived.Kinds)}], as preset.json is"); + if (root.TryGetProperty("formats", out var formats)) + { + if (formats.TryGetProperty("from", out var from) && !SameSet(from, derived.From)) + result.Add("meta.derived_mismatch", MetaName, "formats.from", $"must be [{string.Join(", ", derived.From)}], as preset.json is"); + if (formats.TryGetProperty("to", out var to) && !SameSet(to, derived.To)) + result.Add("meta.derived_mismatch", MetaName, "formats.to", $"must be [{string.Join(", ", derived.To)}], as preset.json is"); + } + if (root.TryGetProperty("minVersion", out var min) && Version.Parse(min.GetString()!) < Version.Parse(derived.MinVersion)) + result.Add("meta.derived_mismatch", MetaName, "minVersion", $"must be at least {derived.MinVersion}, the first Filee that can install it"); + } + } + + private static bool SameSet(JsonElement list, IReadOnlyList expected) => + list.EnumerateArray().Select(e => e.GetString()).ToHashSet(StringComparer.Ordinal).SetEquals(expected); +} diff --git a/src/Filee.Core/Gallery/SharedPresetRules.cs b/src/Filee.Core/Gallery/SharedPresetRules.cs new file mode 100644 index 000000000..85101bdcc --- /dev/null +++ b/src/Filee.Core/Gallery/SharedPresetRules.cs @@ -0,0 +1,331 @@ +// The allow-list of what a preset shared in the preset gallery may hold (#1530): every field of a preset and a toolbar +// profile that may appear in a shared preset.json, with the values it may take. A field that isn't listed here fails the +// check, so a field added to Preset later stays out of the gallery until someone decides it is safe and lists it. +// Fields that would carry something of the sharer's (a folder, a password, a picture's path) or run something on the +// installer's computer are listed as "left out": they may be there, as an export writes them, but only empty. + +using Filee.Core.Formats; +using Filee.Core.Presets; +using Filee.Core.Profiles; + +namespace Filee.Core.Gallery; + +/// What one JSON value of a shared preset may be. +internal abstract record ValueRule +{ + /// JSON null is allowed (the field is optional in the model). + public bool Nullable { get; init; } +} + +/// An object with exactly these fields (each optional); any other field fails. +internal sealed record ObjectRule(IReadOnlyDictionary Fields) : ValueRule; + +/// true or false; when just one of them is allowed. +internal sealed record BoolRule(bool? Only = null) : ValueRule; + +/// A whole number from to ; with only those. +internal sealed record IntRule(long Min, long Max, IReadOnlyCollection? Allowed = null) : ValueRule; + +/// A finite number from to . +internal sealed record NumberRule(double Min, double Max) : ValueRule; + +/// One of , as the enum's name (any case, as the app reads it). +internal sealed record EnumRule(IReadOnlyList Names) : ValueRule; + +/// +/// A string of at most characters that passes (null: fine) and +/// the checks every shared text gets (no URL, no personal path, no control characters). +/// +internal sealed record StringRule(int MaxLength, Func? Check = null) : ValueRule; + +/// A list of at most values that each pass . +internal sealed record ListRule(ValueRule Item, int MaxCount) : ValueRule; + +/// +/// A field left out of shared presets: absent, null, "", false, 0, [] or {} only. says why, for the +/// message. +/// +internal sealed record LeftOutRule(string Why) : ValueRule; + +/// The allow-list itself, one per part of a . +internal static class SharedPresetRules +{ + /// Most presets one shared preset.json holds. + public const int MaxPresets = 20; + + /// Most toolbar profiles (donuts) one shared preset.json holds. + public const int MaxProfiles = 1; + + /// Largest preset.json, in bytes. + public const int MaxBundleBytes = 256 * 1024; + + /// How deep the JSON may nest; a preset needs 5 levels. + public const int MaxDepth = 16; + + /// The kinds of preset that may be shared. Copy to folder needs a folder of one's own. + public static readonly IReadOnlyList Kinds = [nameof(PresetKind.Convert), nameof(PresetKind.Group), nameof(PresetKind.Tool)]; + + /// + /// The tools that may be shared (; null is the crop). Metadata is left out: what it + /// changes (author, GPS) is about people. + /// + public static readonly IReadOnlyList Tools = + [ + null, Preset.CropTool, Preset.ClipTool, Preset.ImageEditTool, Preset.HideTool, Preset.AnnotateTool, + Preset.BackgroundTool, Preset.UpscaleTool, + ]; + + /// + /// Where a shared preset may save: the default location, next to the source, or in a folder next to it (named by + /// , which may not hold a separator). Never a folder of its own. + /// + public static readonly IReadOnlyList Locations = + [nameof(OutputLocation.Default), nameof(OutputLocation.SameFolder), nameof(OutputLocation.Subfolder)]; + + /// The placeholders a file name pattern may use (). + public static readonly IReadOnlyList Placeholders = ["name", "ext", "preset", "date", "time", "index"]; + + private static readonly long[] IcoSizes = [16, 20, 24, 32, 40, 48, 64, 96, 128, 256]; + + private static EnumRule Enum(params T[] except) where T : struct, System.Enum => + new([.. System.Enum.GetNames().Where(n => !except.Select(e => e.ToString()).Contains(n))]); + + private static ObjectRule Obj(params (string Name, ValueRule Rule)[] fields) => + new(fields.ToDictionary(f => f.Name, f => f.Rule, StringComparer.Ordinal)); + + private static readonly BoolRule Flag = new(); + private static readonly StringRule Colour = new(9, SharedText.HexColour); + + /// The fields of one preset. + public static readonly ObjectRule PresetFields = Obj( + ("id", new StringRule(64, SharedText.Id)), + ("name", new StringRule(80)), + ("nameKey", new StringRule(80, SharedText.NameKey) { Nullable = true }), + ("targetFormat", new StringRule(32, SharedText.Id)), + ("kind", new EnumRule(Kinds)), + ("tool", new StringRule(32, SharedText.Id) { Nullable = true }), + ("subPresetIds", new ListRule(new StringRule(64, SharedText.Id), Preset.MaxSubPresets)), + ("icon", new StringRule(48, SharedText.Id) { Nullable = true }), + ("shortName", new StringRule(40) { Nullable = true }), + ("show", Enum() with { Nullable = true }), + ("direction", Enum() with { Nullable = true }), + ("askCompression", Flag), + ("image", Obj( + ("quality", new IntRule(1, 100)), + ("maxSizeMb", new NumberRule(0, 10_000)), + ("maxSizeKeepsPixels", Flag), + ("minSizeMb", new NumberRule(0, 10_000)), + ("resize", Enum()), + ("rotation", Enum()), + ("flip", Enum()), + ("annotations", new LeftOutRule("marks are drawn on one picture, never saved with a preset")), + ("annotationColor", Colour with { Nullable = true }), + ("annotationWidth", Enum()), + ("backdrop", new LeftOutRule("set on one conversion, never saved with a preset")), + ("backdropLook", Obj( + ("kind", Enum()), + ("from", Colour), + ("to", Colour), + ("color", Colour), + ("picture", new LeftOutRule("a picture of one's own is a path on one's computer")), + ("pictureWritten", new LeftOutRule("belongs to a picture of one's own")), + ("pictureLength", new LeftOutRule("belongs to a picture of one's own")), + ("padding", new IntRule(0, BackdropOptions.MaxPadding)), + ("corners", new IntRule(0, BackdropOptions.MaxCorners)), + ("shadow", new IntRule(0, BackdropOptions.MaxShadow)), + ("canvas", Enum())) with + { Nullable = true }), + ("resizePercent", new IntRule(1, 1000)), + ("longEdge", new IntRule(1, CustomImageSize.MaxSide)), + ("width", new IntRule(1, CustomImageSize.MaxSide)), + ("customSize", Obj( + ("width", new IntRule(1, CustomImageSize.MaxSide)), + ("height", new IntRule(1, CustomImageSize.MaxSide)), + ("lockAspect", Flag), + ("shrinkOnly", Flag), + ("unit", Enum()), + ("dpi", new IntRule(CustomImageSize.MinDpi, CustomImageSize.MaxDpi))) with + { Nullable = true }), + ("minShortEdge", new IntRule(1, CustomImageSize.MaxSide) { Nullable = true }), + ("dpi", new IntRule(1, CustomImageSize.MaxDpi) { Nullable = true }), + ("keepMetadata", Flag), + ("keepColorProfile", Flag), + ("maxCompression", Flag), + ("keepSourceQuality", Flag), + ("grayscale", Flag), + ("trimTransparent", Flag), + ("removeBackground", Flag), + ("backgroundTolerance", new IntRule(0, 100)), + ("keepEnclosedBackground", Flag), + ("background", Colour), + ("tiffCompression", Enum()), + ("multiPageTiff", Flag), + ("webpLossless", Flag), + ("joinIntoOne", Flag), + ("joinDirection", Enum()), + ("joinFit", Enum()), + ("joinGap", new IntRule(0, ImageOptions.MaxJoinGap)), + ("joinBackground", new StringRule(16, SharedText.JoinBackground)), + ("fitsTiff", Enum()), + ("fitsDebayer", Flag), + ("icoSizes", new ListRule(new IntRule(16, 256, IcoSizes), IcoSizes.Length)), + ("hideStyle", Enum()), + ("hideStrength", new IntRule(HideTool.MinStrength, HideTool.MaxStrength)), + ("hiddenAreas", new LeftOutRule("the parts of one picture to hide, never saved with a preset")), + ("animationSpeed", new NumberRule(0.1, 10)))), + ("pdf", Obj( + ("mergeIntoSingle", Flag), + ("sourceBookmarks", Flag), + ("normalizeMergePaper", Flag), + ("mergePaper", Enum()), + ("mergeOrientation", Enum()), + ("pageSize", Enum()), + ("marginMm", new NumberRule(0, 200)), + ("renderDpi", new IntRule(1, 2400)), + ("pageRange", new StringRule(200, SharedText.PageRange)), + ("askPageRange", Flag), + ("splitPages", Flag), + ("splitEvery", new IntRule(0, Conversion.PageGroups.MaxSize)), + ("splitParts", new IntRule(0, Conversion.PageGroups.MaxSize)), + ("rotation", Enum()), + ("pagesPerSheet", new IntRule(0, 9, [0, 1, .. PdfOptions.SheetLayouts.Select(n => (long)n)])), + ("sheetPaper", Enum()), + ("sheetOrientation", Enum()), + ("sheetOrder", Enum()), + ("sheetMarginMm", new NumberRule(0, 200)), + ("sheetGapMm", new NumberRule(0, 200)), + ("sheetBorders", Flag), + ("combinePages", Flag), + ("trimPageMargins", Flag), + ("splitTallImage", Flag), + ("compression", Enum()), + ("removeMetadata", Flag), + ("compressWithGhostscript", Flag), + ("watermark", new StringRule(100)), + ("pageNumbers", Flag), + ("password", new LeftOutRule("a password is never shared")), + ("passwordLeftOut", new BoolRule(false)), + ("ocr", Flag), + ("ocrLanguage", new StringRule(64, SharedText.OcrLanguages)))), + ("document", Obj( + ("pdfA", Flag), + ("leaveOutPictures", Flag))), + ("media", Obj( + ("quality", Enum()), + ("maxHeight", new IntRule(0, 8640)), + ("maxSizeMb", new NumberRule(0, 100_000)), + ("maxSizePercent", new IntRule(0, 100)), + ("audioBitrateKbps", new IntRule(0, 1536)), + ("removeAudio", Flag), + ("cropFrame", new StringRule(16, SharedText.CropFrame)), + ("audioChannels", new IntRule(0, 8)), + ("audioSampleRate", new IntRule(0, 384_000)), + ("wavFormat", Enum()), + ("videoCodec", Enum()), + ("audioCodec", Enum()), + ("hardwareEncoding", Flag), + ("saveEveryFrame", Flag), + ("extractMotionPhoto", Flag), + ("caption", Flag), + ("spokenLanguage", new StringRule(16, SharedText.SpokenLanguage)), + ("livePhoto", Enum()), + ("livePhotoStart", new NumberRule(0, 86_400)), + ("livePhotoLength", new NumberRule(0, 60)), + ("livePhotoCover", new NumberRule(0, 60)), + ("frameInterval", new IntRule(1, 86_400)), + ("ringtoneStart", new NumberRule(0, 86_400)), + ("ringtoneLength", new NumberRule(0, 600)), + ("askRingtonePart", Flag), + ("exactCut", Flag), + ("splitEverySeconds", new IntRule(0, 86_400)), + ("splitParts", new IntRule(0, MediaOptions.MaxSplitParts)), + ("clip", new LeftOutRule("a clip is cut from one video, and its music is a file of one's own")), + ("joinIntoOne", Flag), + ("loopMusic", Flag), + ("slideshow", Flag), + ("slideSeconds", new NumberRule(0, 600)), + ("crossfade", Flag), + ("copyAudioWhenPossible", Flag), + ("removeMetadata", Flag))), + ("archive", Obj( + ("combineIntoOne", Flag), + ("level", Enum()))), + ("trace", Obj( + ("mode", Enum()), + ("colors", new IntRule(TracingOptions.MinColors, TracingOptions.MaxColors)), + ("detail", new IntRule(0, 100)))), + ("cad", Obj( + ("version", Enum()), + ("colors", Enum()))), + ("gcode", Obj( + ("view", Enum()))), + ("translate", Obj( + ("enabled", Flag), + ("targetLanguage", new StringRule(16, SharedText.LanguageCode)), + ("sourceLanguage", new StringRule(16, SharedText.LanguageCode)), + ("model", new LeftOutRule("the AI model is the installer's own choice (Settings)")))), + ("upscale", Obj( + ("enabled", Flag), + ("model", new StringRule(80, SharedText.ModelId) { Nullable = true }), + ("scale", new IntRule(1, 8)))), + ("metadata", new LeftOutRule("metadata edits write names, places and dates into files")), + ("output", Obj( + ("location", new EnumRule(Locations)), + ("askEveryTime", Flag), + ("subfolderName", new StringRule(64, SharedText.FileNamePattern)), + ("subfolderOnlyForSeveralFiles", Flag), + ("customFolder", new LeftOutRule("a folder is a path on one's own computer")), + ("keepFoldersFrom", new LeftOutRule("a folder is a path on one's own computer")), + ("fileNamePattern", new StringRule(120, SharedText.FileNamePattern)), + ("renames", new ListRule(Obj( + ("kind", Enum()), + ("find", new StringRule(100)), + ("replace", new StringRule(100, SharedText.RenameReplace)), + // Written by an export, never read back (RenameStep.IsBlank has no setter). + ("isBlank", Flag)), 10)), + ("conflict", new EnumRule([nameof(ConflictPolicy.Rename), nameof(ConflictPolicy.Skip)])), + ("keepDates", Flag), + ("zipResult", Flag), + ("onlyIfSmaller", Flag), + ("replaceOriginal", new BoolRule(false)), + ("moveInsteadOfCopy", new BoolRule(false))))); + + /// The fields of one toolbar profile (a donut). + public static readonly ObjectRule ProfileFields = Obj( + ("id", new StringRule(64, SharedText.Id)), + ("name", new StringRule(80)), + ("nameKey", new StringRule(80, SharedText.NameKey) { Nullable = true }), + ("extensions", new ListRule(new StringRule(16, SharedText.Extension), 400)), + ("isFallback", Flag), + ("presetIds", new ListRule(new StringRule(64, SharedText.Id), ToolbarProfile.MaxSlices)), + ("backPresetIds", new ListRule(new StringRule(64, SharedText.Id), ToolbarProfile.MaxSlices) { Nullable = true }), + ("labelShow", Enum() with { Nullable = true }), + ("labelScale", new NumberRule(ToolbarProfile.MinLabelScale, ToolbarProfile.MaxLabelScale))); + + /// The top of preset.json. + public static readonly ObjectRule BundleFields = Obj( + ("schemaVersion", new IntRule(1, 1)), + ("settingsSchema", new IntRule(1, Settings.AppSettings.CurrentSchemaVersion) { Nullable = true }), + ("presets", new ListRule(PresetFields, MaxPresets)), + ("profiles", new ListRule(ProfileFields, MaxProfiles))); + + /// + /// Every field path the allow-list knows (image.quality, output.renames[].find), with whether it is + /// left out: for the tests that compare it with the model, and for the documentation. + /// + public static IEnumerable<(string Path, bool LeftOut)> Paths(ObjectRule rule, string prefix = "") + { + foreach (var (name, field) in rule.Fields) + { + var path = prefix + name; + yield return (path, field is LeftOutRule); + var inner = field is ListRule list ? list.Item : field; + if (inner is ObjectRule nested) + foreach (var child in Paths(nested, path + (field is ListRule ? "[]." : "."))) + yield return child; + } + } + + /// The format id stands for, for formats.from. + internal static string? FormatOfExtension(string extension) => FormatRegistry.FindByExtension(extension)?.Id; +} diff --git a/src/Filee.Core/Gallery/SharedText.cs b/src/Filee.Core/Gallery/SharedText.cs new file mode 100644 index 000000000..6eacdd1e2 --- /dev/null +++ b/src/Filee.Core/Gallery/SharedText.cs @@ -0,0 +1,149 @@ +// Checks of the texts in a shared preset (#1530): every text is free of links, personal paths and hidden characters, +// and the texts that become file or folder names or are read as codes have the one form the app reads. + +using System.Text.RegularExpressions; +using Filee.Core.Presets; + +namespace Filee.Core.Gallery; + +/// Text checks for shared presets. Each returns why a value is refused, or null when it is fine. +internal static partial class SharedText +{ + /// The only addresses a shared text may contain: Filee's own. + public static readonly IReadOnlyList AllowedUrls = ["https://filee.sh/", "https://github.com/KnifeLemon/"]; + + /// The checks every shared text gets, whatever the field: the problem's code and why, or null. + public static (string Code, string Message)? Common(string text) + { + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + if (char.IsControl(c)) + return ("text.character", "has a control character"); + // Bidirectional overrides and isolates, and zero-width characters, can make a text read as something else. + if (c is '\u200B' or '\u200C' or '\u200D' or '\u200E' or '\u200F' or '\u2060' or '\uFEFF' + || c is >= '\u202A' and <= '\u202E' || c is >= '\u2066' and <= '\u2069') + return ("text.character", "has an invisible or direction-changing character"); + if (char.IsHighSurrogate(c) && i + 1 < text.Length && char.IsLowSurrogate(text[i + 1])) + i++; + else if (char.IsSurrogate(c)) + return ("text.character", "is not valid text"); + } + foreach (Match match in Link().Matches(text)) + { + var rest = text[match.Index..]; + if (!AllowedUrls.Any(allowed => rest.StartsWith(allowed, StringComparison.OrdinalIgnoreCase))) + return ("text.url", "has a link (only links to filee.sh are allowed)"); + } + if (PersonalPath().IsMatch(text)) + return ("text.personal_path", "has a path of a computer's folders (C:\\Users\\…, /home/…)"); + return null; + } + + /// A link: a scheme, "www." or a mail address. + [GeneratedRegex(@"(?i)(\b[a-z][a-z0-9+.\-]*://|\bwww\.|\bmailto:|\bfile:|\b[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}\b)", RegexOptions.CultureInvariant)] + private static partial Regex Link(); + + /// + /// A path that tells whose computer it is or where their files are: a drive (C:\, D:/), a network share (\\server), + /// a home folder (/home/, /Users/, ~/), an environment variable (%USERPROFILE%, $HOME). + /// + [GeneratedRegex(@"(?i)(\b[a-z]:[\\/]|\\\\[^\\\s]|(^|[\s""'(=])/(home|users|root|mnt|media|volumes|private|var|tmp|etc)\b|(^|[\s""'(=])~[\\/]|%[a-z_]+%|\$home\b|\$\{?env|\\users\\)", RegexOptions.CultureInvariant)] + private static partial Regex PersonalPath(); + + /// An id: letters, digits, "-", "_" and "." (preset ids, format ids, tool names, icon keys). + public static string? Id(string text) => + IdPattern().IsMatch(text) ? null : "may only have letters, digits, '-', '_' and '.'"; + + [GeneratedRegex(@"^[A-Za-z0-9][A-Za-z0-9._\-]*$|^\*$", RegexOptions.CultureInvariant)] + private static partial Regex IdPattern(); + + /// A built-in name's translation key (preset.merge_pdf, profile.images). + public static string? NameKey(string text) => + NameKeyPattern().IsMatch(text) ? null : "is not a name key of Filee's"; + + [GeneratedRegex(@"^(preset|profile|group|tool)\.[a-z0-9_.]+$", RegexOptions.CultureInvariant)] + private static partial Regex NameKeyPattern(); + + /// A file extension without its dot. + public static string? Extension(string text) => + ExtensionPattern().IsMatch(text) ? null : "is not a file extension"; + + [GeneratedRegex(@"^[A-Za-z0-9][A-Za-z0-9_\-~]*$", RegexOptions.CultureInvariant)] + private static partial Regex ExtensionPattern(); + + /// "#RRGGBB" or "#RGB". + public static string? HexColour(string text) => + HexPattern().IsMatch(text) ? null : "is not a colour like #RRGGBB"; + + [GeneratedRegex(@"^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$", RegexOptions.CultureInvariant)] + private static partial Regex HexPattern(); + + /// A colour or "transparent" (). + public static string? JoinBackground(string text) => + text == ImageOptions.TransparentJoin ? null : HexColour(text); + + /// Pages as the editor takes them ("1-3,5"), or empty. + public static string? PageRange(string text) => + Conversion.PageRange.Check(text, null).IsValid ? null : "is not a page range like 1-3,5"; + + /// Tesseract languages, "+" between them ("eng", "chi_sim+eng"). + public static string? OcrLanguages(string text) => + OcrPattern().IsMatch(text) ? null : "is not a list of OCR languages like eng or chi_sim+eng"; + + [GeneratedRegex(@"^[a-z]{3}(_[a-z]{3,4})?(\+[a-z]{3}(_[a-z]{3,4})?){0,5}$", RegexOptions.CultureInvariant)] + private static partial Regex OcrPattern(); + + /// "auto" or a language Whisper knows (). + public static string? SpokenLanguage(string text) => + text == WhisperLanguages.Auto || WhisperLanguages.Find(text) is not null ? null : "is not a spoken language Filee knows"; + + /// "auto" or a language code ("en", "pt-BR", "zh-Hant"). + public static string? LanguageCode(string text) => + LanguagePattern().IsMatch(text) ? null : "is not a language code like en or pt-BR"; + + [GeneratedRegex(@"^(auto|[a-z]{2,3}([_\-][A-Za-z]{2,4})?)$", RegexOptions.CultureInvariant)] + private static partial Regex LanguagePattern(); + + /// An upscaling model's id (the app downloads only the curated ones, by id). + public static string? ModelId(string text) => + ModelPattern().IsMatch(text) ? null : "is not a model id"; + + [GeneratedRegex(@"^[A-Za-z0-9][A-Za-z0-9._\-]{0,79}$", RegexOptions.CultureInvariant)] + private static partial Regex ModelPattern(); + + /// "auto", a ratio ("16:9") or empty (). + public static string? CropFrame(string text) => + text.Length == 0 || VideoCrop.Crops(text) ? null : "is not a frame like 16:9 or auto"; + + private static readonly char[] Unsafe = ['\\', '/', ':', '*', '?', '"', '<', '>', '|']; + + /// + /// A file name pattern: text and the placeholders of , never a + /// separator or "..", so the name can't leave the folder it is saved in. + /// + public static string? FileNamePattern(string text) + { + foreach (Match match in Braces().Matches(text)) + { + if (!SharedPresetRules.Placeholders.Contains(match.Groups[1].Value.ToLowerInvariant())) + return $"has an unknown placeholder {match.Value} (use {string.Join(", ", SharedPresetRules.Placeholders.Select(p => "{" + p + "}"))})"; + } + var literal = Braces().Replace(text, ""); + if (literal.Contains('{') || literal.Contains('}')) + return "has an unclosed placeholder"; + if (text.Contains("..")) + return "may not have '..'"; + return text.IndexOfAny(Unsafe) >= 0 ? "may not have \\ / : * ? \" < > |" : null; + } + + [GeneratedRegex(@"\{([^{}]*)\}", RegexOptions.CultureInvariant)] + private static partial Regex Braces(); + + /// + /// What a rename step puts in: no separator, so the name stays one name ("$1" of a regular expression inserts a part + /// of the name, which has none). + /// + public static string? RenameReplace(string text) => + text.IndexOfAny(Unsafe) >= 0 ? "may not have \\ / : * ? \" < > |" : null; +} diff --git a/src/Filee.Core/Settings/FileeJsonContext.cs b/src/Filee.Core/Settings/FileeJsonContext.cs index 990600e6f..ddb90e844 100644 --- a/src/Filee.Core/Settings/FileeJsonContext.cs +++ b/src/Filee.Core/Settings/FileeJsonContext.cs @@ -28,6 +28,13 @@ internal sealed partial class FileeJsonContext : JsonSerializerContext; public sealed class PresetBundle { public int SchemaVersion { get; set; } = 1; + + /// + /// The settings schema () of the Filee that wrote the bundle; null in + /// bundles from before it was written. Tells a later Filee which migrations a bundle's presets still need, and the + /// preset gallery's check that a bundle doesn't come from a newer Filee than the one checking it. + /// + public int? SettingsSchema { get; set; } public List Presets { get; set; } = []; public List Profiles { get; set; } = []; } diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index cc9fddf77..c8f95302b 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -268,8 +268,12 @@ private void RestoreBuiltInPresets(IEnumerable ids) /// Exports presets and profiles to a shareable JSON file, without PDF passwords or paths to one's own pictures. /// public void Export(string path) => - Write(path, new PresetBundle { Presets = [.. Presets.Select(p => p.ForSharing())], Profiles = Profiles }, - FileeJsonContext.Default.PresetBundle); + Write(path, new PresetBundle + { + SettingsSchema = AppSettings.CurrentSchemaVersion, + Presets = [.. Presets.Select(p => p.ForSharing())], + Profiles = Profiles, + }, FileeJsonContext.Default.PresetBundle); /// /// Imports presets and profiles from a bundle. Items with an existing id are replaced, new ones added. diff --git a/tests/Filee.App.Tests/PresetGalleryTests.cs b/tests/Filee.App.Tests/PresetGalleryTests.cs new file mode 100644 index 000000000..18f52fe23 --- /dev/null +++ b/tests/Filee.App.Tests/PresetGalleryTests.cs @@ -0,0 +1,529 @@ +// The "Preset gallery" window (#1530) against a stand-in for filee.sh: an HTTP handler that serves an index.json, the +// pictures (drawn with SkiaSharp here) and each preset.json, all made in the test. It checks the sorting and filters, +// that presets for a newer Filee are left out, that installing adds new presets through the counted download link and +// refuses a file that isn't the listed one, the fallback to the repository, the size limits and the picture cache, and +// renders the window in light and dark for the pull request. + +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.VisualTree; +using Filee.App.Services; +using Filee.App.ViewModels; +using Filee.App.ViewModels.Pages; +using Filee.App.Views; +using Filee.App.Views.Pages; +using Filee.Core.Gallery; +using Filee.Core.Localization; +using Filee.Core.Presets; +using Filee.Core.Profiles; +using Filee.Core.Settings; +using SkiaSharp; + +namespace Filee.App.Tests; + +public sealed class PresetGalleryTests : IDisposable +{ + private readonly string _root = Directory.CreateTempSubdirectory("filee-gallery-tests-").FullName; + + public void Dispose() + { + try { Directory.Delete(_root, true); } catch (IOException) { } + } + + /// filee.sh and the gallery's repository, answered from memory; every request is recorded. + private sealed class FakeGallery : HttpMessageHandler + { + public Dictionary Files { get; } = new(StringComparer.Ordinal); + public List Requests { get; } = []; + public bool SiteDown { get; set; } + public bool Offline { get; set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var uri = request.RequestUri!; + lock (Requests) + Requests.Add(uri); + if (Offline || (SiteDown && uri.Host == "filee.sh")) + throw new HttpRequestException("No route to host."); + Assert.True(uri.Scheme == "https" && uri.Host is "filee.sh" or "raw.githubusercontent.com", uri.ToString()); + var key = uri.Host == "filee.sh" ? uri.AbsolutePath : "repo:" + uri.AbsolutePath["/KnifeLemon/Filee-Presets/main".Length..]; + return Task.FromResult(Files.TryGetValue(key, out var data) + ? new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(data), RequestMessage = request } + : new HttpResponseMessage(HttpStatusCode.NotFound) { RequestMessage = request }); + } + + public int Count(string path) + { + lock (Requests) + return Requests.Count(r => r.AbsolutePath == path); + } + } + + private static byte[] Picture(int width, int height, SKColor sky, SKEncodedImageFormat format = SKEncodedImageFormat.Png) + { + using var bitmap = new SKBitmap(width, height); + using (var canvas = new SKCanvas(bitmap)) + { + canvas.Clear(sky); + using var sun = new SKPaint { Color = SKColors.Gold, IsAntialias = true }; + canvas.DrawCircle(width * 0.7f, height * 0.4f, Math.Min(width, height) / 5f, sun); + using var hill = new SKPaint { Color = new SKColor(60, 140, 90), IsAntialias = true }; + canvas.DrawOval(width * 0.3f, height * 1.05f, width * 0.6f, height * 0.4f, hill); + } + using var image = SKImage.FromBitmap(bitmap); + using var encoded = image.Encode(format, 85); + return encoded.ToArray(); + } + + /// A preset.json as Settings → General → Export writes it, with and a donut. + private byte[] Bundle(IEnumerable presets, ToolbarProfile? profile = null) + { + var data = Path.Combine(_root, "export-" + Guid.NewGuid().ToString("N")[..6]); + var store = new UserDataStore(data); + store.Load(); + store.Presets.Clear(); + store.Presets.AddRange(presets); + store.Profiles.Clear(); + if (profile is not null) + store.Profiles.Add(profile); + var path = Path.Combine(data, "bundle.json"); + store.Export(path); + return File.ReadAllBytes(path); + } + + private static readonly DateTimeOffset Today = new(2026, 11, 15, 0, 0, 0, TimeSpan.Zero); + + /// Adds a shared preset to the gallery: its files, and its entry for index.json. + private JsonObject Share(FakeGallery gallery, string slug, string title, byte[] preset, SKColor sky, string[] kinds, string[] to, + string[] tags, string[] languages, string minVersion = "2.0.0", int days = 0, double hot = 0, int likes = 0, int downloads = 0, + int images = 2, int? discussion = 7) + { + var folder = $"presets/{slug}/"; + gallery.Files["/presets/files/" + folder + "preset.json"] = preset; + gallery.Files["repo:/" + folder + "preset.json"] = preset; + gallery.Files["/presets/download/" + slug] = preset; + var paths = new JsonArray(); + for (var i = 1; i <= images; i++) + { + var picture = Picture(1280, 720, i == 1 ? sky : SKColors.MediumPurple); + gallery.Files[$"/presets/files/{folder}images/{i}.webp"] = picture; + gallery.Files[$"repo:/{folder}images/{i}.webp"] = picture; + paths.Add($"{folder}images/{i}.webp"); + } + var thumb = Picture(480, 270, sky, SKEncodedImageFormat.Webp); + gallery.Files[$"/presets/files/{folder}images/1-thumb.webp"] = thumb; + gallery.Files[$"repo:/{folder}images/1-thumb.webp"] = thumb; + return new JsonObject + { + ["id"] = slug, + ["title"] = title, + ["description"] = $"{title}: made for the gallery tests, with a description long enough to wrap onto a second line in the window.", + ["author"] = "tester-" + slug[..3], + ["tags"] = new JsonArray([.. tags.Select(t => (JsonNode)t)]), + ["languages"] = new JsonArray([.. languages.Select(l => (JsonNode)l)]), + ["kinds"] = new JsonArray([.. kinds.Select(k => (JsonNode)k)]), + ["formats"] = new JsonObject { ["from"] = new JsonArray(), ["to"] = new JsonArray([.. to.Select(t => (JsonNode)t)]) }, + ["minVersion"] = minVersion, + ["created"] = Today.AddDays(-days).ToString("o"), + ["updated"] = Today.AddDays(-days).ToString("o"), + ["discussion"] = discussion, + ["license"] = "CC0-1.0", + ["images"] = paths, + ["thumb"] = $"{folder}images/1-thumb.webp", + ["preset"] = folder + "preset.json", + ["sha256"] = Convert.ToHexStringLower(SHA256.HashData(preset)), + ["stats"] = new JsonObject + { + ["likes"] = likes, + ["comments"] = 1, + ["downloads"] = downloads, + ["hot"] = hot, + ["best"] = new JsonObject { ["day"] = 0, ["week"] = likes, ["month"] = likes, ["year"] = likes + 1, ["all"] = likes + downloads }, + }, + }; + } + + /// + /// Publishes the entries as the repository's index.json (paths presets/<id>/…) and as filee.sh's, whose paths + /// are relative to https://filee.sh/presets/index.json (files/presets/<id>/…) and which has an install link. + /// + private static void Publish(FakeGallery gallery, params JsonObject[] entries) + { + static byte[] Index(IEnumerable list) => Encoding.UTF8.GetBytes(new JsonObject + { + ["version"] = 1, + ["generated"] = Today.ToString("o"), + ["presets"] = new JsonArray([.. list]), + }.ToJsonString()); + gallery.Files["repo:/index.json"] = Index(entries.Select(e => e.DeepClone().AsObject())); + gallery.Files["/presets/index.json"] = Index(entries.Select(e => + { + var site = e.DeepClone().AsObject(); + site["images"] = new JsonArray([.. site["images"]!.AsArray().Select(p => (JsonNode)("files/" + p!.GetValue()))]); + site["thumb"] = "files/" + site["thumb"]!.GetValue(); + site["preset"] = "files/" + site["preset"]!.GetValue(); + site["download"] = "https://filee.sh/presets/download/" + site["id"]!.GetValue(); + return site; + })); + } + + private static Preset Insta() => new() + { + Id = "insta", + Name = "Instagram JPG", + TargetFormat = "jpg", + Image = { Quality = 85, Resize = ResizeMode.LongEdge, LongEdge = 1080, KeepMetadata = false }, + Output = { FileNamePattern = "{name}_insta" }, + }; + + /// A gallery of four shared presets, one of them for a newer Filee. + private FakeGallery Gallery() + { + var gallery = new FakeGallery(); + var scans = new Preset { Id = "scans", Name = "Scans to one PDF", TargetFormat = "pdf", Pdf = { MergeIntoSingle = true, Compression = PdfCompression.Ebook } }; + var left = new Preset { Id = "left", Name = "Turn left", TargetFormat = BuiltInData.SameAsSource, Image = { Rotation = ImageRotation.Left90 } }; + var right = new Preset { Id = "right", Name = "Turn right", TargetFormat = BuiltInData.SameAsSource, Image = { Rotation = ImageRotation.Right90 } }; + var turn = new Preset { Id = "turn", Name = "Turn", Kind = PresetKind.Group, TargetFormat = Preset.GroupTarget, SubPresetIds = ["left", "right"] }; + var donut = new ToolbarProfile { Id = "photos", Name = "Photo desk", Extensions = ["jpg", "png", "heic"], PresetIds = ["turn", "left", "right"], BackPresetIds = [] }; + Publish(gallery, + Share(gallery, "instagram-jpg", "Instagram-ready JPG", Bundle([Insta()]), SKColors.SkyBlue, ["convert"], ["jpg"], ["images", "social"], ["en"], + days: 3, hot: 9, likes: 12, downloads: 340), + Share(gallery, "scan-merge", "Scans to one small PDF", Bundle([scans]), SKColors.LightSalmon, ["convert"], ["pdf"], ["pdf", "office"], ["en", "ko"], + days: 1, hot: 4, likes: 30, downloads: 90), + Share(gallery, "photo-desk", "Photo desk donut", Bundle([turn, left, right], donut), SKColors.MediumAquamarine, ["convert", "toolbar"], [], + ["images"], ["ko"], days: 10, hot: 2, likes: 5, downloads: 20), + Share(gallery, "from-the-future", "Needs a newer Filee", Bundle([Insta()]), SKColors.Gray, ["convert"], ["jpg"], ["images"], ["en"], + minVersion: "99.0.0")); + return gallery; + } + + private UserDataStore Store() + { + var store = new UserDataStore(Path.Combine(_root, "library-" + Guid.NewGuid().ToString("N")[..6])); + store.Load(); + return store; + } + + private (PresetGalleryViewModel Vm, PresetGalleryClient Client, List Opened, List Installed) Open( + FakeGallery gallery, UserDataStore store, string? cache = "") + { + var client = new PresetGalleryClient(gallery, cache); + var opened = new List(); + var installed = new List(); + var vm = new PresetGalleryViewModel(client, store, AppHost.Get(), new Version(2, 5, 0), + uri => { opened.Add(uri); return Task.CompletedTask; }, installed.Add); + _ = vm.LoadAsync(); + ClipWindowTests.WaitFor(() => vm.State != GalleryState.Loading); + return (vm, client, opened, installed); + } + + [AvaloniaFact] + public void The_list_leaves_out_presets_for_a_newer_filee_and_sorts_and_filters() + { + TestServices.EnsureInitialized(); + var (vm, client, _, _) = Open(Gallery(), Store()); + using var _client = client; + using var _vm = vm; + + Assert.Equal(GalleryState.Ready, vm.State); + Assert.Equal(["Instagram-ready JPG", "Scans to one small PDF", "Photo desk donut"], vm.Cards.Select(c => c.Title)); + Assert.Contains("1", vm.NewerText); + + vm.Sort = vm.Sorts.Single(s => s.Value == GallerySort.Best); + Assert.True(vm.ShowPeriods); + vm.Period = vm.Periods.Single(p => p.Value == "all"); + Assert.Equal("Instagram-ready JPG", vm.Cards[0].Title); // 12 + 340 + vm.Period = vm.Periods.Single(p => p.Value == "week"); + Assert.Equal("Scans to one small PDF", vm.Cards[0].Title); // 30 likes this week + vm.Sort = vm.Sorts.Single(s => s.Value == GallerySort.New); + Assert.False(vm.ShowPeriods); + Assert.Equal(["Scans to one small PDF", "Instagram-ready JPG", "Photo desk donut"], vm.Cards.Select(c => c.Title)); + + vm.Kind = vm.Kinds.Single(k => k.Value == "toolbar"); + Assert.Equal(["Photo desk donut"], vm.Cards.Select(c => c.Title)); + vm.Kind = vm.Kinds[0]; + vm.Format = vm.Formats.Single(f => f.Value == "pdf"); + Assert.Equal(["Scans to one small PDF"], vm.Cards.Select(c => c.Title)); + vm.Format = vm.Formats[0]; + vm.Language = vm.Languages.Single(l => l.Value == "ko"); + Assert.Equal(2, vm.Cards.Count); + vm.Tag = vm.Tags.Single(t => t.Value == "images"); + Assert.Equal(["Photo desk donut"], vm.Cards.Select(c => c.Title)); + vm.Tag = vm.Tags[0]; + vm.Language = vm.Languages[0]; + vm.Search = "tester-sca"; + Assert.Equal(["Scans to one small PDF"], vm.Cards.Select(c => c.Title)); + vm.Search = "nothing like this"; + Assert.True(vm.IsEmpty); + Assert.Equal(AppHost.Get()["gallery.empty"], vm.EmptyText); + } + + [AvaloniaFact] + public void Covers_load_into_the_cards() + { + TestServices.EnsureInitialized(); + var gallery = Gallery(); + var (vm, client, _, _) = Open(gallery, Store()); + using var _client = client; + using var _vm = vm; + + ClipWindowTests.WaitFor(() => vm.Cards.All(c => c.Thumb is not null)); + Assert.Equal(480, vm.Cards[0].Thumb!.PixelSize.Width); + Assert.Equal(1, gallery.Count("/presets/files/presets/instagram-jpg/images/1-thumb.webp")); + Assert.Equal(0, gallery.Count("/presets/files/presets/from-the-future/images/1-thumb.webp")); + Assert.Contains("Instagram-ready JPG", vm.Cards[0].AutomationName); + Assert.Contains("tester-ins", vm.Cards[0].AutomationName); + } + + [AvaloniaFact] + public void The_chosen_preset_shows_what_it_adds_and_installs_as_new_presets_through_the_counted_link() + { + TestServices.EnsureInitialized(); + var gallery = Gallery(); + var store = Store(); + var ours = store.Presets.Count; + var (vm, client, opened, installed) = Open(gallery, store); + using var _client = client; + using var _vm = vm; + + vm.Selected = vm.Cards.Single(c => c.Entry.Id == "photo-desk"); + var detail = vm.Detail!; + ClipWindowTests.WaitFor(() => detail.Installable && detail.Images.All(i => i.Bitmap is not null)); + Assert.Equal(["Turn", "Turn left", "Turn right"], detail.Presets.Select(p => p.Name)); + Assert.Contains("Turn left", detail.Presets[0].What); + Assert.Contains("Photo desk", detail.Donut); + Assert.Contains("JPG", detail.Donut); + Assert.Equal(0, gallery.Count("/presets/download/photo-desk")); // looking isn't downloading + + detail.InstallCommand.Execute(null); + ClipWindowTests.WaitFor(() => detail.InstallState != GalleryInstallState.Installing); + + Assert.Equal(GalleryInstallState.Installed, detail.InstallState); + Assert.Equal(1, gallery.Count("/presets/download/photo-desk")); + Assert.Equal(ours + 3, store.Presets.Count); + var result = Assert.Single(installed); + Assert.DoesNotContain(result.Presets, p => p.Id is "turn" or "left" or "right"); + Assert.Same(result.Profile, store.Profiles[^1]); + Assert.False(detail.InstallCommand.CanExecute(null)); // once + + detail.OpenCommentsCommand.Execute(null); + Assert.Equal(new Uri("https://github.com/KnifeLemon/Filee-Presets/discussions/7"), Assert.Single(opened)); + } + + [AvaloniaFact] + public void A_preset_described_in_the_detail_lists_its_target_and_options() + { + TestServices.EnsureInitialized(); + var (vm, client, _, _) = Open(Gallery(), Store()); + using var _client = client; + using var _vm = vm; + + vm.Selected = vm.Cards.Single(c => c.Entry.Id == "instagram-jpg"); + ClipWindowTests.WaitFor(() => vm.Detail!.Installable); + var summary = Assert.Single(vm.Detail!.Presets); + Assert.Equal("Instagram JPG", summary.Name); + Assert.Contains("JPG", summary.What); + Assert.Contains("85", summary.Options); + Assert.Contains("1080", summary.Options); + Assert.Contains("{name}_insta", summary.Options); + Assert.Equal(AppHost.Get()["gallery.donut_none"], vm.Detail.Donut); + } + + [AvaloniaFact] + public void A_download_that_isnt_the_listed_file_installs_nothing() + { + TestServices.EnsureInitialized(); + var gallery = Gallery(); + var store = Store(); + var before = store.Presets.Count; + // The counted link answers with another file than the one index.json lists (and the detail view checked). + var other = Insta(); + other.Image.Quality = 5; + gallery.Files["/presets/download/instagram-jpg"] = Bundle([other]); + var (vm, client, _, installed) = Open(gallery, store); + using var _client = client; + using var _vm = vm; + + vm.Selected = vm.Cards.Single(c => c.Entry.Id == "instagram-jpg"); + ClipWindowTests.WaitFor(() => vm.Detail!.Installable); + vm.Detail!.InstallCommand.Execute(null); + ClipWindowTests.WaitFor(() => vm.Detail.InstallState != GalleryInstallState.Installing); + + Assert.Equal(GalleryInstallState.Failed, vm.Detail.InstallState); + Assert.Equal(AppHost.Get()["gallery.mismatch"], vm.Detail.InstallMessage); + Assert.Equal(before, store.Presets.Count); + Assert.Empty(installed); + } + + [AvaloniaFact] + public void A_listed_preset_the_rules_refuse_cant_be_installed() + { + TestServices.EnsureInitialized(); + var gallery = new FakeGallery(); + var bad = Insta(); + bad.Output.Location = OutputLocation.CustomFolder; + bad.Output.CustomFolder = @"C:\Users\Public"; + Publish(gallery, Share(gallery, "bad-one", "Bad one", Bundle([bad]), SKColors.Red, ["convert"], ["jpg"], [], ["en"])); + var (vm, client, _, _) = Open(gallery, Store()); + using var _client = client; + using var _vm = vm; + + vm.Selected = vm.Cards.Single(); + ClipWindowTests.WaitFor(() => vm.Detail!.SummaryNote != AppHost.Get()["gallery.loading_details"]); + Assert.False(vm.Detail!.Installable); + Assert.False(vm.Detail.InstallCommand.CanExecute(null)); + Assert.Contains("presets[0].output.", vm.Detail.SummaryNote); + } + + [AvaloniaFact] + public void Without_filee_sh_the_repository_is_read_and_without_a_network_retry_works_later() + { + TestServices.EnsureInitialized(); + var gallery = Gallery(); + gallery.SiteDown = true; + var store = Store(); + var (vm, client, _, _) = Open(gallery, store); + using var _client = client; + using var _vm = vm; + + Assert.Equal(GalleryState.Ready, vm.State); + Assert.True(client.FromRepository); + ClipWindowTests.WaitFor(() => vm.Cards.All(c => c.Thumb is not null)); + vm.Selected = vm.Cards.Single(c => c.Entry.Id == "instagram-jpg"); + ClipWindowTests.WaitFor(() => vm.Detail!.Installable); + vm.Detail!.InstallCommand.Execute(null); + ClipWindowTests.WaitFor(() => vm.Detail.InstallState == GalleryInstallState.Installed); + // Every file came from the repository, its preset.json in place of the counted link. + Assert.Equal(2, gallery.Requests.Count(r => r.AbsolutePath == "/KnifeLemon/Filee-Presets/main/presets/instagram-jpg/preset.json")); + Assert.DoesNotContain(gallery.Requests, r => r.Host == "filee.sh" && r.AbsolutePath != "/presets/index.json"); + + gallery.Offline = true; + var (offline, offlineClient, _, _) = Open(gallery, store); + using var _offlineClient = offlineClient; + using var _offline = offline; + Assert.Equal(GalleryState.Failed, offline.State); + Assert.True(offline.IsFailed); + gallery.Offline = false; + gallery.SiteDown = false; + offline.LoadCommand.Execute(null); + ClipWindowTests.WaitFor(() => offline.State == GalleryState.Ready); + Assert.Equal(3, offline.Cards.Count); + } + + [AvaloniaFact] + public void Answers_larger_than_their_limit_are_refused() + { + TestServices.EnsureInitialized(); + var gallery = Gallery(); + gallery.Files["/presets/files/presets/instagram-jpg/images/1-thumb.webp"] = new byte[PresetGalleryClient.MaxThumbBytes + 1]; + var (vm, client, _, _) = Open(gallery, Store()); + using var _client = client; + using var _vm = vm; + ClipWindowTests.WaitFor(() => vm.Cards.Where(c => c.Entry.Id != "instagram-jpg").All(c => c.Thumb is not null)); + Assert.Null(vm.Cards.Single(c => c.Entry.Id == "instagram-jpg").Thumb); + + gallery.Files["/presets/index.json"] = new byte[GalleryIndex.MaxBytes + 1]; + gallery.Files["repo:/index.json"] = new byte[GalleryIndex.MaxBytes + 1]; + var (big, bigClient, _, _) = Open(gallery, Store()); + using var _bigClient = bigClient; + using var _big = big; + Assert.Equal(GalleryState.Failed, big.State); + } + + [AvaloniaFact] + public void Pictures_are_kept_in_the_cache_for_the_next_time() + { + TestServices.EnsureInitialized(); + var gallery = Gallery(); + var cache = Path.Combine(_root, "cache"); + var path = "/presets/files/presets/scan-merge/images/1-thumb.webp"; + var (first, firstClient, _, _) = Open(gallery, Store(), cache); + ClipWindowTests.WaitFor(() => first.Cards.All(c => c.Thumb is not null)); + first.Dispose(); + firstClient.Dispose(); + Assert.Equal(1, gallery.Count(path)); + Assert.NotEmpty(Directory.GetFiles(cache, "*.img")); + + var (second, secondClient, _, _) = Open(gallery, Store(), cache); + using var _secondClient = secondClient; + using var _second = second; + ClipWindowTests.WaitFor(() => second.Cards.All(c => c.Thumb is not null)); + Assert.Equal(1, gallery.Count(path)); + } + + [AvaloniaFact] + public void The_presets_page_opens_the_gallery() + { + TestServices.EnsureInitialized(); + var page = new PresetsPage { DataContext = new PresetsPageViewModel(AppHost.Get(), AppHost.Get(), AppHost.Get()) }; + var window = new Window { Content = page, Width = 1000, Height = 700 }; + window.Show(); + RenderTests.Pump(); + var button = page.GetVisualDescendants().OfType + private void OnUiLanguageChanged(object? sender, EventArgs e) + { + if (State != GalleryState.Ready) + return; + _ = LoadThumbsAsync(); + _ = Detail?.LoadImagesAsync(); } /// This Filee's version, as the update check reads it (a pre-release counts as its release). @@ -280,6 +290,7 @@ private async Task LoadThumbsAsync() public void Dispose() { + _loc.LanguageChanged -= OnUiLanguageChanged; _closing.Cancel(); Detail?.Dispose(); foreach (var card in _all) @@ -294,11 +305,14 @@ public sealed partial class PresetGalleryCardViewModel : ObservableObject, IDisp /// The cover's size in the list, in pixels of its long edge. public const int ThumbEdge = 480; + private readonly ILocalizer _loc; private bool _loading; + private string? _thumbPath; public PresetGalleryCardViewModel(GalleryEntry entry, ILocalizer loc) { Entry = entry; + _loc = loc; Byline = entry.Author.Length == 0 ? "" : loc.Format("gallery.by", entry.Author); Likes = loc.Format("gallery.likes", entry.Stats.Likes.ToString("N0", CultureInfo.CurrentCulture)); Downloads = loc.Format("gallery.downloads", entry.Stats.Downloads.ToString("N0", CultureInfo.CurrentCulture)); @@ -325,20 +339,29 @@ public bool Matches(string query) => || Entry.Author.Contains(query, StringComparison.CurrentCultureIgnoreCase) || Entry.Tags.Any(t => t.Contains(query, StringComparison.CurrentCultureIgnoreCase)); - /// Loads the cover's thumbnail (the cover itself when there is none); a failure leaves the placeholder. + /// + /// Loads the cover's thumbnail in the app's language (; the cover itself when there + /// is none), or again after the language changed; a failure leaves what is shown. + /// public async Task LoadThumbAsync(PresetGalleryClient client, CancellationToken cancellationToken) { - if (Thumb is not null || _loading || (Entry.Thumb ?? Entry.Images.FirstOrDefault()) is not { } path) + var language = _loc.Language; + if (_loading || (Entry.ThumbFor(language) ?? Entry.ImagesFor(language).FirstOrDefault()) is not { } path + || (Thumb is not null && path == _thumbPath)) return; _loading = true; try { var data = await client.GetImageAsync(Entry, path, cancellationToken); var bitmap = await Task.Run(() => GalleryBitmaps.Decode(data, ThumbEdge), cancellationToken); - if (cancellationToken.IsCancellationRequested) + if (cancellationToken.IsCancellationRequested || bitmap is null) bitmap?.Dispose(); else - Thumb = bitmap; + { + var old = Thumb; + (Thumb, _thumbPath) = (bitmap, path); + old?.Dispose(); + } } catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException or IOException or InvalidDataException or ArgumentException) { @@ -370,6 +393,9 @@ public sealed record PresetGallerySummary(string Name, string What, string Optio public bool HasOptions => Options.Length > 0; } +/// A setting of the chosen preset that touches the user's files, in their words (). +public sealed record PresetGalleryWarning(string Code, string Text, bool IsHigh); + /// Whether the chosen preset is being installed, was, or couldn't be. public enum GalleryInstallState { @@ -408,9 +434,6 @@ internal PresetGalleryDetailViewModel(GalleryEntry entry, PresetGalleryClient cl ? string.Join(" · ", loc.Format("gallery.likes", entry.Stats.Likes.ToString("N0", CultureInfo.CurrentCulture)), loc.Format("gallery.downloads", entry.Stats.Downloads.ToString("N0", CultureInfo.CurrentCulture))) : ""; - for (var i = 0; i < entry.Images.Count; i++) - Images.Add(new PresetGalleryImageViewModel(loc.Format("gallery.image", i + 1, entry.Images.Count))); - _shownImage = Images.FirstOrDefault(); } public GalleryEntry Entry { get; } @@ -431,6 +454,9 @@ internal PresetGalleryDetailViewModel(GalleryEntry entry, PresetGalleryClient cl public bool HasSeveralImages => Images.Count > 1; + /// The paths shows, for the language they were picked for. + private IReadOnlyList _imagePaths = []; + /// What each preset in it does. public ObservableCollection Presets { get; } = []; @@ -456,22 +482,63 @@ internal PresetGalleryDetailViewModel(GalleryEntry entry, PresetGalleryClient cl public bool HasComments => Entry.Discussion is not null; + /// + /// What the preset does to the user's files, worked out here from the file itself (never from the index), most + /// serious first. + /// + public ObservableCollection Warnings { get; } = []; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasHighWarnings))] + private bool _hasWarnings; + + /// It can replace, move or overwrite files: Install waits for . + public bool HasHighWarnings => Warnings.Any(w => w.IsHigh); + + /// The user ticked "I understand…" for a preset with . + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(InstallCommand))] + private bool _warningsAccepted; + /// Loads the pictures and what the preset does. public async Task LoadAsync() { SummaryNote = _loc["gallery.loading_details"]; + await LoadSummaryAsync(_cancel.Token); + await LoadImagesAsync(); + } + + /// + /// Loads the pictures in the app's language (), or again after the language + /// changed; nothing happens when the language has the same pictures. + /// + public async Task LoadImagesAsync() + { var token = _cancel.Token; - await LoadSummaryAsync(token); - for (var i = 0; i < Entry.Images.Count && !token.IsCancellationRequested; i++) + var paths = Entry.ImagesFor(_loc.Language); + if (paths.SequenceEqual(_imagePaths) || token.IsCancellationRequested) + return; + _imagePaths = paths; + var shown = ShownImage is { } current ? Images.IndexOf(current) : 0; + foreach (var image in Images) + image.Bitmap?.Dispose(); + Images.Clear(); + for (var i = 0; i < paths.Count; i++) + Images.Add(new PresetGalleryImageViewModel(_loc.Format("gallery.image", i + 1, paths.Count))); + ShownImage = Images.Count == 0 ? null : Images[Math.Clamp(shown, 0, Images.Count - 1)]; + OnPropertyChanged(nameof(HasSeveralImages)); + var set = Images.ToList(); + for (var i = 0; i < paths.Count && !token.IsCancellationRequested && _imagePaths == paths; i++) { try { - var data = await _client.GetImageAsync(Entry, Entry.Images[i], token); + var data = await _client.GetImageAsync(Entry, paths[i], token); var bitmap = await Task.Run(() => GalleryBitmaps.Decode(data, ImageEdge), token); - if (token.IsCancellationRequested) + // A language changed meanwhile: these pictures are no longer shown. + if (token.IsCancellationRequested || _imagePaths != paths) bitmap?.Dispose(); else - Images[i].Bitmap = bitmap; + set[i].Bitmap = bitmap; } catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException or IOException or InvalidDataException or ArgumentException) { @@ -508,11 +575,17 @@ private async Task LoadSummaryAsync(CancellationToken token) ? _loc.Format("gallery.donut_new", _loc.DisplayName(profile), profile.Extensions.Count == 0 ? _loc["gallery.donut_mixed"] : string.Join(", ", profile.Extensions.Take(8).Select(e => e.ToUpperInvariant())) + (profile.Extensions.Count > 8 ? " …" : "")) : _loc["gallery.donut_none"]; + foreach (var warning in check.Derived?.Warnings ?? []) + Warnings.Add(new PresetGalleryWarning(warning.Code, _loc[$"gallery.warning.{warning.Code}"], warning.Severity == SharedPresetWarnings.High)); + HasWarnings = Warnings.Count > 0; + OnPropertyChanged(nameof(HasHighWarnings)); SummaryNote = null; Installable = true; } - private bool CanInstall() => Installable && InstallState is not (GalleryInstallState.Installing or GalleryInstallState.Installed); + private bool CanInstall() => + Installable && (!HasHighWarnings || WarningsAccepted) + && InstallState is not (GalleryInstallState.Installing or GalleryInstallState.Installed); /// Downloads it through the install link and adds it to the library as new presets. [RelayCommand(CanExecute = nameof(CanInstall))] @@ -531,7 +604,9 @@ private async Task InstallAsync() InstallMessage = _loc["gallery.download_failed"]; return; } - var result = GalleryInstaller.Install(_store, json, Entry.Sha256, _loc.DisplayName, _loc.DisplayName); + // The installer checks the file's SHA-256 against the index's, as the view did: it is the file the warnings + // shown were worked out from. + var result = GalleryInstaller.Install(_store, json, Entry.Sha256, _loc.DisplayName, _loc.DisplayName, Entry.Id); if (!result.Ok) { InstallState = GalleryInstallState.Failed; diff --git a/src/Filee.App/Views/GalleryExportWindow.axaml b/src/Filee.App/Views/GalleryExportWindow.axaml new file mode 100644 index 000000000..8da30744a --- /dev/null +++ b/src/Filee.App/Views/GalleryExportWindow.axaml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Filee.App/Views/GalleryExportWindow.axaml.cs b/src/Filee.App/Views/GalleryExportWindow.axaml.cs new file mode 100644 index 000000000..431c4e6f7 --- /dev/null +++ b/src/Filee.App/Views/GalleryExportWindow.axaml.cs @@ -0,0 +1,28 @@ +// "Export for the gallery" (#1530). Escape closes it. + +using Avalonia.Controls; +using Avalonia.Input; +using Filee.App.Services; + +namespace Filee.App.Views; + +public partial class GalleryExportWindow : Window +{ + public GalleryExportWindow() + { + InitializeComponent(); + Motion.Track(this); + UiZoom.Track(this); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + if (e.Key == Key.Escape && !e.Handled) + { + Close(); + e.Handled = true; + return; + } + base.OnKeyDown(e); + } +} diff --git a/src/Filee.App/Views/Pages/PresetsPage.axaml b/src/Filee.App/Views/Pages/PresetsPage.axaml index 3fa146a2b..ac77d3b29 100644 --- a/src/Filee.App/Views/Pages/PresetsPage.axaml +++ b/src/Filee.App/Views/Pages/PresetsPage.axaml @@ -43,6 +43,14 @@ + + diff --git a/src/Filee.App/Views/PresetEditorView.axaml b/src/Filee.App/Views/PresetEditorView.axaml index 2e52942ea..d9f72ca7e 100644 --- a/src/Filee.App/Views/PresetEditorView.axaml +++ b/src/Filee.App/Views/PresetEditorView.axaml @@ -37,6 +37,9 @@ + + diff --git a/src/Filee.App/Views/PresetGalleryWindow.axaml b/src/Filee.App/Views/PresetGalleryWindow.axaml index e2b292edc..1bad84370 100644 --- a/src/Filee.App/Views/PresetGalleryWindow.axaml +++ b/src/Filee.App/Views/PresetGalleryWindow.axaml @@ -44,6 +44,17 @@ + + + @@ -150,6 +161,26 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/Filee.Cli/Help.cs b/src/Filee.Cli/Help.cs index 0116da169..965f4787b 100644 --- a/src/Filee.Cli/Help.cs +++ b/src/Filee.Cli/Help.cs @@ -60,15 +60,18 @@ Lists the presets saved in Filee (built-in and your own). With --json, prints th 'check' checks a shared preset for the preset gallery: a folder with preset.json (what Settings → General → Export writes, with at most 20 presets and one donut), meta.json (optional) and 1 to 4 pictures in images/ (1.png … 4.webp; PNG, JPEG or WebP, 640 to 2,560 pixels on the long edge, at most 2 MB, not animated). - Only fields on the gallery's list may be in preset.json, and nothing that saves outside the source's folder, - runs a program, or carries a path, a password or a link. It reads nothing else and changes nothing. + Only fields on the gallery's list may be in preset.json, and nothing that runs a program or carries a folder + of one's own, a password, a path, a link or personal metadata (names, places, copyright owners). Settings that + touch the installer's files (replacing or moving originals, overwriting, metadata) pass with a warning. It reads + nothing else and changes nothing. Exit codes: 0 the preset may be shared, 1 it has problems, 2 the folder doesn't exist. Options: --json Print the result as JSON: "ok", "errors" (each with "code", "file", "path" and "message") and "derived" (what the preset is: "kinds", "formats" with "from" and - "to", "minVersion", the number of "presets" and "profiles", and the "sha256" of - preset.json) + "to", "minVersion", the number of "presets" and "profiles", the "sha256" of + preset.json and "warnings", each with "code", "severity" (high, medium or info), + "message" and the "presets" that have it) --quiet Print nothing when the preset passes """; diff --git a/src/Filee.Cli/PresetsCheckCommand.cs b/src/Filee.Cli/PresetsCheckCommand.cs index 9c7508827..09f430d74 100644 --- a/src/Filee.Cli/PresetsCheckCommand.cs +++ b/src/Filee.Cli/PresetsCheckCommand.cs @@ -38,6 +38,8 @@ private static void WriteText(PresetCheckResult result, TextWriter output) return; } var derived = result.Derived!; + foreach (var warning in derived.Warnings) + output.WriteLine($"warning ({warning.Severity}): {warning.Message} ({warning.Code}: {string.Join(", ", warning.Presets)})"); output.WriteLine($"OK: {derived.Presets} preset(s), {derived.Profiles} donut(s). Kinds: {string.Join(", ", derived.Kinds)}. " + $"Formats: {(derived.From.Count == 0 ? "any" : string.Join(", ", derived.From))} → {(derived.To.Count == 0 ? "-" : string.Join(", ", derived.To))}. " + $"Needs Filee {derived.MinVersion} or later."); @@ -70,6 +72,17 @@ private static void WriteJson(PresetCheckResult result, TextWriter output) => Cl json.WriteNumber("presets", derived.Presets); json.WriteNumber("profiles", derived.Profiles); json.WriteString("sha256", derived.Sha256); + json.WriteStartArray("warnings"); + foreach (var warning in derived.Warnings) + { + json.WriteStartObject(); + json.WriteString("code", warning.Code); + json.WriteString("severity", warning.Severity); + json.WriteString("message", warning.Message); + WriteList(json, "presets", warning.Presets); + json.WriteEndObject(); + } + json.WriteEndArray(); json.WriteEndObject(); } else diff --git a/src/Filee.Core/Gallery/GalleryExport.cs b/src/Filee.Core/Gallery/GalleryExport.cs new file mode 100644 index 000000000..e481177d3 --- /dev/null +++ b/src/Filee.Core/Gallery/GalleryExport.cs @@ -0,0 +1,37 @@ +// "Export for the gallery" (#1530): a preset.json with only the presets one chooses, and one donut if one likes, for a +// submission to the preset gallery. A group brings its members and a donut its slices, so the file is whole. The same +// check the gallery's CI runs is run on it here, so the problems and warnings show before anything is saved. + +using System.Text.Json; +using Filee.Core.Presets; +using Filee.Core.Profiles; +using Filee.Core.Settings; + +namespace Filee.Core.Gallery; + +/// The file "Export for the gallery" writes, and what the gallery's check says about it. +public sealed record GalleryExportFile(byte[] Json, IReadOnlyList Presets, PresetCheckResult Check); + +public static class GalleryExport +{ + /// + /// The bundle of and (with the presets their groups and slices + /// need, in 's order), as an export writes it, and its check. + /// + public static GalleryExportFile Build(IReadOnlyList library, IEnumerable chosen, ToolbarProfile? profile) + { + var ids = chosen.Select(p => p.Id).ToHashSet(StringComparer.Ordinal); + if (profile is not null) + ids.UnionWith(profile.PresetIds.Concat(profile.BackPresetIds ?? [])); + foreach (var group in library.Where(p => p.IsGroup && ids.Contains(p.Id)).ToList()) + ids.UnionWith(group.SubPresetIds); + var presets = library.Where(p => ids.Contains(p.Id)).ToList(); + var json = JsonSerializer.SerializeToUtf8Bytes(new PresetBundle + { + SettingsSchema = AppSettings.CurrentSchemaVersion, + Presets = [.. presets.Select(p => p.ForSharing())], + Profiles = profile is null ? [] : [profile.Clone()], + }, FileeJsonContext.Default.PresetBundle); + return new GalleryExportFile(json, presets, SharedBundleCheck.Check(json)); + } +} diff --git a/src/Filee.Core/Gallery/GalleryIndex.cs b/src/Filee.Core/Gallery/GalleryIndex.cs index e17d14c05..84814023e 100644 --- a/src/Filee.Core/Gallery/GalleryIndex.cs +++ b/src/Filee.Core/Gallery/GalleryIndex.cs @@ -50,6 +50,32 @@ public sealed record GalleryEntry /// The cover's thumbnail; null without one (the cover is shown instead). public string? Thumb { get; init; } + /// + /// Pictures in a language of the app's ("ko" → its list, gaps already filled with the default pictures), for people + /// using Filee in that language; empty when the preset has none. + /// + public IReadOnlyDictionary> ImagesByLang { get; init; } = new Dictionary>(); + + /// The cover's thumbnail per language, as . + public IReadOnlyDictionary ThumbByLang { get; init; } = new Dictionary(); + + /// + /// The pictures for someone using Filee in : its own set, else its base language's + /// ("pt" for "pt-PT"), else the default set. + /// + public IReadOnlyList ImagesFor(string language) => + ByLanguage(ImagesByLang, language) is { Count: > 0 } images ? images : Images; + + /// The cover's thumbnail for , picked as . + public string? ThumbFor(string language) => ByLanguage(ThumbByLang, language) ?? Thumb; + + private static T? ByLanguage(IReadOnlyDictionary sets, string language) where T : class => + sets.GetValueOrDefault(language) ?? sets.GetValueOrDefault(language.Split('-')[0]); + + /// Every picture of the preset, in any language (what the app may download for it). + public bool HasPicture(string path) => + path == Thumb || Images.Contains(path) || ThumbByLang.Values.Contains(path) || ImagesByLang.Values.Any(set => set.Contains(path)); + /// Its preset.json, as a path relative to index.json. public required string Preset { get; init; } @@ -112,8 +138,20 @@ public static GalleryIndex Parse(ReadOnlySpan json) var title = Text(item, "title", 60).Trim(); if (InFolder(preset, id) != "preset.json" || !Sha256Pattern().IsMatch(sha256) || title.Length == 0) return null; - var images = List(item, "images", 4, 200).Where(path => InFolder(path, id) is { } file && ImageName().IsMatch(file)).ToList(); + bool IsPicture(string path) => InFolder(path, id) is { } file && KnownLanguage(ImageName().Match(file)); + bool IsThumb(string path) => InFolder(path, id) is { } file && KnownLanguage(ThumbName().Match(file)); + var images = List(item, "images", 4, 200).Where(IsPicture).ToList(); var thumb = Text(item, "thumb", 200); + var imagesByLang = new Dictionary>(StringComparer.Ordinal); + var thumbByLang = new Dictionary(StringComparer.Ordinal); + if (item.TryGetProperty("imagesByLang", out var byLang) && byLang.ValueKind == JsonValueKind.Object) + foreach (var set in byLang.EnumerateObject().Where(set => IsLanguage(set.Name)).Take(SharedPresetFolderCheck.Languages.Count * 2)) + if (List(byLang, set.Name, 4, 200).Where(IsPicture).ToList() is { Count: > 0 } list) + imagesByLang[set.Name] = list; + if (item.TryGetProperty("thumbByLang", out var thumbs) && thumbs.ValueKind == JsonValueKind.Object) + foreach (var set in thumbs.EnumerateObject().Where(set => IsLanguage(set.Name)).Take(SharedPresetFolderCheck.Languages.Count * 2)) + if (Text(thumbs, set.Name, 200) is { } path && IsThumb(path)) + thumbByLang[set.Name] = path; var download = Text(item, "download", 200); var formats = item.TryGetProperty("formats", out var f) && f.ValueKind == JsonValueKind.Object ? f : default; var stats = item.TryGetProperty("stats", out var s) && s.ValueKind == JsonValueKind.Object ? s : default; @@ -134,7 +172,9 @@ public static GalleryIndex Parse(ReadOnlySpan json) Discussion = item.TryGetProperty("discussion", out var d) && d.ValueKind == JsonValueKind.Number && d.TryGetInt32(out var n) && n > 0 ? n : null, License = Text(item, "license", 16) is { Length: > 0 } license ? license : SharedPresetFolderCheck.License, Images = images, - Thumb = InFolder(thumb, id) is { } thumbFile && ThumbName().IsMatch(thumbFile) ? thumb : null, + Thumb = IsThumb(thumb) ? thumb : null, + ImagesByLang = imagesByLang, + ThumbByLang = thumbByLang, Preset = preset, Download = IsDownload(download, id) ? download : null, Sha256 = sha256, @@ -164,15 +204,24 @@ private static GalleryStats Stats(JsonElement stats) } // Up to three plain folders before presets// (no "..", no scheme, no leading slash), then a file of the folder. - [GeneratedRegex(@"^(?:[a-z0-9][a-z0-9_\-]*/){0,3}presets/(?[a-z0-9\-]{3,48})/(?preset\.json|images/[1-4](?:-thumb)?\.[a-z]{3,4})$", RegexOptions.CultureInvariant)] + [GeneratedRegex(@"^(?:[a-z0-9][a-z0-9_\-]*/){0,3}presets/(?[a-z0-9\-]{3,48})/(?preset\.json|images/(?:[A-Za-z]{2,3}(?:-[A-Za-z]{2,4})?/)?[1-4](?:-thumb)?\.[a-z]{3,4})$", RegexOptions.CultureInvariant)] private static partial Regex RelativePath(); - [GeneratedRegex(@"^images/[1-4]\.(webp|png|jpg|jpeg)$", RegexOptions.CultureInvariant)] + // A picture of the default set (images/1.webp) or of a language's (images/ko/1.webp). + [GeneratedRegex(@"^images/(?:(?[A-Za-z]{2,3}(?:-[A-Za-z]{2,4})?)/)?[1-4]\.(webp|png|jpg|jpeg)$", RegexOptions.CultureInvariant)] private static partial Regex ImageName(); - [GeneratedRegex(@"^images/[1-4]-thumb\.webp$", RegexOptions.CultureInvariant)] + [GeneratedRegex(@"^images/(?:(?[A-Za-z]{2,3}(?:-[A-Za-z]{2,4})?)/)?[1-4]-thumb\.webp$", RegexOptions.CultureInvariant)] private static partial Regex ThumbName(); + /// A match of a picture's name, in the default set or a set of a language of the app's. + private static bool KnownLanguage(Match match) => + match.Success && (!match.Groups["lang"].Success || SharedPresetFolderCheck.Languages.Contains(match.Groups["lang"].Value)); + + /// A language of the app's, or the base of one ("pt", "zh"): the keys of the per-language pictures. + private static bool IsLanguage(string code) => + SharedPresetFolderCheck.Languages.Any(l => l == code || l.Split('-')[0] == code); + /// /// An install link of the preset's: https://filee.sh/presets/download/<id> or relative to index.json /// (download/<id>). The app also checks that it stays on the host index.json came from. diff --git a/src/Filee.Core/Gallery/GalleryInstaller.cs b/src/Filee.Core/Gallery/GalleryInstaller.cs index eb5f8fe7e..398fe68ff 100644 --- a/src/Filee.Core/Gallery/GalleryInstaller.cs +++ b/src/Filee.Core/Gallery/GalleryInstaller.cs @@ -27,8 +27,9 @@ public static class GalleryInstaller /// to and saves the library. and /// give the names the user sees (a built-in name in their language), to tell a clash. /// + /// The gallery entry's id, kept on each preset (). public static GalleryInstallResult Install(UserDataStore store, ReadOnlySpan json, string sha256, - Func presetName, Func profileName) + Func presetName, Func profileName, string? gallery = null) { var actual = Convert.ToHexStringLower(SHA256.HashData(json)); if (!string.Equals(actual, sha256.Trim(), StringComparison.OrdinalIgnoreCase)) @@ -59,6 +60,8 @@ public static GalleryInstallResult Install(UserDataStore store, ReadOnlySpanThe settings that touch the installer's files, most serious first (). + public IReadOnlyList Warnings { get; init; } = []; +} + +/// +/// A setting of a shared preset that touches the installer's files: (stable, see +/// ), ("high", "medium" or "info"), what it does in English, +/// and the ids of the presets in the file that have it. +/// +public sealed record PresetCheckWarning(string Code, string Severity, string Message, IReadOnlyList Presets); /// The outcome of or . public sealed class PresetCheckResult diff --git a/src/Filee.Core/Gallery/SharedBundleCheck.cs b/src/Filee.Core/Gallery/SharedBundleCheck.cs index aff8ab44c..f7c9507fe 100644 --- a/src/Filee.Core/Gallery/SharedBundleCheck.cs +++ b/src/Filee.Core/Gallery/SharedBundleCheck.cs @@ -132,6 +132,11 @@ private static void CheckPresets(PresetBundle bundle, PresetCheckResult result) if (preset.TargetFormat != Preset.GroupTarget) result.Add("preset.target_unknown", FileName, at + ".targetFormat", $"a group's target is \"{Preset.GroupTarget}\""); } + else if (preset.IsCopyToFolder) + { + if (preset.TargetFormat != Preset.CopyTarget) + result.Add("preset.target_unknown", FileName, at + ".targetFormat", $"Copy to folder's target is \"{Preset.CopyTarget}\""); + } else if (preset.TargetFormat != BuiltInData.SameAsSource && (FormatRegistry.FindById(preset.TargetFormat) is null || preset.TargetFormat == Preset.CopyTarget)) { @@ -177,7 +182,7 @@ private static PresetCheckDerived Derive(PresetBundle bundle, string sha256) var kinds = new List(); if (bundle.Presets.Any(p => p.Kind is PresetKind.Convert or PresetKind.Group)) kinds.Add("convert"); - if (bundle.Presets.Any(p => p.Kind == PresetKind.Tool)) + if (bundle.Presets.Any(p => p.IsTool)) kinds.Add("tool"); if (bundle.Profiles.Count > 0) kinds.Add("toolbar"); @@ -186,7 +191,10 @@ private static PresetCheckDerived Derive(PresetBundle bundle, string sha256) .Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList(); var to = bundle.Presets.Where(p => p.Kind == PresetKind.Convert && p.TargetFormat != BuiltInData.SameAsSource) .Select(p => p.TargetFormat).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList(); - return new PresetCheckDerived(kinds, from, to, BaselineVersion, bundle.Presets.Count, bundle.Profiles.Count, sha256); + return new PresetCheckDerived(kinds, from, to, BaselineVersion, bundle.Presets.Count, bundle.Profiles.Count, sha256) + { + Warnings = SharedPresetWarnings.Of(bundle.Presets), + }; } private static string FirstLine(string text) @@ -274,6 +282,29 @@ public void Check(JsonElement value, ValueRule rule, string path) else if (text.Check?.Invoke(s) is { } why) result.Add("field.value", file, path, $"\"{Shorten(s)}\" {why}"); break; + case MapRule map: + if (value.ValueKind != JsonValueKind.Object) + { + result.Add("field.type", file, path, "must be an object"); + break; + } + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in value.EnumerateObject()) + { + var at = $"{path}.{property.Name}"; + if (!names.Add(property.Name)) + result.Add("field.duplicate", file, at, "appears twice"); + else if (names.Count > map.MaxCount) + { + result.Add("field.range", file, path, $"has more than {map.MaxCount} fields"); + break; + } + else if (map.KeyCheck(property.Name) is { } bad) + result.Add("field.value", file, at, $"\"{Shorten(property.Name)}\" {bad}"); + else + Check(property.Value, map.ValueFor(property.Name), at); + } + break; case ListRule list: if (value.ValueKind != JsonValueKind.Array) { diff --git a/src/Filee.Core/Gallery/SharedPresetFolderCheck.cs b/src/Filee.Core/Gallery/SharedPresetFolderCheck.cs index 268c4288c..971582e48 100644 --- a/src/Filee.Core/Gallery/SharedPresetFolderCheck.cs +++ b/src/Filee.Core/Gallery/SharedPresetFolderCheck.cs @@ -38,6 +38,19 @@ public static partial class SharedPresetFolderCheck /// A preset's id: 3 to 48 of a-z, 0-9 and '-', not starting or ending with '-', and not reserved. public static bool IsSlug(string text) => Slug().IsMatch(text) && !ReservedSlugs.Contains(text); + /// Largest folder of a shared preset, every file in it counted, in bytes. + public const long MaxFolderBytes = 40L * 1024 * 1024; + + /// + /// The app's languages (its i18n files): images/<language>/ may hold pictures in one of them, shown to people + /// using Filee in that language. The app's tests keep this in step with its languages. + /// + public static readonly IReadOnlyList Languages = + [ + "ar", "bn-BD", "de", "en", "es", "fa", "fr", "hi", "id", "it", "ja", "ko", "mn", "ne", "pl", "pt-BR", "pt-PT", "ru", + "th", "tr", "vi", "zh-CN", "zh-TW", + ]; + /// The kinds meta.json may list (see ). public static readonly IReadOnlyList Kinds = ["convert", "tool", "toolbar"]; @@ -96,25 +109,69 @@ public static PresetCheckResult Check(string folder, IGalleryImageDecoder? decod result.Add("images.missing", ImagesFolder + "/", "", $"is missing: a shared preset needs {MinImages} to {MaxImages} pictures (1 is the cover)"); else CheckImages(new DirectoryInfo(Path.Combine(root.FullName, ImagesFolder)), decoder, result); + + var size = root.EnumerateFiles("*", new EnumerationOptions { RecurseSubdirectories = true, AttributesToSkip = FileAttributes.ReparsePoint }) + .Sum(f => f.Length); + if (size > MaxFolderBytes) + result.Add("folder.too_large", "", "", $"holds {size:N0} bytes; a shared preset's folder may hold at most {MaxFolderBytes:N0}"); return result; } - /// The pictures: 1.png … 4.webp (1 to 4, no gaps), each with an optional 1-thumb.webp. + /// + /// The pictures: 1.png … 4.webp (1 to 4, no gaps), each with an optional 1-thumb.webp, and the language sets in + /// images/<language>/ (): the same names, 1 to 4 of the default set's numbers (a number a + /// set leaves out shows the default picture). + /// private static void CheckImages(DirectoryInfo folder, IGalleryImageDecoder? decoder, PresetCheckResult result) + { + var languages = new List(); + var numbers = CheckImageSet(folder, ImagesFolder, decoder, result, languages); + if (numbers.Count < MinImages) + result.Add("images.missing", ImagesFolder + "/", "", $"has no pictures: a shared preset needs {MinImages} to {MaxImages} (1 is the cover)"); + else if (numbers.Max != numbers.Count) + result.Add("images.name", ImagesFolder + "/", "", "the pictures must be numbered from 1 without gaps (1 is the cover)"); + + foreach (var language in languages) + { + var prefix = $"{ImagesFolder}/{language.Name}"; + if (!Languages.Contains(language.Name)) + { + result.Add("images.language", prefix + "/", "", $"is not a language of Filee's ({string.Join(", ", Languages)})"); + continue; + } + var set = CheckImageSet(language, prefix, decoder, result, null); + if (set.Count < MinImages) + result.Add("images.missing", prefix + "/", "", "has no pictures; leave the folder out instead"); + foreach (var number in set.Where(n => !numbers.Contains(n))) + result.Add("images.name", prefix + "/", "", $"has a picture {number}, which the default set (images/) doesn't have"); + } + } + + /// + /// One set of pictures in : checks each, returns their numbers. Folders go to + /// (the default set's language sets), or are refused where there is none. + /// + private static SortedSet CheckImageSet(DirectoryInfo folder, string prefix, IGalleryImageDecoder? decoder, PresetCheckResult result, + List? languages) { var numbers = new SortedSet(); var thumbs = new List<(int Number, FileInfo File)>(); foreach (var entry in folder.EnumerateFileSystemInfos()) { - var name = $"{ImagesFolder}/{entry.Name}"; + var name = $"{prefix}/{entry.Name}"; if (entry.Attributes.HasFlag(FileAttributes.ReparsePoint)) { result.Add("folder.link", name, "", "is a link; the folder may only hold real files"); continue; } + if (entry is DirectoryInfo directory && languages is not null) + { + languages.Add(directory); + continue; + } if (entry is not FileInfo file) { - result.Add("images.unexpected_file", name, "", "doesn't belong in images/ (only 1.png … 4.webp)"); + result.Add("images.unexpected_file", name, "", "doesn't belong here (only 1.png … 4.webp, and a folder per language in images/)"); continue; } var match = ImageName().Match(file.Name); @@ -136,19 +193,15 @@ private static void CheckImages(DirectoryInfo folder, IGalleryImageDecoder? deco } CheckImage(file, name, match.Groups[3].Value.ToLowerInvariant(), thumb: false, decoder, result); } - - if (numbers.Count < MinImages) - result.Add("images.missing", ImagesFolder + "/", "", $"has no pictures: a shared preset needs {MinImages} to {MaxImages} (1 is the cover)"); - else if (numbers.Max != numbers.Count) - result.Add("images.name", ImagesFolder + "/", "", "the pictures must be numbered from 1 without gaps (1 is the cover)"); foreach (var (number, file) in thumbs) { - var name = $"{ImagesFolder}/{file.Name}"; + var name = $"{prefix}/{file.Name}"; if (!numbers.Contains(number)) result.Add("images.name", name, "", $"is a thumbnail of picture {number}, which isn't there"); else CheckImage(file, name, "webp", thumb: true, decoder, result); } + return numbers; } [GeneratedRegex(@"^([1-4])(?:(-thumb)\.webp|\.(png|jpg|jpeg|webp))$", RegexOptions.CultureInvariant)] @@ -218,6 +271,11 @@ private static void CheckImage(FileInfo file, string name, string extension, boo ["updated"] = new StringRule(40, Timestamp), ["discussion"] = new IntRule(1, int.MaxValue) { Nullable = true }, ["license"] = new StringRule(16, text => text == License ? null : $"must be {License}"), + ["warnings"] = new ListRule(new ObjectRule(new Dictionary(StringComparer.Ordinal) + { + ["code"] = new EnumRule([.. SharedPresetWarnings.All.Select(w => w.Code)]), + ["severity"] = new EnumRule([SharedPresetWarnings.High, SharedPresetWarnings.Medium, SharedPresetWarnings.Info]), + }), SharedPresetWarnings.All.Count), }); private static string? Timestamp(string text) => @@ -271,6 +329,10 @@ private static void CheckMeta(FileInfo file, PresetCheckDerived? derived, Preset if (formats.TryGetProperty("to", out var to) && !SameSet(to, derived.To)) result.Add("meta.derived_mismatch", MetaName, "formats.to", $"must be [{string.Join(", ", derived.To)}], as preset.json is"); } + if (root.TryGetProperty("warnings", out var warnings) + && !warnings.EnumerateArray().Select(w => w.TryGetProperty("code", out var c) ? c.GetString() : null).ToHashSet(StringComparer.Ordinal) + .SetEquals(derived.Warnings.Select(w => w.Code))) + result.Add("meta.derived_mismatch", MetaName, "warnings", $"must be [{string.Join(", ", derived.Warnings.Select(w => w.Code))}], as preset.json is"); if (root.TryGetProperty("minVersion", out var min) && Version.Parse(min.GetString()!) < Version.Parse(derived.MinVersion)) result.Add("meta.derived_mismatch", MetaName, "minVersion", $"must be at least {derived.MinVersion}, the first Filee that can install it"); } diff --git a/src/Filee.Core/Gallery/SharedPresetRules.cs b/src/Filee.Core/Gallery/SharedPresetRules.cs index 85101bdcc..927298166 100644 --- a/src/Filee.Core/Gallery/SharedPresetRules.cs +++ b/src/Filee.Core/Gallery/SharedPresetRules.cs @@ -1,8 +1,11 @@ // The allow-list of what a preset shared in the preset gallery may hold (#1530): every field of a preset and a toolbar // profile that may appear in a shared preset.json, with the values it may take. A field that isn't listed here fails the // check, so a field added to Preset later stays out of the gallery until someone decides it is safe and lists it. -// Fields that would carry something of the sharer's (a folder, a password, a picture's path) or run something on the -// installer's computer are listed as "left out": they may be there, as an export writes them, but only empty. +// Shared presets get as much freedom as the model has; settings that touch the installer's files (replacing or moving +// originals, overwriting, editing metadata) are allowed and reported as warnings (SharedPresetWarnings). Only what +// can't work on another computer or would give something of the sharer's away is "left out": a folder of one's own, a +// password, a picture's or a sound's path, and names, places and copyright owners written into metadata. Those may be +// there, as an export writes them, but only empty. using Filee.Core.Formats; using Filee.Core.Presets; @@ -41,6 +44,12 @@ internal sealed record StringRule(int MaxLength, Func? Check = /// A list of at most values that each pass . internal sealed record ListRule(ValueRule Item, int MaxCount) : ValueRule; +/// +/// An object whose field names are data (metadata fields, "XMP-dc:Title"): at most , each name +/// passing (null: fine), each value checked with the rule gives for it. +/// +internal sealed record MapRule(int MaxCount, Func KeyCheck, Func ValueFor) : ValueRule; + /// /// A field left out of shared presets: absent, null, "", false, 0, [] or {} only. says why, for the /// message. @@ -62,25 +71,28 @@ internal static class SharedPresetRules /// How deep the JSON may nest; a preset needs 5 levels. public const int MaxDepth = 16; - /// The kinds of preset that may be shared. Copy to folder needs a folder of one's own. - public static readonly IReadOnlyList Kinds = [nameof(PresetKind.Convert), nameof(PresetKind.Group), nameof(PresetKind.Tool)]; + /// + /// The kinds of preset that may be shared: every kind there is. Copy to folder comes without its folder (the + /// installer chooses one); a warning says so. + /// + public static readonly IReadOnlyList Kinds = + [nameof(PresetKind.Convert), nameof(PresetKind.Group), nameof(PresetKind.Tool), nameof(PresetKind.CopyToFolder)]; /// - /// The tools that may be shared (; null is the crop). Metadata is left out: what it - /// changes (author, GPS) is about people. + /// The tools that may be shared (; null is the crop): every tool there is. A tool that runs + /// a program or a command (none yet) must never be added here. /// public static readonly IReadOnlyList Tools = [ null, Preset.CropTool, Preset.ClipTool, Preset.ImageEditTool, Preset.HideTool, Preset.AnnotateTool, - Preset.BackgroundTool, Preset.UpscaleTool, + Preset.BackgroundTool, Preset.MetadataTool, Preset.UpscaleTool, ]; /// - /// Where a shared preset may save: the default location, next to the source, or in a folder next to it (named by - /// , which may not hold a separator). Never a folder of its own. + /// Where a shared preset may save: every location. The folder of is left + /// out (a path of one's own), so that one saves next to the source until the installer picks a folder. /// - public static readonly IReadOnlyList Locations = - [nameof(OutputLocation.Default), nameof(OutputLocation.SameFolder), nameof(OutputLocation.Subfolder)]; + public static readonly IReadOnlyList Locations = System.Enum.GetNames(); /// The placeholders a file name pattern may use (). public static readonly IReadOnlyList Placeholders = ["name", "ext", "preset", "date", "time", "index"]; @@ -96,6 +108,27 @@ private static ObjectRule Obj(params (string Name, ValueRule Rule)[] fields) => private static readonly BoolRule Flag = new(); private static readonly StringRule Colour = new(9, SharedText.HexColour); + private static readonly ObjectRule Backdrop = Obj( + ("kind", Enum()), + ("from", Colour), + ("to", Colour), + ("color", Colour), + ("picture", new LeftOutRule("a picture of one's own is a path on one's computer")), + ("pictureWritten", new LeftOutRule("belongs to a picture of one's own")), + ("pictureLength", new LeftOutRule("belongs to a picture of one's own")), + ("padding", new IntRule(0, BackdropOptions.MaxPadding)), + ("corners", new IntRule(0, BackdropOptions.MaxCorners)), + ("shadow", new IntRule(0, BackdropOptions.MaxShadow)), + ("canvas", Enum())); + + /// + /// A metadata field given a value: one that names a person, a place or a copyright owner + /// () may only be emptied ([], which removes it), any other may hold text. + /// + private static ValueRule MetadataValue(string field) => SharedText.IsPersonalField(field) + ? new LeftOutRule("names, places and copyright owners are personal data; a shared preset may only remove this field") + : new ListRule(new StringRule(500), 50); + /// The fields of one preset. public static readonly ObjectRule PresetFields = Obj( ("id", new StringRule(64, SharedText.Id)), @@ -110,6 +143,7 @@ private static ObjectRule Obj(params (string Name, ValueRule Rule)[] fields) => ("show", Enum() with { Nullable = true }), ("direction", Enum() with { Nullable = true }), ("askCompression", Flag), + ("gallery", new LeftOutRule("set by the app on a preset installed from the gallery")), ("image", Obj( ("quality", new IntRule(1, 100)), ("maxSizeMb", new NumberRule(0, 10_000)), @@ -118,23 +152,18 @@ private static ObjectRule Obj(params (string Name, ValueRule Rule)[] fields) => ("resize", Enum()), ("rotation", Enum()), ("flip", Enum()), - ("annotations", new LeftOutRule("marks are drawn on one picture, never saved with a preset")), - ("annotationColor", Colour with { Nullable = true }), - ("annotationWidth", Enum()), - ("backdrop", new LeftOutRule("set on one conversion, never saved with a preset")), - ("backdropLook", Obj( - ("kind", Enum()), - ("from", Colour), - ("to", Colour), + ("annotations", new ListRule(Obj( + ("kind", Enum()), ("color", Colour), - ("picture", new LeftOutRule("a picture of one's own is a path on one's computer")), - ("pictureWritten", new LeftOutRule("belongs to a picture of one's own")), - ("pictureLength", new LeftOutRule("belongs to a picture of one's own")), - ("padding", new IntRule(0, BackdropOptions.MaxPadding)), - ("corners", new IntRule(0, BackdropOptions.MaxCorners)), - ("shadow", new IntRule(0, BackdropOptions.MaxShadow)), - ("canvas", Enum())) with + ("width", Enum()), + ("points", new ListRule(Obj(("x", new NumberRule(-1, 2)), ("y", new NumberRule(-1, 2))), 5000)), + ("text", new StringRule(500) { Nullable = true }), + ("number", new IntRule(-9999, 9999))), 500) { Nullable = true }), + ("annotationColor", Colour with { Nullable = true }), + ("annotationWidth", Enum()), + ("backdrop", Backdrop with { Nullable = true }), + ("backdropLook", Backdrop with { Nullable = true }), ("resizePercent", new IntRule(1, 1000)), ("longEdge", new IntRule(1, CustomImageSize.MaxSide)), ("width", new IntRule(1, CustomImageSize.MaxSide)), @@ -171,7 +200,13 @@ private static ObjectRule Obj(params (string Name, ValueRule Rule)[] fields) => ("icoSizes", new ListRule(new IntRule(16, 256, IcoSizes), IcoSizes.Length)), ("hideStyle", Enum()), ("hideStrength", new IntRule(HideTool.MinStrength, HideTool.MaxStrength)), - ("hiddenAreas", new LeftOutRule("the parts of one picture to hide, never saved with a preset")), + ("hiddenAreas", new ListRule(Obj( + ("x", new NumberRule(0, 1)), + ("y", new NumberRule(0, 1)), + ("width", new NumberRule(0, 1)), + ("height", new NumberRule(0, 1)), + ("style", Enum() with { Nullable = true })), 500) + { Nullable = true }), ("animationSpeed", new NumberRule(0.1, 10)))), ("pdf", Obj( ("mergeIntoSingle", Flag), @@ -204,7 +239,7 @@ private static ObjectRule Obj(params (string Name, ValueRule Rule)[] fields) => ("watermark", new StringRule(100)), ("pageNumbers", Flag), ("password", new LeftOutRule("a password is never shared")), - ("passwordLeftOut", new BoolRule(false)), + ("passwordLeftOut", Flag), ("ocr", Flag), ("ocrLanguage", new StringRule(64, SharedText.OcrLanguages)))), ("document", Obj( @@ -239,7 +274,25 @@ private static ObjectRule Obj(params (string Name, ValueRule Rule)[] fields) => ("exactCut", Flag), ("splitEverySeconds", new IntRule(0, 86_400)), ("splitParts", new IntRule(0, MediaOptions.MaxSplitParts)), - ("clip", new LeftOutRule("a clip is cut from one video, and its music is a file of one's own")), + ("clip", Obj( + ("parts", new ListRule(Obj( + ("start", new NumberRule(0, 1_000_000)), + ("end", new NumberRule(0, 1_000_000)), + ("blank", Flag), + ("volume", new NumberRule(0, ClipPart.MaxVolume)), + ("muted", Flag), + ("speed", new NumberRule(ClipPart.MinSpeed, ClipPart.MaxSpeed))), 1000)), + ("fadeIn", new NumberRule(0, ClipEdit.MaxFade)), + ("fadeOut", new NumberRule(0, ClipEdit.MaxFade)), + ("music", Obj( + ("path", new LeftOutRule("a sound of one's own is a path on one's computer")), + ("offset", new NumberRule(0, 1_000_000)), + ("from", new NumberRule(0, 1_000_000)), + ("length", new NumberRule(0, 1_000_000)), + ("volume", new NumberRule(0, ClipPart.MaxVolume)), + ("fade", Flag)) with + { Nullable = true })) with + { Nullable = true }), ("joinIntoOne", Flag), ("loopMusic", Flag), ("slideshow", Flag), @@ -263,12 +316,17 @@ private static ObjectRule Obj(params (string Name, ValueRule Rule)[] fields) => ("enabled", Flag), ("targetLanguage", new StringRule(16, SharedText.LanguageCode)), ("sourceLanguage", new StringRule(16, SharedText.LanguageCode)), - ("model", new LeftOutRule("the AI model is the installer's own choice (Settings)")))), + ("model", new StringRule(100, SharedText.OllamaModel) { Nullable = true }))), ("upscale", Obj( ("enabled", Flag), ("model", new StringRule(80, SharedText.ModelId) { Nullable = true }), ("scale", new IntRule(1, 8)))), - ("metadata", new LeftOutRule("metadata edits write names, places and dates into files")), + ("metadata", Obj( + ("set", new MapRule(200, SharedText.MetadataField, MetadataValue)), + ("remove", new ListRule(new StringRule(100, SharedText.MetadataField), 500)), + ("removeLocation", Flag), + ("removeAll", Flag)) with + { Nullable = true }), ("output", Obj( ("location", new EnumRule(Locations)), ("askEveryTime", Flag), @@ -283,12 +341,12 @@ private static ObjectRule Obj(params (string Name, ValueRule Rule)[] fields) => ("replace", new StringRule(100, SharedText.RenameReplace)), // Written by an export, never read back (RenameStep.IsBlank has no setter). ("isBlank", Flag)), 10)), - ("conflict", new EnumRule([nameof(ConflictPolicy.Rename), nameof(ConflictPolicy.Skip)])), + ("conflict", Enum()), ("keepDates", Flag), ("zipResult", Flag), ("onlyIfSmaller", Flag), - ("replaceOriginal", new BoolRule(false)), - ("moveInsteadOfCopy", new BoolRule(false))))); + ("replaceOriginal", Flag), + ("moveInsteadOfCopy", Flag)))); /// The fields of one toolbar profile (a donut). public static readonly ObjectRule ProfileFields = Obj( diff --git a/src/Filee.Core/Gallery/SharedPresetWarnings.cs b/src/Filee.Core/Gallery/SharedPresetWarnings.cs new file mode 100644 index 000000000..135d78838 --- /dev/null +++ b/src/Filee.Core/Gallery/SharedPresetWarnings.cs @@ -0,0 +1,69 @@ +// The warnings of a shared preset (#1530): settings the gallery allows that touch the installer's files (replacing or +// moving originals, overwriting files, writing or removing metadata, a fixed AI model, edits drawn into every file). The +// check derives them from preset.json; the gallery's CI copies the codes into meta.json and index.json for the site, +// and the app derives them again from the file it installs, never trusting the index. The codes are a contract with +// the site and the gallery's repository: add new ones, never rename one. + +using Filee.Core.Presets; + +namespace Filee.Core.Gallery; + +public static class SharedPresetWarnings +{ + public const string High = "high", Medium = "medium", Info = "info"; + + /// Every warning code, with its severity and what it means, most serious first. + public static readonly IReadOnlyList<(string Code, string Severity, string Message)> All = + [ + ("replaces_original", High, "Replaces the original files: the result takes their place and the originals go to the Recycle Bin / Trash."), + ("moves_original", High, "Moves the original files into a folder instead of copying them."), + ("overwrites_existing", Medium, "Overwrites a file that already has the result's name instead of adding a number."), + ("copies_to_folder", Medium, "Copies files into a folder; you choose the folder after installing."), + ("edits_metadata", Medium, "Writes metadata fields into the files it converts."), + ("fixed_edits", Medium, "Draws the same marks, hidden areas, background or clip into every file it converts."), + ("strips_metadata", Info, "Removes metadata (such as camera details, dates or location) from the files it converts."), + ("uses_translate_model", Info, "Translates with a particular AI model, which you may need to download in Ollama."), + ]; + + /// The severity of ; null for a code this version doesn't know. + public static string? SeverityOf(string code) => All.FirstOrDefault(w => w.Code == code).Severity; + + /// The warnings give, each with the ids of the presets that have it. + public static IReadOnlyList Of(IEnumerable presets) + { + var found = new Dictionary>(StringComparer.Ordinal); + foreach (var preset in presets) + foreach (var code in Codes(preset)) + { + if (!found.TryGetValue(code, out var ids)) + found[code] = ids = []; + ids.Add(preset.Id); + } + return [.. All.Where(w => found.ContainsKey(w.Code)).Select(w => new PresetCheckWarning(w.Code, w.Severity, w.Message, found[w.Code]))]; + } + + /// The warning codes of one preset. + public static IEnumerable Codes(Preset preset) + { + if (preset.IsGroup) + yield break; + var output = preset.Output; + if (output.ReplaceOriginal) + yield return "replaces_original"; + if (preset.IsCopyToFolder && output.MoveInsteadOfCopy) + yield return "moves_original"; + if (output.Conflict == ConflictPolicy.Overwrite) + yield return "overwrites_existing"; + if (preset.IsCopyToFolder) + yield return "copies_to_folder"; + if (preset.Metadata is { Set.Count: > 0 }) + yield return "edits_metadata"; + if (preset.Image.HasAnnotations || preset.Image.HidesAreas || preset.Image.AddsBackdrop || preset.Media.Clip is { Parts.Count: > 0 }) + yield return "fixed_edits"; + if (!preset.Image.KeepMetadata || preset.Pdf.RemoveMetadata || preset.Media.RemoveMetadata + || preset.Metadata is { Remove.Count: > 0 } or { RemoveLocation: true } or { RemoveAll: true }) + yield return "strips_metadata"; + if (preset.Translate.Enabled && !string.IsNullOrWhiteSpace(preset.Translate.Model)) + yield return "uses_translate_model"; + } +} diff --git a/src/Filee.Core/Gallery/SharedText.cs b/src/Filee.Core/Gallery/SharedText.cs index 6eacdd1e2..cb89a2104 100644 --- a/src/Filee.Core/Gallery/SharedText.cs +++ b/src/Filee.Core/Gallery/SharedText.cs @@ -112,6 +112,26 @@ public static (string Code, string Message)? Common(string text) [GeneratedRegex(@"^[A-Za-z0-9][A-Za-z0-9._\-]{0,79}$", RegexOptions.CultureInvariant)] private static partial Regex ModelPattern(); + /// An Ollama model name ("llama3.1:8b", "library/qwen2.5"): never an address. + public static string? OllamaModel(string text) => + OllamaPattern().IsMatch(text) && !text.Contains("//", StringComparison.Ordinal) ? null : "is not a model name like llama3.1:8b"; + + [GeneratedRegex(@"^[A-Za-z0-9][A-Za-z0-9._:/\-]{0,99}$", RegexOptions.CultureInvariant)] + private static partial Regex OllamaPattern(); + + /// One metadata field, "Group:Tag" (). + public static string? MetadataField(string text) => + MetadataEdits.IsFieldKey(text) ? null : "is not a metadata field like XMP-dc:Title"; + + /// + /// True for a metadata field that names a person, a place or a copyright owner (author, artist, creator, copyright, + /// owner, credit, GPS and place fields, contact details): a shared preset may only remove it, never give it a value. + /// + public static bool IsPersonalField(string field) => PersonalField().IsMatch(field); + + [GeneratedRegex(@"(?i)(author|artist|creator|copyright|owner|by-?line|credit|rights|contact|e-?mail|phone|address|city|country|state|province|location|gps|latitude|longitude|altitude|position|lastmodifiedby|company|manager|name|person|serial|keywords?)", RegexOptions.CultureInvariant)] + private static partial Regex PersonalField(); + /// "auto", a ratio ("16:9") or empty (). public static string? CropFrame(string text) => text.Length == 0 || VideoCrop.Crops(text) ? null : "is not a frame like 16:9 or auto"; diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs index 90dad9577..d1e0e7fe1 100644 --- a/src/Filee.Core/Presets/Preset.cs +++ b/src/Filee.Core/Presets/Preset.cs @@ -183,6 +183,12 @@ public sealed class Preset [JsonConverter(typeof(LabelDirectionJsonConverter))] public LabelDirection? Direction { get; set; } + /// + /// The preset gallery entry (its id on filee.sh) this preset was installed from, so the editor can say so; null for + /// every other preset. Never shared (). + /// + public string? Gallery { get; set; } + /// A conversion or a group. A kind this version doesn't know (from a newer Filee) is read as a group. [JsonConverter(typeof(PresetKindJsonConverter))] public PresetKind Kind { get; set; } = PresetKind.Convert; @@ -428,6 +434,11 @@ public Preset WithoutPassword(bool mark) public Preset ForSharing() { var copy = WithoutPassword(mark: false); + if (copy.Gallery is not null) + { + copy = ReferenceEquals(copy, this) ? Clone() : copy; + copy.Gallery = null; + } if (copy.Image.Backdrop?.Picture is null && copy.Image.BackdropLook?.Picture is null) return copy; copy = ReferenceEquals(copy, this) ? Clone() : copy; @@ -459,6 +470,7 @@ public Preset ForSharing() Direction = Direction, AskCompression = AskCompression, Kind = Kind, + Gallery = Gallery, Tool = Tool, SubPresetIds = [.. SubPresetIds], }; diff --git a/tests/Filee.App.Tests/PresetGalleryTests.cs b/tests/Filee.App.Tests/PresetGalleryTests.cs index 18f52fe23..9d252aa9a 100644 --- a/tests/Filee.App.Tests/PresetGalleryTests.cs +++ b/tests/Filee.App.Tests/PresetGalleryTests.cs @@ -168,6 +168,12 @@ static byte[] Index(IEnumerable list) => Encoding.UTF8.GetBytes(new site["thumb"] = "files/" + site["thumb"]!.GetValue(); site["preset"] = "files/" + site["preset"]!.GetValue(); site["download"] = "https://filee.sh/presets/download/" + site["id"]!.GetValue(); + if (site["imagesByLang"] is JsonObject byLang) + foreach (var (language, list) in byLang.ToList()) + byLang[language] = new JsonArray([.. list!.AsArray().Select(p => (JsonNode)("files/" + p!.GetValue()))]); + if (site["thumbByLang"] is JsonObject thumbs) + foreach (var (language, path) in thumbs.ToList()) + thumbs[language] = "files/" + path!.GetValue(); return site; })); } @@ -456,6 +462,196 @@ public void Pictures_are_kept_in_the_cache_for_the_next_time() Assert.Equal(1, gallery.Count(path)); } + /// A gallery of one preset that replaces the originals (a high warning) and strips metadata. + private FakeGallery RiskyGallery() + { + var gallery = new FakeGallery(); + var risky = Insta(); + risky.Output.ReplaceOriginal = true; + Publish(gallery, Share(gallery, "in-place-jpg", "JPG in place", Bundle([risky]), SKColors.Tomato, ["convert"], ["jpg"], ["images"], ["en"])); + return gallery; + } + + [AvaloniaFact] + public void A_preset_that_can_replace_originals_warns_and_installs_only_once_the_user_agrees() + { + TestServices.EnsureInitialized(); + var store = Store(); + var (vm, client, _, installed) = Open(RiskyGallery(), store); + using var _client = client; + using var _vm = vm; + + vm.Selected = vm.Cards.Single(); + var detail = vm.Detail!; + ClipWindowTests.WaitFor(() => detail.Installable); + var loc = AppHost.Get(); + Assert.Equal([loc["gallery.warning.replaces_original"], loc["gallery.warning.strips_metadata"]], detail.Warnings.Select(w => w.Text)); + Assert.True(detail.HasHighWarnings); + Assert.False(detail.InstallCommand.CanExecute(null)); + + detail.WarningsAccepted = true; + Assert.True(detail.InstallCommand.CanExecute(null)); + detail.InstallCommand.Execute(null); + ClipWindowTests.WaitFor(() => detail.InstallState == GalleryInstallState.Installed); + + // Kept as shared, and marked as from the gallery. + var preset = Assert.Single(Assert.Single(installed).Presets); + Assert.True(preset.Output.ReplaceOriginal); + Assert.Equal("in-place-jpg", preset.Gallery); + var editor = new PresetEditorViewModel(preset, loc); + Assert.Contains("in-place-jpg", editor.GalleryNote); + Assert.Null(new PresetEditorViewModel(Insta(), loc).GalleryNote); + } + + [AvaloniaFact] + public void A_preset_with_only_milder_warnings_installs_without_the_tick() + { + TestServices.EnsureInitialized(); + var (vm, client, _, _) = Open(Gallery(), Store()); + using var _client = client; + using var _vm = vm; + + vm.Selected = vm.Cards.Single(c => c.Entry.Id == "instagram-jpg"); + ClipWindowTests.WaitFor(() => vm.Detail!.Installable); + Assert.True(vm.Detail!.HasWarnings); // it removes metadata + Assert.False(vm.Detail.HasHighWarnings); + Assert.True(vm.Detail.InstallCommand.CanExecute(null)); + } + + [AvaloniaFact] + public void Pictures_follow_the_apps_language_and_change_with_it() + { + TestServices.EnsureInitialized("en"); + var gallery = Gallery(); + var entry = Share(gallery, "korean-shots", "Korean screenshots", Bundle([Insta()]), SKColors.SkyBlue, ["convert"], ["jpg"], [], ["ko"]); + var korean = Picture(1280, 720, SKColors.HotPink); + var koreanThumb = Picture(480, 270, SKColors.HotPink, SKEncodedImageFormat.Webp); + gallery.Files["/presets/files/presets/korean-shots/images/ko/1.webp"] = korean; + gallery.Files["/presets/files/presets/korean-shots/images/ko/1-thumb.webp"] = koreanThumb; + entry["imagesByLang"] = new JsonObject { ["ko"] = new JsonArray("presets/korean-shots/images/ko/1.webp", "presets/korean-shots/images/2.webp") }; + entry["thumbByLang"] = new JsonObject { ["ko"] = "presets/korean-shots/images/ko/1-thumb.webp" }; + Publish(gallery, entry); + var localization = AppHost.Get(); + var (vm, client, _, _) = Open(gallery, Store()); + using var _client = client; + try + { + var card = vm.Cards.Single(); + ClipWindowTests.WaitFor(() => card.Thumb is not null); + Assert.Equal(0, gallery.Count("/presets/files/presets/korean-shots/images/ko/1-thumb.webp")); + vm.Selected = card; + ClipWindowTests.WaitFor(() => vm.Detail!.Images.All(i => i.Bitmap is not null)); + var english = card.Thumb; + + localization.SetLanguage("ko"); + ClipWindowTests.WaitFor(() => card.Thumb != english && vm.Detail!.Images.All(i => i.Bitmap is not null)); + Assert.Equal(1, gallery.Count("/presets/files/presets/korean-shots/images/ko/1-thumb.webp")); + Assert.Equal(1, gallery.Count("/presets/files/presets/korean-shots/images/ko/1.webp")); + Assert.Equal(2, vm.Detail!.Images.Count); + Assert.Equal(AppHost.Get().Format("gallery.image", 1, 2), vm.Detail.Images[0].Name); + } + finally + { + vm.Dispose(); + TestServices.EnsureInitialized("en"); + } + } + + [AvaloniaFact] + public void The_validator_knows_every_language_of_the_app() + { + Assert.Equal(LocalizationService.Languages.Select(l => l.Code).Order(StringComparer.Ordinal), + SharedPresetFolderCheck.Languages.Order(StringComparer.Ordinal)); + } + + [AvaloniaFact] + public void Export_for_the_gallery_checks_the_choice_and_saves_preset_json() + { + TestServices.EnsureInitialized(); + var store = Store(); + var own = store.FindPreset("to-png")!; + var opened = new List(); + var path = Path.Combine(_root, "shared", "preset.json"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var vm = new GalleryExportViewModel(store, AppHost.Get(), own, uri => { opened.Add(uri); return Task.CompletedTask; }, + () => Task.FromResult(path)); + + Assert.True(vm.CanSave); + Assert.Single(vm.Presets, p => p.IsChosen); + vm.SaveCommand.Execute(null); + ClipWindowTests.WaitFor(() => vm.Status == AppHost.Get()["gallery.export_saved"]); + Assert.True(SharedBundleCheck.Check(File.ReadAllBytes(path)).Ok); + Assert.Equal(AppHost.Get()["gallery.export_saved"], vm.Status); + + // A folder of one's own can't be shared: listed, and Save waits. + own.Output.Location = OutputLocation.CustomFolder; + own.Output.CustomFolder = Path.Combine(_root, "out"); + own.Output.ReplaceOriginal = true; + vm.Presets.Single(p => p.Preset == own).IsChosen = false; + vm.Presets.Single(p => p.Preset == own).IsChosen = true; + Assert.False(vm.CanSave); + Assert.Contains(vm.Problems, p => p.StartsWith("presets[0].output.customFolder", StringComparison.Ordinal)); + // Without the folder it can be shared, with the warning the gallery will show. + own.Output.CustomFolder = ""; + vm.Presets.Single(p => p.Preset == own).IsChosen = false; + vm.Presets.Single(p => p.Preset == own).IsChosen = true; + Assert.True(vm.CanSave); + Assert.Contains(vm.Warnings, w => w.Code == "replaces_original" && w.IsHigh); + + vm.Presets.Single(p => p.Preset == own).IsChosen = false; + Assert.False(vm.CanSave); + Assert.Equal(AppHost.Get()["gallery.export_none"], vm.Status); + + vm.OpenFormCommand.Execute(null); + Assert.Equal(GalleryExportViewModel.SubmitUri, Assert.Single(opened)); + } + + [AvaloniaTheory] + [InlineData("en", false)] + [InlineData("ko", true)] + [InlineData("ar", false)] + public void The_export_and_the_warnings_render(string language, bool dark) + { + TestServices.EnsureInitialized(language); + var theme = AppHost.Get(); + if (dark) + theme.Apply(new ThemeSettings { Mode = ThemeMode.Dark }, language); + try + { + var (vm, client, _, _) = Open(RiskyGallery(), Store()); + using var _client = client; + using var _vm = vm; + vm.Selected = vm.Cards.Single(); + ClipWindowTests.WaitFor(() => vm.Detail!.Installable && vm.Detail.Images.All(i => i.Bitmap is not null)); + var window = new PresetGalleryWindow { DataContext = vm }; + window.Show(); + RenderTests.Pump(); + Assert.True(window.GetVisualDescendants().OfType().Single(b => b.Name == "WarningPanel").IsEffectivelyVisible); + Assert.True(window.GetVisualDescendants().OfType().Single(b => b.Name == "AcceptRisk").IsEffectivelyVisible); + RenderTests.Save(window, $"preset-gallery-warning-{language}-{(dark ? "dark" : "light")}.png"); + window.Close(); + + var store = Store(); + var own = store.FindPreset("to-png")!; + own.Output.Location = OutputLocation.CustomFolder; + own.Output.CustomFolder = @"C:\Users\Public\Out"; + own.Output.Conflict = Filee.Core.Presets.ConflictPolicy.Overwrite; + var export = new GalleryExportWindow + { + DataContext = new GalleryExportViewModel(store, AppHost.Get(), own, _ => Task.CompletedTask, () => Task.FromResult(null)), + }; + export.Show(); + RenderTests.Pump(); + RenderTests.Save(export, $"gallery-export-{language}-{(dark ? "dark" : "light")}.png"); + export.Close(); + } + finally + { + if (dark) + theme.Apply(new ThemeSettings(), language); + } + } + [AvaloniaFact] public void The_presets_page_opens_the_gallery() { @@ -467,6 +663,8 @@ public void The_presets_page_opens_the_gallery() var button = page.GetVisualDescendants().OfType