diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs index 688873860..28ae0769c 100644 --- a/src/Filee.App/App.axaml.cs +++ b/src/Filee.App/App.axaml.cs @@ -149,6 +149,7 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) AppHost.Get().EnsureToast(); AppHost.Get().FeedbackDue += (_, _) => AppHost.Get().ShowFeedbackNotice(); AppHost.Get().Start(); + AppHost.Get().Start(); // Originals of files from a browser that a crash kept from being deleted ("Save only the converted file"). _ = Task.Run(() => Filee.Core.Web.WebDrop.DeleteStale(Filee.Core.Web.WebDrop.DefaultWorkRoot, TimeSpan.FromDays(1))); // Likewise the work folders of conversions that never got to clean up after themselves. @@ -171,6 +172,7 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) AppHost.Get().Dispose(); AppHost.Get().Dispose(); AppHost.Get().Dispose(); + AppHost.Get().DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5)); AppHost.Get().DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5)); }; diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 2e478d2ed..5776f098c 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -228,7 +228,7 @@ "preset.crop": "اقتصاص", "crop.save": "اقتصاص وحفظ", "crop.reset": "إعادة تعيين", - "crop.skip": "تخطٍّ", + "crop.skip": "تخطٍّ", "crop.aspect_title": "نسبة الأبعاد:", "crop.aspect_free": "حرة", "crop.aspect_original": "الأصلية", @@ -431,6 +431,21 @@ "general.browser_drops_only_converted_hint": "يبقى الأصل في مجلد مؤقت ويُحذف بعد اكتمال التحويل.", "general.zip_geo": "حفظ كل نتيجة جغرافية بصيغة ZIP", "general.zip_geo_hint": "تُجمَّع نتائج Shapefile وGeoJSON وKML وGPX وغيرها من البيانات الجغرافية في ZIP واحد لكل ملف، مع كل إعداد مسبق.\nعند التعطيل: يُطبَّق ذلك فقط على الإعدادات المسبقة التي تفعّل «حفظ النتيجة بصيغة ZIP».", + "general.extension_change": "تغيير امتداد الملف", + "general.extension_change_hint": "تحويل الملف تلقائيًا إلى صيغته الجديدة عند تغيير امتداده إلى صيغة مدعومة (مثل صورة إلى صورة أو فيديو إلى فيديو أو مستند إلى PDF). يتم تجاهل الصيغ غير المدعومة.", + "general.convert_on_extension_change": "تحويل الملفات عند تغيير الامتداد", + "general.confirm_extension_change": "طلب التأكيد قبل التحويل", + "general.confirm_extension_change_hint": "إظهار مربع حوار للتأكيد قبل تحويل الملف المعاد تسميته.", + "general.extension_change_scope": "نطاق مراقبة الملفات المعاد تسميتها", + "general.extension_change_scope_watch_folders": "مجلدات المراقبة فقط (موصى به)", + "general.extension_change_scope_entire_system": "الكمبيوتر بالكامل", + "general.extension_change_scope_entire_system_hint": "يراقب جميع الأقراص الثابتة ومجلدات المستخدم، مع تخطي مجلدات النظام والمؤقتة والمخفية.", + "extension_confirm.title": "تحويل الملف؟", + "extension_confirm.text": "تم تغيير امتداد الملف \"{0}\" من {1} إلى {2}. هل ترغب في أن يقوم Filee بتحويل الملف إلى {2}؟", + "extension_confirm.convert": "تحويل", + "extension_confirm.dont_ask": "التحويل دائمًا مباشرة دون السؤال مجددًا", + "extension_progress.title": "جارٍ تحويل الملف...", + "extension_progress.text": "جارٍ تحويل \"{0}\" إلى {1}...", "general.check_updates": "التحقق من التحديثات تلقائيًا", "general.close_action": "عند إغلاق النافذة", "general.close.Ask": "السؤال كل مرة", @@ -808,7 +823,7 @@ "presets.conflict": "إذا كان الملف موجودًا", "presets.conflict.Rename": "إضافة رقم", "presets.conflict.Overwrite": "استبداله", - "presets.conflict.Skip": "تخطٍّ", + "presets.conflict.Skip": "تخطٍّ", "presets.editor_title": "تعديل الإعداد المسبق", "presets.delete_confirm": "هل تريد حذف «{0}»؟ سيُزال أيضًا من جميع أشرطة الأدوات والمجموعات.", "presets.name_required": "أدخل اسمًا", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index bd6d41814..66eb35ebc 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -431,6 +431,21 @@ "general.browser_drops_only_converted_hint": "Das Original bleibt in einem temporären Ordner und wird nach der Konvertierung gelöscht.", "general.zip_geo": "Alle Geodaten-Ergebnisse als ZIP speichern", "general.zip_geo_hint": "Shapefile, GeoJSON, KML, GPX und andere Geodaten-Ergebnisse werden bei jeder Vorlage in eine ZIP-Datei pro Datei gepackt.\nAusgeschaltet: Gilt nur für Vorlagen mit „Ergebnis als ZIP speichern“.", + "general.extension_change": "Dateiendung umbenennen", + "general.extension_change_hint": "Konvertiert eine Datei automatisch in das neue Format, wenn ihre Erweiterung in ein unterstütztes Format umbenannt wird (z. B. Bild zu Bild, Video zu Video oder Dokument zu PDF). Nicht unterstützte Formate werden ignoriert.", + "general.convert_on_extension_change": "Dateien bei Änderung der Endung konvertieren", + "general.confirm_extension_change": "Vor dem Konvertieren nach Bestätigung fragen", + "general.confirm_extension_change_hint": "Einen Dialog zur Bestätigung anzeigen, bevor die umbenannte Datei konvertiert wird.", + "general.extension_change_scope": "Suchbereich für umbenannte Dateien", + "general.extension_change_scope_watch_folders": "Nur Überwachungsordner (empfohlen)", + "general.extension_change_scope_entire_system": "Gesamter Computer", + "general.extension_change_scope_entire_system_hint": "Überwacht alle festen Laufwerke und Benutzerordner; System-, Cache- und versteckte Ordner werden übersprungen.", + "extension_confirm.title": "Datei konvertieren?", + "extension_confirm.text": "Die Dateiendung von „{0}“ wurde von {1} in {2} geändert. Möchten Sie, dass Filee die Datei in {2} konvertiert?", + "extension_confirm.convert": "Konvertieren", + "extension_confirm.dont_ask": "Immer direkt konvertieren, ohne nachzufragen", + "extension_progress.title": "Datei wird konvertiert …", + "extension_progress.text": "„{0}“ wird in {1} konvertiert …", "general.check_updates": "Automatisch nach Updates suchen", "general.close_action": "Beim Schließen des Fensters", "general.close.Ask": "Jedes Mal fragen", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index f41855faa..b8065cd40 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -443,6 +443,21 @@ "general.browser_drops_only_converted_hint": "The original waits in a temporary folder and is deleted once it's converted.", "general.zip_geo": "Save every geospatial result as a ZIP", "general.zip_geo_hint": "Shapefile, GeoJSON, KML, GPX and the other geospatial results are packed into one ZIP per file, with every preset.\nOff: only presets with \"Save the result as a ZIP\" do it.", + "general.extension_change": "File extension rename", + "general.extension_change_hint": "Automatically convert a file to its new format when you rename its extension to a supported format (such as image to image, video to video, or document to PDF). Unsupported formats are ignored.", + "general.convert_on_extension_change": "Convert files when extension is changed", + "general.confirm_extension_change": "Ask for confirmation before converting", + "general.confirm_extension_change_hint": "Show a dialog to confirm before converting the renamed file.", + "general.extension_change_scope": "Where to look for renamed files", + "general.extension_change_scope_watch_folders": "Watch folders only (recommended)", + "general.extension_change_scope_entire_system": "Entire computer", + "general.extension_change_scope_entire_system_hint": "Monitors all fixed drives and user directories, skipping system, cache, and hidden folders.", + "extension_confirm.title": "Convert file?", + "extension_confirm.text": "The file extension of \"{0}\" was changed from {1} to {2}. Would you like Filee to convert the file to {2}?", + "extension_confirm.convert": "Convert", + "extension_confirm.dont_ask": "Always convert directly without asking", + "extension_progress.title": "Converting file...", + "extension_progress.text": "Converting \"{0}\" to {1}...", "general.check_updates": "Check for updates automatically", "general.close_action": "When I close the window", "general.close.Ask": "Ask every time", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 18f3d58d9..aaf7e0cb0 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -431,6 +431,21 @@ "general.browser_drops_only_converted_hint": "El original espera en una carpeta temporal y se elimina en cuanto se convierte.", "general.zip_geo": "Guardar todo resultado geoespacial como ZIP", "general.zip_geo_hint": "Shapefile, GeoJSON, KML, GPX y los demás resultados geoespaciales se empaquetan en un ZIP por archivo, con cualquier preajuste.\nDesactivado: solo lo hacen los preajustes con «Guardar el resultado como ZIP».", + "general.extension_change": "Cambio de extensión de archivo", + "general.extension_change_hint": "Convierte automáticamente un archivo a su nuevo formato al cambiar su extensión a un formato compatible (como imagen a imagen, video a video o documento a PDF). Los formatos no compatibles se ignoran.", + "general.convert_on_extension_change": "Convertir archivos al cambiar la extensión", + "general.confirm_extension_change": "Pedir confirmación antes de convertir", + "general.confirm_extension_change_hint": "Mostrar un cuadro de diálogo de confirmación antes de convertir el archivo renombrado.", + "general.extension_change_scope": "Dónde buscar archivos renombrados", + "general.extension_change_scope_watch_folders": "Solo carpetas vigiladas (recomendado)", + "general.extension_change_scope_entire_system": "Todo el equipo", + "general.extension_change_scope_entire_system_hint": "Supervisa todas las unidades fijas y carpetas de usuario, omitiendo carpetas del sistema, caché y ocultas.", + "extension_confirm.title": "¿Convertir archivo?", + "extension_confirm.text": "La extensión del archivo \"{0}\" se cambió de {1} a {2}. ¿Deseas que Filee convierta el archivo a {2}?", + "extension_confirm.convert": "Convertir", + "extension_confirm.dont_ask": "Convertir siempre directamente sin preguntar", + "extension_progress.title": "Convirtiendo archivo...", + "extension_progress.text": "Convirtiendo \"{0}\" a {1}...", "general.check_updates": "Buscar actualizaciones automáticamente", "general.close_action": "Al cerrar la ventana", "general.close.Ask": "Preguntar cada vez", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index c5d243b5e..aa55ba026 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -443,6 +443,21 @@ "general.browser_drops_only_converted_hint": "L'original attend dans un dossier temporaire et est supprimé une fois converti.", "general.zip_geo": "Enregistrer chaque résultat géospatial en ZIP", "general.zip_geo_hint": "Les résultats Shapefile, GeoJSON, KML, GPX et autres formats géospatiaux sont regroupés dans un ZIP par fichier, quel que soit le préréglage.\nDésactivé : seuls les préréglages avec « Enregistrer le résultat en ZIP » le font.", + "general.extension_change": "Renommage de l'extension de fichier", + "general.extension_change_hint": "Convertit automatiquement un fichier lorsque vous modifiez son extension vers un format pris en charge (par exemple d'image à image, vidéo à vidéo ou document vers PDF). Les formats non pris en charge sont ignorés.", + "general.convert_on_extension_change": "Convertir les fichiers lorsque l'extension est modifiée", + "general.confirm_extension_change": "Demander confirmation avant de convertir", + "general.confirm_extension_change_hint": "Afficher une boîte de dialogue pour confirmer avant de convertir le fichier renommé.", + "general.extension_change_scope": "Où rechercher les fichiers renommés", + "general.extension_change_scope_watch_folders": "Dossiers surveillés uniquement (recommandé)", + "general.extension_change_scope_entire_system": "Tout l'ordinateur", + "general.extension_change_scope_entire_system_hint": "Surveille tous les disques fixes et dossiers utilisateur, en ignorant les dossiers système, cache et masqués.", + "extension_confirm.title": "Convertir le fichier ?", + "extension_confirm.text": "L'extension du fichier « {0} » a été modifiée de {1} en {2}. Souhaitez-vous que Filee convertisse le fichier en {2} ?", + "extension_confirm.convert": "Convertir", + "extension_confirm.dont_ask": "Toujours convertir directement sans demander", + "extension_progress.title": "Conversion du fichier en cours...", + "extension_progress.text": "Conversion de « {0} » en {1}...", "general.check_updates": "Rechercher automatiquement les mises à jour", "general.close_action": "À la fermeture de la fenêtre", "general.close.Ask": "Toujours demander", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index a3e798a5e..ba66a53a6 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -431,6 +431,21 @@ "general.browser_drops_only_converted_hint": "मूल फ़ाइल एक अस्थायी फ़ोल्डर में रहती है और कन्वर्ट होने के बाद हटा दी जाती है।", "general.zip_geo": "हर भू-स्थानिक नतीजे को ZIP के रूप में सहेजें", "general.zip_geo_hint": "हर प्रीसेट के साथ, Shapefile, GeoJSON, KML, GPX और दूसरे भू-स्थानिक नतीजे हर फ़ाइल के लिए एक ZIP में पैक किए जाते हैं।\nबंद होने पर: केवल वही प्रीसेट ऐसा करते हैं जिनमें \"नतीजे को ZIP के रूप में सहेजें\" चालू है।", + "general.extension_change": "फ़ाइल एक्सटेंशन का नाम बदलना", + "general.extension_change_hint": "जब आप किसी फ़ाइल का एक्सटेंशन किसी समर्थित प्रारूप (जैसे छवि से छवि, वीडियो से वीडियो, या दस्तावेज़ से PDF) में बदलते हैं, तो उसे स्वचालित रूप से नए प्रारूप में बदलें। असमर्थित प्रारूप अनदेखे किए जाते हैं।", + "general.convert_on_extension_change": "एक्सटेंशन बदलने पर फ़ाइलों को बदलें", + "general.confirm_extension_change": "बदलने से पहले पुष्टि के लिए पूछें", + "general.confirm_extension_change_hint": "पुनर्नामित फ़ाइल को बदलने से पहले पुष्टि संवाद दिखाएं।", + "general.extension_change_scope": "पुनर्नामित फ़ाइलों को कहाँ देखें", + "general.extension_change_scope_watch_folders": "केवल निगरानी फ़ोल्डर (अनुशंसित)", + "general.extension_change_scope_entire_system": "पूरा कंप्यूटर", + "general.extension_change_scope_entire_system_hint": "सिस्टम, कैश और छिपे हुए फ़ोल्डरों को छोड़ते हुए, सभी निश्चित ड्राइव और उपयोगकर्ता फ़ोल्डरों की निगरानी करता है।", + "extension_confirm.title": "फ़ाइल बदलें?", + "extension_confirm.text": "\"{0}\" का फ़ाइल एक्सटेंशन {1} से बदलकर {2} कर दिया गया है। क्या आप चाहते हैं कि Filee फ़ाइल को {2} में बदल दे?", + "extension_confirm.convert": "बदलें", + "extension_confirm.dont_ask": "बिना पूछे हमेशा सीधे बदलें", + "extension_progress.title": "फ़ाइल बदली जा रही है...", + "extension_progress.text": "\"{0}\" को {1} में बदला जा रहा है...", "general.check_updates": "अपडेट अपने-आप जाँचें", "general.close_action": "जब मैं विंडो बंद करूँ", "general.close.Ask": "हर बार पूछें", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index b6960a369..df53cb0bd 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -431,6 +431,21 @@ "general.browser_drops_only_converted_hint": "元ファイルは一時フォルダーに置かれ、変換が終わると削除されます。", "general.zip_geo": "地理空間データの変換結果はすべてZIPで保存する", "general.zip_geo_hint": "どのプリセットでも、Shapefile、GeoJSON、KML、GPXなどの地理空間データの変換結果をファイルごとに1つのZIPにまとめます。\nオフの場合は、「変換結果をZIPで保存」を有効にしたプリセットだけに適用されます。", + "general.extension_change": "ファイル拡張子の変更", + "general.extension_change_hint": "拡張子を対応フォーマットに変更したとき(画像から画像、動画から動画、文書からPDFなど)、自動的に新しいフォーマットへ変換します。非対応の形式は無視されます。", + "general.convert_on_extension_change": "拡張子変更時にファイルを変換する", + "general.confirm_extension_change": "変換前に確認メッセージを表示する", + "general.confirm_extension_change_hint": "名前を変更したファイルを変換する前に確認ダイアログを表示します。", + "general.extension_change_scope": "名前変更されたファイルの監視場所", + "general.extension_change_scope_watch_folders": "監視フォルダーのみ(推奨)", + "general.extension_change_scope_entire_system": "コンピューター全体", + "general.extension_change_scope_entire_system_hint": "システム、キャッシュ、隠しフォルダーを除外し、すべての固定ドライブとユーザーフォルダーを監視します。", + "extension_confirm.title": "ファイルを変換しますか?", + "extension_confirm.text": "「{0}」の拡張子が {1} から {2} に変更されました。Filee でこのファイルを {2} に変換しますか?", + "extension_confirm.convert": "変換", + "extension_confirm.dont_ask": "確認せずに常に直接変換する", + "extension_progress.title": "ファイルを変換中…", + "extension_progress.text": "「{0}」を {1} に変換しています…", "general.check_updates": "更新を自動で確認", "general.close_action": "ウィンドウを閉じたとき", "general.close.Ask": "毎回確認", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 09604ddae..e053b01a3 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -443,6 +443,21 @@ "general.browser_drops_only_converted_hint": "원본은 임시 폴더에 두었다가 변환이 끝나면 지워요.", "general.zip_geo": "지리 정보 결과를 모두 ZIP으로 저장", "general.zip_geo_hint": "Shapefile, GeoJSON, KML, GPX 등 지리 정보 결과를 모든 프리셋에서 파일마다 ZIP 하나로 묶어요.\n꺼 두면 \"결과를 ZIP으로 저장\"을 켠 프리셋만 묶어요.", + "general.extension_change": "파일 확장자 이름 변경", + "general.extension_change_hint": "지원되는 형식으로 확장자를 변경할 때(예: 이미지 간 변환, 비디오 간 변환 또는 문서를 PDF로 변환) 파일을 새 형식으로 자동 변환합니다. 지원되지 않는 형식은 무시됩니다.", + "general.convert_on_extension_change": "확장자 변경 시 파일 변환", + "general.confirm_extension_change": "변환하기 전에 확인 메시지 표시", + "general.confirm_extension_change_hint": "이름이 바뀐 파일을 변환하기 전에 확인 대화 상자를 표시합니다.", + "general.extension_change_scope": "이름이 바뀐 파일 감시 위치", + "general.extension_change_scope_watch_folders": "감시 폴더만 (권장)", + "general.extension_change_scope_entire_system": "컴퓨터 ì „ì²´", + "general.extension_change_scope_entire_system_hint": "시스템, 캐시 및 숨김 폴더를 제외한 모든 ê³ ì • 드라이브 및 사용자 디렉터리를 감시합니다。", + "extension_confirm.title": "파일을 변환할까요?", + "extension_confirm.text": "\"{0}\"의 파일 확장자가 {1}에서 {2}(으)로 변경되었습니다. Filee에서 파일을 {2}(으)로 변환할까요?", + "extension_confirm.convert": "변환", + "extension_confirm.dont_ask": "묻지 않고 항상 직접 변환", + "extension_progress.title": "파일 변환 중…", + "extension_progress.text": "\"{0}\"을(를) {1}(으)로 변환하는 중…", "general.check_updates": "자동으로 업데이트 확인", "general.close_action": "창을 닫을 때", "general.close.Ask": "매번 묻기", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index dade701b1..fc6ee581a 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -431,6 +431,21 @@ "general.browser_drops_only_converted_hint": "मूल फाइल अस्थायी फोल्डरमा रहन्छ र रूपान्तरण भएपछि मेटाइन्छ।", "general.zip_geo": "प्रत्येक भौगोलिक परिणामलाई ZIP को रूपमा सुरक्षित गर्नुहोस्", "general.zip_geo_hint": "Shapefile, GeoJSON, KML, GPX र अन्य भौगोलिक परिणामहरू हरेक प्रिसेटसँग प्रति फाइल एउटा ZIP मा प्याक गरिन्छ।\nबन्द हुँदा: केवल \"परिणामलाई ZIP को रूपमा सुरक्षित गर्नुहोस्\" सक्षम भएका प्रिसेटहरूले मात्र यसो गर्छन्।", + "general.extension_change": "फाइल विस्तार पुन: नामाकरण", + "general.extension_change_hint": "समर्थित ढाँचामा फाइल एक्सटेन्सन परिवर्तन गर्दा (जस्तै छविबाट छवि, भिडियोबाट भिडियो, वा कागजातबाट PDF) स्वचालित रूपमा नयाँ ढाँचामा रूपान्तरण गर्नुहोस्। असमर्थित ढाँचाहरू बेवास्ता गरिन्छन्।", + "general.convert_on_extension_change": "विस्तार परिवर्तन हुँदा फाइलहरू रूपान्तरण गर्नुहोस्", + "general.confirm_extension_change": "रूपान्तरण गर्नु अघि पुष्टिकरण माग्नुहोस्", + "general.confirm_extension_change_hint": "पुन: नामाकरण गरिएको फाइल रूपान्तरण गर्नु अघि पुष्टिकरण संवाद देखाउनुहोस्।", + "general.extension_change_scope": "पुन: नामाकरण गरिएका फाइलहरू कहाँ खोज्ने", + "general.extension_change_scope_watch_folders": "निगरानी फोल्डरहरू मात्र (सिफारिस गरिएको)", + "general.extension_change_scope_entire_system": "सम्पूर्ण कम्प्युटर", + "general.extension_change_scope_entire_system_hint": "प्रणाली, क्यास र लुकेका फोल्डरहरू छोडेर सबै निश्चित ड्राइभ र प्रयोगकर्ता फोल्डरहरू निगरानी गर्दछ।", + "extension_confirm.title": "फाइल रूपान्तरण गर्ने?", + "extension_confirm.text": "\"{0}\" को फाइल विस्तार {1} बाट {2} मा परिवर्तन गरिएको थियो। के तपाईं Filee ले फाइललाई {2} मा रूपान्तरण गरोस् भन्ने चाहनुहुन्छ?", + "extension_confirm.convert": "रूपान्तरण गर्नुहोस्", + "extension_confirm.dont_ask": "सोधपुछ नगरी सधैं सिधै रूपान्तरण गर्नुहोस्", + "extension_progress.title": "फाइल रूपान्तरण हुँदैछ...", + "extension_progress.text": "\"{0}\" लाई {1} मा रूपान्तरण गरिँदैछ...", "general.check_updates": "स्वचालित रूपमा अपडेटहरू जाँच गर्नुहोस्", "general.close_action": "जब म विन्डो बन्द गर्छु", "general.close.Ask": "प्रत्येक पटक सोध्नुहोस्", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 0c7af06e2..662945a87 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -443,6 +443,21 @@ "general.browser_drops_only_converted_hint": "Oryginał czeka w folderze tymczasowym i jest usuwany po konwersji.", "general.zip_geo": "Zapisuj każdy wynik geoprzestrzenny jako ZIP", "general.zip_geo_hint": "Shapefile, GeoJSON, KML, GPX i inne wyniki geoprzestrzenne są pakowane do jednego ZIP na plik, w każdym ustawieniu.\nWyłączone: robią to tylko ustawienia z opcją „Zapisz wynik jako ZIP”.", + "general.extension_change": "Zmiana rozszerzenia pliku", + "general.extension_change_hint": "Automatycznie konwertuj plik na nowy format po zmianie rozszerzenia na obsługiwany format (np. obraz na obraz, wideo na wideo lub dokument na PDF). Nieobsługiwane formaty są ignorowane.", + "general.convert_on_extension_change": "Konwertuj pliki po zmianie rozszerzenia", + "general.confirm_extension_change": "Pytaj o potwierdzenie przed konwersją", + "general.confirm_extension_change_hint": "Wyświetl okno dialogowe z prośbą o potwierdzenie przed konwersją pliku o zmienionej nazwie.", + "general.extension_change_scope": "Gdzie szukać zmienionych plików", + "general.extension_change_scope_watch_folders": "Tylko obserwowane foldery (zalecane)", + "general.extension_change_scope_entire_system": "CaÅ‚y komputer", + "general.extension_change_scope_entire_system_hint": "Monitoruje wszystkie dyski staÅ‚e i foldery użytkownika, z pominiÄ™ciem folderów systemowych, pamiÄ™ci podrÄ™cznej i ukrytych.", + "extension_confirm.title": "Skonwertować plik?", + "extension_confirm.text": "Rozszerzenie pliku „{0}” zostało zmienione z {1} na {2}. Czy chcesz, aby Filee przekonwertowało ten plik na {2}?", + "extension_confirm.convert": "Konwertuj", + "extension_confirm.dont_ask": "Zawsze konwertuj bezpośrednio bez pytania", + "extension_progress.title": "Konwertowanie pliku...", + "extension_progress.text": "Konwertowanie „{0}” na {1}...", "general.check_updates": "Automatycznie sprawdzaj aktualizacje", "general.close_action": "Po zamknięciu okna", "general.close.Ask": "Pytaj za każdym razem", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 88d47fdae..3fe1ccc0f 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -443,6 +443,21 @@ "general.browser_drops_only_converted_hint": "O original fica numa pasta temporária e é apagado depois da conversão.", "general.zip_geo": "Salvar todo resultado geoespacial como ZIP", "general.zip_geo_hint": "Shapefile, GeoJSON, KML, GPX e os outros resultados geoespaciais são compactados num ZIP por arquivo, em toda predefinição.\nDesligado: só as predefinições com “Salvar o resultado como ZIP” fazem isso.", + "general.extension_change": "Renomeação de extensão de arquivo", + "general.extension_change_hint": "Converter automaticamente um arquivo para o novo formato ao renomear sua extensão para um formato compatível (como imagem para imagem, vídeo para vídeo ou documento para PDF). Formatos não suportados são ignorados.", + "general.convert_on_extension_change": "Converter arquivos ao alterar a extensão", + "general.confirm_extension_change": "Pedir confirmação antes de converter", + "general.confirm_extension_change_hint": "Exibir uma caixa de diálogo para confirmar antes de converter o arquivo renomeado.", + "general.extension_change_scope": "Onde procurar arquivos renomeados", + "general.extension_change_scope_watch_folders": "Apenas pastas vigiadas (recomendado)", + "general.extension_change_scope_entire_system": "Computador inteiro", + "general.extension_change_scope_entire_system_hint": "Monitora todas as unidades fixas e pastas de usuário, ignorando pastas do sistema, cache e ocultas.", + "extension_confirm.title": "Converter arquivo?", + "extension_confirm.text": "A extensão de \"{0}\" foi alterada de {1} para {2}. Deseja que o Filee converta o arquivo para {2}?", + "extension_confirm.convert": "Converter", + "extension_confirm.dont_ask": "Sempre converter diretamente sem perguntar", + "extension_progress.title": "Convertendo arquivo...", + "extension_progress.text": "Convertendo \"{0}\" para {1}...", "general.check_updates": "Procurar atualizações automaticamente", "general.close_action": "Quando eu fechar a janela", "general.close.Ask": "Perguntar sempre", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index f89ce25c5..adc2df0ff 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -443,6 +443,21 @@ "general.browser_drops_only_converted_hint": "O original fica numa pasta temporária e é eliminado após a conversão.", "general.zip_geo": "Guardar todos os resultados geoespaciais em ZIP", "general.zip_geo_hint": "Shapefile, GeoJSON, KML, GPX e os restantes resultados geoespaciais são comprimidos num ZIP por ficheiro, em todas as predefinições.\nDesativado: só o fazem as predefinições com «Guardar o resultado em ZIP».", + "general.extension_change": "Mudança de extensão de ficheiro", + "general.extension_change_hint": "Converter automaticamente um ficheiro para o novo formato ao renomear a extensão para um formato suportado (como imagem para imagem, vídeo para vídeo ou documento para PDF). Formatos não suportados são ignorados.", + "general.convert_on_extension_change": "Converter ficheiros ao alterar a extensão", + "general.confirm_extension_change": "Pedir confirmação antes de converter", + "general.confirm_extension_change_hint": "Apresentar uma caixa de diálogo para confirmar antes de converter o ficheiro renomeado.", + "general.extension_change_scope": "Onde procurar ficheiros renomeados", + "general.extension_change_scope_watch_folders": "Apenas pastas vigiadas (recomendado)", + "general.extension_change_scope_entire_system": "Computador inteiro", + "general.extension_change_scope_entire_system_hint": "Monitoriza todas as unidades fixas e pastas de utilizador, ignorando pastas do sistema, cache e ocultas.", + "extension_confirm.title": "Converter ficheiro?", + "extension_confirm.text": "A extensão do ficheiro \"{0}\" foi alterada de {1} para {2}. Deseja que o Filee converta o ficheiro para {2}?", + "extension_confirm.convert": "Converter", + "extension_confirm.dont_ask": "Converter sempre diretamente sem perguntar", + "extension_progress.title": "A converter ficheiro...", + "extension_progress.text": "A converter \"{0}\" para {1}...", "general.check_updates": "Procurar atualizações automaticamente", "general.close_action": "Ao fechar a janela", "general.close.Ask": "Perguntar sempre", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 47f36cb2d..24ede8fe7 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -443,6 +443,21 @@ "general.browser_drops_only_converted_hint": "Оригинал ждёт во временной папке и удаляется после конвертации.", "general.zip_geo": "Сохранять любой результат с геоданными в ZIP", "general.zip_geo_hint": "Shapefile, GeoJSON, KML, GPX и другие результаты с геоданными упаковываются в один ZIP на файл, с любым пресетом.\nВыключено: так делают только пресеты с включённым «Сохранять результат в ZIP».", + "general.extension_change": "Переименование расширения файла", + "general.extension_change_hint": "Автоматически конвертировать файл в новый формат при изменении его расширения на поддерживаемый формат (например, изображение в изображение, видео в видео или документ в PDF). Неподдерживаемые форматы игнорируются.", + "general.convert_on_extension_change": "Конвертировать файлы при смене расширения", + "general.confirm_extension_change": "Запрашивать подтверждение перед конвертацией", + "general.confirm_extension_change_hint": "Показывать диалог подтверждения перед конвертацией переименованного файла.", + "general.extension_change_scope": "Где искать переименованные файлы", + "general.extension_change_scope_watch_folders": "Только папки наблюдения (рекомендуется)", + "general.extension_change_scope_entire_system": "Весь компьютер", + "general.extension_change_scope_entire_system_hint": "Отслеживает все фиксированные диски и папки пользователя, пропуская системные, кэш-память и скрытые папки.", + "extension_confirm.title": "Конвертировать файл?", + "extension_confirm.text": "Расширение файла «{0}» было изменено с {1} на {2}. Вы хотите, чтобы Filee конвертировал файл в {2}?", + "extension_confirm.convert": "Конвертировать", + "extension_confirm.dont_ask": "Всегда конвертировать напрямую без запроса", + "extension_progress.title": "Конвертация файла…", + "extension_progress.text": "Конвертация «{0}» в {1}…", "general.check_updates": "Проверять обновления автоматически", "general.close_action": "Когда я закрываю окно", "general.close.Ask": "Спрашивать каждый раз", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index ff9466b61..2bd71dd9a 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -431,6 +431,21 @@ "general.browser_drops_only_converted_hint": "ไฟล์ต้นฉบับจะถูกพักไว้ในโฟลเดอร์ชั่วคราวและจะถูกลบหลังจากแปลงไฟล์เสร็จสิ้น", "general.zip_geo": "บันทึกผลลัพธ์ข้อมูลเชิงพื้นที่ทั้งหมดเป็น ZIP", "general.zip_geo_hint": "Shapefile, GeoJSON, KML, GPX และผลลัพธ์เชิงพื้นที่อื่นๆ จะถูกรวมเป็นไฟล์ ZIP หนึ่งไฟล์ต่อหนึ่งรายการในทุกพรีเซ็ต\nหากปิด: จะทำเฉพาะพรีเซ็ตที่เลือก \"บันทึกผลลัพธ์เป็น ZIP\"", + "general.extension_change": "เปลี่ยนชื่อนามสกุลไฟล์", + "general.extension_change_hint": "แปลงไฟล์เป็นรูปแบบใหม่โดยอัตโนมัติเมื่อเปลี่ยนนามสกุลเป็นรูปแบบที่รองรับ (เช่น รูปภาพเป็นรูปภาพ, วิดีโอเป็นวิดีโอ หรือเอกสารเป็น PDF) รูปแบบที่ไม่รองรับจะถูกละเว้น", + "general.convert_on_extension_change": "แปลงไฟล์เมื่อนามสกุลไฟล์เปลี่ยน", + "general.confirm_extension_change": "ถามเพื่อยืนยันก่อนแปลงไฟล์", + "general.confirm_extension_change_hint": "แสดงหน้าต่างยืนยันก่อนแปลงไฟล์ที่เปลี่ยนชื่อ", + "general.extension_change_scope": "ตำแหน่งที่จะค้นหาไฟล์ที่เปลี่ยนชื่อ", + "general.extension_change_scope_watch_folders": "เฉพาะโฟลเดอร์เฝ้าดู (แนะนำ)", + "general.extension_change_scope_entire_system": "ทั้งคอมพิวเตอร์", + "general.extension_change_scope_entire_system_hint": "เฝ้าดูไดรฟ์ที่ติดตั้งอยู่ทั้งหมดและไดเรกทอรีผู้ใช้ โดยข้ามโฟลเดอร์ระบบ แคช และที่ซ่อนอยู่", + "extension_confirm.title": "แปลงไฟล์หรือไม่?", + "extension_confirm.text": "นามสกุลไฟล์ของ \"{0}\" เปลี่ยนจาก {1} เป็น {2} คุณต้องการให้ Filee แปลงไฟล์เป็น {2} หรือไม่?", + "extension_confirm.convert": "แปลงไฟล์", + "extension_confirm.dont_ask": "แปลงไฟล์โดยตรงเสมอโดยไม่ต้องถาม", + "extension_progress.title": "กำลังแปลงไฟล์...", + "extension_progress.text": "กำลังแปลง \"{0}\" เป็น {1}...", "general.check_updates": "ตรวจสอบการอัปเดตอัตโนมัติ", "general.close_action": "เมื่อปิดหน้าต่าง", "general.close.Ask": "ถามทุกครั้ง", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index ad1043c97..0b1f6dd3b 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -431,6 +431,21 @@ "general.browser_drops_only_converted_hint": "Özgün dosya geçici klasörde bekler ve dönüştürülünce silinir.", "general.zip_geo": "Her coğrafi veri sonucunu ZIP olarak kaydet", "general.zip_geo_hint": "Tüm ön ayarlarda Shapefile, GeoJSON, KML, GPX ve diğer coğrafi veri sonuçları dosya başına tek bir ZIP'e paketlenir.\nKapalıysa yalnızca “Sonucu ZIP olarak kaydet” seçili ön ayarlar bunu yapar.", + "general.extension_change": "Dosya uzantısı yeniden adlandırma", + "general.extension_change_hint": "Uzantısını desteklenen bir biçime değiştirdiğinizde (örneğin resimden resme, videodan videoya veya belgeden PDF'ye) dosyayı otomatik olarak yeni biçimine dönüştürün. Desteklenmeyen biçimler yoksayılır.", + "general.convert_on_extension_change": "Uzantı değiştirildiğinde dosyaları dönüştür", + "general.confirm_extension_change": "Dönüştürmeden önce onay iste", + "general.confirm_extension_change_hint": "Yeniden adlandırılan dosyayı dönüştürmeden önce bir onay iletişim kutusu gösterin.", + "general.extension_change_scope": "Yeniden adlandırılan dosyaların aranacağı yer", + "general.extension_change_scope_watch_folders": "Yalnızca izleme klasörleri (önerilen)", + "general.extension_change_scope_entire_system": "Tüm bilgisayar", + "general.extension_change_scope_entire_system_hint": "Sistem, önbellek ve gizli klasörleri atlayarak tüm sabit sürücüleri ve kullanıcı dizinlerini izler.", + "extension_confirm.title": "Dosya dönüştürülsün mü?", + "extension_confirm.text": "\"{0}\" dosyasının uzantısı {1} yerine {2} olarak değiştirildi. Filee'nin bu dosyayı {2} biçimine dönüştürmesini ister misiniz?", + "extension_confirm.convert": "Dönüştür", + "extension_confirm.dont_ask": "Her zaman sormadan doğrudan dönüştür", + "extension_progress.title": "Dosya dönüştürülüyor...", + "extension_progress.text": "\"{0}\" dosyası {1} biçimine dönüştürülüyor...", "general.check_updates": "Güncellemeleri otomatik denetle", "general.close_action": "Pencereyi kapattığımda", "general.close.Ask": "Her seferinde sor", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index e0b384c6f..f67010a96 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -443,6 +443,21 @@ "general.browser_drops_only_converted_hint": "原文件先放在临时文件夹中,转换完成后删除。", "general.zip_geo": "将所有地理信息结果保存为 ZIP", "general.zip_geo_hint": "无论使用哪个预设,Shapefile、GeoJSON、KML、GPX 等地理信息结果都会按每个文件打包成一个 ZIP。\n关闭后,只有开启了“将结果保存为 ZIP”的预设会这样做。", + "general.extension_change": "重命名文件扩展名", + "general.extension_change_hint": "当将文件扩展名重命名为支持的格式(如图片转图片、视频转视频或文档转 PDF)时,自动将其转换为新格式。不支持的格式将被忽略。", + "general.convert_on_extension_change": "更改扩展名时自动转换文件", + "general.confirm_extension_change": "转换前提示确认", + "general.confirm_extension_change_hint": "在转换已重命名的文件前显示确认对话框。", + "general.extension_change_scope": "在哪查找重命名文件", + "general.extension_change_scope_watch_folders": "仅监视文件夹(推荐)", + "general.extension_change_scope_entire_system": "整台电脑", + "general.extension_change_scope_entire_system_hint": "监视所有固定驱动器和用户目录,跳过系统、缓存和隐藏文件夹。", + "extension_confirm.title": "转换文件?", + "extension_confirm.text": "“{0}”的文件扩展名已从 {1} 改为 {2}。是否让 Filee 将该文件转换为 {2}?", + "extension_confirm.convert": "转换", + "extension_confirm.dont_ask": "始终直接转换,不再询问", + "extension_progress.title": "正在转换文件…", + "extension_progress.text": "正在将“{0}”转换为 {1}…", "general.check_updates": "自动检查更新", "general.close_action": "关闭窗口时", "general.close.Ask": "每次询问", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index ae6a38060..c93226316 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -443,6 +443,21 @@ "general.browser_drops_only_converted_hint": "原始檔案先放在臨時資料夾中,轉換完成後刪除。", "general.zip_geo": "將所有地理資訊結果儲存為 ZIP", "general.zip_geo_hint": "無論使用哪個預設,Shapefile、GeoJSON、KML、GPX 等地理資訊結果都會按每個檔案打包成一個 ZIP。\n關閉後,只有開啟了「將結果儲存為 ZIP」的預設會這樣做。", + "general.extension_change": "重新命名副檔名", + "general.extension_change_hint": "當將副檔名重新命名為支援的格式(如圖片轉圖片、影片轉影片或文件轉 PDF)時,自動將其轉換為新格式。不支援的格式將被忽略。", + "general.convert_on_extension_change": "變更副檔名時自動轉換檔案", + "general.confirm_extension_change": "轉換前詢問確認", + "general.confirm_extension_change_hint": "在轉換已重新命名的檔案前顯示確認對話方塊。", + "general.extension_change_scope": "在哪搜尋重新命名的檔案", + "general.extension_change_scope_watch_folders": "僅監視資料夾(建議)", + "general.extension_change_scope_entire_system": "整部電腦", + "general.extension_change_scope_entire_system_hint": "監視所有固定磁碟機與使用者目錄,略過系統、快取及隱藏資料夾。", + "extension_confirm.title": "轉換檔案?", + "extension_confirm.text": "「{0}」的副檔名已從 {1} 變更為 {2}。是否讓 Filee 將該檔案轉換為 {2}?", + "extension_confirm.convert": "轉換", + "extension_confirm.dont_ask": "一律直接轉換,不再詢問", + "extension_progress.title": "正在轉換檔案…", + "extension_progress.text": "正在將「{0}」轉換為 {1}…", "general.check_updates": "自動檢查更新", "general.close_action": "關閉視窗時", "general.close.Ask": "每次詢問", diff --git a/src/Filee.App/Services/AppHost.cs b/src/Filee.App/Services/AppHost.cs index 2eb90c92a..5dd746abf 100644 --- a/src/Filee.App/Services/AppHost.cs +++ b/src/Filee.App/Services/AppHost.cs @@ -68,6 +68,7 @@ public static IServiceProvider Build(string? dataDirectory = null, UserDataStore services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Filee.App/Services/ExtensionChangeService.cs b/src/Filee.App/Services/ExtensionChangeService.cs new file mode 100644 index 000000000..1b2da7fa8 --- /dev/null +++ b/src/Filee.App/Services/ExtensionChangeService.cs @@ -0,0 +1,277 @@ +using Avalonia.Threading; +using Filee.Core.Conversion; +using Filee.Core.Platform; +using Filee.Core.Presets; +using Filee.Core.Settings; +using Filee.Core.Watching; +using Microsoft.Extensions.Logging; + +namespace Filee.App.Services; + +/// +/// Monitors file extension changes across user-configured watch folders and automatically converts them +/// according to user settings. +/// +public sealed class ExtensionChangeService : IAsyncDisposable +{ + private readonly UserDataStore _store; + private readonly WindowService _windowService; + private readonly IPlatformServices _platform; + private readonly ILogger _log; + private readonly ExtensionChangeWatcher _watcher; + private readonly EventHandler _settingsChangedHandler; + private bool _started; + private bool _isWatching; + private List _currentRoots = []; + + public ExtensionChangeService( + UserDataStore store, + ConverterCatalog catalog, + ConversionService conversions, + WindowService windowService, + IPlatformServices platform, + ILogger log) + { + _store = store; + _windowService = windowService; + _platform = platform; + _log = log; + + _watcher = new ExtensionChangeWatcher( + () => catalog.CreatePlanner(), + async (sourcePath, targetFormat, outputPath, ct) => + { + var preset = new Preset { TargetFormat = targetFormat }; + preset.Output.Location = OutputLocation.CustomFolder; + preset.Output.CustomFolder = Path.GetDirectoryName(outputPath) ?? ""; + preset.Output.Conflict = ConflictPolicy.Overwrite; + var job = await conversions.RunAsync([sourcePath], preset).ConfigureAwait(false); + return job is not null && job.Files.Any(f => f.State == FileState.Done); + }, + platform.MoveToTrash, + log); + + _watcher.ExtensionChangeDetected += OnExtensionChangeDetectedAsync; + _settingsChangedHandler = (_, _) => UpdateWatcherState(); + } + + public void Start() + { + if (_started) + return; + + _started = true; + _store.SettingsChanged += _settingsChangedHandler; + UpdateWatcherState(); + } + + private void UpdateWatcherState() + { + var enabled = _store.Settings.ConvertOnExtensionChange; + if (!enabled) + { + if (_isWatching) + { + _watcher.Stop(); + _isWatching = false; + _currentRoots.Clear(); + _log.LogInformation("Extension change auto-conversion stopped"); + } + return; + } + + var newRoots = GetWatchRoots(); + if (_isWatching && RootsEqual(_currentRoots, newRoots)) + return; + + _watcher.Stop(); + if (newRoots.Count > 0) + { + _watcher.Start(newRoots); + _isWatching = true; + _currentRoots = [.. newRoots]; + _log.LogInformation("Extension change auto-conversion started on {Count} roots", newRoots.Count); + } + else + { + _isWatching = false; + _currentRoots.Clear(); + _log.LogInformation("Extension change auto-conversion has no active watch roots"); + } + } + + private static bool RootsEqual(IReadOnlyList a, IReadOnlyList b) + { + if (a.Count != b.Count) + return false; + + for (var i = 0; i < a.Count; i++) + { + if (!string.Equals(a[i].Directory, b[i].Directory, FileSystemPaths.Comparison) || + a[i].IncludeSubdirectories != b[i].IncludeSubdirectories) + { + return false; + } + } + return true; + } + + private IReadOnlyList GetWatchRoots() + { + var folders = new List(); + var seen = new HashSet(FileSystemPaths.Comparer); + + void AddFolder(string folder, bool recursive) + { + if (string.IsNullOrWhiteSpace(folder)) + return; + + try + { + if (Directory.Exists(folder)) + { + var full = Path.GetFullPath(folder); + if (seen.Add(full)) + { + folders.Add(new WatchedFolder(full, recursive)); + } + } + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + { + _log.LogWarning(ex, "Could not access folder for extension change watching: {Folder}", folder); + } + } + + // Always include user-configured watch folders + foreach (var rule in _store.Settings.WatchFolders) + { + if (rule.Enabled && !string.IsNullOrWhiteSpace(rule.Folder)) + AddFolder(rule.Folder, rule.IncludeSubfolders); + } + + // If configured to watch the entire computer, include fixed drives or user roots + if (_store.Settings.ExtensionChangeScope == ExtensionChangeScope.EntireSystem) + { + if (OperatingSystem.IsWindows()) + { + try + { + foreach (var drive in DriveInfo.GetDrives()) + { + if (drive.DriveType == DriveType.Fixed && drive.IsReady) + { + AddFolder(drive.RootDirectory.FullName, recursive: true); + } + } + } + catch (Exception ex) + { + _log.LogWarning(ex, "Could not enumerate fixed drives for extension change monitoring"); + } + } + + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrWhiteSpace(userProfile)) + AddFolder(userProfile, recursive: true); + + var desktop = Environment.GetFolderPath(Environment.SpecialFolder.Desktop); + if (!string.IsNullOrWhiteSpace(desktop)) + AddFolder(desktop, recursive: true); + + var docs = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + if (!string.IsNullOrWhiteSpace(docs)) + AddFolder(docs, recursive: true); + + if (!string.IsNullOrWhiteSpace(_platform.DownloadsFolder)) + AddFolder(_platform.DownloadsFolder, recursive: true); + } + + // Avoid duplicate watchers by eliminating folders already covered by a recursive parent + var result = new List(); + foreach (var candidate in folders) + { + var isCovered = folders.Any(other => + !ReferenceEquals(other, candidate) && + other.IncludeSubdirectories && + IsDescendant(candidate.Directory, other.Directory)); + + if (!isCovered) + result.Add(candidate); + } + + return result; + } + + private static bool IsDescendant(string childPath, string parentPath) + { + try + { + var rel = Path.GetRelativePath(parentPath, childPath); + return rel != "." && !rel.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(rel); + } + catch + { + return false; + } + } + + private async Task OnExtensionChangeDetectedAsync(ExtensionChangeCandidate candidate) + { + if (!_store.Settings.ConvertOnExtensionChange) + return; + + if (_store.Settings.ConfirmExtensionChange) + { + var confirmed = await Dispatcher.UIThread.InvokeAsync(async () => + { + return await _windowService.ConfirmExtensionChangeAsync( + candidate.FileName, + candidate.SourceFormat.DisplayName, + candidate.TargetFormat.DisplayName); + }); + + if (!confirmed) + { + _log.LogInformation("User cancelled extension conversion for {File}", candidate.NewPath); + return; + } + } + + _log.LogInformation("Executing extension conversion for {File} from {Source} to {Target}", + candidate.NewPath, candidate.SourceFormat.Id, candidate.TargetFormat.Id); + + IDisposable? progress = null; + try + { + progress = await Dispatcher.UIThread.InvokeAsync(() => + { + return _windowService.ShowExtensionChangeProgress( + candidate.FileName, + candidate.TargetFormat.DisplayName); + }); + + var success = await candidate.PerformConversionAsync(CancellationToken.None).ConfigureAwait(false); + if (!success) + { + _log.LogWarning("Extension change conversion for {File} did not complete. Original file preserved.", candidate.NewPath); + } + } + finally + { + if (progress is not null) + { + await Dispatcher.UIThread.InvokeAsync(() => progress.Dispose()); + } + } + } + + public async ValueTask DisposeAsync() + { + _store.SettingsChanged -= _settingsChangedHandler; + _watcher.Stop(); + _isWatching = false; + _currentRoots.Clear(); + await _watcher.DisposeAsync(); + } +} diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index 90a21e0f0..d666c4d43 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -6,6 +6,7 @@ using Avalonia.Layout; using Avalonia.Media; using Avalonia.Platform.Storage; +using Avalonia.Threading; using Filee.App.ViewModels; using Filee.App.Views; using Filee.Core.Conversion; @@ -118,6 +119,51 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem } } + /// Asks the user whether they want to convert a file whose extension was changed. + public async Task ConfirmExtensionChangeAsync(string fileName, string sourceFormat, string targetFormat) + { + var message = loc.Format("extension_confirm.text", fileName, sourceFormat, targetFormat); + var dialog = new ExtensionChangeConfirmWindow(message); + PrepareWindow(dialog); + var closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + dialog.Closed += (_, _) => + { + if (dialog.DontAskAgain) + { + var store = services.GetRequiredService(); + store.Settings.ConfirmExtensionChange = false; + store.SaveSettings(); + } + closed.TrySetResult(dialog.Tag as bool? == true); + }; + dialog.Show(); + dialog.Activate(); + return await closed.Task; + } + + /// Shows a progress dialog while converting a file whose extension was changed, and returns a disposable handle to close it. + public IDisposable ShowExtensionChangeProgress(string fileName, string targetFormat) + { + var message = loc.Format("extension_progress.text", fileName, targetFormat); + var dialog = new ExtensionChangeProgressWindow(message); + PrepareWindow(dialog); + dialog.Show(); + return new ExtensionChangeProgressScope(dialog); + } + + private sealed class ExtensionChangeProgressScope(Window window) : IDisposable + { + private int _disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + Dispatcher.UIThread.Post(() => window.Close()); + } + } + } + public MainWindow? Main => _main; /// @@ -699,3 +745,4 @@ public void ApplyTo(MainWindow window) } } } + diff --git a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs index c55d35401..936d9dc88 100644 --- a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs @@ -50,6 +50,10 @@ public GeneralPageViewModel(UserDataStore store, ILocalizer loc, IPlatformServic _browserFolder = store.Settings.BrowserDrops.Folder; _saveOnlyConverted = store.Settings.BrowserDrops.SaveOnlyConverted; _zipGeoResults = store.Settings.ZipGeoResults; + _convertOnExtensionChange = store.Settings.ConvertOnExtensionChange; + _confirmExtensionChange = store.Settings.ConfirmExtensionChange; + _scopeWatchFolders = store.Settings.ExtensionChangeScope == ExtensionChangeScope.WatchFolders; + _scopeEntireSystem = store.Settings.ExtensionChangeScope == ExtensionChangeScope.EntireSystem; CloseChoices = Enum.GetValues().Select(v => new Choice(v, loc[$"general.close.{v}"])).ToList(); _closeChoice = CloseChoices.First(c => c.Value == store.Settings.CloseAction); store.SettingsChanged += OnSettingsChanged; @@ -120,11 +124,46 @@ partial void OnCloseChoiceChanged(Choice? oldValue, Choice s.CloseAction = newValue.Value); } + [ObservableProperty] private bool _convertOnExtensionChange; + [ObservableProperty] private bool _confirmExtensionChange; + [ObservableProperty] private bool _scopeWatchFolders; + [ObservableProperty] private bool _scopeEntireSystem; + + partial void OnConvertOnExtensionChangeChanged(bool value) => Save(s => s.ConvertOnExtensionChange = value); + partial void OnConfirmExtensionChangeChanged(bool value) => Save(s => s.ConfirmExtensionChange = value); + + partial void OnScopeWatchFoldersChanged(bool value) + { + if (value) + { + ScopeEntireSystem = false; + Save(s => s.ExtensionChangeScope = ExtensionChangeScope.WatchFolders); + } + } + + partial void OnScopeEntireSystemChanged(bool value) + { + if (value) + { + ScopeWatchFolders = false; + Save(s => s.ExtensionChangeScope = ExtensionChangeScope.EntireSystem); + } + } + // The close dialog may answer "Don't ask again" while this page stays open behind the hidden window. private void OnSettingsChanged(object? sender, EventArgs e) { if (CloseChoice.Value != _store.Settings.CloseAction) CloseChoice = CloseChoices.First(c => c.Value == _store.Settings.CloseAction); + if (ConvertOnExtensionChange != _store.Settings.ConvertOnExtensionChange) + ConvertOnExtensionChange = _store.Settings.ConvertOnExtensionChange; + if (ConfirmExtensionChange != _store.Settings.ConfirmExtensionChange) + ConfirmExtensionChange = _store.Settings.ConfirmExtensionChange; + var scope = _store.Settings.ExtensionChangeScope; + if (ScopeWatchFolders != (scope == ExtensionChangeScope.WatchFolders)) + ScopeWatchFolders = scope == ExtensionChangeScope.WatchFolders; + if (ScopeEntireSystem != (scope == ExtensionChangeScope.EntireSystem)) + ScopeEntireSystem = scope == ExtensionChangeScope.EntireSystem; } public void Dispose() => _store.SettingsChanged -= OnSettingsChanged; diff --git a/src/Filee.App/Views/ExtensionChangeConfirmWindow.axaml b/src/Filee.App/Views/ExtensionChangeConfirmWindow.axaml new file mode 100644 index 000000000..f772c1e80 --- /dev/null +++ b/src/Filee.App/Views/ExtensionChangeConfirmWindow.axaml @@ -0,0 +1,36 @@ + + + + + + + + + + + + public double UiScale { get; set; } = 1; + /// + /// When a user renames a file by changing its extension (e.g. xyz.png -> xyz.pdf), convert the file + /// to the new format if supported. + /// + public bool ConvertOnExtensionChange { get; set; } + + /// + /// When is on, ask for confirmation before converting the renamed file. + /// When false, convert directly. + /// + public bool ConfirmExtensionChange { get; set; } = true; + + /// + /// Scope of folders to watch for file extension renames: configured watch folders only, or the entire computer. + /// + [JsonConverter(typeof(LenientEnumJsonConverter))] + public ExtensionChangeScope ExtensionChangeScope { get; set; } = ExtensionChangeScope.WatchFolders; + + /// /// Returns itself, or a copy with the app-wide output settings applied: it saves to /// when the preset's location is , and keeps the diff --git a/src/Filee.Core/Watching/ExtensionChangeWatcher.cs b/src/Filee.Core/Watching/ExtensionChangeWatcher.cs new file mode 100644 index 000000000..00bb86469 --- /dev/null +++ b/src/Filee.Core/Watching/ExtensionChangeWatcher.cs @@ -0,0 +1,676 @@ +// Detects when a user renames a file to a different extension (e.g. xyz.png -> xyz.pdf), +// validates the file signature, checks that a conversion route exists, waits for OS locks to settle, +// and safely converts the file while avoiding infinite loops. + +using System.Collections.Concurrent; +using Filee.Core.Conversion; +using Filee.Core.Formats; +using Filee.Core.Platform; +using Filee.Core.Web; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Filee.Core.Watching; + +/// Folder to monitor for file extension changes. +public sealed record WatchedFolder(string Directory, bool IncludeSubdirectories = true); + +/// Details of a detected file extension change candidate ready for conversion. +public sealed class ExtensionChangeCandidate +{ + public required string OldPath { get; init; } + public required string NewPath { get; init; } + public required FileFormat SourceFormat { get; init; } + public required FileFormat TargetFormat { get; init; } + public required string FileName { get; init; } + public required Func> PerformConversionAsync { get; init; } +} + +/// +/// Cross-platform watcher that monitors directory roots for file extension changes. +/// +public sealed class ExtensionChangeWatcher : IAsyncDisposable +{ + private static readonly TimeSpan DefaultSettleTimeout = TimeSpan.FromSeconds(OperatingSystem.IsWindows() ? 3 : 5); + private static readonly TimeSpan InProgressTtl = TimeSpan.FromSeconds(20); + + private readonly Func _createPlanner; + private readonly Func> _convertFile; + private readonly Func? _moveToTrash; + private readonly ILogger _log; + private readonly TimeSpan _settleTimeout; + private readonly List _watchers = []; + private readonly ConcurrentDictionary _inProgressOperations = new(FileSystemPaths.Comparer); + private readonly Lock _lock = new(); + private bool _disposed; + + /// + /// Raised when a valid extension change is detected and settled. + /// Handlers can inspect the candidate and either trigger conversion directly or prompt the user. + /// + public event Func? ExtensionChangeDetected; + + /// Factory for route planner to test whether conversion is possible. + /// Delegate: (sourceFile, targetFormat, outputFile, ct) -> bool success. + /// Optional delegate to move the original file to Recycle Bin / Trash when replacing. + /// Logger instance. + /// Maximum time to wait for the renamed file to unlock and settle. + public ExtensionChangeWatcher( + Func createPlanner, + Func> convertFile, + Func? moveToTrash = null, + ILogger? log = null, + TimeSpan? settleTimeout = null) + { + _createPlanner = createPlanner; + _convertFile = convertFile; + _moveToTrash = moveToTrash; + _log = log ?? NullLogger.Instance; + _settleTimeout = settleTimeout ?? DefaultSettleTimeout; + } + + /// Marks a path as actively modified by Filee so filesystem events on it are ignored. + public void MarkInProgress(string path) + { + _inProgressOperations[path] = DateTimeOffset.UtcNow.Add(InProgressTtl); + } + + /// Unmarks a path from the in-progress list. + public void UnmarkInProgress(string path) + { + _inProgressOperations.TryRemove(path, out _); + } + + /// True if the path is currently being modified by Filee or recently was. + public bool IsInProgress(string path) + { + PruneExpiredInProgress(); + return _inProgressOperations.ContainsKey(path); + } + + private void PruneExpiredInProgress() + { + var now = DateTimeOffset.UtcNow; + foreach (var (p, expiry) in _inProgressOperations) + { + if (expiry < now) + _inProgressOperations.TryRemove(p, out _); + } + } + + /// Starts watching the given folder targets. + public void Start(IEnumerable targets) + { + lock (_lock) + { + Stop(); + var sorted = targets + .GroupBy(t => t.Directory, FileSystemPaths.Comparer) + .Select(g => g.First()) + .OrderBy(t => t.Directory.Length) + .ToList(); + + foreach (var target in sorted) + { + if (!Directory.Exists(target.Directory)) + continue; + + // If an active watcher already covers this folder recursively, skip redundant child watcher + if (target.IncludeSubdirectories && _watchers.Any(w => w.IncludeSubdirectories && IsSubpath(target.Directory, w.Path))) + continue; + + try + { + var watcher = new FileSystemWatcher(target.Directory) + { + IncludeSubdirectories = target.IncludeSubdirectories, + NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite, + InternalBufferSize = 64 * 1024, + }; + watcher.Renamed += OnRenamed; + watcher.Error += (_, e) => + { + _log.LogWarning(e.GetException(), "Filesystem watcher error on {Directory}", target.Directory); + }; + watcher.EnableRaisingEvents = true; + _watchers.Add(watcher); + _log.LogInformation("Watching extension changes in {Directory} (recursive: {Recursive})", + target.Directory, target.IncludeSubdirectories); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or PlatformNotSupportedException) + { + _log.LogWarning(ex, "Could not start extension watcher on {Directory}", target.Directory); + } + } + } + } + + /// Starts watching the given directory paths recursively. + public void Start(IEnumerable directories) => + Start(directories.Select(d => new WatchedFolder(d, true))); + + private static bool IsSubpath(string path, string parent) + { + var fullPath = Path.GetFullPath(path); + var fullParent = Path.GetFullPath(parent); + if (!fullParent.EndsWith(Path.DirectorySeparatorChar) && !fullParent.EndsWith(Path.AltDirectorySeparatorChar)) + fullParent += Path.DirectorySeparatorChar; + + return fullPath.StartsWith(fullParent, FileSystemPaths.Comparison); + } + + /// Stops all active directory watchers. + public void Stop() + { + lock (_lock) + { + foreach (var w in _watchers) + { + try + { + w.EnableRaisingEvents = false; + w.Dispose(); + } + catch + { + // Ignore disposal errors on teardown + } + } + _watchers.Clear(); + } + } + + private void OnRenamed(object sender, RenamedEventArgs e) + { + _ = Task.Run(async () => + { + try + { + await HandleRenameAsync(e.OldFullPath, e.FullPath).ConfigureAwait(false); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Error handling extension rename from {Old} to {New}", e.OldFullPath, e.FullPath); + } + }); + } + + /// + /// Processes a rename event. Can also be called directly in tests. + /// Returns the candidate if valid, or null if ignored. + /// + public async Task HandleRenameAsync(string oldPath, string newPath, CancellationToken cancellationToken = default) + { + if (_disposed) + return null; + + if (ShouldIgnorePath(oldPath) || ShouldIgnorePath(newPath)) + return null; + + if (IsInProgress(oldPath) || IsInProgress(newPath)) + return null; + + var oldExt = FormatRegistry.ExtensionOf(oldPath); + var newExt = FormatRegistry.ExtensionOf(newPath); + + if (string.IsNullOrWhiteSpace(oldExt) || string.IsNullOrWhiteSpace(newExt)) + return null; + + if (string.Equals(oldExt, newExt, StringComparison.OrdinalIgnoreCase)) + return null; + + var sourceFormat = FormatRegistry.FindByExtension(oldExt); + var targetFormat = FormatRegistry.FindByExtension(newExt); + + if (sourceFormat is null || targetFormat is null) + return null; + + if (string.Equals(sourceFormat.Id, targetFormat.Id, StringComparison.OrdinalIgnoreCase)) + return null; + + if (!targetFormat.Writable) + return null; + + // Check if a conversion route exists between source and target format + var planner = _createPlanner(); + if (planner.Plan(sourceFormat.Id, targetFormat.Id) is null) + return null; + + // Wait until the file is accessible and unlocked + if (!await WaitForFileReadyAsync(newPath, _settleTimeout, cancellationToken).ConfigureAwait(false)) + return null; + + // Read file header to inspect actual file signature + var head = ReadHeader(newPath, WebFormats.SniffLength); + if (head.Length == 0) + return null; + + var sniffed = WebFormats.Sniff(head); + if (sniffed is not null) + { + // If the file already matches the target format internally (e.g. already a PDF), don't convert! + if (string.Equals(sniffed, targetFormat.Id, StringComparison.OrdinalIgnoreCase)) + return null; + + // If sniffed format is known and different from source, check if route from sniffed format to target exists + if (!string.Equals(sniffed, sourceFormat.Id, StringComparison.OrdinalIgnoreCase)) + { + var sniffedFormat = FormatRegistry.FindByExtension(sniffed); + if (sniffedFormat is not null) + { + if (planner.Plan(sniffedFormat.Id, targetFormat.Id) is null) + return null; + + sourceFormat = sniffedFormat; + } + } + } + + var fileName = Path.GetFileName(newPath); + var candidate = new ExtensionChangeCandidate + { + OldPath = oldPath, + NewPath = newPath, + SourceFormat = sourceFormat, + TargetFormat = targetFormat, + FileName = fileName, + PerformConversionAsync = ct => ConvertSafelyAsync(newPath, sourceFormat, targetFormat, ct), + }; + + if (ExtensionChangeDetected is { } handler) + { + await handler.Invoke(candidate).ConfigureAwait(false); + } + + return candidate; + } + + /// + /// Executes a safe conversion: writes to a temporary output file, validates it, + /// and replaces the destination file atomically while preserving the original on failure. + /// Handles read-only files, cross-device filesystem moves, and temporary file locks. + /// + public async Task ConvertSafelyAsync( + string filePath, + FileFormat sourceFormat, + FileFormat targetFormat, + CancellationToken cancellationToken = default) + { + if (!File.Exists(filePath)) + return false; + + string workDir; + try + { + workDir = Path.Combine(Path.GetTempPath(), "Filee-ExtensionChange", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + { + // Fallback to user app data if system temp directory is restricted + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + workDir = Path.Combine(localAppData, "Filee", "Temp", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + } + + var tempSource = Path.Combine(workDir, $"{FormatRegistry.NameWithoutExtension(filePath)}.{sourceFormat.Id}"); + var tempOutput = Path.Combine(workDir, $"{FormatRegistry.NameWithoutExtension(filePath)}.{targetFormat.Id}"); + + MarkInProgress(filePath); + MarkInProgress(tempSource); + MarkInProgress(tempOutput); + + try + { + // Copy source to temporary working location with retries for locks + var sourceCopied = false; + for (var attempt = 1; attempt <= 3; attempt++) + { + try + { + File.Copy(filePath, tempSource, overwrite: true); + sourceCopied = true; + break; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + if (attempt == 3) throw; + await Task.Delay(attempt * 100, cancellationToken).ConfigureAwait(false); + } + } + + if (!sourceCopied || !File.Exists(tempSource)) + { + _log.LogWarning("Could not copy source file {File} due to lock or permission restrictions", filePath); + return false; + } + + var success = await _convertFile(tempSource, targetFormat.Id, tempOutput, cancellationToken).ConfigureAwait(false); + if (!success || !File.Exists(tempOutput)) + { + _log.LogWarning("Conversion of {File} to {Target} failed", filePath, targetFormat.Id); + return false; + } + + var outputInfo = new FileInfo(tempOutput); + if (outputInfo.Length == 0) + { + _log.LogWarning("Converted output for {File} to {Target} is empty", filePath, targetFormat.Id); + return false; + } + + // Verify output signature if sniffable + var outputHead = ReadHeader(tempOutput, WebFormats.SniffLength); + var outputSniffed = WebFormats.Sniff(outputHead); + if (outputSniffed is not null && !string.Equals(outputSniffed, targetFormat.Id, StringComparison.OrdinalIgnoreCase)) + { + _log.LogWarning("Converted output signature for {File} does not match {Target}", filePath, targetFormat.Id); + return false; + } + + // If destination file is read-only, clear attribute so move/copy can overwrite + FileAttributes? originalAttrs = null; + try + { + if (File.Exists(filePath)) + { + originalAttrs = File.GetAttributes(filePath); + if ((originalAttrs.Value & FileAttributes.ReadOnly) != 0) + { + var newAttrs = originalAttrs.Value & ~FileAttributes.ReadOnly; + File.SetAttributes(filePath, newAttrs == 0 ? FileAttributes.Normal : newAttrs); + } + } + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + { + _log.LogDebug(ex, "Could not check or clear ReadOnly attribute on {File}", filePath); + } + + // Move the original file to Recycle Bin / Trash, or preserve it as a backup file if trash is refused + string? backupPath = null; + var movedToTrash = false; + + if (_moveToTrash is not null) + { + try + { + var refusal = _moveToTrash(filePath); + movedToTrash = refusal is null && !File.Exists(filePath); + if (refusal is not null) + { + _log.LogInformation("Trash refused original {File} ({Reason}); preserving as backup file", filePath, refusal.ReasonKey); + } + } + catch (Exception ex) + { + _log.LogWarning(ex, "Could not move original {File} to trash; preserving as backup file", filePath); + } + } + + // If not moved to trash, rename original to backup path to ensure it is never overwritten or lost + if (!movedToTrash && File.Exists(filePath)) + { + backupPath = GetBackupPath(filePath, sourceFormat.Id); + try + { + File.Move(filePath, backupPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _log.LogWarning(ex, "Could not preserve original file {File} before replacing; aborting conversion to prevent data loss", filePath); + return false; + } + } + + // Safely place the converted output at filePath. + // Retries handle momentary locks by file managers/antivirus/indexers. + // Fallback handles cross-device links (EXDEV on Linux/macOS when temp is tmpfs). + var placed = false; + Exception? lastError = null; + + for (var attempt = 1; attempt <= 5; attempt++) + { + try + { + File.Move(tempOutput, filePath, overwrite: true); + placed = true; + break; + } + catch (IOException ioEx) + { + lastError = ioEx; + try + { + File.Copy(tempOutput, filePath, overwrite: true); + try { File.Delete(tempOutput); } catch { } + placed = true; + break; + } + catch (Exception copyEx) + { + lastError = copyEx; + } + } + catch (UnauthorizedAccessException uaEx) + { + lastError = uaEx; + try + { + File.Copy(tempOutput, filePath, overwrite: true); + try { File.Delete(tempOutput); } catch { } + placed = true; + break; + } + catch (Exception copyEx) + { + lastError = copyEx; + } + } + + if (attempt < 5) + { + await Task.Delay(attempt * 100, cancellationToken).ConfigureAwait(false); + } + } + + if (!placed) + { + _log.LogWarning(lastError, "Could not place converted output at {File}", filePath); + // Restore original file so it is not lost + if (backupPath is not null && File.Exists(backupPath) && !File.Exists(filePath)) + { + try { File.Move(backupPath, filePath); } catch { } + } + else if (movedToTrash && !File.Exists(filePath) && File.Exists(tempSource)) + { + try { File.Copy(tempSource, filePath); } catch { } + } + return false; + } + + _log.LogInformation("Successfully converted {File} from {Source} to {Target} (original preserved)", filePath, sourceFormat.Id, targetFormat.Id); + return true; + } + catch (UnauthorizedAccessException uaEx) + { + _log.LogWarning(uaEx, "Permission declined during extension change conversion on {File}", filePath); + return false; + } + catch (Exception ex) + { + _log.LogError(ex, "Failed to safely convert extension change on {File}", filePath); + return false; + } + finally + { + UnmarkInProgress(tempSource); + UnmarkInProgress(tempOutput); + // Give a brief window before unmarking destination to ensure OS file system events finish settling + _ = Task.Delay(1000).ContinueWith(_ => + { + UnmarkInProgress(filePath); + try + { + if (Directory.Exists(workDir)) + Directory.Delete(workDir, recursive: true); + } + catch + { + // Ignore temp dir cleanup errors + } + }); + } + } + + private static readonly char[] PathSeparators = ['/', '\\']; + + /// Checks whether a path belongs to system, temp, or ignored folders. + public static bool ShouldIgnorePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return true; + + var lastSep = path.LastIndexOfAny(PathSeparators); + var name = lastSep >= 0 ? path[(lastSep + 1)..] : path; + if (string.IsNullOrWhiteSpace(name)) + return true; + + // Temporary or system files + if (name.StartsWith('.') || name.StartsWith("._") + || name.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".temp", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".crdownload", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".part", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".download", StringComparison.OrdinalIgnoreCase) + || name.Contains(".filee-", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "desktop.ini", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "thumbs.db", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var isTemp = path.StartsWith(Path.GetTempPath(), StringComparison.OrdinalIgnoreCase); + + // Ignored system and build directories + var parts = path.Split(PathSeparators, StringSplitOptions.RemoveEmptyEntries); + foreach (var part in parts) + { + if (part.StartsWith('.') && part.Length > 1) + return true; + if ((!isTemp && part.Equals("AppData", StringComparison.OrdinalIgnoreCase)) + || part.Equals("node_modules", StringComparison.OrdinalIgnoreCase) + || part.Equals("bin", StringComparison.OrdinalIgnoreCase) + || part.Equals("obj", StringComparison.OrdinalIgnoreCase) + || part.Equals("$Recycle.Bin", StringComparison.OrdinalIgnoreCase) + || part.Equals("System Volume Information", StringComparison.OrdinalIgnoreCase) + || part.Equals("Windows", StringComparison.OrdinalIgnoreCase) + || (!isTemp && part.Equals("Library", StringComparison.OrdinalIgnoreCase)) + || part.Equals("proc", StringComparison.OrdinalIgnoreCase) + || part.Equals("sys", StringComparison.OrdinalIgnoreCase) + || part.Equals("dev", StringComparison.OrdinalIgnoreCase) + || part.Equals("run", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// Waits for a file to become accessible, non-empty, and stable in size. + public static async Task WaitForFileReadyAsync(string path, TimeSpan timeout, CancellationToken cancellationToken = default) + { + var deadline = DateTime.UtcNow + timeout; + long lastLength = -1; + var stableCount = 0; + var consecutiveDenied = 0; + + while (DateTime.UtcNow < deadline && !cancellationToken.IsCancellationRequested) + { + try + { + if (!File.Exists(path)) + return false; + + var info = new FileInfo(path); + var length = info.Length; + + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + consecutiveDenied = 0; + if (length == lastLength && length > 0) + { + stableCount++; + if (stableCount >= 2) + return true; + } + else if (length > 0) + { + lastLength = length; + stableCount = 1; + } + } + } + catch (IOException) + { + stableCount = 0; + consecutiveDenied = 0; + } + catch (UnauthorizedAccessException) + { + stableCount = 0; + consecutiveDenied++; + // Stop waiting if access is repeatedly declined for over a second + if (consecutiveDenied >= 10) + return false; + } + + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + + return stableCount >= 1 && File.Exists(path) && new FileInfo(path).Length > 0; + } + + private static byte[] ReadHeader(string path, int maxBytes) + { + try + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + var buffer = new byte[Math.Min(maxBytes, (int)Math.Min(stream.Length, maxBytes))]; + var read = stream.Read(buffer, 0, buffer.Length); + if (read < buffer.Length) + Array.Resize(ref buffer, read); + return buffer; + } + catch + { + return []; + } + } + + private static string GetBackupPath(string filePath, string sourceFormatId) + { + var dir = Path.GetDirectoryName(Path.GetFullPath(filePath)) ?? ""; + var nameWithoutExt = FormatRegistry.NameWithoutExtension(filePath); + var candidate = Path.Combine(dir, $"{nameWithoutExt}.original.{sourceFormatId}"); + if (!File.Exists(candidate)) + return candidate; + + for (var i = 2; i <= 100; i++) + { + candidate = Path.Combine(dir, $"{nameWithoutExt}.original ({i}).{sourceFormatId}"); + if (!File.Exists(candidate)) + return candidate; + } + + return Path.Combine(dir, $"{nameWithoutExt}.original.{Guid.NewGuid().ToString("N")[..6]}.{sourceFormatId}"); + } + + public ValueTask DisposeAsync() + { + _disposed = true; + Stop(); + return ValueTask.CompletedTask; + } +} diff --git a/tests/Filee.App.Tests/ExtensionChangeSettingsTests.cs b/tests/Filee.App.Tests/ExtensionChangeSettingsTests.cs new file mode 100644 index 000000000..b7055b1ae --- /dev/null +++ b/tests/Filee.App.Tests/ExtensionChangeSettingsTests.cs @@ -0,0 +1,74 @@ +using Filee.App.ViewModels.Pages; +using Filee.Core.Localization; +using Filee.Core.Platform; +using Filee.Core.Settings; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Filee.App.Tests; + +public sealed class ExtensionChangeSettingsTests +{ + private sealed class MockLocalizer : ILocalizer + { + public string Language => "en"; + public event EventHandler? LanguageChanged { add { } remove { } } + public string this[string key] => key; + public string Format(string key, params object[] args) => string.Format(key, args); + } + + [Fact] + public void Settings_defaults_and_toggle_behavior() + { + var tempDir = Path.Combine(Path.GetTempPath(), "FileeSettingsTest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var store = new UserDataStore(tempDir, NullLogger.Instance); + Assert.False(store.Settings.ConvertOnExtensionChange); + Assert.True(store.Settings.ConfirmExtensionChange); + Assert.Equal(ExtensionChangeScope.WatchFolders, store.Settings.ExtensionChangeScope); + + var loc = new MockLocalizer(); + var vm = new GeneralPageViewModel(store, loc, new NullPlatformServices(), NullLogger.Instance); + + Assert.False(vm.ConvertOnExtensionChange); + Assert.True(vm.ConfirmExtensionChange); + Assert.True(vm.ScopeWatchFolders); + Assert.False(vm.ScopeEntireSystem); + + // Toggle ConvertOnExtensionChange + vm.ConvertOnExtensionChange = true; + Assert.True(store.Settings.ConvertOnExtensionChange); + + // Toggle ConfirmExtensionChange + vm.ConfirmExtensionChange = false; + Assert.False(store.Settings.ConfirmExtensionChange); + + // Toggle Scope to EntireSystem + vm.ScopeEntireSystem = true; + Assert.True(vm.ScopeEntireSystem); + Assert.False(vm.ScopeWatchFolders); + Assert.Equal(ExtensionChangeScope.EntireSystem, store.Settings.ExtensionChangeScope); + + // Toggle Scope back to WatchFolders + vm.ScopeWatchFolders = true; + Assert.True(vm.ScopeWatchFolders); + Assert.False(vm.ScopeEntireSystem); + Assert.Equal(ExtensionChangeScope.WatchFolders, store.Settings.ExtensionChangeScope); + + // React to external change (e.g. from dialog or external setting reload) + store.Settings.ConfirmExtensionChange = true; + store.Settings.ExtensionChangeScope = ExtensionChangeScope.EntireSystem; + store.SaveSettings(); + + Assert.True(vm.ConfirmExtensionChange); + Assert.True(vm.ScopeEntireSystem); + Assert.False(vm.ScopeWatchFolders); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } +} diff --git a/tests/Filee.Core.Tests/ExtensionChangeWatcherTests.cs b/tests/Filee.Core.Tests/ExtensionChangeWatcherTests.cs new file mode 100644 index 000000000..65dbcba3a --- /dev/null +++ b/tests/Filee.Core.Tests/ExtensionChangeWatcherTests.cs @@ -0,0 +1,430 @@ +using Filee.Core.Conversion; +using Filee.Core.Formats; +using Filee.Core.Platform; +using Filee.Core.Watching; +using Xunit; + +namespace Filee.Core.Tests; + +public sealed class ExtensionChangeWatcherTests +{ + private static readonly byte[] PngHeader = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00]; + private static readonly byte[] PdfHeader = "%PDF-1.7 header mock content"u8.ToArray(); + + private sealed class DummyConverter : IConverter + { + public string Id => "dummy"; + public string DisplayName => "Dummy"; + public IReadOnlyList Edges => + [ + new("png", "pdf", 1), + new("jpg", "png", 1), + new("png", "webp", 1), + ]; + + public int MaxParallelism => 0; + public EngineStatus GetStatus() => new(true); + public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) => + Task.FromResult>([step.InputPath]); + } + + private static RoutePlanner CreatePlanner() => + new([new DummyConverter()]); + + [Fact] + public void ShouldIgnorePath_filters_system_and_temp_files() + { + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath(@"C:\folder\file.tmp")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath(@"C:\folder\.hidden")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath(@"/folder/.hidden")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath(@"C:\folder\file.crdownload")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath(@"C:\folder\test.filee-conv.tmp")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath(@"C:\folder\desktop.ini")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath(@"C:\AppData\Local\file.png")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath(@"C:\repo\.git\hooks\file.png")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath(@"/repo/.git/hooks/file.png")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath(@"C:\repo\bin\Debug\file.png")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath(@"/repo/bin/Debug/file.png")); + + Assert.False(ExtensionChangeWatcher.ShouldIgnorePath(@"C:\Users\User\Documents\report.pdf")); + Assert.False(ExtensionChangeWatcher.ShouldIgnorePath(@"/home/user/desktop/photo.png")); + } + + [Fact] + public async Task HandleRename_ignores_same_extension() + { + var watcher = new ExtensionChangeWatcher(CreatePlanner, (_, _, _, _) => Task.FromResult(true)); + var candidate = await watcher.HandleRenameAsync(@"C:\folder\photo1.png", @"C:\folder\photo2.png", TestContext.Current.CancellationToken); + Assert.Null(candidate); + } + + [Fact] + public async Task HandleRename_ignores_unsupported_target_format() + { + var watcher = new ExtensionChangeWatcher(CreatePlanner, (_, _, _, _) => Task.FromResult(true)); + var candidate = await watcher.HandleRenameAsync(@"C:\folder\photo.png", @"C:\folder\photo.xyzrandom", TestContext.Current.CancellationToken); + Assert.Null(candidate); + } + + [Fact] + public async Task HandleRename_ignores_when_no_conversion_route_exists() + { + // DummyConverter has no route from png to mp4 + var watcher = new ExtensionChangeWatcher(CreatePlanner, (_, _, _, _) => Task.FromResult(true)); + var candidate = await watcher.HandleRenameAsync(@"C:\folder\photo.png", @"C:\folder\photo.mp4", TestContext.Current.CancellationToken); + Assert.Null(candidate); + } + + [Fact] + public async Task HandleRename_ignores_paths_currently_in_progress() + { + var watcher = new ExtensionChangeWatcher(CreatePlanner, (_, _, _, _) => Task.FromResult(true)); + var path = @"C:\folder\busy.pdf"; + watcher.MarkInProgress(path); + + Assert.True(watcher.IsInProgress(path)); + var candidate = await watcher.HandleRenameAsync(@"C:\folder\busy.png", path, TestContext.Current.CancellationToken); + Assert.Null(candidate); + + watcher.UnmarkInProgress(path); + Assert.False(watcher.IsInProgress(path)); + } + + [Fact] + public async Task HandleRename_ignores_file_if_already_matching_target_signature() + { + var dir = Path.Combine(Path.GetTempPath(), "FileeWatcherTest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var oldPath = Path.Combine(dir, "doc.png"); + var newPath = Path.Combine(dir, "doc.pdf"); + + // Write PDF bytes even though old name was png + await File.WriteAllBytesAsync(newPath, PdfHeader, TestContext.Current.CancellationToken); + + var watcher = new ExtensionChangeWatcher(CreatePlanner, (_, _, _, _) => Task.FromResult(true), settleTimeout: TimeSpan.FromSeconds(1)); + var candidate = await watcher.HandleRenameAsync(oldPath, newPath, TestContext.Current.CancellationToken); + + // Sniff shows it is already a PDF internally, so it should NOT convert! + Assert.Null(candidate); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task HandleRename_detects_valid_candidate_when_signature_is_png_and_target_is_pdf() + { + var dir = Path.Combine(Path.GetTempPath(), "FileeWatcherTest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var oldPath = Path.Combine(dir, "doc.png"); + var newPath = Path.Combine(dir, "doc.pdf"); + + // Write PNG bytes (the user renamed doc.png to doc.pdf, so doc.pdf has PNG data inside) + await File.WriteAllBytesAsync(newPath, PngHeader, TestContext.Current.CancellationToken); + + var detected = false; + var watcher = new ExtensionChangeWatcher( + CreatePlanner, + (_, _, _, _) => Task.FromResult(true), + settleTimeout: TimeSpan.FromSeconds(1)); + + watcher.ExtensionChangeDetected += c => + { + detected = true; + Assert.Equal("png", c.SourceFormat.Id); + Assert.Equal("pdf", c.TargetFormat.Id); + Assert.Equal("doc.pdf", c.FileName); + return Task.CompletedTask; + }; + + var candidate = await watcher.HandleRenameAsync(oldPath, newPath, TestContext.Current.CancellationToken); + Assert.NotNull(candidate); + Assert.True(detected); + Assert.Equal("png", candidate.SourceFormat.Id); + Assert.Equal("pdf", candidate.TargetFormat.Id); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task ConvertSafely_converts_and_atomically_replaces_target_file() + { + var dir = Path.Combine(Path.GetTempPath(), "FileeWatcherTest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var filePath = Path.Combine(dir, "photo.pdf"); + await File.WriteAllBytesAsync(filePath, PngHeader, TestContext.Current.CancellationToken); + + var watcher = new ExtensionChangeWatcher( + CreatePlanner, + async (_, _, output, ct) => + { + // Mock conversion producing valid PDF + await File.WriteAllBytesAsync(output, PdfHeader, ct); + return true; + }); + + var pngFormat = FormatRegistry.Get("png"); + var pdfFormat = FormatRegistry.Get("pdf"); + + var success = await watcher.ConvertSafelyAsync(filePath, pngFormat, pdfFormat, TestContext.Current.CancellationToken); + Assert.True(success); + + // Verify file was replaced with PDF content + var finalBytes = await File.ReadAllBytesAsync(filePath, TestContext.Current.CancellationToken); + Assert.Equal(PdfHeader, finalBytes); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task ConvertSafely_preserves_original_file_if_conversion_fails() + { + var dir = Path.Combine(Path.GetTempPath(), "FileeWatcherTest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var filePath = Path.Combine(dir, "photo.pdf"); + await File.WriteAllBytesAsync(filePath, PngHeader, TestContext.Current.CancellationToken); + + var watcher = new ExtensionChangeWatcher( + CreatePlanner, + (_, _, _, _) => Task.FromResult(false)); // Conversion fails + + var pngFormat = FormatRegistry.Get("png"); + var pdfFormat = FormatRegistry.Get("pdf"); + + var success = await watcher.ConvertSafelyAsync(filePath, pngFormat, pdfFormat, TestContext.Current.CancellationToken); + Assert.False(success); + + // Verify file was preserved with original PNG bytes + var finalBytes = await File.ReadAllBytesAsync(filePath, TestContext.Current.CancellationToken); + Assert.Equal(PngHeader, finalBytes); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task ConvertSafely_succeeds_even_when_destination_file_is_read_only() + { + var dir = Path.Combine(Path.GetTempPath(), "FileeWatcherTest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var filePath = Path.Combine(dir, "photo.pdf"); + await File.WriteAllBytesAsync(filePath, PngHeader, TestContext.Current.CancellationToken); + + // Mark destination file as ReadOnly (which normally causes UnauthorizedAccessException on overwrite) + File.SetAttributes(filePath, FileAttributes.ReadOnly); + + var watcher = new ExtensionChangeWatcher( + CreatePlanner, + async (_, _, output, ct) => + { + await File.WriteAllBytesAsync(output, PdfHeader, ct); + return true; + }); + + var pngFormat = FormatRegistry.Get("png"); + var pdfFormat = FormatRegistry.Get("pdf"); + + var success = await watcher.ConvertSafelyAsync(filePath, pngFormat, pdfFormat, TestContext.Current.CancellationToken); + Assert.True(success); + + // Verify file was replaced with PDF content + var finalBytes = await File.ReadAllBytesAsync(filePath, TestContext.Current.CancellationToken); + Assert.Equal(PdfHeader, finalBytes); + } + finally + { + // Clear ReadOnly attribute before directory deletion + var filePath = Path.Combine(dir, "photo.pdf"); + if (File.Exists(filePath)) + { + File.SetAttributes(filePath, FileAttributes.Normal); + } + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void ShouldIgnorePath_filters_unix_virtual_and_system_files() + { + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath("/proc/1/mounts")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath("/sys/devices/system/cpu")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath("/dev/shm/file.png")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath("/Users/User/._file.png")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath("/home/user/.cache/file.png")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath("/Users/User/Library/Caches/file.png")); + + Assert.False(ExtensionChangeWatcher.ShouldIgnorePath("/home/user/Downloads/document.pdf")); + Assert.False(ExtensionChangeWatcher.ShouldIgnorePath("/Users/User/Desktop/photo.jpg")); + } + + [Fact] + public async Task ConvertSafely_moves_original_to_trash_when_trash_available() + { + var dir = Path.Combine(Path.GetTempPath(), "FileeWatcherTest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var filePath = Path.Combine(dir, "photo.pdf"); + await File.WriteAllBytesAsync(filePath, PngHeader, TestContext.Current.CancellationToken); + + string? trashedPath = null; + TrashRefusal? MockTrash(string path) + { + trashedPath = path; + File.Delete(path); + return null; + } + + var watcher = new ExtensionChangeWatcher( + CreatePlanner, + async (_, _, output, ct) => + { + await File.WriteAllBytesAsync(output, PdfHeader, ct); + return true; + }, + moveToTrash: MockTrash); + + var pngFormat = FormatRegistry.Get("png"); + var pdfFormat = FormatRegistry.Get("pdf"); + + var success = await watcher.ConvertSafelyAsync(filePath, pngFormat, pdfFormat, TestContext.Current.CancellationToken); + Assert.True(success); + + Assert.Equal(filePath, trashedPath); + + var finalBytes = await File.ReadAllBytesAsync(filePath, TestContext.Current.CancellationToken); + Assert.Equal(PdfHeader, finalBytes); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task ConvertSafely_preserves_original_file_as_backup_when_trash_is_not_available() + { + var dir = Path.Combine(Path.GetTempPath(), "FileeWatcherTest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var filePath = Path.Combine(dir, "photo.pdf"); + await File.WriteAllBytesAsync(filePath, PngHeader, TestContext.Current.CancellationToken); + + var watcher = new ExtensionChangeWatcher( + CreatePlanner, + async (_, _, output, ct) => + { + await File.WriteAllBytesAsync(output, PdfHeader, ct); + return true; + }, + moveToTrash: null); + + var pngFormat = FormatRegistry.Get("png"); + var pdfFormat = FormatRegistry.Get("pdf"); + + var success = await watcher.ConvertSafelyAsync(filePath, pngFormat, pdfFormat, TestContext.Current.CancellationToken); + Assert.True(success); + + var backupPath = Path.Combine(dir, "photo.original.png"); + Assert.True(File.Exists(backupPath)); + var backupBytes = await File.ReadAllBytesAsync(backupPath, TestContext.Current.CancellationToken); + Assert.Equal(PngHeader, backupBytes); + + var finalBytes = await File.ReadAllBytesAsync(filePath, TestContext.Current.CancellationToken); + Assert.Equal(PdfHeader, finalBytes); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task WaitForFileReady_waits_for_locked_file_to_become_accessible() + { + var dir = Path.Combine(Path.GetTempPath(), "FileeWatcherTest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var filePath = Path.Combine(dir, "locked.pdf"); + await File.WriteAllBytesAsync(filePath, PngHeader, TestContext.Current.CancellationToken); + + var stream = new FileStream(filePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + _ = Task.Run(async () => + { + await Task.Delay(300, TestContext.Current.CancellationToken); + stream.Dispose(); + }, TestContext.Current.CancellationToken); + + var ready = await ExtensionChangeWatcher.WaitForFileReadyAsync(filePath, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + Assert.True(ready); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task Real_FileSystemWatcher_detects_extension_rename_on_disk() + { + var dir = Path.Combine(Path.GetTempPath(), "FileeWatcherTest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var watcher = new ExtensionChangeWatcher(CreatePlanner, (_, _, _, _) => Task.FromResult(true), settleTimeout: TimeSpan.FromSeconds(2)); + try + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + watcher.ExtensionChangeDetected += candidate => + { + tcs.TrySetResult(candidate); + return Task.CompletedTask; + }; + + watcher.Start([dir]); + + var oldPath = Path.Combine(dir, "sample.png"); + var newPath = Path.Combine(dir, "sample.pdf"); + + await File.WriteAllBytesAsync(oldPath, PngHeader, TestContext.Current.CancellationToken); + await Task.Delay(200, TestContext.Current.CancellationToken); + + File.Move(oldPath, newPath); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var detected = await tcs.Task.WaitAsync(cts.Token); + + Assert.NotNull(detected); + Assert.Equal("png", detected.SourceFormat.Id); + Assert.Equal("pdf", detected.TargetFormat.Id); + Assert.Equal("sample.pdf", detected.FileName); + } + finally + { + watcher.Stop(); + await watcher.DisposeAsync(); + Directory.Delete(dir, recursive: true); + } + } +}