diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0412d726b..1e4e3fa56 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -613,6 +613,30 @@ 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 of one's own, passwords, picture and sound paths, metadata + naming people, places or owners), 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). Settings that touch the installer's files + pass with a warning (`SharedPresetWarnings`, codes shared with the gallery's repository and filee.sh); the window + shows them and asks for a tick before installing a preset with a high one. +- `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. + `GalleryExport` builds the preset.json "Export for the gallery…" saves (Presets page). +- 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..b9efaa62f 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -1850,5 +1850,91 @@ "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}", + "gallery.warnings_title": "قبل التثبيت", + "gallery.warning.replaces_original": "يستبدل ملفاتك الأصلية: تحل النتيجة محلها وتنتقل الملفات الأصلية إلى سلة المحذوفات.", + "gallery.warning.moves_original": "ينقل ملفاتك الأصلية إلى مجلد بدلًا من نسخها.", + "gallery.warning.overwrites_existing": "يكتب فوق ملف يحمل اسم النتيجة نفسه بدلًا من إضافة رقم.", + "gallery.warning.copies_to_folder": "ينسخ الملفات إلى مجلد. اختر المجلد في الإعداد المسبق بعد التثبيت.", + "gallery.warning.edits_metadata": "يكتب حقول بيانات وصفية في الملفات التي يحوّلها.", + "gallery.warning.fixed_edits": "يضع العلامات أو المناطق المخفية أو الخلفية أو المقطع نفسه في كل ملف يحوّله.", + "gallery.warning.strips_metadata": "يزيل البيانات الوصفية (بيانات الكاميرا والتواريخ والموقع) من الملفات التي يحوّلها.", + "gallery.warning.uses_translate_model": "يترجم بنموذج ذكاء اصطناعي محدد قد تحتاج إلى تنزيله في Ollama.", + "gallery.accept_risk": "أفهم أن هذا الإعداد المسبق قد يستبدل ملفاتي أو ينقلها أو يكتب فوقها.", + "presets.from_gallery": "مثبّت من معرض الإعدادات المسبقة ({0}). يحتفظ بالإعدادات التي شورك بها.", + "gallery.export": "تصدير للمعرض…", + "gallery.export_title": "تصدير لمعرض الإعدادات المسبقة", + "gallery.export_lead": "حدّد الإعدادات المسبقة التي تريد مشاركتها، واختر شريطًا دائريًا إن شئت. يُفحص الملف بالطريقة التي يفحص بها المعرض. احفظه ثم أرسله مع صورة إلى 4 صور عبر النموذج.", + "gallery.export_presets": "الإعدادات المسبقة", + "gallery.export_donut": "الشريط الدائري", + "gallery.export_no_donut": "بلا شريط دائري", + "gallery.export_problems": "لا يمكن مشاركته بعد: أصلح ما هو مذكور أدناه.", + "gallery.export_ok": "جاهز للمشاركة: {0} من الإعدادات المسبقة.", + "gallery.export_save": "حفظ preset.json…", + "gallery.export_saved": "تم الحفظ.", + "gallery.export_submit": "فتح نموذج الإرسال", + "gallery.export_none": "حدّد إعدادًا مسبقًا واحدًا على الأقل أو اختر شريطًا دائريًا.", + "gallery.export_warnings": "سيعرض المعرض هذه التحذيرات:" } diff --git a/src/Filee.App/Assets/i18n/bn-BD.json b/src/Filee.App/Assets/i18n/bn-BD.json index d99bc260f..1f3f09ac1 100644 --- a/src/Filee.App/Assets/i18n/bn-BD.json +++ b/src/Filee.App/Assets/i18n/bn-BD.json @@ -1847,5 +1847,91 @@ "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}", + "gallery.warnings_title": "ইনস্টল করার আগে", + "gallery.warning.replaces_original": "আপনার মূল ফাইলগুলো বদলে দেয়: ফলাফল সেগুলোর জায়গা নেয় এবং মূল ফাইলগুলো রিসাইকেল বিন / ট্র্যাশে যায়।", + "gallery.warning.moves_original": "আপনার মূল ফাইলগুলো কপি না করে একটি ফোল্ডারে সরিয়ে নেয়।", + "gallery.warning.overwrites_existing": "ফলাফলের নামে আগে থেকে থাকা ফাইলে নম্বর যোগ না করে সেটির ওপর লিখে দেয়।", + "gallery.warning.copies_to_folder": "ফাইলগুলো একটি ফোল্ডারে কপি করে। ইনস্টল করার পর প্রিসেটে ফোল্ডারটি বেছে নিন।", + "gallery.warning.edits_metadata": "রূপান্তরিত ফাইলে মেটাডেটা ফিল্ড লেখে।", + "gallery.warning.fixed_edits": "প্রতিটি রূপান্তরিত ফাইলে একই চিহ্ন, লুকানো অংশ, ব্যাকগ্রাউন্ড বা ক্লিপ বসায়।", + "gallery.warning.strips_metadata": "রূপান্তরিত ফাইল থেকে মেটাডেটা (ক্যামেরার তথ্য, তারিখ, অবস্থান) সরিয়ে দেয়।", + "gallery.warning.uses_translate_model": "একটি নির্দিষ্ট AI মডেল দিয়ে অনুবাদ করে, যা আপনাকে Ollama-তে ডাউনলোড করতে হতে পারে।", + "gallery.accept_risk": "আমি বুঝি যে এই প্রিসেট আমার ফাইল বদলে দিতে, সরাতে বা সেগুলোর ওপর লিখে দিতে পারে।", + "presets.from_gallery": "প্রিসেট গ্যালারি থেকে ইনস্টল করা ({0})। শেয়ার করার সময়ের সেটিংসই রাখে।", + "gallery.export": "গ্যালারির জন্য এক্সপোর্ট করুন…", + "gallery.export_title": "প্রিসেট গ্যালারির জন্য এক্সপোর্ট", + "gallery.export_lead": "যে প্রিসেটগুলো শেয়ার করবেন সেগুলোতে টিক দিন, চাইলে একটি ডোনাট টুলবারও বেছে নিন। ফাইলটি গ্যালারির মতো করেই পরীক্ষা করা হয়। সেভ করুন, তারপর ফর্মের মাধ্যমে ১ থেকে ৪টি ছবিসহ জমা দিন।", + "gallery.export_presets": "প্রিসেট", + "gallery.export_donut": "ডোনাট টুলবার", + "gallery.export_no_donut": "কোনো ডোনাট টুলবার নয়", + "gallery.export_problems": "এখনো শেয়ার করা যাবে না: নিচের বিষয়গুলো ঠিক করুন।", + "gallery.export_ok": "শেয়ার করার জন্য প্রস্তুত: {0}টি প্রিসেট।", + "gallery.export_save": "preset.json সেভ করুন…", + "gallery.export_saved": "সেভ হয়েছে।", + "gallery.export_submit": "জমা দেওয়ার ফর্ম খুলুন", + "gallery.export_none": "অন্তত একটি প্রিসেটে টিক দিন বা একটি ডোনাট টুলবার বেছে নিন।", + "gallery.export_warnings": "গ্যালারি এই সতর্কবার্তাগুলো দেখাবে:" } diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index ab9cf21bf..e7beb2c13 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -1849,5 +1849,91 @@ "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}", + "gallery.warnings_title": "Vor dem Installieren", + "gallery.warning.replaces_original": "Ersetzt deine Originaldateien: Das Ergebnis tritt an ihre Stelle, die Originale kommen in den Papierkorb.", + "gallery.warning.moves_original": "Verschiebt deine Originaldateien in einen Ordner, statt sie zu kopieren.", + "gallery.warning.overwrites_existing": "Überschreibt eine Datei, die schon den Namen des Ergebnisses hat, statt eine Nummer anzuhängen.", + "gallery.warning.copies_to_folder": "Kopiert Dateien in einen Ordner. Wähle den Ordner nach dem Installieren in der Vorlage.", + "gallery.warning.edits_metadata": "Schreibt Metadatenfelder in die umgewandelten Dateien.", + "gallery.warning.fixed_edits": "Zeichnet in jede umgewandelte Datei dieselben Markierungen, verdeckten Bereiche, denselben Hintergrund oder Clip.", + "gallery.warning.strips_metadata": "Entfernt Metadaten (Kameradaten, Datum, Ort) aus den umgewandelten Dateien.", + "gallery.warning.uses_translate_model": "Übersetzt mit einem bestimmten KI-Modell, das du eventuell in Ollama herunterladen musst.", + "gallery.accept_risk": "Mir ist klar, dass diese Vorlage meine Dateien ersetzen, verschieben oder überschreiben kann.", + "presets.from_gallery": "Aus der Vorlagengalerie installiert ({0}). Sie behält die Einstellungen, mit denen sie geteilt wurde.", + "gallery.export": "Für die Galerie exportieren …", + "gallery.export_title": "Für die Vorlagengalerie exportieren", + "gallery.export_lead": "Hake die Vorlagen an, die du teilen möchtest, und wähle bei Bedarf ein Ringmenü. Die Datei wird so geprüft wie in der Galerie. Speichere sie und reiche sie dann mit 1 bis 4 Bildern über das Formular ein.", + "gallery.export_presets": "Vorlagen", + "gallery.export_donut": "Ringmenü", + "gallery.export_no_donut": "Kein Ringmenü", + "gallery.export_problems": "Noch nicht teilbar: Behebe, was unten steht.", + "gallery.export_ok": "Bereit zum Teilen: {0} Vorlage(n).", + "gallery.export_save": "preset.json speichern …", + "gallery.export_saved": "Gespeichert.", + "gallery.export_submit": "Einreichungsformular öffnen", + "gallery.export_none": "Hake mindestens eine Vorlage an oder wähle ein Ringmenü.", + "gallery.export_warnings": "Die Galerie zeigt diese Warnungen:" } diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index b9eebb3f2..df704b5ed 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -1871,5 +1871,91 @@ "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}", + "gallery.warnings_title": "Before you install", + "gallery.warning.replaces_original": "Replaces your original files: the result takes their place and the originals go to the Recycle Bin / Trash.", + "gallery.warning.moves_original": "Moves your original files into a folder instead of copying them.", + "gallery.warning.overwrites_existing": "Overwrites a file that already has the result's name instead of adding a number.", + "gallery.warning.copies_to_folder": "Copies files into a folder. Choose the folder in the preset after installing.", + "gallery.warning.edits_metadata": "Writes metadata fields into the files it converts.", + "gallery.warning.fixed_edits": "Draws the same marks, hidden areas, background or clip into every file it converts.", + "gallery.warning.strips_metadata": "Removes metadata (camera details, dates, location) from the files it converts.", + "gallery.warning.uses_translate_model": "Translates with a particular AI model, which you may need to download in Ollama.", + "gallery.accept_risk": "I understand that this preset can replace, move or overwrite my files.", + "presets.from_gallery": "Installed from the preset gallery ({0}). It keeps the settings it was shared with.", + "gallery.export": "Export for the gallery…", + "gallery.export_title": "Export for the preset gallery", + "gallery.export_lead": "Tick the presets to share, and choose a donut if you like. The file is checked the way the gallery checks it. Save it, then submit it with 1 to 4 pictures through the form.", + "gallery.export_presets": "Presets", + "gallery.export_donut": "Donut", + "gallery.export_no_donut": "No donut", + "gallery.export_problems": "Can't be shared yet: fix what is listed below.", + "gallery.export_ok": "Ready to share: {0} preset(s).", + "gallery.export_save": "Save preset.json…", + "gallery.export_saved": "Saved.", + "gallery.export_submit": "Open the submission form", + "gallery.export_none": "Tick at least one preset or choose a donut.", + "gallery.export_warnings": "The gallery will show these warnings:" } diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index f496216e4..13f12364d 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -1851,5 +1851,91 @@ "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}", + "gallery.warnings_title": "Antes de instalar", + "gallery.warning.replaces_original": "Reemplaza tus archivos originales: el resultado ocupa su lugar y los originales van a la Papelera.", + "gallery.warning.moves_original": "Mueve tus archivos originales a una carpeta en lugar de copiarlos.", + "gallery.warning.overwrites_existing": "Sobrescribe un archivo que ya tiene el nombre del resultado en lugar de añadir un número.", + "gallery.warning.copies_to_folder": "Copia archivos a una carpeta. Elige la carpeta en el ajuste después de instalarlo.", + "gallery.warning.edits_metadata": "Escribe campos de metadatos en los archivos que convierte.", + "gallery.warning.fixed_edits": "Pone las mismas marcas, zonas ocultas, fondo o fragmento en cada archivo que convierte.", + "gallery.warning.strips_metadata": "Quita los metadatos (datos de la cámara, fechas, ubicación) de los archivos que convierte.", + "gallery.warning.uses_translate_model": "Traduce con un modelo de IA concreto, que quizá tengas que descargar en Ollama.", + "gallery.accept_risk": "Entiendo que este ajuste puede reemplazar, mover o sobrescribir mis archivos.", + "presets.from_gallery": "Instalado desde la galería de ajustes preestablecidos ({0}). Conserva la configuración con la que se compartió.", + "gallery.export": "Exportar para la galería…", + "gallery.export_title": "Exportar para la galería de ajustes preestablecidos", + "gallery.export_lead": "Marca los ajustes que quieras compartir y, si quieres, elige un menú circular. El archivo se revisa igual que en la galería. Guárdalo y envíalo con 1 a 4 imágenes mediante el formulario.", + "gallery.export_presets": "Ajustes preestablecidos", + "gallery.export_donut": "Menú circular", + "gallery.export_no_donut": "Sin menú circular", + "gallery.export_problems": "Aún no se puede compartir: corrige lo que aparece abajo.", + "gallery.export_ok": "Listo para compartir: {0} ajuste(s).", + "gallery.export_save": "Guardar preset.json…", + "gallery.export_saved": "Guardado.", + "gallery.export_submit": "Abrir el formulario de envío", + "gallery.export_none": "Marca al menos un ajuste o elige un menú circular.", + "gallery.export_warnings": "La galería mostrará estas advertencias:" } diff --git a/src/Filee.App/Assets/i18n/fa.json b/src/Filee.App/Assets/i18n/fa.json index ddf929c3d..5dc9976f5 100644 --- a/src/Filee.App/Assets/i18n/fa.json +++ b/src/Filee.App/Assets/i18n/fa.json @@ -1847,5 +1847,91 @@ "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}", + "gallery.warnings_title": "پیش از نصب", + "gallery.warning.replaces_original": "فایل‌های اصلی شما را جایگزین می‌کند: نتیجه جای آن‌ها را می‌گیرد و فایل‌های اصلی به سطل بازیافت می‌روند.", + "gallery.warning.moves_original": "فایل‌های اصلی شما را به‌جای کپی کردن به یک پوشه منتقل می‌کند.", + "gallery.warning.overwrites_existing": "فایلی را که از قبل هم‌نام نتیجه است، به‌جای افزودن شماره، بازنویسی می‌کند.", + "gallery.warning.copies_to_folder": "فایل‌ها را در یک پوشه کپی می‌کند. پس از نصب، پوشه را در پیش‌تنظیم انتخاب کنید.", + "gallery.warning.edits_metadata": "فیلدهای فراداده را در فایل‌هایی که تبدیل می‌کند می‌نویسد.", + "gallery.warning.fixed_edits": "همان علامت‌ها، ناحیه‌های پنهان، پس‌زمینه یا کلیپ را در همه فایل‌هایی که تبدیل می‌کند قرار می‌دهد.", + "gallery.warning.strips_metadata": "فراداده (اطلاعات دوربین، تاریخ‌ها، مکان) را از فایل‌هایی که تبدیل می‌کند حذف می‌کند.", + "gallery.warning.uses_translate_model": "با یک مدل هوش مصنوعی مشخص ترجمه می‌کند که شاید لازم باشد آن را در Ollama بارگیری کنید.", + "gallery.accept_risk": "می‌دانم که این پیش‌تنظیم می‌تواند فایل‌هایم را جایگزین، منتقل یا بازنویسی کند.", + "presets.from_gallery": "از گالری پیش‌تنظیم‌ها نصب شده است ({0}). همان تنظیماتی را دارد که با آن هم‌رسانی شده است.", + "gallery.export": "برون‌بری برای گالری…", + "gallery.export_title": "برون‌بری برای گالری پیش‌تنظیم‌ها", + "gallery.export_lead": "پیش‌تنظیم‌هایی را که می‌خواهید هم‌رسانی کنید علامت بزنید و اگر خواستید یک نوار ابزار دونات هم انتخاب کنید. فایل همان‌طور بررسی می‌شود که گالری بررسی می‌کند. آن را ذخیره کنید و سپس با ۱ تا ۴ تصویر از طریق فرم بفرستید.", + "gallery.export_presets": "پیش‌تنظیم‌ها", + "gallery.export_donut": "نوار ابزار دونات", + "gallery.export_no_donut": "بدون نوار ابزار دونات", + "gallery.export_problems": "هنوز نمی‌توان هم‌رسانی کرد: موارد زیر را درست کنید.", + "gallery.export_ok": "آماده هم‌رسانی: {0} پیش‌تنظیم.", + "gallery.export_save": "ذخیره preset.json…", + "gallery.export_saved": "ذخیره شد.", + "gallery.export_submit": "باز کردن فرم ارسال", + "gallery.export_none": "دست‌کم یک پیش‌تنظیم را علامت بزنید یا یک نوار ابزار دونات انتخاب کنید.", + "gallery.export_warnings": "گالری این هشدارها را نشان می‌دهد:" } diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index b08536140..1f9b0bb10 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -1871,5 +1871,91 @@ "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}", + "gallery.warnings_title": "Avant d'installer", + "gallery.warning.replaces_original": "Remplace vos fichiers d'origine : le résultat prend leur place et les originaux vont dans la Corbeille.", + "gallery.warning.moves_original": "Déplace vos fichiers d'origine dans un dossier au lieu de les copier.", + "gallery.warning.overwrites_existing": "Écrase un fichier qui porte déjà le nom du résultat au lieu d'ajouter un numéro.", + "gallery.warning.copies_to_folder": "Copie des fichiers dans un dossier. Choisissez le dossier dans le préréglage après l'installation.", + "gallery.warning.edits_metadata": "Écrit des champs de métadonnées dans les fichiers convertis.", + "gallery.warning.fixed_edits": "Ajoute les mêmes annotations, zones masquées, arrière-plan ou extrait à chaque fichier converti.", + "gallery.warning.strips_metadata": "Supprime les métadonnées (appareil photo, dates, lieu) des fichiers convertis.", + "gallery.warning.uses_translate_model": "Traduit avec un modèle d'IA précis, que vous devrez peut-être télécharger dans Ollama.", + "gallery.accept_risk": "Je comprends que ce préréglage peut remplacer, déplacer ou écraser mes fichiers.", + "presets.from_gallery": "Installé depuis la galerie de préréglages ({0}). Il garde les réglages avec lesquels il a été partagé.", + "gallery.export": "Exporter pour la galerie…", + "gallery.export_title": "Exporter pour la galerie de préréglages", + "gallery.export_lead": "Cochez les préréglages à partager et, si vous le souhaitez, un menu circulaire. Le fichier est vérifié comme le fait la galerie. Enregistrez-le, puis soumettez-le avec 1 à 4 images via le formulaire.", + "gallery.export_presets": "Préréglages", + "gallery.export_donut": "Menu circulaire", + "gallery.export_no_donut": "Aucun menu circulaire", + "gallery.export_problems": "Pas encore partageable : corrigez ce qui est indiqué ci-dessous.", + "gallery.export_ok": "Prêt à partager : {0} préréglage(s).", + "gallery.export_save": "Enregistrer preset.json…", + "gallery.export_saved": "Enregistré.", + "gallery.export_submit": "Ouvrir le formulaire de soumission", + "gallery.export_none": "Cochez au moins un préréglage ou choisissez un menu circulaire.", + "gallery.export_warnings": "La galerie affichera ces avertissements :" } diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 4a38f4f8c..7af0a4e8d 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -1851,5 +1851,91 @@ "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}", + "gallery.warnings_title": "इंस्टॉल करने से पहले", + "gallery.warning.replaces_original": "आपकी मूल फ़ाइलों को बदल देता है: परिणाम उनकी जगह ले लेता है और मूल फ़ाइलें रीसायकल बिन / ट्रैश में चली जाती हैं।", + "gallery.warning.moves_original": "आपकी मूल फ़ाइलों को कॉपी करने के बजाय किसी फ़ोल्डर में ले जाता है।", + "gallery.warning.overwrites_existing": "परिणाम के नाम वाली मौजूदा फ़ाइल पर नंबर जोड़ने के बजाय उसे ओवरराइट कर देता है।", + "gallery.warning.copies_to_folder": "फ़ाइलों को किसी फ़ोल्डर में कॉपी करता है। इंस्टॉल करने के बाद प्रीसेट में फ़ोल्डर चुनें।", + "gallery.warning.edits_metadata": "रूपांतरित फ़ाइलों में मेटाडेटा फ़ील्ड लिखता है।", + "gallery.warning.fixed_edits": "हर रूपांतरित फ़ाइल में वही निशान, छिपे हिस्से, बैकग्राउंड या क्लिप डालता है।", + "gallery.warning.strips_metadata": "रूपांतरित फ़ाइलों से मेटाडेटा (कैमरे की जानकारी, तारीख़ें, स्थान) हटाता है।", + "gallery.warning.uses_translate_model": "किसी ख़ास AI मॉडल से अनुवाद करता है, जिसे आपको Ollama में डाउनलोड करना पड़ सकता है।", + "gallery.accept_risk": "मैं समझता/समझती हूँ कि यह प्रीसेट मेरी फ़ाइलों को बदल, हटा या ओवरराइट कर सकता है।", + "presets.from_gallery": "प्रीसेट गैलरी से इंस्टॉल किया गया ({0})। इसमें वही सेटिंग्स हैं जिनके साथ इसे साझा किया गया था।", + "gallery.export": "गैलरी के लिए निर्यात करें…", + "gallery.export_title": "प्रीसेट गैलरी के लिए निर्यात करें", + "gallery.export_lead": "साझा करने वाले प्रीसेट चुनें, और चाहें तो एक डोनट टूलबार भी। फ़ाइल को उसी तरह जाँचा जाता है जैसे गैलरी जाँचती है। उसे सहेजें, फिर फ़ॉर्म से 1 से 4 तस्वीरों के साथ भेजें।", + "gallery.export_presets": "प्रीसेट", + "gallery.export_donut": "डोनट टूलबार", + "gallery.export_no_donut": "कोई डोनट टूलबार नहीं", + "gallery.export_problems": "अभी साझा नहीं किया जा सकता: नीचे दी गई बातें ठीक करें।", + "gallery.export_ok": "साझा करने के लिए तैयार: {0} प्रीसेट।", + "gallery.export_save": "preset.json सहेजें…", + "gallery.export_saved": "सहेज लिया गया।", + "gallery.export_submit": "सबमिशन फ़ॉर्म खोलें", + "gallery.export_none": "कम से कम एक प्रीसेट चुनें या एक डोनट टूलबार चुनें।", + "gallery.export_warnings": "गैलरी ये चेतावनियाँ दिखाएगी:" } diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index 96d8c0066..268fbbcb5 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -1849,5 +1849,91 @@ "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}", + "gallery.warnings_title": "Sebelum memasang", + "gallery.warning.replaces_original": "Mengganti file asli Anda: hasilnya menempati tempat file asli, dan file asli dipindahkan ke Recycle Bin / Sampah.", + "gallery.warning.moves_original": "Memindahkan file asli Anda ke folder alih-alih menyalinnya.", + "gallery.warning.overwrites_existing": "Menimpa file yang sudah bernama sama dengan hasilnya alih-alih menambahkan nomor.", + "gallery.warning.copies_to_folder": "Menyalin file ke sebuah folder. Pilih foldernya di preset setelah dipasang.", + "gallery.warning.edits_metadata": "Menulis kolom metadata ke file yang dikonversi.", + "gallery.warning.fixed_edits": "Menambahkan tanda, area tersembunyi, latar, atau klip yang sama ke setiap file yang dikonversi.", + "gallery.warning.strips_metadata": "Menghapus metadata (detail kamera, tanggal, lokasi) dari file yang dikonversi.", + "gallery.warning.uses_translate_model": "Menerjemahkan dengan model AI tertentu, yang mungkin perlu Anda unduh di Ollama.", + "gallery.accept_risk": "Saya mengerti bahwa preset ini dapat mengganti, memindahkan, atau menimpa file saya.", + "presets.from_gallery": "Dipasang dari galeri preset ({0}). Pengaturannya tetap seperti saat dibagikan.", + "gallery.export": "Ekspor untuk galeri…", + "gallery.export_title": "Ekspor untuk galeri preset", + "gallery.export_lead": "Centang preset yang ingin dibagikan, dan pilih toolbar donut jika perlu. File diperiksa dengan cara yang sama seperti galeri. Simpan, lalu kirim bersama 1–4 gambar melalui formulir.", + "gallery.export_presets": "Preset", + "gallery.export_donut": "Toolbar donut", + "gallery.export_no_donut": "Tanpa toolbar donut", + "gallery.export_problems": "Belum dapat dibagikan: perbaiki yang tercantum di bawah.", + "gallery.export_ok": "Siap dibagikan: {0} preset.", + "gallery.export_save": "Simpan preset.json…", + "gallery.export_saved": "Tersimpan.", + "gallery.export_submit": "Buka formulir pengiriman", + "gallery.export_none": "Centang setidaknya satu preset atau pilih toolbar donut.", + "gallery.export_warnings": "Galeri akan menampilkan peringatan berikut:" } diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index c3c54a568..71fb4708a 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -1848,5 +1848,91 @@ "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}", + "gallery.warnings_title": "Prima di installare", + "gallery.warning.replaces_original": "Sostituisce i file originali: il risultato prende il loro posto e gli originali vanno nel Cestino.", + "gallery.warning.moves_original": "Sposta i file originali in una cartella invece di copiarli.", + "gallery.warning.overwrites_existing": "Sovrascrive un file che ha già il nome del risultato invece di aggiungere un numero.", + "gallery.warning.copies_to_folder": "Copia i file in una cartella. Scegli la cartella nel preset dopo l'installazione.", + "gallery.warning.edits_metadata": "Scrive campi di metadati nei file che converte.", + "gallery.warning.fixed_edits": "Aggiunge a ogni file convertito gli stessi segni, aree nascoste, sfondo o clip.", + "gallery.warning.strips_metadata": "Rimuove i metadati (dati della fotocamera, date, posizione) dai file che converte.", + "gallery.warning.uses_translate_model": "Traduce con un modello di IA preciso, che potresti dover scaricare in Ollama.", + "gallery.accept_risk": "Ho capito che questo preset può sostituire, spostare o sovrascrivere i miei file.", + "presets.from_gallery": "Installato dalla galleria dei preset ({0}). Mantiene le impostazioni con cui è stato condiviso.", + "gallery.export": "Esporta per la galleria…", + "gallery.export_title": "Esporta per la galleria dei preset", + "gallery.export_lead": "Spunta i preset da condividere e, se vuoi, scegli una barra donut. Il file viene controllato come fa la galleria. Salvalo, poi invialo con 1-4 immagini tramite il modulo.", + "gallery.export_presets": "Preset", + "gallery.export_donut": "Barra donut", + "gallery.export_no_donut": "Nessuna barra donut", + "gallery.export_problems": "Non ancora condivisibile: correggi quanto indicato sotto.", + "gallery.export_ok": "Pronto da condividere: {0} preset.", + "gallery.export_save": "Salva preset.json…", + "gallery.export_saved": "Salvato.", + "gallery.export_submit": "Apri il modulo di invio", + "gallery.export_none": "Spunta almeno un preset o scegli una barra donut.", + "gallery.export_warnings": "La galleria mostrerà questi avvisi:" } diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index b4dfcfc96..070bb0311 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -1851,5 +1851,91 @@ "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}", + "gallery.warnings_title": "インストールする前に", + "gallery.warning.replaces_original": "元のファイルを置き換えます: 結果が元の場所に入り、元のファイルはごみ箱に移動します。", + "gallery.warning.moves_original": "元のファイルをコピーせず、フォルダーに移動します。", + "gallery.warning.overwrites_existing": "結果と同じ名前のファイルがあると、番号を付けずに上書きします。", + "gallery.warning.copies_to_folder": "ファイルをフォルダーにコピーします。インストール後にプリセットでフォルダーを選んでください。", + "gallery.warning.edits_metadata": "変換するファイルにメタデータの項目を書き込みます。", + "gallery.warning.fixed_edits": "変換するすべてのファイルに、同じ書き込み、隠す範囲、背景またはクリップを入れます。", + "gallery.warning.strips_metadata": "変換するファイルからメタデータ(カメラの情報、日時、位置)を削除します。", + "gallery.warning.uses_translate_model": "特定の AI モデルで翻訳します。Ollama でそのモデルのダウンロードが必要な場合があります。", + "gallery.accept_risk": "このプリセットが自分のファイルを置き換え、移動、上書きできることを理解しました。", + "presets.from_gallery": "プリセットギャラリーからインストールしました({0})。共有されたときの設定のままです。", + "gallery.export": "ギャラリー用に書き出す…", + "gallery.export_title": "プリセットギャラリー用に書き出す", + "gallery.export_lead": "共有するプリセットにチェックを入れ、必要ならドーナツメニューを選びます。ファイルはギャラリーと同じ方法で確認されます。保存したら、画像 1〜4 枚と一緒にフォームから投稿してください。", + "gallery.export_presets": "プリセット", + "gallery.export_donut": "ドーナツメニュー", + "gallery.export_no_donut": "ドーナツメニューなし", + "gallery.export_problems": "まだ共有できません: 下の項目を直してください。", + "gallery.export_ok": "共有できます: プリセット {0} 個", + "gallery.export_save": "preset.json を保存…", + "gallery.export_saved": "保存しました。", + "gallery.export_submit": "投稿フォームを開く", + "gallery.export_none": "プリセットを 1 つ以上選ぶか、ドーナツメニューを選んでください。", + "gallery.export_warnings": "ギャラリーには次の注意が表示されます:" } diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index c1fb69441..f78314ecb 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -1871,5 +1871,91 @@ "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}", + "gallery.warnings_title": "설치하기 전에 확인하세요", + "gallery.warning.replaces_original": "원본 파일을 바꿔요: 결과가 원본 자리에 놓이고 원본은 휴지통으로 가요.", + "gallery.warning.moves_original": "원본 파일을 복사하지 않고 폴더로 옮겨요.", + "gallery.warning.overwrites_existing": "결과와 이름이 같은 파일이 있으면 번호를 붙이지 않고 덮어써요.", + "gallery.warning.copies_to_folder": "파일을 폴더에 복사해요. 설치한 뒤 프리셋에서 폴더를 골라 주세요.", + "gallery.warning.edits_metadata": "변환하는 파일에 메타데이터 항목을 써요.", + "gallery.warning.fixed_edits": "변환하는 모든 파일에 같은 표시, 가린 영역, 배경이나 클립을 넣어요.", + "gallery.warning.strips_metadata": "변환하는 파일에서 메타데이터(카메라 정보, 날짜, 위치)를 지워요.", + "gallery.warning.uses_translate_model": "특정 AI 모델로 번역해요. Ollama에서 그 모델을 내려받아야 할 수 있어요.", + "gallery.accept_risk": "이 프리셋이 내 파일을 바꾸거나 옮기거나 덮어쓸 수 있다는 걸 알고 있어요.", + "presets.from_gallery": "프리셋 갤러리에서 설치했어요 ({0}). 공유된 설정을 그대로 써요.", + "gallery.export": "갤러리용으로 내보내기…", + "gallery.export_title": "프리셋 갤러리용으로 내보내기", + "gallery.export_lead": "공유할 프리셋을 고르고, 원하면 원형 툴바도 고르세요. 갤러리와 같은 방식으로 파일을 검사해요. 저장한 뒤 그림 1~4장과 함께 제출 양식으로 보내 주세요.", + "gallery.export_presets": "프리셋", + "gallery.export_donut": "원형 툴바", + "gallery.export_no_donut": "원형 툴바 없음", + "gallery.export_problems": "아직 공유할 수 없어요: 아래 항목을 고쳐 주세요.", + "gallery.export_ok": "공유할 준비가 됐어요: 프리셋 {0}개", + "gallery.export_save": "preset.json 저장…", + "gallery.export_saved": "저장했어요.", + "gallery.export_submit": "제출 양식 열기", + "gallery.export_none": "프리셋을 하나 이상 고르거나 원형 툴바를 골라 주세요.", + "gallery.export_warnings": "갤러리에 이런 경고가 표시돼요:" } diff --git a/src/Filee.App/Assets/i18n/mn.json b/src/Filee.App/Assets/i18n/mn.json index 13298c639..20d5a3ad3 100644 --- a/src/Filee.App/Assets/i18n/mn.json +++ b/src/Filee.App/Assets/i18n/mn.json @@ -1847,5 +1847,91 @@ "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}", + "gallery.warnings_title": "Суулгахаас өмнө", + "gallery.warning.replaces_original": "Таны эх файлуудыг солино: үр дүн нь тэдгээрийн оронд орж, эх файлууд хогийн саванд очно.", + "gallery.warning.moves_original": "Таны эх файлуудыг хуулахын оронд хавтас руу зөөнө.", + "gallery.warning.overwrites_existing": "Үр дүнтэй ижил нэртэй файл байвал дугаар нэмэхийн оронд дээр нь бичнэ.", + "gallery.warning.copies_to_folder": "Файлуудыг хавтас руу хуулна. Суулгасны дараа урьдчилсан тохиргоонд хавтсаа сонгоно уу.", + "gallery.warning.edits_metadata": "Хөрвүүлж буй файлд мета өгөгдлийн талбар бичнэ.", + "gallery.warning.fixed_edits": "Хөрвүүлж буй файл бүрт ижил тэмдэг, нуусан хэсэг, дэвсгэр эсвэл клип оруулна.", + "gallery.warning.strips_metadata": "Хөрвүүлж буй файлаас мета өгөгдлийг (камерын мэдээлэл, огноо, байршил) устгана.", + "gallery.warning.uses_translate_model": "Тодорхой AI загвараар орчуулна; тэр загварыг Ollama-д татах шаардлагатай байж магадгүй.", + "gallery.accept_risk": "Энэ урьдчилсан тохиргоо миний файлуудыг сольж, зөөж, эсвэл дээр нь бичиж болохыг ойлгож байна.", + "presets.from_gallery": "Урьдчилсан тохиргооны цомгоос суулгасан ({0}). Хуваалцсан тохиргоогоо хэвээр хадгална.", + "gallery.export": "Цомогт зориулж экспортлох…", + "gallery.export_title": "Урьдчилсан тохиргооны цомогт зориулж экспортлох", + "gallery.export_lead": "Хуваалцах урьдчилсан тохиргоонуудаа чагтлаад, хүсвэл цагираг самбар сонгоно уу. Файлыг цомгийн шалгадаг аргаар шалгана. Хадгалаад, дараа нь маягтаар 1–4 зурагтай нь илгээнэ үү.", + "gallery.export_presets": "Урьдчилсан тохиргоо", + "gallery.export_donut": "Цагираг самбар", + "gallery.export_no_donut": "Цагираг самбаргүй", + "gallery.export_problems": "Одоохондоо хуваалцах боломжгүй: доор заасныг засна уу.", + "gallery.export_ok": "Хуваалцахад бэлэн: {0} урьдчилсан тохиргоо.", + "gallery.export_save": "preset.json хадгалах…", + "gallery.export_saved": "Хадгаллаа.", + "gallery.export_submit": "Илгээх маягтыг нээх", + "gallery.export_none": "Дор хаяж нэг урьдчилсан тохиргоо чагтлах эсвэл цагираг самбар сонгоно уу.", + "gallery.export_warnings": "Цомог дараах сануулгуудыг харуулна:" } diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 1037c1caf..e405795fe 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -1851,5 +1851,91 @@ "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}", + "gallery.warnings_title": "स्थापना गर्नुअघि", + "gallery.warning.replaces_original": "तपाईंका मूल फाइलहरू बदल्छ: नतिजाले तिनको ठाउँ लिन्छ र मूल फाइलहरू रिसाइकल बिन / ट्र्यासमा जान्छन्।", + "gallery.warning.moves_original": "तपाईंका मूल फाइलहरू प्रतिलिपि गर्नुको सट्टा फोल्डरमा सार्छ।", + "gallery.warning.overwrites_existing": "नतिजाको नाम भएको फाइल पहिल्यै छ भने नम्बर थप्नुको सट्टा त्यसमाथि लेख्छ।", + "gallery.warning.copies_to_folder": "फाइलहरू फोल्डरमा प्रतिलिपि गर्छ। स्थापना गरेपछि प्रिसेटमा फोल्डर छान्नुहोस्।", + "gallery.warning.edits_metadata": "रूपान्तरण गरिने फाइलहरूमा मेटाडाटा फिल्डहरू लेख्छ।", + "gallery.warning.fixed_edits": "रूपान्तरण गरिने हरेक फाइलमा उही चिह्न, लुकाइएका क्षेत्र, पृष्ठभूमि वा क्लिप राख्छ।", + "gallery.warning.strips_metadata": "रूपान्तरण गरिने फाइलहरूबाट मेटाडाटा (क्यामेराको जानकारी, मिति, स्थान) हटाउँछ।", + "gallery.warning.uses_translate_model": "कुनै खास AI मोडेलले अनुवाद गर्छ, जुन तपाईंले Ollama मा डाउनलोड गर्नुपर्ने हुन सक्छ।", + "gallery.accept_risk": "मलाई थाहा छ, यो प्रिसेटले मेरा फाइलहरू बदल्न, सार्न वा तिनमाथि लेख्न सक्छ।", + "presets.from_gallery": "प्रिसेट ग्यालरीबाट स्थापना गरिएको ({0})। यसले साझा गर्दाकै सेटिङहरू राख्छ।", + "gallery.export": "ग्यालरीका लागि निर्यात गर्नुहोस्…", + "gallery.export_title": "प्रिसेट ग्यालरीका लागि निर्यात", + "gallery.export_lead": "साझा गर्ने प्रिसेटहरू छान्नुहोस्, र चाहनुहुन्छ भने एउटा डोनट टुलबार पनि। फाइललाई ग्यालरीले जाँचेझैँ जाँचिन्छ। यसलाई सेभ गर्नुहोस्, त्यसपछि फारमबाट १ देखि ४ तस्बिरसहित पठाउनुहोस्।", + "gallery.export_presets": "प्रिसेटहरू", + "gallery.export_donut": "डोनट टुलबार", + "gallery.export_no_donut": "डोनट टुलबार छैन", + "gallery.export_problems": "अहिले साझा गर्न सकिँदैन: तल देखाइएका कुरा सच्याउनुहोस्।", + "gallery.export_ok": "साझा गर्न तयार: {0} प्रिसेट।", + "gallery.export_save": "preset.json सेभ गर्नुहोस्…", + "gallery.export_saved": "सेभ भयो।", + "gallery.export_submit": "पेस गर्ने फारम खोल्नुहोस्", + "gallery.export_none": "कम्तीमा एउटा प्रिसेट छान्नुहोस् वा डोनट टुलबार छान्नुहोस्।", + "gallery.export_warnings": "ग्यालरीले यी चेतावनीहरू देखाउनेछ:" } diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index bb64aceae..88f03fa31 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -1871,5 +1871,91 @@ "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}", + "gallery.warnings_title": "Przed instalacją", + "gallery.warning.replaces_original": "Zastępuje oryginalne pliki: wynik zajmuje ich miejsce, a oryginały trafiają do Kosza.", + "gallery.warning.moves_original": "Przenosi oryginalne pliki do folderu zamiast je kopiować.", + "gallery.warning.overwrites_existing": "Nadpisuje plik o nazwie wyniku zamiast dodać numer.", + "gallery.warning.copies_to_folder": "Kopiuje pliki do folderu. Po instalacji wybierz folder w ustawieniu.", + "gallery.warning.edits_metadata": "Zapisuje pola metadanych w konwertowanych plikach.", + "gallery.warning.fixed_edits": "Dodaje do każdego konwertowanego pliku te same oznaczenia, ukryte obszary, tło lub klip.", + "gallery.warning.strips_metadata": "Usuwa metadane (dane aparatu, daty, lokalizację) z konwertowanych plików.", + "gallery.warning.uses_translate_model": "Tłumaczy określonym modelem AI, który być może trzeba pobrać w Ollama.", + "gallery.accept_risk": "Rozumiem, że to ustawienie może zastąpić, przenieść lub nadpisać moje pliki.", + "presets.from_gallery": "Zainstalowane z galerii ustawień wstępnych ({0}). Zachowuje ustawienia, z którymi zostało udostępnione.", + "gallery.export": "Eksportuj do galerii…", + "gallery.export_title": "Eksport do galerii ustawień wstępnych", + "gallery.export_lead": "Zaznacz ustawienia do udostępnienia i w razie potrzeby wybierz pasek-pierścień. Plik jest sprawdzany tak jak w galerii. Zapisz go, a potem wyślij przez formularz razem z 1–4 obrazami.", + "gallery.export_presets": "Ustawienia wstępne", + "gallery.export_donut": "Pasek-pierścień", + "gallery.export_no_donut": "Bez paska-pierścienia", + "gallery.export_problems": "Jeszcze nie można udostępnić: popraw to, co podano poniżej.", + "gallery.export_ok": "Gotowe do udostępnienia: ustawienia: {0}.", + "gallery.export_save": "Zapisz preset.json…", + "gallery.export_saved": "Zapisano.", + "gallery.export_submit": "Otwórz formularz zgłoszenia", + "gallery.export_none": "Zaznacz co najmniej jedno ustawienie lub wybierz pasek-pierścień.", + "gallery.export_warnings": "Galeria pokaże te ostrzeżenia:" } diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index cbec29537..530131a01 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -1871,5 +1871,91 @@ "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}", + "gallery.warnings_title": "Antes de instalar", + "gallery.warning.replaces_original": "Substitui seus arquivos originais: o resultado fica no lugar deles e os originais vão para a Lixeira.", + "gallery.warning.moves_original": "Move seus arquivos originais para uma pasta em vez de copiá-los.", + "gallery.warning.overwrites_existing": "Sobrescreve um arquivo que já tem o nome do resultado em vez de adicionar um número.", + "gallery.warning.copies_to_folder": "Copia arquivos para uma pasta. Escolha a pasta na predefinição depois de instalar.", + "gallery.warning.edits_metadata": "Grava campos de metadados nos arquivos que converte.", + "gallery.warning.fixed_edits": "Coloca as mesmas marcações, áreas ocultas, fundo ou trecho em cada arquivo que converte.", + "gallery.warning.strips_metadata": "Remove os metadados (dados da câmera, datas, localização) dos arquivos que converte.", + "gallery.warning.uses_translate_model": "Traduz com um modelo de IA específico, que talvez você precise baixar no Ollama.", + "gallery.accept_risk": "Entendo que esta predefinição pode substituir, mover ou sobrescrever meus arquivos.", + "presets.from_gallery": "Instalada pela galeria de predefinições ({0}). Mantém as configurações com que foi compartilhada.", + "gallery.export": "Exportar para a galeria…", + "gallery.export_title": "Exportar para a galeria de predefinições", + "gallery.export_lead": "Marque as predefinições que quer compartilhar e, se quiser, escolha um menu circular. O arquivo é verificado como a galeria verifica. Salve e depois envie com 1 a 4 imagens pelo formulário.", + "gallery.export_presets": "Predefinições", + "gallery.export_donut": "Menu circular", + "gallery.export_no_donut": "Sem menu circular", + "gallery.export_problems": "Ainda não pode ser compartilhado: corrija o que está listado abaixo.", + "gallery.export_ok": "Pronto para compartilhar: {0} predefinição(ões).", + "gallery.export_save": "Salvar preset.json…", + "gallery.export_saved": "Salvo.", + "gallery.export_submit": "Abrir o formulário de envio", + "gallery.export_none": "Marque pelo menos uma predefinição ou escolha um menu circular.", + "gallery.export_warnings": "A galeria vai mostrar estes avisos:" } diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index a7c27a580..d3a4867a2 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -1871,5 +1871,91 @@ "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}", + "gallery.warnings_title": "Antes de instalar", + "gallery.warning.replaces_original": "Substitui os teus ficheiros originais: o resultado fica no lugar deles e os originais vão para a Reciclagem.", + "gallery.warning.moves_original": "Move os teus ficheiros originais para uma pasta em vez de os copiar.", + "gallery.warning.overwrites_existing": "Substitui um ficheiro que já tem o nome do resultado em vez de acrescentar um número.", + "gallery.warning.copies_to_folder": "Copia ficheiros para uma pasta. Escolhe a pasta na predefinição depois de instalar.", + "gallery.warning.edits_metadata": "Escreve campos de metadados nos ficheiros que converte.", + "gallery.warning.fixed_edits": "Coloca as mesmas marcações, áreas ocultas, fundo ou excerto em cada ficheiro que converte.", + "gallery.warning.strips_metadata": "Remove os metadados (dados da câmara, datas, localização) dos ficheiros que converte.", + "gallery.warning.uses_translate_model": "Traduz com um modelo de IA específico, que talvez tenhas de transferir no Ollama.", + "gallery.accept_risk": "Compreendo que esta predefinição pode substituir, mover ou sobrescrever os meus ficheiros.", + "presets.from_gallery": "Instalada a partir da galeria de predefinições ({0}). Mantém as definições com que foi partilhada.", + "gallery.export": "Exportar para a galeria…", + "gallery.export_title": "Exportar para a galeria de predefinições", + "gallery.export_lead": "Marca as predefinições que queres partilhar e, se quiseres, escolhe uma barra circular. O ficheiro é verificado como a galeria o verifica. Guarda-o e depois submete-o com 1 a 4 imagens através do formulário.", + "gallery.export_presets": "Predefinições", + "gallery.export_donut": "Barra circular", + "gallery.export_no_donut": "Sem barra circular", + "gallery.export_problems": "Ainda não pode ser partilhado: corrige o que está indicado abaixo.", + "gallery.export_ok": "Pronto a partilhar: {0} predefinição(ões).", + "gallery.export_save": "Guardar preset.json…", + "gallery.export_saved": "Guardado.", + "gallery.export_submit": "Abrir o formulário de submissão", + "gallery.export_none": "Marca pelo menos uma predefinição ou escolhe uma barra circular.", + "gallery.export_warnings": "A galeria vai mostrar estes avisos:" } diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 7fb769f62..2eb2e1aca 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -1871,5 +1871,91 @@ "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}", + "gallery.warnings_title": "Перед установкой", + "gallery.warning.replaces_original": "Заменяет исходные файлы: результат занимает их место, а исходные файлы попадают в корзину.", + "gallery.warning.moves_original": "Перемещает исходные файлы в папку вместо копирования.", + "gallery.warning.overwrites_existing": "Перезаписывает файл с таким же именем, как у результата, вместо добавления номера.", + "gallery.warning.copies_to_folder": "Копирует файлы в папку. После установки выберите папку в пресете.", + "gallery.warning.edits_metadata": "Записывает поля метаданных в преобразуемые файлы.", + "gallery.warning.fixed_edits": "Добавляет в каждый преобразуемый файл одни и те же пометки, скрытые области, фон или фрагмент.", + "gallery.warning.strips_metadata": "Удаляет метаданные (данные камеры, даты, местоположение) из преобразуемых файлов.", + "gallery.warning.uses_translate_model": "Переводит определённой моделью ИИ, которую может понадобиться скачать в Ollama.", + "gallery.accept_risk": "Я понимаю, что этот пресет может заменить, переместить или перезаписать мои файлы.", + "presets.from_gallery": "Установлен из галереи пресетов ({0}). Сохраняет настройки, с которыми им поделились.", + "gallery.export": "Экспорт для галереи…", + "gallery.export_title": "Экспорт для галереи пресетов", + "gallery.export_lead": "Отметьте пресеты, которыми хотите поделиться, и при желании выберите круговое меню. Файл проверяется так же, как в галерее. Сохраните его, а затем отправьте через форму вместе с 1–4 изображениями.", + "gallery.export_presets": "Пресеты", + "gallery.export_donut": "Круговое меню", + "gallery.export_no_donut": "Без кругового меню", + "gallery.export_problems": "Пока нельзя опубликовать: исправьте то, что указано ниже.", + "gallery.export_ok": "Готово к публикации: пресетов {0}.", + "gallery.export_save": "Сохранить preset.json…", + "gallery.export_saved": "Сохранено.", + "gallery.export_submit": "Открыть форму отправки", + "gallery.export_none": "Отметьте хотя бы один пресет или выберите круговое меню.", + "gallery.export_warnings": "В галерее будут показаны такие предупреждения:" } diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 4ee2f8993..bff059024 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -1851,5 +1851,91 @@ "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}", + "gallery.warnings_title": "ก่อนติดตั้ง", + "gallery.warning.replaces_original": "แทนที่ไฟล์ต้นฉบับ: ผลลัพธ์จะอยู่แทนที่ และไฟล์ต้นฉบับจะไปอยู่ในถังรีไซเคิล", + "gallery.warning.moves_original": "ย้ายไฟล์ต้นฉบับไปยังโฟลเดอร์แทนการคัดลอก", + "gallery.warning.overwrites_existing": "เขียนทับไฟล์ที่มีชื่อเดียวกับผลลัพธ์แทนการเติมหมายเลข", + "gallery.warning.copies_to_folder": "คัดลอกไฟล์ไปยังโฟลเดอร์ เลือกโฟลเดอร์ในพรีเซ็ตหลังติดตั้ง", + "gallery.warning.edits_metadata": "เขียนช่องข้อมูลเมตาลงในไฟล์ที่แปลง", + "gallery.warning.fixed_edits": "ใส่เครื่องหมาย พื้นที่ที่ซ่อน พื้นหลัง หรือคลิปแบบเดียวกันลงในทุกไฟล์ที่แปลง", + "gallery.warning.strips_metadata": "ลบข้อมูลเมตา (ข้อมูลกล้อง วันที่ ตำแหน่ง) ออกจากไฟล์ที่แปลง", + "gallery.warning.uses_translate_model": "แปลด้วยโมเดล AI ที่ระบุ ซึ่งคุณอาจต้องดาวน์โหลดใน Ollama", + "gallery.accept_risk": "ฉันเข้าใจว่าพรีเซ็ตนี้อาจแทนที่ ย้าย หรือเขียนทับไฟล์ของฉัน", + "presets.from_gallery": "ติดตั้งจากแกลเลอรีพรีเซ็ต ({0}) ใช้การตั้งค่าตามที่แชร์ไว้", + "gallery.export": "ส่งออกสำหรับแกลเลอรี…", + "gallery.export_title": "ส่งออกสำหรับแกลเลอรีพรีเซ็ต", + "gallery.export_lead": "เลือกพรีเซ็ตที่จะแชร์ และเลือกแถบโดนัทได้หากต้องการ ไฟล์จะถูกตรวจแบบเดียวกับที่แกลเลอรีตรวจ บันทึกไฟล์ แล้วส่งพร้อมรูป 1–4 รูปผ่านแบบฟอร์ม", + "gallery.export_presets": "พรีเซ็ต", + "gallery.export_donut": "แถบโดนัท", + "gallery.export_no_donut": "ไม่มีแถบโดนัท", + "gallery.export_problems": "ยังแชร์ไม่ได้: แก้ไขรายการด้านล่าง", + "gallery.export_ok": "พร้อมแชร์: พรีเซ็ต {0} รายการ", + "gallery.export_save": "บันทึก preset.json…", + "gallery.export_saved": "บันทึกแล้ว", + "gallery.export_submit": "เปิดแบบฟอร์มส่ง", + "gallery.export_none": "เลือกพรีเซ็ตอย่างน้อยหนึ่งรายการหรือเลือกแถบโดนัท", + "gallery.export_warnings": "แกลเลอรีจะแสดงคำเตือนต่อไปนี้:" } diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 39612b88b..f8838160c 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -1849,5 +1849,91 @@ "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}", + "gallery.warnings_title": "Kurmadan önce", + "gallery.warning.replaces_original": "Orijinal dosyalarınızı değiştirir: sonuç onların yerine geçer, orijinaller Geri Dönüşüm Kutusu'na / Çöp Kutusu'na gider.", + "gallery.warning.moves_original": "Orijinal dosyalarınızı kopyalamak yerine bir klasöre taşır.", + "gallery.warning.overwrites_existing": "Sonuçla aynı adı taşıyan bir dosyayı numara eklemek yerine üzerine yazar.", + "gallery.warning.copies_to_folder": "Dosyaları bir klasöre kopyalar. Kurduktan sonra klasörü ön ayarda seçin.", + "gallery.warning.edits_metadata": "Dönüştürdüğü dosyalara meta veri alanları yazar.", + "gallery.warning.fixed_edits": "Dönüştürdüğü her dosyaya aynı işaretleri, gizli alanları, arka planı veya klibi ekler.", + "gallery.warning.strips_metadata": "Dönüştürdüğü dosyalardan meta verileri (kamera bilgileri, tarihler, konum) kaldırır.", + "gallery.warning.uses_translate_model": "Belirli bir yapay zekâ modeliyle çevirir; bu modeli Ollama'da indirmeniz gerekebilir.", + "gallery.accept_risk": "Bu ön ayarın dosyalarımı değiştirebileceğini, taşıyabileceğini veya üzerine yazabileceğini anlıyorum.", + "presets.from_gallery": "Ön ayar galerisinden kuruldu ({0}). Paylaşıldığı ayarları korur.", + "gallery.export": "Galeri için dışa aktar…", + "gallery.export_title": "Ön ayar galerisi için dışa aktar", + "gallery.export_lead": "Paylaşmak istediğiniz ön ayarları işaretleyin, isterseniz bir halka araç çubuğu seçin. Dosya, galerinin denetlediği şekilde denetlenir. Kaydedin, ardından formdan 1–4 görselle birlikte gönderin.", + "gallery.export_presets": "Ön ayarlar", + "gallery.export_donut": "Halka araç çubuğu", + "gallery.export_no_donut": "Halka araç çubuğu yok", + "gallery.export_problems": "Henüz paylaşılamaz: aşağıda listelenenleri düzeltin.", + "gallery.export_ok": "Paylaşmaya hazır: {0} ön ayar.", + "gallery.export_save": "preset.json dosyasını kaydet…", + "gallery.export_saved": "Kaydedildi.", + "gallery.export_submit": "Gönderim formunu aç", + "gallery.export_none": "En az bir ön ayar işaretleyin veya bir halka araç çubuğu seçin.", + "gallery.export_warnings": "Galeri şu uyarıları gösterecek:" } diff --git a/src/Filee.App/Assets/i18n/vi.json b/src/Filee.App/Assets/i18n/vi.json index 79ed7ebbe..683721dea 100644 --- a/src/Filee.App/Assets/i18n/vi.json +++ b/src/Filee.App/Assets/i18n/vi.json @@ -1871,5 +1871,91 @@ "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}", + "gallery.warnings_title": "Trước khi cài đặt", + "gallery.warning.replaces_original": "Thay thế tệp gốc: kết quả nằm vào chỗ của chúng và tệp gốc được chuyển vào Thùng rác.", + "gallery.warning.moves_original": "Di chuyển tệp gốc vào một thư mục thay vì sao chép.", + "gallery.warning.overwrites_existing": "Ghi đè tệp đã có tên giống kết quả thay vì thêm số.", + "gallery.warning.copies_to_folder": "Sao chép tệp vào một thư mục. Hãy chọn thư mục trong cấu hình sẵn sau khi cài đặt.", + "gallery.warning.edits_metadata": "Ghi các trường siêu dữ liệu vào tệp được chuyển đổi.", + "gallery.warning.fixed_edits": "Thêm cùng một dấu, vùng che, nền hoặc đoạn cắt vào mọi tệp được chuyển đổi.", + "gallery.warning.strips_metadata": "Xóa siêu dữ liệu (thông tin máy ảnh, ngày, vị trí) khỏi tệp được chuyển đổi.", + "gallery.warning.uses_translate_model": "Dịch bằng một mô hình AI cụ thể, có thể bạn cần tải mô hình đó trong Ollama.", + "gallery.accept_risk": "Tôi hiểu rằng cấu hình sẵn này có thể thay thế, di chuyển hoặc ghi đè tệp của tôi.", + "presets.from_gallery": "Đã cài đặt từ thư viện cấu hình sẵn ({0}). Giữ nguyên cài đặt khi được chia sẻ.", + "gallery.export": "Xuất cho thư viện…", + "gallery.export_title": "Xuất cho thư viện cấu hình sẵn", + "gallery.export_lead": "Đánh dấu các cấu hình sẵn muốn chia sẻ và, nếu muốn, chọn một thanh công cụ vòng tròn. Tệp được kiểm tra giống như thư viện kiểm tra. Lưu lại, rồi gửi kèm 1 đến 4 hình qua biểu mẫu.", + "gallery.export_presets": "Cấu hình sẵn", + "gallery.export_donut": "Thanh công cụ vòng tròn", + "gallery.export_no_donut": "Không có thanh công cụ vòng tròn", + "gallery.export_problems": "Chưa thể chia sẻ: hãy sửa các mục bên dưới.", + "gallery.export_ok": "Sẵn sàng chia sẻ: {0} cấu hình sẵn.", + "gallery.export_save": "Lưu preset.json…", + "gallery.export_saved": "Đã lưu.", + "gallery.export_submit": "Mở biểu mẫu gửi", + "gallery.export_none": "Hãy đánh dấu ít nhất một cấu hình sẵn hoặc chọn một thanh công cụ vòng tròn.", + "gallery.export_warnings": "Thư viện sẽ hiện các cảnh báo này:" } diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index e3b1176ef..0889fc511 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -1871,5 +1871,91 @@ "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}", + "gallery.warnings_title": "安装前请注意", + "gallery.warning.replaces_original": "替换原始文件:结果会放在原文件的位置,原文件移到回收站。", + "gallery.warning.moves_original": "将原始文件移动到文件夹,而不是复制。", + "gallery.warning.overwrites_existing": "如果已有与结果同名的文件,会直接覆盖,而不是添加编号。", + "gallery.warning.copies_to_folder": "将文件复制到文件夹。安装后请在预设中选择文件夹。", + "gallery.warning.edits_metadata": "向转换的文件写入元数据字段。", + "gallery.warning.fixed_edits": "在转换的每个文件上加入相同的标注、隐藏区域、背景或片段。", + "gallery.warning.strips_metadata": "从转换的文件中删除元数据(相机信息、日期、位置)。", + "gallery.warning.uses_translate_model": "使用特定的 AI 模型翻译,可能需要先在 Ollama 中下载该模型。", + "gallery.accept_risk": "我了解此预设可能会替换、移动或覆盖我的文件。", + "presets.from_gallery": "从预设广场安装({0}),保留分享时的设置。", + "gallery.export": "导出到预设广场…", + "gallery.export_title": "导出到预设广场", + "gallery.export_lead": "勾选要分享的预设,也可以选择一个环形工具栏。文件会按预设广场的方式检查。保存后,通过表单连同 1 到 4 张图片一起提交。", + "gallery.export_presets": "预设", + "gallery.export_donut": "环形工具栏", + "gallery.export_no_donut": "不含环形工具栏", + "gallery.export_problems": "暂时无法分享:请修正下面列出的问题。", + "gallery.export_ok": "可以分享:{0} 个预设。", + "gallery.export_save": "保存 preset.json…", + "gallery.export_saved": "已保存。", + "gallery.export_submit": "打开提交表单", + "gallery.export_none": "请至少勾选一个预设或选择一个环形工具栏。", + "gallery.export_warnings": "预设广场将显示以下提醒:" } diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index c7b54bec2..998ea9048 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -1871,5 +1871,91 @@ "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}", + "gallery.warnings_title": "安裝前請注意", + "gallery.warning.replaces_original": "取代原始檔案:結果會放在原檔案的位置,原檔案移到資源回收筒。", + "gallery.warning.moves_original": "將原始檔案移到資料夾,而不是複製。", + "gallery.warning.overwrites_existing": "如果已有與結果同名的檔案,會直接覆寫,而不是加上編號。", + "gallery.warning.copies_to_folder": "將檔案複製到資料夾。安裝後請在預設中選擇資料夾。", + "gallery.warning.edits_metadata": "在轉換的檔案中寫入中繼資料欄位。", + "gallery.warning.fixed_edits": "在轉換的每個檔案中加入相同的標註、隱藏區域、背景或片段。", + "gallery.warning.strips_metadata": "從轉換的檔案中移除中繼資料(相機資訊、日期、位置)。", + "gallery.warning.uses_translate_model": "使用特定的 AI 模型翻譯,可能需要先在 Ollama 中下載該模型。", + "gallery.accept_risk": "我了解此預設可能會取代、移動或覆寫我的檔案。", + "presets.from_gallery": "從預設廣場安裝({0}),保留分享時的設定。", + "gallery.export": "匯出到預設廣場…", + "gallery.export_title": "匯出到預設廣場", + "gallery.export_lead": "勾選要分享的預設,也可以選擇一個環形工具列。檔案會依預設廣場的方式檢查。儲存後,透過表單連同 1 到 4 張圖片一起提交。", + "gallery.export_presets": "預設", + "gallery.export_donut": "環形工具列", + "gallery.export_no_donut": "不含環形工具列", + "gallery.export_problems": "暫時無法分享:請修正下列問題。", + "gallery.export_ok": "可以分享:{0} 個預設。", + "gallery.export_save": "儲存 preset.json…", + "gallery.export_saved": "已儲存。", + "gallery.export_submit": "開啟提交表單", + "gallery.export_none": "請至少勾選一個預設或選擇一個環形工具列。", + "gallery.export_warnings": "預設廣場將顯示以下提醒:" } diff --git a/src/Filee.App/Services/PresetGalleryClient.cs b/src/Filee.App/Services/PresetGalleryClient.cs new file mode 100644 index 000000000..5f2dcc580 --- /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 pictures or thumbnails, in any language), from the cache when it + /// has it for this version of the preset. + /// + public async Task GetImageAsync(GalleryEntry entry, string path, CancellationToken cancellationToken) + { + if (!entry.HasPicture(path)) + throw new ArgumentException("Not one of the preset's pictures.", nameof(path)); + var max = path.EndsWith("-thumb.webp", StringComparison.Ordinal) ? 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..4ebdd4eda 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -852,6 +852,104 @@ public void ShowWhatsNew(Controls.SlideDeck deck) internal WhatsNewWindow? WhatsNew => _whatsNew; + private PresetGalleryWindow? _gallery; + private GalleryExportWindow? _galleryExport; + + internal GalleryExportWindow? GalleryExport => _galleryExport; + + /// + /// Opens "Export for the gallery" (#1530) with ticked; a second call brings the open one + /// to the front. + /// + public void ShowGalleryExport(Preset? selected) + { + if (_galleryExport is not null) + { + _galleryExport.Activate(); + return; + } + GalleryExportWindow? window = null; + var vm = new GalleryExportViewModel(services.GetRequiredService(), loc, selected, + async uri => + { + if (window is not null) + await window.Launcher.LaunchUriAsync(uri); + }, + async () => + { + if (window is null) + return null; + var file = await window.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + SuggestedFileName = "preset.json", + DefaultExtension = "json", + FileTypeChoices = [new FilePickerFileType("JSON") { Patterns = ["*.json"] }], + }); + return file?.TryGetLocalPath(); + }); + window = new GalleryExportWindow { DataContext = vm }; + PrepareWindow(window); + window.Closed += (_, _) => + { + if (_galleryExport == window) + _galleryExport = null; + trimmer.Request(); + }; + _galleryExport = window; + if (_main is { IsVisible: true } main && main.WindowState != WindowState.Minimized) + window.Show(main); + else + { + window.WindowStartupLocation = WindowStartupLocation.CenterScreen; + window.Show(); + } + window.Activate(); + } + + 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/GalleryExportViewModel.cs b/src/Filee.App/ViewModels/GalleryExportViewModel.cs new file mode 100644 index 000000000..4492c9b0f --- /dev/null +++ b/src/Filee.App/ViewModels/GalleryExportViewModel.cs @@ -0,0 +1,131 @@ +// "Export for the gallery" (#1530): the presets one ticks, and one donut if one likes, saved as a preset.json for a +// submission to the preset gallery. The file is checked as the gallery's CI checks it each time the choice changes, so +// what can't be shared and what will be shown as a warning are listed before it is saved; the submission form opens in +// the browser. + +using System.Collections.ObjectModel; +using System.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Filee.Core.Gallery; +using Filee.Core.Localization; +using Filee.Core.Presets; +using Filee.Core.Profiles; +using Filee.Core.Settings; + +namespace Filee.App.ViewModels; + +/// A preset of the library, ticked or not for the export. +public sealed partial class GalleryExportChoice(Preset preset, string name, bool chosen) : ObservableObject +{ + public Preset Preset { get; } = preset; + public string Name { get; } = name; + + [ObservableProperty] private bool _isChosen = chosen; +} + +public sealed partial class GalleryExportViewModel : ObservableObject +{ + /// The gallery repository's form for a new preset. + public static readonly Uri SubmitUri = new("https://github.com/KnifeLemon/Filee-Presets/issues/new?template=submit-preset.yml"); + + private readonly UserDataStore _store; + private readonly ILocalizer _loc; + private readonly Func _open; + private readonly Func> _pickPath; + private GalleryExportFile? _file; + + /// The preset ticked to begin with (the one chosen on the Presets page). + /// Asks where to save preset.json; null when the user cancels. + public GalleryExportViewModel(UserDataStore store, ILocalizer loc, Preset? selected, Func open, Func> pickPath) + { + _store = store; + _loc = loc; + _open = open; + _pickPath = pickPath; + foreach (var preset in store.Presets) + { + var choice = new GalleryExportChoice(preset, loc.DisplayName(preset), preset == selected); + choice.PropertyChanged += OnChoiceChanged; + Presets.Add(choice); + } + Donuts = [new(null, loc["gallery.export_no_donut"]), .. store.Profiles.Select(p => new Choice(p, loc.DisplayName(p)))]; + _donut = Donuts[0]; + Recheck(); + } + + public ObservableCollection Presets { get; } = []; + + public IReadOnlyList> Donuts { get; } + + [ObservableProperty] private Choice _donut; + + partial void OnDonutChanged(Choice value) => Recheck(); + + /// What keeps the file from being shared, as the gallery's check says it (English, with where). + public ObservableCollection Problems { get; } = []; + + /// What the gallery will warn about, in the user's words. + public ObservableCollection Warnings { get; } = []; + + [ObservableProperty] private bool _hasProblems; + [ObservableProperty] private bool _hasWarnings; + + /// "Ready to share", "Choose at least one preset", "Saved". + [ObservableProperty] private string? _status; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SaveCommand))] + private bool _canSave; + + private void OnChoiceChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(GalleryExportChoice.IsChosen)) + Recheck(); + } + + /// Builds the file anew from the choice and checks it. + private void Recheck() + { + Problems.Clear(); + Warnings.Clear(); + var chosen = Presets.Where(c => c.IsChosen).Select(c => c.Preset).ToList(); + if (chosen.Count == 0 && Donut.Value is null) + { + _file = null; + (HasProblems, HasWarnings, CanSave) = (false, false, false); + Status = _loc["gallery.export_none"]; + return; + } + _file = GalleryExport.Build(_store.Presets, chosen, Donut.Value); + foreach (var error in _file.Check.Errors) + Problems.Add(error.Path.Length == 0 ? error.Message : $"{error.Path}: {error.Message}"); + foreach (var warning in _file.Check.Derived?.Warnings ?? []) + Warnings.Add(new PresetGalleryWarning(warning.Code, _loc[$"gallery.warning.{warning.Code}"], warning.Severity == SharedPresetWarnings.High)); + HasProblems = Problems.Count > 0; + HasWarnings = Warnings.Count > 0; + CanSave = _file.Check.Ok; + Status = CanSave ? _loc.Format("gallery.export_ok", _file.Presets.Count) : _loc["gallery.export_problems"]; + } + + /// Saves preset.json where the user says. + [RelayCommand(CanExecute = nameof(CanSave))] + private async Task SaveAsync() + { + if (_file is null || await _pickPath() is not { } path) + return; + try + { + await File.WriteAllBytesAsync(path, _file.Json); + Status = _loc["gallery.export_saved"]; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Status = ex.Message; + } + } + + /// Opens the gallery's submission form in the browser. + [RelayCommand] + private Task OpenFormAsync() => _open(SubmitUri); +} diff --git a/src/Filee.App/ViewModels/GallerySummaries.cs b/src/Filee.App/ViewModels/GallerySummaries.cs new file mode 100644 index 000000000..f38988ba8 --- /dev/null +++ b/src/Filee.App/ViewModels/GallerySummaries.cs @@ -0,0 +1,137 @@ +// 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; + } + } +} + +/// Converters of the preset gallery's window. +public static class GalleryConverters +{ + /// A high warning in semibold, the others regular. + public static readonly Avalonia.Data.Converters.IValueConverter Emphasis = + new Avalonia.Data.Converters.FuncValueConverter(high => high ? Avalonia.Media.FontWeight.SemiBold : Avalonia.Media.FontWeight.Normal); +} diff --git a/src/Filee.App/ViewModels/Pages/PresetsPageViewModel.cs b/src/Filee.App/ViewModels/Pages/PresetsPageViewModel.cs index 34435aa45..1c26e64de 100644 --- a/src/Filee.App/ViewModels/Pages/PresetsPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/PresetsPageViewModel.cs @@ -129,6 +129,18 @@ 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)); + }); + + /// Opens "Export for the gallery" with the chosen preset ticked. + [RelayCommand] + private void ExportForGallery() => _windows.ShowGalleryExport(SelectedPreset?.Preset); + private void AddPreset(Preset preset) { _store.Presets.Add(preset); diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index d4b3feb52..ce06455df 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -116,6 +116,9 @@ public sealed partial class PresetEditorViewModel : ObservableObject private readonly Preset _edit; private readonly ILocalizer _loc; + /// "From the preset gallery (…)" for a preset installed from there (#1530); null for every other. + public string? GalleryNote { get; } + public PresetEditorViewModel(Preset preset, ILocalizer loc, IReadOnlySet? turnedOff = null, bool folderInput = false) : this(preset, loc, turnedOff, null, folderInput) { @@ -130,6 +133,7 @@ internal PresetEditorViewModel(Preset preset, ILocalizer loc, IReadOnlySetThe 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]; + _loc.LanguageChanged += OnUiLanguageChanged; + } + + /// The app's language changed while the window is open: pictures in that language, where a preset has them. + 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). + 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() + { + _loc.LanguageChanged -= OnUiLanguageChanged; + _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 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)); + 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 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) + { + 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 || bitmap is null) + bitmap?.Dispose(); + else + { + var old = Thumb; + (Thumb, _thumbPath) = (bitmap, path); + old?.Dispose(); + } + } + 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; +} + +/// 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 +{ + 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))) + : ""; + } + + 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; + + /// The paths shows, for the language they were picked for. + private IReadOnlyList _imagePaths = []; + + /// 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; + + /// + /// 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; + 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, paths[i], token); + var bitmap = await Task.Run(() => GalleryBitmaps.Decode(data, ImageEdge), token); + // A language changed meanwhile: these pictures are no longer shown. + if (token.IsCancellationRequested || _imagePaths != paths) + bitmap?.Dispose(); + else + set[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"]; + 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 && (!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))] + 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; + } + // 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; + 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/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 730257ea2..ac77d3b29 100644 --- a/src/Filee.App/Views/Pages/PresetsPage.axaml +++ b/src/Filee.App/Views/Pages/PresetsPage.axaml @@ -35,6 +35,22 @@ ToolTip.Tip="{DynamicResource presets.delete}" AutomationProperties.Name="{DynamicResource presets.delete}"> + + + + 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 new file mode 100644 index 000000000..1bad84370 --- /dev/null +++ b/src/Filee.App/Views/PresetGalleryWindow.axaml @@ -0,0 +1,282 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..965f4787b 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,32 @@ 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 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", 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 + + """; + 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..09f430d74 --- /dev/null +++ b/src/Filee.Cli/PresetsCheckCommand.cs @@ -0,0 +1,124 @@ +// "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!; + 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."); + } + + 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.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 + 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/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/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..84814023e --- /dev/null +++ b/src/Filee.Core/Gallery/GalleryIndex.cs @@ -0,0 +1,270 @@ +// 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; } + + /// + /// 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; } + + /// + /// 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; + 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; + 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 = IsThumb(thumb) ? thumb : null, + ImagesByLang = imagesByLang, + ThumbByLang = thumbByLang, + 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/(?:[A-Za-z]{2,3}(?:-[A-Za-z]{2,4})?/)?[1-4](?:-thumb)?\.[a-z]{3,4})$", RegexOptions.CultureInvariant)] + private static partial Regex RelativePath(); + + // 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/(?:(?[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. + /// + 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..398fe68ff --- /dev/null +++ b/src/Filee.Core/Gallery/GalleryInstaller.cs @@ -0,0 +1,116 @@ +// 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. + /// + /// The gallery entry's id, kept on each preset (). + public static GalleryInstallResult Install(UserDataStore store, ReadOnlySpan json, string sha256, + Func presetName, Func profileName, string? gallery = null) + { + 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) + { + // Kept as shared, settings and all: the window showed its warnings before the user chose to install it. + preset.Gallery = gallery; + 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..1bfbb2e45 --- /dev/null +++ b/src/Filee.Core/Gallery/PresetCheckResult.cs @@ -0,0 +1,82 @@ +// 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 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 +{ + /// 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..f7c9507fe --- /dev/null +++ b/src/Filee.Core/Gallery/SharedBundleCheck.cs @@ -0,0 +1,341 @@ +// 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.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)) + { + 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.IsTool)) + 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) + { + Warnings = SharedPresetWarnings.Of(bundle.Presets), + }; + } + + 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 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) + { + 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..971582e48 --- /dev/null +++ b/src/Filee.Core/Gallery/SharedPresetFolderCheck.cs @@ -0,0 +1,343 @@ +// 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); + + /// 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"]; + + /// + /// 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); + + 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, 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 = $"{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 here (only 1.png … 4.webp, and a folder per language in images/)"); + 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); + } + foreach (var (number, file) in thumbs) + { + 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)] + 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}"), + ["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) => + 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("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"); + } + } + + 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..927298166 --- /dev/null +++ b/src/Filee.Core/Gallery/SharedPresetRules.cs @@ -0,0 +1,389 @@ +// 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. +// 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; +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; + +/// +/// 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. +/// +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: 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): 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.MetadataTool, Preset.UpscaleTool, + ]; + + /// + /// 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 = System.Enum.GetNames(); + + /// 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); + + 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)), + ("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), + ("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)), + ("maxSizeKeepsPixels", Flag), + ("minSizeMb", new NumberRule(0, 10_000)), + ("resize", Enum()), + ("rotation", Enum()), + ("flip", Enum()), + ("annotations", new ListRule(Obj( + ("kind", Enum()), + ("color", Colour), + ("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)), + ("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 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), + ("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", Flag), + ("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", 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), + ("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 StringRule(100, SharedText.OllamaModel) { Nullable = true }))), + ("upscale", Obj( + ("enabled", Flag), + ("model", new StringRule(80, SharedText.ModelId) { Nullable = true }), + ("scale", new IntRule(1, 8)))), + ("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), + ("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", Enum()), + ("keepDates", Flag), + ("zipResult", Flag), + ("onlyIfSmaller", Flag), + ("replaceOriginal", Flag), + ("moveInsteadOfCopy", Flag)))); + + /// 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/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 new file mode 100644 index 000000000..46ef2c901 --- /dev/null +++ b/src/Filee.Core/Gallery/SharedText.cs @@ -0,0 +1,170 @@ +// 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(); + + /// 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 clearly names a person, a place or a copyright owner (author, artist, creator, + /// copyright and rights holder, by-line, owner, credit, GPS and location fields, contact details): a shared preset may + /// only remove it, never give it a value. A document's title or name is not one of them. + /// + public static bool IsPersonalField(string field) => PersonalField().IsMatch(field); + + [GeneratedRegex(@"(?i)(author|artist|creator|copyright|rights|by-?line|owner|credit|contact|e-?mail|phone|address|gps|latitude|longitude|altitude|location|city|country|province)", 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"; + + 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/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/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..9d252aa9a --- /dev/null +++ b/tests/Filee.App.Tests/PresetGalleryTests.cs @@ -0,0 +1,727 @@ +// 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(); + 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; + })); + } + + 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)); + } + + /// 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() + { + 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