diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 972eee166..663dca9e9 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -74,6 +74,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "عدد الملفات: {0}", "donut.drop_to": "أفلت للتحويل إلى {0}", + "donut.ordinary_conversion.single": "يُحوَّل هذا الملف تحويلاً عادياً", + "donut.ordinary_conversion.all": "تُحوَّل جميع الملفات القابلة للتحويل تحويلاً عادياً", + "donut.ordinary_conversion.some": "تُحوَّل بعض الملفات تحويلاً عادياً", "donut.pick_in_group": "حرّك المؤشر إلى الخارج للاختيار", "donut.group_empty": "لم تعد هناك إعدادات مسبقة في هذه المجموعة. عدّلها من الإعدادات ← شريط الأدوات الدائري", "donut.click_to": "انقر للتحويل إلى {0}", @@ -818,6 +821,9 @@ "toolbar.mixed_others": "تفتح التحديدات المختلطة الأخرى «{0}».", "toolbar.mixed_others_self": "لا يوجد ملف تعريف مختلط بلا امتدادات محددة، لذا يُستخدم هذا الملف أيضًا لكل مزيج آخر.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "تُحوَّل صيغة الإدخال هذه تحويلاً عادياً", + "toolbar.chip_tip.ordinary_conversion.all": "تُحوَّل جميع صيغ الإدخال القابلة للتحويل تحويلاً عادياً", + "toolbar.chip_tip.ordinary_conversion.some": "تُحوَّل بعض صيغ الإدخال تحويلاً عادياً", "toolbar.chip_tip.makes_same": "→ التنسيق نفسه", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "الصيغ المقبولة: {0}", diff --git a/src/Filee.App/Assets/i18n/bn-BD.json b/src/Filee.App/Assets/i18n/bn-BD.json index 6845cfd50..d20d3e54a 100644 --- a/src/Filee.App/Assets/i18n/bn-BD.json +++ b/src/Filee.App/Assets/i18n/bn-BD.json @@ -88,6 +88,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0}টি ফাইল", "donut.drop_to": "ছেড়ে দিন → {0}", + "donut.ordinary_conversion.single": "এই ফাইলটি সাধারণ রূপান্তর হিসেবে চলে", + "donut.ordinary_conversion.all": "রূপান্তরযোগ্য সব ফাইল সাধারণ রূপান্তর হিসেবে চলে", + "donut.ordinary_conversion.some": "কিছু ফাইল সাধারণ রূপান্তর হিসেবে চলে", "donut.pick_in_group": "নির্বাচন করতে বাইরের দিকে সরান", "donut.group_empty": "এই গ্রুপে আর কোনো প্রিসেট অবশিষ্ট নেই। সেটিংস → ডোনাট টুলবারে এটি সম্পাদনা করুন", "donut.click_to": "ক্লিক করুন → {0}", @@ -817,6 +820,9 @@ "toolbar.mixed_others": "অন্যান্য মিশ্র ফাইলের জন্য “{0}” খুলবে।", "toolbar.mixed_others_self": "চিহ্নিত না থাকা কোনো মিশ্র প্রোফাইল অবশিষ্ট নেই, তাই এটি অন্য সব মিশ্রণের জন্যও ব্যবহৃত হবে।", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "এই ইনপুট ফরম্যাটটি সাধারণ রূপান্তর হিসেবে চলে", + "toolbar.chip_tip.ordinary_conversion.all": "রূপান্তরযোগ্য সব ইনপুট ফরম্যাট সাধারণ রূপান্তর হিসেবে চলে", + "toolbar.chip_tip.ordinary_conversion.some": "কিছু ইনপুট ফরম্যাট সাধারণ রূপান্তর হিসেবে চলে", "toolbar.chip_tip.makes_same": "→ একই ফরম্যাট", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "গৃহীত ফরম্যাট: {0}", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 6190cee2f..1d23d46b4 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -74,6 +74,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} Dateien", "donut.drop_to": "Ablegen → {0}", + "donut.ordinary_conversion.single": "Diese Datei wird normal konvertiert", + "donut.ordinary_conversion.all": "Alle konvertierbaren Dateien werden normal konvertiert", + "donut.ordinary_conversion.some": "Einige Dateien werden normal konvertiert", "donut.pick_in_group": "Zum Auswählen nach außen bewegen", "donut.group_empty": "Diese Gruppe enthält keine Vorlagen mehr. Bearbeite sie unter Einstellungen → Ringmenü", "donut.click_to": "Klicken → {0}", @@ -818,6 +821,9 @@ "toolbar.mixed_others": "Andere Dateimischungen öffnen „{0}“.", "toolbar.mixed_others_self": "Kein Profil für gemischte Auswahlen ist ohne Auswahl geblieben. Dieses Profil wird daher auch für alle anderen Dateimischungen verwendet.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "Dieses Eingabeformat wird normal konvertiert", + "toolbar.chip_tip.ordinary_conversion.all": "Alle konvertierbaren Eingabeformate werden normal konvertiert", + "toolbar.chip_tip.ordinary_conversion.some": "Einige Eingabeformate werden normal konvertiert", "toolbar.chip_tip.makes_same": "→ Gleiches Format", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Formate: {0}", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 5ad8929cb..a20a6e051 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -78,6 +78,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} files", "donut.drop_to": "Drop → {0}", + "donut.ordinary_conversion.single": "This file runs as an ordinary conversion", + "donut.ordinary_conversion.all": "All convertible files run as ordinary conversions", + "donut.ordinary_conversion.some": "Some files run as ordinary conversions", "donut.pick_in_group": "Move outward to choose", "donut.group_empty": "No presets left in this group. Edit it in Settings → Donut toolbar", "donut.click_to": "Click → {0}", @@ -832,6 +835,9 @@ "toolbar.mixed_others": "Other mixes of files open “{0}”.", "toolbar.mixed_others_self": "No mixed profile is left with nothing checked, so this one is also used for every other mix.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "This input format runs as an ordinary conversion", + "toolbar.chip_tip.ordinary_conversion.all": "All convertible input formats run as ordinary conversions", + "toolbar.chip_tip.ordinary_conversion.some": "Some input formats run as ordinary conversions", "toolbar.chip_tip.makes_same": "→ Same format", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Accepted formats: {0}", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index ba6f22f55..5f7068872 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -74,6 +74,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} archivos", "donut.drop_to": "Suelta → {0}", + "donut.ordinary_conversion.single": "Este archivo usa una conversión normal", + "donut.ordinary_conversion.all": "Todos los archivos convertibles usan una conversión normal", + "donut.ordinary_conversion.some": "Algunos archivos usan una conversión normal", "donut.pick_in_group": "Muévete hacia fuera para elegir", "donut.group_empty": "No quedan ajustes preestablecidos en este grupo. Edítalo en Ajustes → Menú circular", "donut.click_to": "Clic → {0}", @@ -818,6 +821,9 @@ "toolbar.mixed_others": "Las demás combinaciones de archivos abren «{0}».", "toolbar.mixed_others_self": "Ningún perfil mixto quedó sin nada marcado, así que este también se usa para todas las demás combinaciones.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "Este formato de entrada usa una conversión normal", + "toolbar.chip_tip.ordinary_conversion.all": "Todos los formatos de entrada convertibles usan una conversión normal", + "toolbar.chip_tip.ordinary_conversion.some": "Algunos formatos de entrada usan una conversión normal", "toolbar.chip_tip.makes_same": "→ Mismo formato", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Formatos admitidos: {0}", diff --git a/src/Filee.App/Assets/i18n/fa.json b/src/Filee.App/Assets/i18n/fa.json index ff787bfef..291b4dfb9 100644 --- a/src/Filee.App/Assets/i18n/fa.json +++ b/src/Filee.App/Assets/i18n/fa.json @@ -88,6 +88,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} فایل", "donut.drop_to": "رها کردن ← {0}", + "donut.ordinary_conversion.single": "این فایل به‌صورت عادی تبدیل می‌شود", + "donut.ordinary_conversion.all": "همهٔ فایل‌های قابل تبدیل به‌صورت عادی تبدیل می‌شوند", + "donut.ordinary_conversion.some": "بعضی فایل‌ها به‌صورت عادی تبدیل می‌شوند", "donut.pick_in_group": "حرکت به سمت بیرون برای انتخاب", "donut.group_empty": "پیش‌تنظیمی در این گروه باقی نمانده است. آن را در تنظیمات ← نوار ابزار دونات ویرایش کنید", "donut.click_to": "کلیک ← {0}", @@ -817,6 +820,9 @@ "toolbar.mixed_others": "سایر ترکیب‌های فایلی «{0}» را باز می‌کنند.", "toolbar.mixed_others_self": "هیچ نمایه ترکیبی بدون علامت باقی نمانده است، بنابراین این نمایه برای هر ترکیب دیگری نیز استفاده می‌شود.", "toolbar.chip_tip.makes": "← {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "این فرمت ورودی به‌صورت عادی تبدیل می‌شود", + "toolbar.chip_tip.ordinary_conversion.all": "همهٔ فرمت‌های ورودی قابل تبدیل به‌صورت عادی تبدیل می‌شوند", + "toolbar.chip_tip.ordinary_conversion.some": "بعضی فرمت‌های ورودی به‌صورت عادی تبدیل می‌شوند", "toolbar.chip_tip.makes_same": "← همان فرمت", "toolbar.chip_tip.makes_name": "← {0}", "toolbar.chip_tip.works_with": "فرمت‌های پذیرفته‌شده: {0}", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index dfe7b3dc6..7c8a1cbb3 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -78,6 +78,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} fichiers", "donut.drop_to": "Déposer → {0}", + "donut.ordinary_conversion.single": "Ce fichier utilise une conversion normale", + "donut.ordinary_conversion.all": "Tous les fichiers convertibles utilisent une conversion normale", + "donut.ordinary_conversion.some": "Certains fichiers utilisent une conversion normale", "donut.pick_in_group": "Éloignez-vous pour choisir", "donut.group_empty": "Plus aucun préréglage dans ce groupe. Modifiez-le dans Paramètres → Menu circulaire", "donut.click_to": "Clic → {0}", @@ -832,6 +835,9 @@ "toolbar.mixed_others": "Les autres mélanges de fichiers ouvrent « {0} ».", "toolbar.mixed_others_self": "Il ne reste aucun profil mixte sans case cochée : celui-ci sert donc aussi pour tous les autres mélanges.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "Ce format d’entrée utilise une conversion normale", + "toolbar.chip_tip.ordinary_conversion.all": "Tous les formats d’entrée convertibles utilisent une conversion normale", + "toolbar.chip_tip.ordinary_conversion.some": "Certains formats d’entrée utilisent une conversion normale", "toolbar.chip_tip.makes_same": "→ Même format", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Formats acceptés : {0}", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index e24a35e4b..bf9b82aaf 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -74,6 +74,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} फ़ाइलें", "donut.drop_to": "छोड़ें → {0}", + "donut.ordinary_conversion.single": "यह फ़ाइल सामान्य कन्वर्ज़न के रूप में चलती है", + "donut.ordinary_conversion.all": "कन्वर्ट हो सकने वाली सभी फ़ाइलें सामान्य कन्वर्ज़न के रूप में चलती हैं", + "donut.ordinary_conversion.some": "कुछ फ़ाइलें सामान्य कन्वर्ज़न के रूप में चलती हैं", "donut.pick_in_group": "चुनने के लिए बाहर की ओर ले जाएँ", "donut.group_empty": "इस समूह में कोई प्रीसेट नहीं बचा है। इसे सेटिंग्स → डोनट टूलबार में बदलें", "donut.click_to": "क्लिक करें → {0}", @@ -818,6 +821,9 @@ "toolbar.mixed_others": "फ़ाइलों के अन्य मिश्रण “{0}” खोलते हैं।", "toolbar.mixed_others_self": "कोई मिश्रित प्रोफ़ाइल बिना चयन के नहीं बची है, इसलिए यह प्रोफ़ाइल हर अन्य मिश्रण के लिए भी इस्तेमाल होती है।", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "यह इनपुट फ़ॉर्मैट सामान्य कन्वर्ज़न के रूप में चलता है", + "toolbar.chip_tip.ordinary_conversion.all": "कन्वर्ट हो सकने वाले सभी इनपुट फ़ॉर्मैट सामान्य कन्वर्ज़न के रूप में चलते हैं", + "toolbar.chip_tip.ordinary_conversion.some": "कुछ इनपुट फ़ॉर्मैट सामान्य कन्वर्ज़न के रूप में चलते हैं", "toolbar.chip_tip.makes_same": "→ वही फ़ॉर्मैट", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "स्वीकार्य फ़ॉर्मैट: {0}", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index 001a0b25a..9de142916 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -74,6 +74,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} file", "donut.drop_to": "Lepas → {0}", + "donut.ordinary_conversion.single": "File ini menjalankan konversi biasa", + "donut.ordinary_conversion.all": "Semua file yang dapat dikonversi menjalankan konversi biasa", + "donut.ordinary_conversion.some": "Sebagian file menjalankan konversi biasa", "donut.pick_in_group": "Arahkan keluar untuk memilih", "donut.group_empty": "Tidak ada preset tersisa di grup ini. Edit di Pengaturan → Toolbar donut", "donut.click_to": "Klik → {0}", @@ -818,6 +821,9 @@ "toolbar.mixed_others": "Kombinasi file lainnya membuka “{0}”.", "toolbar.mixed_others_self": "Tidak ada profil campuran yang dibiarkan tanpa centang, sehingga profil ini juga digunakan untuk setiap kombinasi lainnya.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "Format input ini menjalankan konversi biasa", + "toolbar.chip_tip.ordinary_conversion.all": "Semua format input yang dapat dikonversi menjalankan konversi biasa", + "toolbar.chip_tip.ordinary_conversion.some": "Sebagian format input menjalankan konversi biasa", "toolbar.chip_tip.makes_same": "→ Format sama", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Format yang didukung: {0}", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index 82452a65c..e8e0b74e1 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -73,6 +73,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} file", "donut.drop_to": "Rilascia → {0}", +"donut.ordinary_conversion.single": "Questo file usa una conversione normale", +"donut.ordinary_conversion.all": "Tutti i file convertibili usano una conversione normale", +"donut.ordinary_conversion.some": "Alcuni file usano una conversione normale", "donut.pick_in_group": "Spostati verso l'esterno per scegliere", "donut.group_empty": "Nessun preset rimasto in questo gruppo. Modificalo in Impostazioni → Barra donut", "donut.click_to": "Clic → {0}", @@ -817,6 +820,9 @@ "toolbar.mixed_others": "Le altre combinazioni di file aprono “{0}”.", "toolbar.mixed_others_self": "Non è rimasto nessun profilo misto senza selezioni, quindi questo viene usato anche per ogni altra combinazione.", "toolbar.chip_tip.makes": "→ {0} · {1}", +"toolbar.chip_tip.ordinary_conversion.single": "Questo formato di ingresso usa una conversione normale", +"toolbar.chip_tip.ordinary_conversion.all": "Tutti i formati di ingresso convertibili usano una conversione normale", +"toolbar.chip_tip.ordinary_conversion.some": "Alcuni formati di ingresso usano una conversione normale", "toolbar.chip_tip.makes_same": "→ Stesso formato", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Formati accettati: {0}", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 50ff2919f..f7afcf886 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -74,6 +74,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0}ファイル", "donut.drop_to": "ドロップ → {0}", + "donut.ordinary_conversion.single": "このファイルは通常の変換として実行されます", + "donut.ordinary_conversion.all": "変換可能なファイルはすべて通常の変換として実行されます", + "donut.ordinary_conversion.some": "一部のファイルは通常の変換として実行されます", "donut.pick_in_group": "外側に動かして選択", "donut.group_empty": "このグループにプリセットがありません。「設定」→「ドーナツメニュー」で編集できます", "donut.click_to": "クリック → {0}", @@ -818,6 +821,9 @@ "toolbar.mixed_others": "ほかの形式の組み合わせには「{0}」が使われます。", "toolbar.mixed_others_self": "何も選択されていない複数形式用プロファイルがほかにないため、このプロファイルが残りすべての組み合わせにも使われます。", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "この入力形式は通常の変換として実行されます", + "toolbar.chip_tip.ordinary_conversion.all": "変換可能な入力形式はすべて通常の変換として実行されます", + "toolbar.chip_tip.ordinary_conversion.some": "一部の入力形式は通常の変換として実行されます", "toolbar.chip_tip.makes_same": "→ 同じ形式", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "対応形式: {0}", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 0637320af..47220f0d2 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -78,6 +78,9 @@ "donut.files_same": "{1} {0}개", "donut.files_mixed": "파일 {0}개", "donut.drop_to": "놓으면 {0}(으)로", + "donut.ordinary_conversion.single": "이 파일은 일반 변환으로 실행돼요", + "donut.ordinary_conversion.all": "변환 가능한 파일은 모두 일반 변환으로 실행돼요", + "donut.ordinary_conversion.some": "일부 파일은 일반 변환으로 실행돼요", "donut.pick_in_group": "바깥으로 옮겨 고르세요", "donut.group_empty": "묶음에 남은 프리셋 없음 (설정 → 원형 툴바에서 편집)", "donut.click_to": "클릭하면 {0}(으)로", @@ -832,6 +835,9 @@ "toolbar.mixed_others": "그 밖의 혼합 선택에는 “{0}”이(가) 열려요.", "toolbar.mixed_others_self": "아무것도 체크하지 않은 혼합 프로필이 없어서 그 밖의 혼합 선택에도 이 프로필이 쓰여요.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "이 입력 형식은 일반 변환으로 실행돼요", + "toolbar.chip_tip.ordinary_conversion.all": "변환 가능한 입력 형식은 모두 일반 변환으로 실행돼요", + "toolbar.chip_tip.ordinary_conversion.some": "일부 입력 형식은 일반 변환으로 실행돼요", "toolbar.chip_tip.makes_same": "→ 같은 형식", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "받을 수 있는 형식 {0}개", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index c21995118..5a4e42040 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -74,6 +74,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} फाइलहरू", "donut.drop_to": "छाड्नुहोस् → {0}", + "donut.ordinary_conversion.single": "यो फाइल सामान्य रूपान्तरणको रूपमा चल्छ", + "donut.ordinary_conversion.all": "रूपान्तरण गर्न मिल्ने सबै फाइल सामान्य रूपान्तरणको रूपमा चल्छन्", + "donut.ordinary_conversion.some": "केही फाइल सामान्य रूपान्तरणको रूपमा चल्छन्", "donut.pick_in_group": "छनोट गर्न बाहिरतर्फ लैजानुहोस्", "donut.group_empty": "यस समूहमा कुनै प्रिसेट बाँकी छैन। यसलाई सेटिङहरू → डोनट टुलबारमा सम्पादन गर्नुहोस्", "donut.click_to": "क्लिक गर्नुहोस् → {0}", @@ -818,6 +821,9 @@ "toolbar.mixed_others": "फाइलहरूको अन्य मिश्रणहरूले “{0}” खोल्छन्।", "toolbar.mixed_others_self": "कुनै पनि अनचेक गरिएको मिश्रित प्रोफाइल बाँकी छैन, त्यसैले यो प्रोफाइल हरेक अन्य मिश्रणका लागि पनि प्रयोग हुन्छ।", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "यो इनपुट ढाँचा सामान्य रूपान्तरणको रूपमा चल्छ", + "toolbar.chip_tip.ordinary_conversion.all": "रूपान्तरण गर्न मिल्ने सबै इनपुट ढाँचा सामान्य रूपान्तरणको रूपमा चल्छन्", + "toolbar.chip_tip.ordinary_conversion.some": "केही इनपुट ढाँचा सामान्य रूपान्तरणको रूपमा चल्छन्", "toolbar.chip_tip.makes_same": "→ उही ढाँचा", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "समर्थित ढाँचाहरू: {0}", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 5f4c13220..3a1e6f4f2 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -78,6 +78,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "Pliki: {0}", "donut.drop_to": "Upuść → {0}", + "donut.ordinary_conversion.single": "Ten plik jest konwertowany zwyczajnie", + "donut.ordinary_conversion.all": "Wszystkie pliki możliwe do konwersji są konwertowane zwyczajnie", + "donut.ordinary_conversion.some": "Niektóre pliki są konwertowane zwyczajnie", "donut.pick_in_group": "Przesuń na zewnątrz, by wybrać", "donut.group_empty": "W tej grupie nie ma już ustawień. Edytuj ją w Ustawienia → Pasek-pierścień", "donut.click_to": "Kliknij → {0}", @@ -832,6 +835,9 @@ "toolbar.mixed_others": "Inne mieszanki plików otwierają „{0}”.", "toolbar.mixed_others_self": "Nie został żaden mieszany profil bez zaznaczeń, więc ten jest używany także dla każdej innej mieszanki.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "Ten format wejściowy jest konwertowany zwyczajnie", + "toolbar.chip_tip.ordinary_conversion.all": "Wszystkie formaty wejściowe możliwe do konwersji są konwertowane zwyczajnie", + "toolbar.chip_tip.ordinary_conversion.some": "Niektóre formaty wejściowe są konwertowane zwyczajnie", "toolbar.chip_tip.makes_same": "→ Ten sam format", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Obsługiwane formaty: {0}", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index dca3fc60b..815776eb7 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -78,6 +78,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} arquivos", "donut.drop_to": "Solte → {0}", + "donut.ordinary_conversion.single": "Este arquivo usa uma conversão normal", + "donut.ordinary_conversion.all": "Todos os arquivos que podem ser convertidos usam uma conversão normal", + "donut.ordinary_conversion.some": "Alguns arquivos usam uma conversão normal", "donut.pick_in_group": "Mova para fora para escolher", "donut.group_empty": "Não restam predefinições neste grupo. Edite em Configurações → Menu circular", "donut.click_to": "Clique → {0}", @@ -832,6 +835,9 @@ "toolbar.mixed_others": "Outras misturas de arquivos abrem “{0}”.", "toolbar.mixed_others_self": "Não restou perfil misto sem nada marcado, então este também é usado para toda outra mistura.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "Este formato de entrada usa uma conversão normal", + "toolbar.chip_tip.ordinary_conversion.all": "Todos os formatos de entrada que podem ser convertidos usam uma conversão normal", + "toolbar.chip_tip.ordinary_conversion.some": "Alguns formatos de entrada usam uma conversão normal", "toolbar.chip_tip.makes_same": "→ Mesmo formato", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Formatos aceitos: {0}", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 56a6ed887..6c7c3d31d 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -78,6 +78,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} ficheiros", "donut.drop_to": "Largar → {0}", + "donut.ordinary_conversion.single": "Este ficheiro usa uma conversão normal", + "donut.ordinary_conversion.all": "Todos os ficheiros que podem ser convertidos usam uma conversão normal", + "donut.ordinary_conversion.some": "Alguns ficheiros usam uma conversão normal", "donut.pick_in_group": "Move o ponteiro para fora para escolher", "donut.group_empty": "Este grupo já não tem predefinições. Edita-o em Definições → Barra circular", "donut.click_to": "Clicar → {0}", @@ -832,6 +835,9 @@ "toolbar.mixed_others": "As restantes misturas de ficheiros abrem «{0}».", "toolbar.mixed_others_self": "Não resta nenhum perfil misto sem opções selecionadas, pelo que este também é utilizado para todas as outras misturas.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "Este formato de entrada usa uma conversão normal", + "toolbar.chip_tip.ordinary_conversion.all": "Todos os formatos de entrada que podem ser convertidos usam uma conversão normal", + "toolbar.chip_tip.ordinary_conversion.some": "Alguns formatos de entrada usam uma conversão normal", "toolbar.chip_tip.makes_same": "→ Mesmo formato", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Formatos aceites: {0}", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 7a8fb4b0f..f62790e46 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -78,6 +78,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "Файлов: {0}", "donut.drop_to": "Отпустите → {0}", + "donut.ordinary_conversion.single": "Этот файл обрабатывается как обычная конвертация", + "donut.ordinary_conversion.all": "Все преобразуемые файлы — обычная конвертация", + "donut.ordinary_conversion.some": "Некоторые файлы — обычная конвертация", "donut.pick_in_group": "Ведите наружу, чтобы выбрать", "donut.group_empty": "В этой группе не осталось пресетов. Настройте её в разделе «Настройки → Круговое меню»", "donut.click_to": "Щёлкните → {0}", @@ -832,6 +835,9 @@ "toolbar.mixed_others": "Остальные смеси файлов открывают «{0}».", "toolbar.mixed_others_self": "Не осталось смешанного профиля без отметок, поэтому этот используется и для всех остальных смесей.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "Этот входной формат обрабатывается как обычная конвертация", + "toolbar.chip_tip.ordinary_conversion.all": "Все конвертируемые входные форматы обрабатываются как обычная конвертация", + "toolbar.chip_tip.ordinary_conversion.some": "Некоторые входные форматы обрабатываются как обычная конвертация", "toolbar.chip_tip.makes_same": "→ Тот же формат", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Поддерживаемые форматы: {0}", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index d4e85e791..8cb1e96f2 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -74,6 +74,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} ไฟล์", "donut.drop_to": "วาง → {0}", + "donut.ordinary_conversion.single": "ไฟล์นี้ใช้การแปลงตามปกติ", + "donut.ordinary_conversion.all": "ไฟล์ทั้งหมดที่แปลงได้ใช้การแปลงตามปกติ", + "donut.ordinary_conversion.some": "ไฟล์บางไฟล์ใช้การแปลงตามปกติ", "donut.pick_in_group": "เลื่อนออกด้านนอกเพื่อเลือก", "donut.group_empty": "ไม่มีพรีเซ็ตเหลือในกลุ่มนี้ แก้ไขได้ในการตั้งค่า → แถบโดนัท", "donut.click_to": "คลิก → {0}", @@ -818,6 +821,9 @@ "toolbar.mixed_others": "ไฟล์รวมประเภทแบบอื่นๆ จะเปิด “{0}”", "toolbar.mixed_others_self": "ไม่มีโปรไฟล์ไฟล์รวมอันอื่นที่ไม่ได้เลือกอะไรไว้ ดังนั้นโปรไฟล์นี้จะถูกใช้กับไฟล์รวมแบบอื่นๆ ทั้งหมดด้วย", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "รูปแบบข้อมูลเข้านี้ใช้การแปลงตามปกติ", + "toolbar.chip_tip.ordinary_conversion.all": "รูปแบบข้อมูลเข้าทั้งหมดที่แปลงได้ใช้การแปลงตามปกติ", + "toolbar.chip_tip.ordinary_conversion.some": "รูปแบบข้อมูลเข้าบางรูปแบบใช้การแปลงตามปกติ", "toolbar.chip_tip.makes_same": "→ รูปแบบเดียวกัน", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "รูปแบบที่รองรับ: {0}", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 551990948..379053df6 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -74,6 +74,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} dosya", "donut.drop_to": "Bırak → {0}", + "donut.ordinary_conversion.single": "Bu dosya normal dönüştürme olarak çalışır", + "donut.ordinary_conversion.all": "Dönüşebilen tüm dosyalar normal dönüşümle işlenir", + "donut.ordinary_conversion.some": "Bazı dosyalar normal dönüştürme olarak çalışır", "donut.pick_in_group": "Seçmek için dışa doğru ilerleyin", "donut.group_empty": "Bu grupta ön ayar kalmadı. Ayarlar → Halka araç çubuğu bölümünden düzenleyin", "donut.click_to": "Tıkla → {0}", @@ -818,6 +821,9 @@ "toolbar.mixed_others": "Diğer karışık dosya seçimleri “{0}” profilini açar.", "toolbar.mixed_others_self": "Hiçbir karışık profil tamamen işaretsiz kalmadığı için bu profil diğer tüm karışık seçimlerde de kullanılır.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "Bu girdi biçimi normal dönüştürme olarak çalışır", + "toolbar.chip_tip.ordinary_conversion.all": "Dönüştürülebilen tüm girdi biçimleri normal dönüştürme olarak çalışır", + "toolbar.chip_tip.ordinary_conversion.some": "Bazı girdi biçimleri normal dönüştürme olarak çalışır", "toolbar.chip_tip.makes_same": "→ Aynı biçim", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Desteklenen biçimler: {0}", diff --git a/src/Filee.App/Assets/i18n/vi.json b/src/Filee.App/Assets/i18n/vi.json index 3e9d4f700..2b2b4f4ad 100644 --- a/src/Filee.App/Assets/i18n/vi.json +++ b/src/Filee.App/Assets/i18n/vi.json @@ -78,6 +78,9 @@ "donut.files_same": "{0} × {1}", "donut.files_mixed": "{0} tệp", "donut.drop_to": "Thả → {0}", + "donut.ordinary_conversion.single": "Tệp này dùng chuyển đổi thông thường", + "donut.ordinary_conversion.all": "Tất cả tệp có thể chuyển đổi đều dùng chuyển đổi thông thường", + "donut.ordinary_conversion.some": "Một số tệp dùng chuyển đổi thông thường", "donut.pick_in_group": "Di chuyển ra ngoài để chọn", "donut.group_empty": "Nhóm này không còn cấu hình sẵn nào. Hãy chỉnh sửa trong Cài đặt → Thanh công cụ vòng tròn", "donut.click_to": "Nhấp → {0}", @@ -832,6 +835,9 @@ "toolbar.mixed_others": "Các tổ hợp tệp khác sẽ mở “{0}”.", "toolbar.mixed_others_self": "Không còn hồ sơ hỗn hợp nào không chọn mục nào, nên hồ sơ này cũng được dùng cho mọi tổ hợp khác.", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "Định dạng đầu vào này dùng chuyển đổi thông thường", + "toolbar.chip_tip.ordinary_conversion.all": "Tất cả định dạng đầu vào có thể chuyển đổi đều dùng chuyển đổi thông thường", + "toolbar.chip_tip.ordinary_conversion.some": "Một số định dạng đầu vào dùng chuyển đổi thông thường", "toolbar.chip_tip.makes_same": "→ Cùng định dạng", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "Định dạng được nhận: {0}", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 756c675cd..25ffc6e12 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -78,6 +78,9 @@ "donut.files_same": "{0} 个 {1}", "donut.files_mixed": "{0} 个文件", "donut.drop_to": "松开 → {0}", + "donut.ordinary_conversion.single": "此文件将进行普通转换", + "donut.ordinary_conversion.all": "所有可转换的文件都将进行普通转换", + "donut.ordinary_conversion.some": "部分文件将进行普通转换", "donut.pick_in_group": "向外移动以选择", "donut.group_empty": "此组中已没有预设,请在“设置 → 环形工具栏”中编辑", "donut.click_to": "点击 → {0}", @@ -832,6 +835,9 @@ "toolbar.mixed_others": "其他混合选择会打开“{0}”。", "toolbar.mixed_others_self": "没有未勾选任何项的混合配置,因此其他混合选择也会使用此配置。", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "此输入格式将进行普通转换", + "toolbar.chip_tip.ordinary_conversion.all": "所有可转换的输入格式都将进行普通转换", + "toolbar.chip_tip.ordinary_conversion.some": "部分输入格式将进行普通转换", "toolbar.chip_tip.makes_same": "→ 相同格式", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "支持的格式:{0}", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 7263329bf..f43f30c31 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -78,6 +78,9 @@ "donut.files_same": "{0} 個 {1}", "donut.files_mixed": "{0} 個檔案", "donut.drop_to": "放開 → {0}", + "donut.ordinary_conversion.single": "此檔案將執行一般轉換", + "donut.ordinary_conversion.all": "所有可轉換的檔案都將執行一般轉換", + "donut.ordinary_conversion.some": "部分檔案將執行一般轉換", "donut.pick_in_group": "向外移動以選擇", "donut.group_empty": "此群組中已沒有預設,請在「設定 → 環形工具列」中編輯", "donut.click_to": "點擊 → {0}", @@ -832,6 +835,9 @@ "toolbar.mixed_others": "其他混合選取會開啟「{0}」。", "toolbar.mixed_others_self": "沒有未勾選任何項目的混合設定檔,因此其他混合選取也會使用此設定檔。", "toolbar.chip_tip.makes": "→ {0} · {1}", + "toolbar.chip_tip.ordinary_conversion.single": "此輸入格式將執行一般轉換", + "toolbar.chip_tip.ordinary_conversion.all": "所有可轉換的輸入格式都將執行一般轉換", + "toolbar.chip_tip.ordinary_conversion.some": "部分輸入格式將執行一般轉換", "toolbar.chip_tip.makes_same": "→ 相同格式", "toolbar.chip_tip.makes_name": "→ {0}", "toolbar.chip_tip.works_with": "支援的格式:{0}", diff --git a/src/Filee.App/Controls/DonutMenu.cs b/src/Filee.App/Controls/DonutMenu.cs index 3a95641d2..9c9d9985b 100644 --- a/src/Filee.App/Controls/DonutMenu.cs +++ b/src/Filee.App/Controls/DonutMenu.cs @@ -44,6 +44,9 @@ public sealed class DonutItem /// Tooltip shown for disabled slices. public string? DisabledReason { get; init; } + /// A runnable slice's operation differs from its name, shown in the hub, not as an error. + public string? OrdinaryConversionNotice { get; init; } + /// /// A group slice: converts nothing itself; hovering it fans these out in a small ring outside the donut, where they /// are clicked or dropped on like ordinary slices. @@ -89,6 +92,8 @@ public sealed partial class DonutMenu : Control AvaloniaProperty.Register(nameof(CenterTitle)); public static readonly StyledProperty CenterSubtitleProperty = AvaloniaProperty.Register(nameof(CenterSubtitle)); + public static readonly StyledProperty CenterNoticeProperty = + AvaloniaProperty.Register(nameof(CenterNotice)); public static readonly StyledProperty IsEditModeProperty = AvaloniaProperty.Register(nameof(IsEditMode)); public static readonly StyledProperty AllowContextEditProperty = @@ -170,7 +175,7 @@ public sealed partial class DonutMenu : Control static DonutMenu() { AffectsMeasure(OuterRadiusProperty, ReserveSubRingProperty, GroupRingSizeProperty); - AffectsRender(HoleRatioProperty, SliceOpacityProperty, CenterTitleProperty, CenterSubtitleProperty, + AffectsRender(HoleRatioProperty, SliceOpacityProperty, CenterTitleProperty, CenterSubtitleProperty, CenterNoticeProperty, IsEditModeProperty, SliceBrushProperty, SliceBorderBrushProperty, CenterBrushProperty, AccentBrushProperty, OnAccentBrushProperty, ForegroundProperty, SecondaryForegroundProperty, SuccessBrushProperty, CornerRadiusProperty, CenterHintProperty, CenterHintWheelProperty, ShowCaptionsProperty, LabelScaleProperty, DefaultShowProperty, @@ -198,6 +203,7 @@ public DonutMenu() public double SliceOpacity { get => GetValue(SliceOpacityProperty); set => SetValue(SliceOpacityProperty, value); } public string? CenterTitle { get => GetValue(CenterTitleProperty); set => SetValue(CenterTitleProperty, value); } public string? CenterSubtitle { get => GetValue(CenterSubtitleProperty); set => SetValue(CenterSubtitleProperty, value); } + public string? CenterNotice { get => GetValue(CenterNoticeProperty); set => SetValue(CenterNoticeProperty, value); } /// Settings editor mode: shows ✎ on hover, raises drag and edit events instead of invoking slices. public bool IsEditMode { get => GetValue(IsEditModeProperty); set => SetValue(IsEditModeProperty, value); } @@ -1058,12 +1064,21 @@ private void DrawCenter(DrawingContext context, Point center, double r1, Typefac using var scaled = context.PushTransform(Matrix.CreateTranslation(-center.X, -center.Y) * Matrix.CreateScale(scale, scale) * Matrix.CreateTranslation(center.X, center.Y)); var maxWidth = radius * 1.6 / scale; - var title = CachedText(CenterTitle ?? "", bold, 14, Foreground, maxWidth, maxLines: 2); - var subtitle = CachedText(CenterSubtitle ?? "", regular, 11.5, cancelHot ? DangerBrush : SecondaryForeground, maxWidth, maxLines: 2); + var notice = !string.IsNullOrEmpty(CenterNotice); + var title = notice + ? CachedText(CenterNotice!, regular, 11.5, Foreground, maxWidth, maxLines: int.MaxValue) + : CachedText(CenterTitle ?? "", bold, 14, Foreground, maxWidth, maxLines: 2); + var subtitle = CachedText(CenterSubtitle ?? "", regular, 11.5, cancelHot ? DangerBrush : SecondaryForeground, + maxWidth, maxLines: notice ? int.MaxValue : 2); // "⟳ Tab back": small, under the rest, a little narrower (the hub is round), with room for its icons. var hintIcons = FlipArrowSize + (CenterHintWheel ? MouseWidth + MouseGap : 0) + IconGap; var hint = string.IsNullOrEmpty(CenterHint) ? null : CachedText(CenterHint, regular, 10.5, SecondaryForeground, maxWidth * 0.85 - hintIcons); var total = title.Height + (string.IsNullOrEmpty(CenterSubtitle) ? 0 : subtitle.Height + 2) + (hint is null ? 0 : hint.Height + 5); + // The notice replaces the file count. Keep both it and the action hint complete, including in a small hub. + var width = Math.Max(Math.Max(title.Width, subtitle.Width), hint is null ? 0 : hint.Width + hintIcons); + var fitScale = notice ? Math.Min(1, radius * 1.9 / scale / Math.Sqrt(width * width + total * total)) : 1; + using var fit = context.PushTransform(Matrix.CreateTranslation(-center.X, -center.Y) + * Matrix.CreateScale(fitScale, fitScale) * Matrix.CreateTranslation(center.X, center.Y)); var y = center.Y - total / 2; DrawCentered(context, title, center.X, y); y += title.Height; diff --git a/src/Filee.App/Services/PresetAvailability.cs b/src/Filee.App/Services/PresetAvailability.cs index 18734c0f0..7a3b74354 100644 --- a/src/Filee.App/Services/PresetAvailability.cs +++ b/src/Filee.App/Services/PresetAvailability.cs @@ -9,6 +9,9 @@ namespace Filee.App.Services; public enum PresetUse { Usable, NeedsPackage, Unusable } +/// Whether the agreed extraction presets convert usable inputs without their named operation. +public enum OrdinaryConversion { None, All, Some } + /// Why a preset can or cannot run; usable presets have no reason. public readonly record struct PresetJudgement(PresetUse Use, string? Reason); @@ -63,6 +66,24 @@ public IReadOnlyList UsableFormats(Preset preset, IReadOnlyListCounts only runnable inputs; an envelope's operation is unknown until its file is opened. + public OrdinaryConversion OrdinaryConversionFor(Preset preset, IReadOnlyList formats) + { + Func? asNamed = preset.Id switch + { + "extract-frames-png" or "extract-frames-jpg" => preset.SavesFramesOf, + "extract-motion" => preset.ExtractsMotionOf, + _ => null, + }; + if (asNamed is null) + return OrdinaryConversion.None; + var usable = UsableFormats(preset, formats); + var ordinary = usable.Count(format => !asNamed(format.Id) + && !_catalog.All.OfType().Any(opener => opener.Opens(format.Id))); + return ordinary == 0 ? OrdinaryConversion.None + : ordinary == usable.Count ? OrdinaryConversion.All : OrdinaryConversion.Some; + } + private EnginePackageState? PackageEnabling(Func possible, Func plannerFor) => _packages.FirstOrDefault(p => !p.IsInstalled && possible(plannerFor(p))); diff --git a/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs b/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs index 04e178fc4..0535c1c66 100644 --- a/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/ToolbarPageViewModel.cs @@ -91,6 +91,8 @@ internal static readonly (PaletteTab Id, string LabelKey, FormatCategory[] Categ private sealed class PaletteJudging { public Dictionary Judged { get; } = []; + public Dictionary Notices { get; } = []; + public IReadOnlyList Formats { get; set; } = []; public PresetAvailability.PaletteSession? Session { get; set; } } @@ -784,6 +786,14 @@ private void Commit() if (!_tipsByPreset.TryGetValue(preset.Id, out var tip)) _tipsByPreset[preset.Id] = tip = new PresetFormatTip(preset, _availability, _catalog.CreatePlanner, _loc); + _judgingByProfile.TryGetValue(Profile?.Id ?? "", out var judging); + tip.OrdinaryConversionNotice = (JudgesCurrentProfile ? judging?.Notices.GetValueOrDefault(preset.Id) : null) switch + { + OrdinaryConversion.All => _loc[judging!.Formats.Count == 1 + ? "toolbar.chip_tip.ordinary_conversion.single" : "toolbar.chip_tip.ordinary_conversion.all"], + OrdinaryConversion.Some => _loc["toolbar.chip_tip.ordinary_conversion.some"], + _ => null, + }; return tip; } @@ -1002,12 +1012,16 @@ private void JudgeSome(object pass, string profileId) var pending = ShownPresets().Where(p => !p.IsGroup && (profile.Id == "folders" || !placed.Contains(p.Id)) && !judging.Judged.ContainsKey(p.Id)).ToList(); if (pending.Count > 0) { - judging.Session ??= _availability.ForPalette(profile.Id == "folders" ? [FormatRegistry.FindById(FormatRegistry.Folder)!] : profile.Extensions.Select(FormatRegistry.FindByExtension) - .OfType().DistinctBy(f => f.Id).ToList()); + if (judging.Session is null) + { + judging.Formats = profile.Id == "folders" ? [FormatRegistry.FindById(FormatRegistry.Folder)!] + : profile.Extensions.Select(FormatRegistry.FindByExtension).OfType().DistinctBy(f => f.Id).ToList(); + judging.Session = _availability.ForPalette(judging.Formats); + } var clock = Stopwatch.StartNew(); foreach (var preset in pending) { - judging.Judged[preset.Id] = JudgeOne(judging.Session, preset); + judging.Judged[preset.Id] = JudgeOne(judging, preset); JudgementObserved?.Invoke(profileId, preset.Id); if (clock.Elapsed >= JudgeSlice) break; @@ -1023,11 +1037,15 @@ private void JudgeSome(object pass, string profileId) } // A failed probe must not end this dispatcher job. Folder offers fail closed; other profiles keep their fallback. - private PresetJudgement JudgeOne(PresetAvailability.PaletteSession session, Preset preset) + private PresetJudgement JudgeOne(PaletteJudging judging, Preset preset) { + judging.Notices.Remove(preset.Id); try { - return session.Judge(preset); + var result = judging.Session!.Judge(preset); + if (result.Use == PresetUse.Usable) + judging.Notices[preset.Id] = _availability.OrdinaryConversionFor(preset, judging.Formats); + return result; } catch (Exception ex) when (ex is not OutOfMemoryException) { diff --git a/src/Filee.App/ViewModels/PresetFormatTip.cs b/src/Filee.App/ViewModels/PresetFormatTip.cs index 5a0af463f..0de4ae7cf 100644 --- a/src/Filee.App/ViewModels/PresetFormatTip.cs +++ b/src/Filee.App/ViewModels/PresetFormatTip.cs @@ -25,6 +25,7 @@ public sealed partial class PresetFormatTip( [ObservableProperty] private string? _worksWith; [ObservableProperty] private string? _anyFile; [ObservableProperty] private string? _reason; + [ObservableProperty] private string? _ordinaryConversionNotice; [ObservableProperty] private IReadOnlyList _summaryRows = []; [ObservableProperty] private IReadOnlyList _fullRows = []; [ObservableProperty] private string? _footer; diff --git a/src/Filee.App/ViewModels/RadialViewModel.cs b/src/Filee.App/ViewModels/RadialViewModel.cs index 15a5b94a9..d31b250b4 100644 --- a/src/Filee.App/ViewModels/RadialViewModel.cs +++ b/src/Filee.App/ViewModels/RadialViewModel.cs @@ -18,6 +18,8 @@ public sealed partial class RadialViewModel(UserDataStore store, ILocalizer loc, [ObservableProperty] private IReadOnlyList _items = []; [ObservableProperty] private string _centerTitle = ""; [ObservableProperty] private string? _centerSubtitle; + [ObservableProperty] private string? _centerNotice; + private bool _singleInput; /// The job the progress ring shows, for the download badge above it; null when the ring shows none. [ObservableProperty] private JobViewModel? _progressJob; @@ -151,6 +153,7 @@ public void LoadForConnect() IsClickMode = true; IsConnectMode = true; Files = []; + CenterNotice = null; DonutOpacity = 1; HitBackground = "Transparent"; ApplyAppearance(); @@ -186,6 +189,7 @@ public void ResetForDrag() IsClickMode = false; IsConnectMode = false; Files = []; + CenterNotice = null; ApplyAppearance(); ReserveSubRing = store.Profiles.Any(profile => HasGroups(profile.PresetIds.Concat(profile.BackPresetIds ?? []))); _loads++; @@ -207,6 +211,7 @@ public void Load(IReadOnlyList files, bool clickMode, bool backFace = fa IsClickMode = clickMode; IsConnectMode = false; Files = files; + CenterNotice = null; DonutOpacity = 1; HitBackground = "Transparent"; ApplyAppearance(); @@ -216,6 +221,7 @@ public void Load(IReadOnlyList files, bool clickMode, bool backFace = fa var folders = files.Where(Directory.Exists).ToHashSet(Filee.Core.Platform.FileSystemPaths.Comparer); var mainFiles = CompanionFiles.WithoutCompanions(files.Where(p => !folders.Contains(p)).ToList()).ToHashSet(Filee.Core.Platform.FileSystemPaths.Comparer); var counted = files.Where(p => folders.Contains(p) || mainFiles.Contains(p)).ToList(); + _singleInput = counted.Count == 1; var foldersOnly = counted.Count > 0 && counted.All(folders.Contains); var profile = (foldersOnly ? store.Profiles.FirstOrDefault(p => p.Id == "folders") : null) ?? ProfileSelector.Select(store.Profiles, counted); @@ -267,6 +273,7 @@ public bool ToggleFace() if (!CanFlip) return false; ShowFace(!IsBackFace); + CenterNotice = null; CenterSubtitle = IsClickMode ? loc["donut.close_hint"] : null; return true; } @@ -344,6 +351,12 @@ DonutItem ItemOf(Preset p, bool caption = true) IconKey = SliceIcons.For(p), Show = p.Show, Direction = p.Direction, + OrdinaryConversionNotice = reason is not null ? null : availability.OrdinaryConversionFor(p, formats) switch + { + OrdinaryConversion.All => loc[_singleInput ? "donut.ordinary_conversion.single" : "donut.ordinary_conversion.all"], + OrdinaryConversion.Some => loc["donut.ordinary_conversion.some"], + _ => null, + }, }; } @@ -375,6 +388,10 @@ DonutItem ItemOf(Preset p, bool caption = true) /// Updates the centre hint for the emphasised slice (and slice of its group's ring). public void ShowHint(int index, int sub, bool centerHot) { + var item = !centerHot && index >= 0 && index < Items.Count ? Items[index] : null; + if (item?.Children is { } ring) + item = sub >= 0 && sub < ring.Count ? ring[sub] : null; + CenterNotice = item is { IsEnabled: true, IsGroup: false } ? item.OrdinaryConversionNotice : null; if (centerHot) CenterSubtitle = loc["donut.cancel"]; else if (index >= 0 && index < Items.Count && Items[index].Children is { Count: > 0 } children && diff --git a/src/Filee.App/Views/Pages/ToolbarPage.axaml b/src/Filee.App/Views/Pages/ToolbarPage.axaml index f74ebb4d9..9d54447aa 100644 --- a/src/Filee.App/Views/Pages/ToolbarPage.axaml +++ b/src/Filee.App/Views/Pages/ToolbarPage.axaml @@ -205,6 +205,8 @@ IsVisible="{Binding Title, Converter={x:Static ObjectConverters.IsNotNull}}" /> + format == "p7m"; + public string InnerFormat(string path, string format) => throw new NotSupportedException("No content preflight."); + } + + [AvaloniaTheory] + [InlineData("extract-frames-png", "mp4", "jpg")] + [InlineData("extract-frames-jpg", "mp4", "png")] + [InlineData("extract-motion", "jpg", "png")] + [InlineData("extract-motion", "heic", "png")] + [InlineData("extract-motion", "avif", "png")] + [InlineData("extract-motion", "livp", "png")] + public void Only_runnable_inputs_without_the_named_operation_count(string id, string named, string ordinary) + { + using var f = new Fixture(new Inputs()); + var preset = f.Preset(id); + OrdinaryConversion Notice(params string[] formats) => + f.Availability.OrdinaryConversionFor(preset, formats.Select(FormatRegistry.Get).ToList()); + + Assert.Equal(OrdinaryConversion.None, Notice(named)); + Assert.Equal(OrdinaryConversion.All, Notice(ordinary)); + Assert.Equal(OrdinaryConversion.All, Notice(ordinary, ordinary)); + Assert.Equal(OrdinaryConversion.Some, Notice(named, ordinary)); + Assert.Equal(OrdinaryConversion.All, Notice(ordinary, "wav")); + Assert.Equal(OrdinaryConversion.None, Notice(named, "wav")); + Assert.Equal(OrdinaryConversion.None, Notice("wav")); + Assert.Equal(OrdinaryConversion.None, Notice()); + } + + [AvaloniaFact] + public void The_fixed_list_does_not_probe_other_presets() + { + using var f = new Fixture(); + var availability = new PresetAvailability(f.Catalog, f.Loc, [], () => throw new InvalidOperationException("Unexpected probe.")); + foreach (var id in new[] { "to-png", "live-photo-iphone", "motion-photo-android", "caption-video", "searchable-pdf" }) + Assert.Equal(OrdinaryConversion.None, + availability.OrdinaryConversionFor(f.Preset(id), [Fixture.Format("png")])); + } + + [AvaloniaFact] + public void Current_options_missing_packages_and_opaque_envelopes_are_not_guessed() + { + using var f = new Fixture(new Inputs()); + var frames = new Preset { Id = "extract-frames-jpg", TargetFormat = "jpg" }; + Assert.Equal(OrdinaryConversion.All, f.Availability.OrdinaryConversionFor(frames, [Fixture.Format("mp4")])); + var motion = new Preset { Id = "extract-motion", TargetFormat = "mp4" }; + Assert.Equal(OrdinaryConversion.All, f.Availability.OrdinaryConversionFor(motion, [Fixture.Format("jpg")])); + var preset = f.Preset("extract-frames-png"); + Assert.Equal(OrdinaryConversion.None, f.Availability.OrdinaryConversionFor(preset, [Fixture.Format("p7m")])); + Assert.Equal(OrdinaryConversion.Some, + f.Availability.OrdinaryConversionFor(preset, [Fixture.Format("png"), Fixture.Format("p7m")])); + + using var missing = new Fixture(); + Assert.NotNull(missing.Availability.Check(missing.Preset("extract-motion"), [Fixture.Format("png")])); + Assert.Equal(OrdinaryConversion.None, + missing.Availability.OrdinaryConversionFor(missing.Preset("extract-motion"), [Fixture.Format("png")])); + } + + private sealed class Live : IDisposable + { + public Live(Fixture f, bool click, params string[] files) + { + Motion.Configure(true, () => false); + f.Store.Profiles.Clear(); + f.Store.Profiles.Add(new ToolbarProfile + { + Id = "notice-donut", + IsFallback = true, + PresetIds = ["extract-frames-png", "extract-motion", "test-jpg", "test-wav", "notice-group"], + BackPresetIds = ["test-jpg", "extract-frames-jpg", "notice-group"], + }); + f.Store.Presets.Add(new Preset + { + Id = "notice-group", + Name = "Extraction", + Kind = PresetKind.Group, + SubPresetIds = ["extract-frames-png", "extract-motion"], + }); + Vm = new RadialViewModel(f.Store, f.Loc, f.Availability); + Vm.Load(files, click); + Vm.FitToThisDonut(); + Window = new RadialWindow { DataContext = Vm }; + Donut.ActiveIndexChanged += (_, index) => Vm.ShowHint(index, Donut.ActiveSubIndex, Donut.IsCenterHot); + Window.Show(); + Dispatcher.UIThread.RunJobs(); + } + + public RadialViewModel Vm { get; } + public RadialWindow Window { get; } + public DonutMenu Donut => Window.DonutControl; + public Point Center => new(Donut.Bounds.Width / 2, Donut.Bounds.Height / 2); + + public DonutHit PointAt(int index) + { + var at = DonutGeometry.PointAt(Center, Donut.OuterRadius * 0.7, DonutGeometry.MidAngle(index, Donut.Items!.Count)); + if (Vm.IsClickMode) + Window.MouseMove(Donut.TranslatePoint(at, Window)!.Value); + else + Donut.SetExternalHit(Donut.HitTest(at), at); + Dispatcher.UIThread.RunJobs(); + return Donut.HitTest(at); + } + + public void Dispose() + { + Window.Close(); + Motion.Configure(false, () => false); + } + } + + [AvaloniaTheory] + [InlineData(true, "single")] + [InlineData(false, "single")] + [InlineData(true, "all")] + [InlineData(false, "all")] + [InlineData(true, "some")] + [InlineData(false, "some")] + public void The_real_donut_keeps_the_action_hint_and_the_slice_runnable(bool click, string scope) + { + using var f = new Fixture(new Inputs()); + string[] files = scope switch + { + "all" => ["first.png", "second.png"], + "some" => ["clip.mp4", "photo.png"], + _ => ["photo.png"], + }; + using var live = new Live(f, click, files); + var title = live.Donut.CenterTitle; + var hit = live.PointAt(0); + Assert.Equal(f.Loc["donut.ordinary_conversion." + scope], live.Donut.CenterNotice); + Assert.Equal(f.Loc.Format(click ? "donut.click_to" : "donut.drop_to", Assert.IsType(live.Donut.Items![0].FullName)), + live.Donut.CenterSubtitle); + Assert.Equal(title, live.Donut.CenterTitle); + Assert.True(live.Donut.Items![0].IsEnabled); + Assert.True(live.Donut.Converts(hit)); + Assert.Same(f.Preset("extract-frames-png"), live.Vm.PresetAt(0, -1)); + + live.PointAt(2); + Assert.Null(live.Donut.CenterNotice); + live.PointAt(3); + Assert.Null(live.Donut.CenterNotice); + Assert.Equal(live.Donut.Items[3].DisabledReason, live.Donut.CenterSubtitle); + } + + [AvaloniaFact] + public void Groups_faces_cancellation_and_reuse_do_not_keep_an_old_notice() + { + using var f = new Fixture(new Inputs()); + using var live = new Live(f, click: false, "photo.png"); + live.PointAt(4); + Assert.Null(live.Donut.CenterNotice); + live.Donut.ExpandPendingGroup(); + var at = live.Donut.SubSliceCenter(4, 0); + live.Donut.SetExternalHit(live.Donut.HitTest(at), at); + Assert.Equal(f.Loc["donut.ordinary_conversion.single"], live.Donut.CenterNotice); + Assert.True(live.Donut.Converts(live.Donut.HitTest(at))); + Assert.Same(f.Preset("extract-frames-png"), live.Vm.PresetAt(4, 0)); + live.Donut.CollapseGroup(); + Assert.Null(live.Donut.CenterNotice); + + live.PointAt(0); + live.Donut.SetExternalHit(new DonutHit(DonutHitKind.Center), live.Center); + Assert.Null(live.Donut.CenterNotice); + Assert.Equal(f.Loc["donut.cancel"], live.Donut.CenterSubtitle); + live.PointAt(0); + Assert.NotNull(live.Donut.CenterNotice); + Assert.True(live.Donut.Flip()); + Assert.Null(live.Donut.CenterNotice); + Assert.Equal("test-jpg", live.Donut.Items![0].Id); + live.PointAt(1); + Assert.Equal(f.Loc["donut.ordinary_conversion.single"], live.Donut.CenterNotice); + live.PointAt(2); + Assert.Null(live.Donut.CenterNotice); + live.Donut.ExpandPendingGroup(); + at = live.Donut.SubSliceCenter(2, 1); + live.Donut.SetExternalHit(live.Donut.HitTest(at), at); + Assert.Equal(f.Loc["donut.ordinary_conversion.single"], live.Donut.CenterNotice); + Assert.Same(f.Preset("extract-motion"), live.Vm.PresetAt(2, 1)); + live.Donut.SetExternalHit(new DonutHit(DonutHitKind.Outside), new Point(-20, -20)); + Assert.Null(live.Donut.CenterNotice); + + live.PointAt(1); + live.Vm.Load(["clip.mp4"], clickMode: false); + Assert.Null(live.Donut.CenterNotice); + live.PointAt(0); + Assert.Null(live.Donut.CenterNotice); + live.Vm.Load(["photo.png"], clickMode: false); + live.PointAt(0); + Assert.NotNull(live.Donut.CenterNotice); + live.Vm.ResetForDrag(); + Assert.Null(live.Donut.CenterNotice); + live.Vm.Load(["photo.png"], clickMode: false); + live.PointAt(0); + Assert.NotNull(live.Donut.CenterNotice); + live.Vm.LoadForConnect(); + Assert.Null(live.Donut.CenterNotice); + Assert.True(live.Vm.IsConnectMode); + Assert.Equal("preset.filee_connect", Assert.Single(live.Donut.Items!).Id); + } + + [AvaloniaFact] + public void Keyboard_hover_and_flip_use_the_real_click_window() + { + using var f = new Fixture(new Inputs()); + using var live = new Live(f, click: true, "photo.png"); + live.PointAt(0); + Assert.NotNull(live.Donut.CenterNotice); + live.Window.KeyPress(Key.Right, RawInputModifiers.None, PhysicalKey.ArrowRight, null); + Dispatcher.UIThread.RunJobs(); + Assert.Equal(1, live.Donut.ActiveIndex); + Assert.Equal(f.Loc["donut.ordinary_conversion.single"], live.Donut.CenterNotice); + live.Donut.FlipKey = "Tab"; + live.Window.KeyPress(Key.Tab, RawInputModifiers.None, PhysicalKey.Tab, null); + Dispatcher.UIThread.RunJobs(); + Assert.True(live.Vm.IsBackFace); + Assert.Null(live.Donut.CenterNotice); + live.PointAt(1); + Assert.NotNull(live.Donut.CenterNotice); + var escaped = false; + live.Window.EscapePressed += () => escaped = true; + live.Window.KeyPress(Key.Escape, RawInputModifiers.None, PhysicalKey.Escape, null); + Dispatcher.UIThread.RunJobs(); + Assert.True(escaped); + } + + [AvaloniaFact] + public void Unknown_files_folders_and_companions_do_not_become_ordinary_inputs() + { + using var f = new Fixture(new Inputs()); + using var live = new Live(f, click: false, "photo.png", "unknown.notice-unknown"); + live.PointAt(0); + Assert.Equal(f.Loc["donut.ordinary_conversion.all"], live.Donut.CenterNotice); + var folder = Directory.CreateTempSubdirectory("filee-notice-"); + try + { + live.Vm.Load(["photo.png", folder.FullName], clickMode: false); + live.PointAt(0); + Assert.Equal(f.Loc["donut.ordinary_conversion.all"], live.Donut.CenterNotice); + live.Vm.Load(new[] { "shp", "dbf", "shx" }.Select(ext => Path.Combine(folder.FullName, "map." + ext)).ToList(), + clickMode: false); + live.PointAt(0); + Assert.Equal(f.Loc["donut.ordinary_conversion.single"], live.Donut.CenterNotice); + } + finally { folder.Delete(); } + } + + [AvaloniaTheory] + [InlineData("en", false)] + [InlineData("ko", true)] + [InlineData("ru", false)] + [InlineData("es", true)] + [InlineData("ar", true)] + public void The_notice_is_actually_drawn_in_the_hub(string language, bool dark) + { + TestServices.EnsureInitialized(language); + var app = Avalonia.Application.Current!; + var before = app.RequestedThemeVariant; + var donut = new DonutMenu { Items = [new() { Id = "one", Label = "PNG" }], CenterTitle = "" }; + var window = new Window { Content = donut, SizeToContent = SizeToContent.WidthAndHeight }; + try + { + app.RequestedThemeVariant = dark ? Avalonia.Styling.ThemeVariant.Dark : Avalonia.Styling.ThemeVariant.Light; + window.Show(); + RenderTests.Pump(); + var empty = HubPixels(donut); + donut.CenterNotice = AppHost.Get()["donut.ordinary_conversion.all"]; + RenderTests.Pump(); + Assert.False(empty.SequenceEqual(HubPixels(donut))); + donut.CenterSubtitle = AppHost.Get().Format("donut.drop_to", "PNG"); + RenderTests.Pump(); + var withAction = HubPixels(donut); + donut.CenterSubtitle = null; + RenderTests.Pump(); + Assert.False(withAction.SequenceEqual(HubPixels(donut))); + } + finally + { + window.Close(); + app.RequestedThemeVariant = before; + TestServices.EnsureInitialized("en"); + } + } + + private static byte[] HubPixels(DonutMenu donut) + { + using var bitmap = new RenderTargetBitmap(new PixelSize((int)donut.Bounds.Width, (int)donut.Bounds.Height)); + bitmap.Render(donut); + var radius = (int)(donut.OuterRadius * donut.HoleRatio - 8); + var pixels = new byte[radius * radius * 16]; + var pin = GCHandle.Alloc(pixels, GCHandleType.Pinned); + try + { + bitmap.CopyPixels(new PixelRect((int)donut.Bounds.Width / 2 - radius, (int)donut.Bounds.Height / 2 - radius, + radius * 2, radius * 2), pin.AddrOfPinnedObject(), pixels.Length, radius * 8); + } + finally { pin.Free(); } + return pixels; + } +} diff --git a/tests/Filee.App.Tests/PaletteChipTipModelTests.cs b/tests/Filee.App.Tests/PaletteChipTipModelTests.cs index 1f8d16cdf..b6d9c0a20 100644 --- a/tests/Filee.App.Tests/PaletteChipTipModelTests.cs +++ b/tests/Filee.App.Tests/PaletteChipTipModelTests.cs @@ -5,6 +5,7 @@ using Filee.App.ViewModels.Pages; using Filee.Core.Conversion; using Filee.Core.Formats; +using Filee.Core.Localization; using Filee.Core.Presets; namespace Filee.App.Tests; @@ -15,6 +16,99 @@ public class PaletteChipTipModelTests private static PresetFormatTip Tip(ToolbarPageViewModel vm, string id) => Assert.IsType(vm.Palette.Single(c => c.PresetId == id).Tip); + [AvaloniaFact] + public void Ordinary_notice_follows_the_profile_and_extensions_without_rebuilding_formats() + { + using var f = new PresetAvailabilityTests.Fixture(new OrdinaryConversionNoticeTests.Inputs()); + var other = new Filee.Core.Profiles.ToolbarProfile { Id = "notice-video", Extensions = ["mp4"], PresetIds = [] }; + f.Store.Profiles.Add(other); + using var vm = f.PageWithPalette("png"); + Dispatcher.UIThread.RunJobs(); + var originalProfile = vm.SelectedProfile; + var tip = Tip(vm, "extract-frames-jpg"); + tip.EnsureBuilt(); + var rows = tip.FullRows; + Assert.Equal(f.Loc["toolbar.chip_tip.ordinary_conversion.single"], tip.OrdinaryConversionNotice); + + vm.SelectedProfile = vm.Profiles.Single(p => p.Profile == other); + Dispatcher.UIThread.RunJobs(); + Assert.Same(tip, Tip(vm, "extract-frames-jpg")); + Assert.Null(tip.OrdinaryConversionNotice); + vm.SelectedProfile = originalProfile; + Dispatcher.UIThread.RunJobs(); + Assert.Same(tip, Tip(vm, "extract-frames-jpg")); + Assert.Equal(f.Loc["toolbar.chip_tip.ordinary_conversion.single"], tip.OrdinaryConversionNotice); + + vm.ExtensionTags!.AddCommand.Execute("mp4"); + Dispatcher.UIThread.RunJobs(); + Assert.Same(tip, Tip(vm, "extract-frames-jpg")); + Assert.Equal(f.Loc["toolbar.chip_tip.ordinary_conversion.some"], tip.OrdinaryConversionNotice); + vm.ExtensionTags.RemoveCommand.Execute(vm.ExtensionTags.Tags.Single(t => t.Value == "png")); + Dispatcher.UIThread.RunJobs(); + Assert.Same(tip, Tip(vm, "extract-frames-jpg")); + Assert.Null(tip.OrdinaryConversionNotice); + vm.ExtensionTags.RemoveCommand.Execute(vm.ExtensionTags.Tags.Single()); + Dispatcher.UIThread.RunJobs(); + Assert.Same(tip, Tip(vm, "extract-frames-jpg")); + Assert.Null(tip.OrdinaryConversionNotice); + vm.IsMixed = true; + Dispatcher.UIThread.RunJobs(); + Assert.Same(tip, Tip(vm, "extract-frames-jpg")); + Assert.Null(tip.OrdinaryConversionNotice); + vm.MixedExtensions!.Set(["png"], check: true); + Dispatcher.UIThread.RunJobs(); + Assert.Same(tip, Tip(vm, "extract-frames-jpg")); + Assert.Equal(f.Loc["toolbar.chip_tip.ordinary_conversion.single"], tip.OrdinaryConversionNotice); + vm.MixedExtensions.Set(["png"], check: false); + Dispatcher.UIThread.RunJobs(); + Assert.Same(tip, Tip(vm, "extract-frames-jpg")); + Assert.Null(tip.OrdinaryConversionNotice); + Assert.Same(rows, tip.FullRows); + Assert.Equal(1, tip.BuildCount); + } + + [AvaloniaFact] + public void Engine_changes_rejudge_the_notice_and_failed_probes_do_not_attach_one() + { + var inputs = new OrdinaryConversionNoticeTests.Inputs(); + using var f = new PresetAvailabilityTests.Fixture(inputs); + using (var vm = f.PageWithPalette("png")) + { + Dispatcher.UIThread.RunJobs(); + var before = Tip(vm, "extract-frames-jpg"); + Assert.NotNull(before.OrdinaryConversionNotice); + inputs.Available = f.Built.Available = false; + f.Catalog.Refresh(); + Dispatcher.UIThread.RunJobs(); + vm.PaletteSearch = f.Loc.DisplayName(f.Preset("extract-frames-jpg")); + var unavailable = vm.VisiblePalette.Single(c => c.PresetId == "extract-frames-jpg"); + Assert.NotNull(unavailable.Reason); + Assert.IsType(unavailable.Tip); + inputs.Available = f.Built.Available = true; + f.Catalog.Refresh(); + Dispatcher.UIThread.RunJobs(); + var after = Tip(vm, "extract-frames-jpg"); + Assert.NotSame(before, after); + Assert.Equal(f.Loc["toolbar.chip_tip.ordinary_conversion.single"], after.OrdinaryConversionNotice); + } + + var profile = new Filee.Core.Profiles.ToolbarProfile { Id = "notice-failed-probe", Extensions = ["png"], PresetIds = [] }; + f.Store.Profiles.Add(profile); + var probeFails = false; + var failing = new PresetAvailability(f.Catalog, f.Loc, [], + () => probeFails ? throw new InvalidOperationException("Probe failed.") : f.Catalog.CreatePlanner()); + using var failed = new ToolbarPageViewModel(f.Store, f.Loc, AppHost.Get(), failing, f.Catalog, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + failed.SelectedProfile = failed.Profiles.Single(p => p.Profile == profile); + Dispatcher.UIThread.RunJobs(); + Assert.NotNull(Tip(failed, "extract-frames-jpg").OrdinaryConversionNotice); + probeFails = true; + failed.PresetEdited(f.Preset("extract-frames-jpg")); + Dispatcher.UIThread.RunJobs(); + Assert.Null(failed.Palette.Single(c => c.PresetId == "extract-frames-jpg").Reason); + Assert.Null(Tip(failed, "extract-frames-jpg").OrdinaryConversionNotice); + } + [AvaloniaFact] public void T1_usable_formats_match_single_format_checks_in_registry_order() { diff --git a/tests/Filee.App.Tests/PaletteChipTipViewTests.cs b/tests/Filee.App.Tests/PaletteChipTipViewTests.cs index 254adbe5d..c3e842f60 100644 --- a/tests/Filee.App.Tests/PaletteChipTipViewTests.cs +++ b/tests/Filee.App.Tests/PaletteChipTipViewTests.cs @@ -116,6 +116,108 @@ private static string TextOf(TextBlock block) => block.Inlines is { Count: > 0 } ? string.Concat(inlines.OfType().Select(r => r.Text)) : block.Text ?? ""; + [AvaloniaTheory] + [InlineData("png", "single", false)] + [InlineData("png,mp4", "some", false)] + [InlineData("png,jpg", "all", false)] + [InlineData("jpg,jpeg", "single", false)] + [InlineData("mp4", null, false)] + [InlineData("", null, false)] + [InlineData("notice-unknown", null, false)] + [InlineData("png", "single", true)] + [InlineData("", null, true)] + public void Ordinary_notice_is_an_additional_row_in_a_runnable_chips_real_tip(string extensions, string? scope, bool fallback) + { + using var f = new Fixture(new OrdinaryConversionNoticeTests.Inputs()); + using var vm = f.Page(new ToolbarProfile + { + Id = "notice-tip", + Extensions = extensions.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList(), + IsFallback = fallback, + PresetIds = [], + }); + using var view = new View(vm); + if (extensions == "notice-unknown") + { + vm.PaletteSearch = f.Loc.DisplayName(f.Preset("extract-frames-jpg")); + RenderTests.Pump(); + var unknown = view.Chip("extract-frames-jpg"); + var reason = ((PaletteChip)unknown.DataContext!).Reason; + Assert.NotNull(reason); + Assert.Equal(reason, View.Open(unknown).Content); + View.Close(unknown); + return; + } + var chip = view.Chip("extract-frames-jpg"); + var model = Assert.IsType(chip.DataContext); + Assert.Null(model.Reason); + Assert.True(chip.IsEnabled); + Assert.Equal(1, chip.Opacity); + var tip = View.Open(chip); + var block = tip.GetVisualDescendants().OfType().Single(b => b.Name == "TipOrdinaryConversionNotice"); + Assert.Equal(scope is not null, block.IsEffectivelyVisible); + Assert.Equal(scope is null ? null : f.Loc["toolbar.chip_tip.ordinary_conversion." + scope], block.Text); + Assert.NotNull(Read(tip).Makes); + Assert.NotNull(Read(tip).WorksWith); + Assert.NotEmpty(Read(tip).Rows); + Assert.DoesNotContain("ordinary_conversion.", string.Join(" ", Read(tip).Lines)); + View.Close(chip); + var preset = f.Preset("extract-frames-jpg"); + Assert.True(vm.Insert(preset.Id, 0)); + Assert.Contains(preset.Id, vm.SelectedProfile!.Profile.PresetIds); + } + + [AvaloniaFact] + public void An_open_ordinary_tip_updates_and_clears_when_input_extensions_change() + { + using var f = new Fixture(new OrdinaryConversionNoticeTests.Inputs()); + using var vm = f.PageWithPalette("png"); + using var view = new View(vm); + var tipModel = Assert.IsType(((PaletteChip)view.Chip("extract-frames-jpg").DataContext!).Tip); + void Check(string? scope) + { + var chip = view.Chip("extract-frames-jpg"); + var tip = View.Open(chip); + Assert.Same(tipModel, ((PaletteChip)chip.DataContext!).Tip); + var block = tip.GetVisualDescendants().OfType().Single(b => b.Name == "TipOrdinaryConversionNotice"); + Assert.Equal(scope is not null, block.IsEffectivelyVisible); + Assert.Equal(scope is null ? null : f.Loc["toolbar.chip_tip.ordinary_conversion." + scope], block.Text); + View.Close(chip); + } + + Check("single"); + vm.ExtensionTags!.AddCommand.Execute("mp4"); + RenderTests.Pump(); + Check("some"); + vm.ExtensionTags.RemoveCommand.Execute(vm.ExtensionTags.Tags.Single(t => t.Value == "png")); + RenderTests.Pump(); + Check(null); + vm.ExtensionTags.RemoveCommand.Execute(vm.ExtensionTags.Tags.Single()); + RenderTests.Pump(); + Check(null); + Assert.Equal(1, tipModel.BuildCount); + } + + [AvaloniaFact] + public void Folder_and_missing_engine_chips_keep_only_their_reason_tip() + { + using var f = new Fixture(); + using var vm = f.Page(new ToolbarProfile { Id = "folders", PresetIds = [] }); + using var view = new View(vm); + vm.PaletteSearch = f.Loc.DisplayName(f.Preset("extract-frames-jpg")); + RenderTests.Pump(); + Assert.DoesNotContain(vm.Palette, c => c.PresetId == "extract-frames-jpg"); + Assert.DoesNotContain(view.Chips, c => ((PaletteChip)c.DataContext!).PresetId == "extract-frames-jpg"); + + using var ordinary = f.PageWithPalette("png"); + using var ordinaryView = new View(ordinary); + var chip = ordinaryView.Chip("extract-motion"); + var reason = ((PaletteChip)chip.DataContext!).Reason; + Assert.NotNull(reason); + Assert.Equal(reason, View.Open(chip).Content); + View.Close(chip); + } + private static Shown Read(ToolTip tip) { var blocks = tip.GetVisualDescendants().OfType().Where(b => b.IsEffectivelyVisible).ToList(); diff --git a/tests/Filee.Core.Tests/JobQueueTests.cs b/tests/Filee.Core.Tests/JobQueueTests.cs index 91f2f4d6f..a0690c081 100644 --- a/tests/Filee.Core.Tests/JobQueueTests.cs +++ b/tests/Filee.Core.Tests/JobQueueTests.cs @@ -436,6 +436,48 @@ public async Task A_refused_conversion_says_why_in_the_users_language() Assert.Equal(("error.too_many_frames", "216,000 > 100,000"), (file.ErrorKey, file.ErrorDetail)); } + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task A_mixed_drop_selects_extraction_per_source_and_does_not_retry_a_refused_motion_photo(bool motion, bool refused) + { + using var dir = new TempDir(); + var from = motion ? "jpg" : "mp4"; + var target = motion ? "mp4" : "jpg"; + var named = dir.File("named." + from); + var plain = dir.File("plain.png"); + var special = new FakeConverter("extraction", new ConversionEdge(from, target, FrameSequence: !motion, MotionPhoto: motion)) + { + Failure = refused ? new ConversionRefusedException("error.no_motion_photo") : null, + }; + var ordinary = new FakeConverter("ordinary", new ConversionEdge(from, target), new ConversionEdge("png", target)); + await using var queue = new JobQueue(new ConverterCatalog([special, ordinary])); + var preset = new Preset { TargetFormat = target, Media = { SaveEveryFrame = !motion, ExtractMotionPhoto = motion } }; + + var job = await Run(queue, [named, plain], preset); + + Assert.Equal([named], special.OriginalSources); + Assert.Equal([plain], ordinary.OriginalSources); + var namedResult = job.Files.Single(f => f.SourcePath == named); + var plainResult = job.Files.Single(f => f.SourcePath == plain); + Assert.Equal(FileState.Done, plainResult.State); + Assert.EndsWith("by ordinary", File.ReadAllText(Assert.Single(plainResult.Outputs))); + if (refused) + { + Assert.Equal(JobState.CompletedWithErrors, job.State); + Assert.Equal(FileState.Failed, namedResult.State); + Assert.Equal("error.no_motion_photo", namedResult.ErrorKey); + Assert.Empty(namedResult.Outputs); + } + else + { + Assert.Equal(JobState.Completed, job.State); + Assert.Equal(FileState.Done, namedResult.State); + Assert.EndsWith("by extraction", File.ReadAllText(Assert.Single(namedResult.Outputs))); + } + } + [Fact] public async Task Multi_step_route_keeps_intermediates_out_of_the_output_folder() {