From fc452e7d46f8839710c044fff51e61f8949192ff Mon Sep 17 00:00:00 2001 From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:10:26 +0530 Subject: [PATCH 1/4] Add "Replace the original" to presets (#332) Off by default. For a finished result of the source's own format, the original goes to the Recycle Bin / Trash and the result takes its name and place. When the original can't go there (no Recycle Bin on the drive, a network share, a file open elsewhere, the command line), both are kept and the file's notice says why. Watch folders and files from a browser never replace. - Windows: SHFileOperation with undo, only on local fixed drives, and a file open in another program is refused before Windows is asked. - macOS: NSFileManager trashItemAtURL. - Linux: the freedesktop.org home Trash with a .trashinfo record, only on the same filesystem. --- docs/ARCHITECTURE.md | 5 + src/Filee.App/Assets/i18n/ar.json | 4 + src/Filee.App/Assets/i18n/en.json | 4 + src/Filee.App/Assets/i18n/es.json | 4 + src/Filee.App/Assets/i18n/fr.json | 4 + src/Filee.App/Assets/i18n/hi.json | 4 + src/Filee.App/Assets/i18n/ja.json | 4 + src/Filee.App/Assets/i18n/ko.json | 4 + src/Filee.App/Assets/i18n/ne.json | 4 + src/Filee.App/Assets/i18n/pl.json | 4 + src/Filee.App/Assets/i18n/pt-BR.json | 4 + src/Filee.App/Assets/i18n/ru.json | 4 + src/Filee.App/Assets/i18n/th.json | 4 + src/Filee.App/Assets/i18n/zh-CN.json | 4 + src/Filee.App/Assets/i18n/zh-TW.json | 4 + src/Filee.App/Services/AppHost.cs | 3 +- src/Filee.App/ViewModels/JobViewModel.cs | 2 +- .../ViewModels/PresetEditorViewModel.cs | 4 + src/Filee.App/Views/PresetEditorView.axaml | 5 + src/Filee.Cli/Cli.cs | 7 +- src/Filee.Core/Conversion/ConversionJob.cs | 3 + src/Filee.Core/Conversion/JobQueue.cs | 43 ++++- src/Filee.Core/Platform/IPlatformServices.cs | 6 + src/Filee.Core/Presets/OutputRule.cs | 8 + src/Filee.Core/Watching/WatchRule.cs | 2 + src/Filee.Platform.Linux/FreedesktopTrash.cs | 72 ++++++++ .../LinuxPlatformServices.cs | 2 + .../MacOSPlatformServices.cs | 2 + src/Filee.Platform.MacOS/NativeMethods.cs | 28 ++++ src/Filee.Platform.Windows/RecycleBin.cs | 69 ++++++++ .../WindowsPlatformServices.cs | 2 + tests/Filee.App.Tests/RenderTests.cs | 15 ++ .../Filee.Core.Tests/ReplaceOriginalTests.cs | 158 ++++++++++++++++++ tests/Filee.Platform.Tests/TrashTests.cs | 85 ++++++++++ 34 files changed, 572 insertions(+), 5 deletions(-) create mode 100644 src/Filee.Platform.Linux/FreedesktopTrash.cs create mode 100644 src/Filee.Platform.Windows/RecycleBin.cs create mode 100644 tests/Filee.Core.Tests/ReplaceOriginalTests.cs create mode 100644 tests/Filee.Platform.Tests/TrashTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5cd601a26..2de410787 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -48,6 +48,11 @@ Each name stays reserved while a conversion holds it. A conversion that fails or is cancelled deletes what it began writing, but only at a path that was its alone: nothing was there before and no other conversion was handed the same name ("Overwrite"). It frees its names and removes an output folder it made if that is left empty. + *Replace the original* (`OutputRule.ReplaceOriginal`, off by default) comes after that, for a finished result of + the source's own format: the source goes to the Recycle Bin / Trash (`IPlatformServices.MoveToTrash`) and the + result is moved to its path. When it can't go there (no Recycle Bin on the drive, a network share, a file open + elsewhere, the command line), both stay and the file's notice says why. Watch folders and files from a browser + never replace. "Merge into one PDF" converts each file to PDF and merges with PDFsharp. "Compress into one archive" packs the dropped files, unconverted, with the archive engine (`IFileCombiner`). "Merge videos", "Merge audio" and "Video from images" join the files, in the order picked in the merge order diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index e97ca3ee5..b6b322fbb 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -104,6 +104,8 @@ "error.geo_unsupported_shape": "هذا النوع من الأشكال غير مدعوم", "error.geo_too_large": "سيتجاوز ملف Shapefile حد الصيغة البالغ 2 غيغابايت", "notice.pdf_not_smaller": "تعذّر تقليل الحجم، لذا نُسخ الملف كما هو", + "notice.original_kept": "تم الاحتفاظ بالأصل وحُفظت النتيجة كنسخة", + "notice.original_in_trash": "الأصل في سلة المحذوفات، واحتفظت النتيجة باسمها", "notice.empty_spreadsheet_pages_removed": "تمت إزالة صفحات جدول البيانات الفارغة", "notice.live_photo_pair": "حُفظ كصورة وملف MOV بالاسم نفسه. استوردهما معًا (صور iCloud أو AirDrop أو تطبيق الصور على Mac) لتحصل على Live Photo", "notice.geo_datum_assumed": "المرجع الجيوديسي ليس WGS 84؛ حُوّلت المواقع على افتراض أنه كذلك، وقد تنحرف بمقدار يصل إلى مئات الأمتار", @@ -604,6 +606,8 @@ "presets.keep_dates_hint": "تحصل الملفات المحوّلة على تاريخَي إنشاء الأصل وتعديله، فتبقى الصور مرتبة حسب التاريخ.", "presets.zip_result": "حفظ النتيجة بصيغة ZIP", "presets.zip_result_hint": "تُجمَّع ملفات النتيجة (مثل ‎.shp و‎.shx و‎.dbf و‎.prj و‎.cpg في Shapefile) في ZIP واحد بجانب الأصل، بدلًا من ملفات منفصلة.", + "presets.replace_original": "استبدال الأصل", + "presets.replace_original_hint": "تأخذ النتيجة اسم الأصل ومكانه، وينتقل الأصل إلى سلة المحذوفات. فقط عندما يكون للنتيجة التنسيق نفسه، ولا يحدث أبدًا لمجلدات المراقبة أو الملفات القادمة من متصفح. إذا تعذّر نقل الأصل، يبقى في مكانه وتُحفظ النتيجة كنسخة.", "presets.conflict": "إذا كان الملف موجودًا", "presets.conflict.Rename": "إضافة رقم", "presets.conflict.Overwrite": "استبداله", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index a8576b230..426e2c626 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -110,6 +110,8 @@ "error.geo_unsupported_shape": "This kind of shape isn't supported", "error.geo_too_large": "The Shapefile would pass the format's 2 GB limit", "notice.pdf_not_smaller": "Couldn't make it smaller, so it was copied as it is", + "notice.original_kept": "The original was kept, the result was saved as a copy", + "notice.original_in_trash": "The original is in the Recycle Bin / Trash, the result kept its own name", "notice.empty_spreadsheet_pages_removed": "Empty spreadsheet pages were removed", "notice.live_photo_pair": "Saved as a photo and a MOV with the same name. Import both together (iCloud Photos, AirDrop or Photos on a Mac) to get a Live Photo", "notice.geo_datum_assumed": "The datum isn't WGS 84; positions were converted as if it were and can be off by up to hundreds of metres", @@ -619,6 +621,8 @@ "presets.keep_dates_hint": "Converted files get the created and modified dates of the original, so photos still sort by date.", "presets.zip_result": "Save the result as a ZIP", "presets.zip_result_hint": "The files of the result (a Shapefile's .shp, .shx, .dbf, .prj and .cpg) are packed into one ZIP next to the source, in place of loose files.", + "presets.replace_original": "Replace the original", + "presets.replace_original_hint": "The result takes the original's name and place, and the original goes to the Recycle Bin / Trash. Only when the result has the same format, never for watch folders or files from a browser. When the original can't go there, it stays and the result is saved as a copy.", "presets.conflict": "If the file exists", "presets.conflict.Rename": "Add a number", "presets.conflict.Overwrite": "Replace it", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 91298bff1..86a5d3b65 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -104,6 +104,8 @@ "error.geo_unsupported_shape": "Este tipo de forma no es compatible", "error.geo_too_large": "El Shapefile superaría el límite de 2 GB del formato", "notice.pdf_not_smaller": "No se pudo reducir su tamaño, por lo que se copió tal cual", + "notice.original_kept": "Se conservó el original, el resultado se guardó como copia", + "notice.original_in_trash": "El original está en la Papelera, el resultado conservó su propio nombre", "notice.empty_spreadsheet_pages_removed": "Se eliminaron las páginas vacías de la hoja de cálculo", "notice.live_photo_pair": "Guardado como una foto y un MOV con el mismo nombre. Impórtalos juntos (Fotos de iCloud, AirDrop o Fotos en un Mac) para obtener una Live Photo", "notice.geo_datum_assumed": "El datum no es WGS 84; las posiciones se convirtieron como si lo fuera y pueden desviarse hasta cientos de metros", @@ -604,6 +606,8 @@ "presets.keep_dates_hint": "Los archivos convertidos reciben las fechas de creación y modificación del original, así que las fotos siguen ordenándose por fecha.", "presets.zip_result": "Guardar el resultado como ZIP", "presets.zip_result_hint": "Los archivos del resultado (.shp, .shx, .dbf, .prj y .cpg de un Shapefile) se empaquetan en un solo ZIP junto al origen, en lugar de archivos sueltos.", + "presets.replace_original": "Reemplazar el original", + "presets.replace_original_hint": "El resultado toma el nombre y el lugar del original, y el original va a la Papelera de reciclaje. Solo cuando el resultado tiene el mismo formato, nunca en carpetas vigiladas ni con archivos de un navegador. Si el original no puede ir a la Papelera, se queda y el resultado se guarda como copia.", "presets.conflict": "Si el archivo existe", "presets.conflict.Rename": "Añadir un número", "presets.conflict.Overwrite": "Reemplazarlo", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index c82921d56..ba62d5e75 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -110,6 +110,8 @@ "error.geo_unsupported_shape": "Ce type de forme n'est pas pris en charge", "error.geo_too_large": "Le Shapefile dépasserait la limite de 2 Go du format", "notice.pdf_not_smaller": "Impossible de réduire sa taille : le fichier a été copié tel quel", + "notice.original_kept": "L'original a été conservé, le résultat a été enregistré en copie", + "notice.original_in_trash": "L'original est dans la Corbeille, le résultat a gardé son propre nom", "notice.empty_spreadsheet_pages_removed": "Les pages vides de la feuille de calcul ont été supprimées", "notice.live_photo_pair": "Enregistré en une photo et un MOV de même nom. Importez-les ensemble (Photos iCloud, AirDrop ou Photos sur Mac) pour obtenir une Live Photo", "notice.geo_datum_assumed": "Le datum n'est pas WGS 84 ; les positions ont été converties comme s'il l'était et peuvent être décalées de plusieurs centaines de mètres", @@ -619,6 +621,8 @@ "presets.keep_dates_hint": "Les fichiers convertis reprennent les dates de création et de modification de l'original : les photos restent triées par date.", "presets.zip_result": "Enregistrer le résultat en ZIP", "presets.zip_result_hint": "Les fichiers du résultat (les .shp, .shx, .dbf, .prj et .cpg d'un Shapefile) sont regroupés dans un seul ZIP à côté de l'original, au lieu de fichiers séparés.", + "presets.replace_original": "Remplacer l'original", + "presets.replace_original_hint": "Le résultat prend le nom et la place de l'original, et l'original va dans la Corbeille. Seulement quand le résultat a le même format, jamais pour les dossiers surveillés ni les fichiers venant d'un navigateur. Si l'original ne peut pas aller dans la Corbeille, il reste et le résultat est enregistré en copie.", "presets.conflict": "Si le fichier existe", "presets.conflict.Rename": "Ajouter un numéro", "presets.conflict.Overwrite": "Le remplacer", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index d72aeadb5..61c196cbc 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -104,6 +104,8 @@ "error.geo_unsupported_shape": "इस तरह का आकार समर्थित नहीं है", "error.geo_too_large": "Shapefile फ़ॉर्मैट की 2 GB सीमा से बड़ी हो जाएगी", "notice.pdf_not_smaller": "इसे छोटा नहीं किया जा सका, इसलिए जैसा है वैसा ही कॉपी कर दिया गया", + "notice.original_kept": "मूल फ़ाइल रखी गई, परिणाम कॉपी के रूप में सहेजा गया", + "notice.original_in_trash": "मूल फ़ाइल रीसायकल बिन / ट्रैश में है, परिणाम ने अपना नाम रखा", "notice.empty_spreadsheet_pages_removed": "स्प्रेडशीट के खाली पृष्ठ हटा दिए गए", "notice.live_photo_pair": "एक ही नाम की फ़ोटो और MOV के रूप में सहेजा गया। लाइव फ़ोटो पाने के लिए दोनों को साथ में इम्पोर्ट करें (iCloud Photos, AirDrop या Mac पर Photos)", "notice.geo_datum_assumed": "डेटम WGS 84 नहीं है। स्थितियाँ ऐसे कन्वर्ट की गईं मानो वह WGS 84 हो, इसलिए वे कुछ सौ मीटर तक खिसक सकती हैं", @@ -604,6 +606,8 @@ "presets.keep_dates_hint": "कन्वर्ट की गई फ़ाइलों को मूल फ़ाइल की बनाने और बदलने की तारीखें मिलती हैं, ताकि फ़ोटो अब भी तारीख के अनुसार क्रमित रहें।", "presets.zip_result": "नतीजे को ZIP के रूप में सहेजें", "presets.zip_result_hint": "नतीजे की फ़ाइलें (Shapefile की .shp, .shx, .dbf, .prj और .cpg) अलग-अलग रखने के बजाय स्रोत के बगल में एक ZIP में पैक की जाती हैं।", + "presets.replace_original": "मूल फ़ाइल बदलें", + "presets.replace_original_hint": "परिणाम मूल फ़ाइल का नाम और जगह ले लेता है, और मूल फ़ाइल रीसायकल बिन / ट्रैश में चली जाती है। केवल तब जब परिणाम का फ़ॉर्मैट वही हो, वॉच फ़ोल्डर या ब्राउज़र से आई फ़ाइलों के लिए कभी नहीं। अगर मूल फ़ाइल वहाँ नहीं जा सकती, तो वह रहती है और परिणाम एक कॉपी के रूप में सहेजा जाता है।", "presets.conflict": "अगर फ़ाइल पहले से मौजूद हो", "presets.conflict.Rename": "नंबर जोड़ें", "presets.conflict.Overwrite": "बदल दें", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 2bf8a1fae..e5cfa1495 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -104,6 +104,8 @@ "error.geo_unsupported_shape": "この種類の図形には対応していません", "error.geo_too_large": "Shapefile形式の上限(2 GB)を超えてしまいます", "notice.pdf_not_smaller": "サイズを小さくできなかったため、元のままコピーしました", + "notice.original_kept": "元のファイルを残し、結果をコピーとして保存しました", + "notice.original_in_trash": "元のファイルはごみ箱にあります。結果は別の名前のままです", "notice.empty_spreadsheet_pages_removed": "スプレッドシートの空白ページを削除しました", "notice.live_photo_pair": "同じ名前の写真とMOVとして保存しました。両方を一緒に取り込むと(iCloud写真、AirDrop、Macの写真アプリ)Live Photoになります", "notice.geo_datum_assumed": "測地系がWGS 84ではありません。WGS 84とみなして変換したため、位置が最大で数百メートルずれることがあります", @@ -604,6 +606,8 @@ "presets.keep_dates_hint": "変換後のファイルに元ファイルの作成日時と更新日時を引き継ぎます。変換後も写真を日付順に並べられます。", "presets.zip_result": "変換結果をZIPで保存", "presets.zip_result_hint": "変換結果のファイル(Shapefileの.shp、.shx、.dbf、.prj、.cpg)を、ばらばらに置く代わりに元ファイルの隣の1つのZIPにまとめます。", + "presets.replace_original": "元のファイルを置き換える", + "presets.replace_original_hint": "変換結果が元のファイルの名前と場所を引き継ぎ、元のファイルはごみ箱に移動します。結果が同じ形式のときだけで、監視フォルダーやブラウザーからのファイルには使われません。元のファイルをごみ箱に移動できないときは残し、結果はコピーとして保存します。", "presets.conflict": "同名ファイルがある場合", "presets.conflict.Rename": "連番を付ける", "presets.conflict.Overwrite": "上書き", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 19aaab67b..f619fd023 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -110,6 +110,8 @@ "error.geo_unsupported_shape": "지원하지 않는 도형 종류예요", "error.geo_too_large": "Shapefile의 한계인 2GB를 넘어요", "notice.pdf_not_smaller": "더 작게 만들 수 없어서 그대로 복사했어요", + "notice.original_kept": "원본을 그대로 두고 결과를 사본으로 저장했습니다", + "notice.original_in_trash": "원본은 휴지통에 있고 결과는 자기 이름을 유지했습니다", "notice.empty_spreadsheet_pages_removed": "스프레드시트의 빈 페이지를 제거했어요", "notice.live_photo_pair": "같은 이름의 사진과 MOV로 저장했어요. 두 파일을 함께 가져오면(iCloud 사진, AirDrop, Mac의 사진 앱) 라이브 포토가 돼요", "notice.geo_datum_assumed": "측지계가 WGS 84가 아니라서 같은 것으로 보고 변환했어요. 위치가 수백 m까지 어긋날 수 있어요", @@ -619,6 +621,8 @@ "presets.keep_dates_hint": "변환한 파일에 원본의 만든 날짜와 수정한 날짜를 그대로 넣어요.\n그래서 사진을 날짜순으로 정렬해도 순서가 바뀌지 않아요.", "presets.zip_result": "결과를 ZIP으로 저장", "presets.zip_result_hint": "결과 파일들(Shapefile의 .shp, .shx, .dbf, .prj, .cpg)을 낱개로 두지 않고 원본 옆에 ZIP 하나로 묶어요.", + "presets.replace_original": "원본 바꾸기", + "presets.replace_original_hint": "결과가 원본의 이름과 위치를 가져가고 원본은 휴지통으로 이동합니다. 결과가 같은 형식일 때만 적용되며, 감시 폴더나 브라우저에서 가져온 파일에는 적용되지 않습니다. 원본을 휴지통으로 옮길 수 없으면 원본은 그대로 두고 결과를 사본으로 저장합니다.", "presets.conflict": "같은 파일이 있으면", "presets.conflict.Rename": "번호 붙이기", "presets.conflict.Overwrite": "덮어쓰기", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index a1c291ffb..d2f84a484 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -104,6 +104,8 @@ "error.geo_unsupported_shape": "यस प्रकारको आकार समर्थित छैन", "error.geo_too_large": "Shapefile ढाँचाको २ GB सीमा नाघ्नेछ", "notice.pdf_not_smaller": "यसलाई सानो बनाउन सकिएन, त्यसैले जस्तो छ त्यस्तै प्रतिलिपि गरियो", + "notice.original_kept": "मूल फाइल राखियो, नतिजा प्रतिलिपिको रूपमा सुरक्षित भयो", + "notice.original_in_trash": "मूल फाइल रिसाइकल बिन / ट्र्यासमा छ, नतिजाले आफ्नै नाम राख्यो", "notice.empty_spreadsheet_pages_removed": "स्प्रेडसिटका खाली पृष्ठहरू हटाइए", "notice.live_photo_pair": "एउटै नामको फोटो र MOV का रूपमा सुरक्षित गरियो। लाइभ फोटो पाउन दुवैलाई सँगै आयात गर्नुहोस् (iCloud Photos, AirDrop वा Mac मा Photos)", "notice.geo_datum_assumed": "डेटम WGS 84 होइन; स्थितिहरू यो WGS 84 भए जस्तै रूपान्तरण गरिए, जसले केही सय मिटरसम्म फरक पार्न सक्छ", @@ -604,6 +606,8 @@ "presets.keep_dates_hint": "रूपान्तरित फाइलहरूले मूल फाइलका सिर्जना र परिमार्जन मितिहरू पाउँछन्, जसले गर्दा फोटोहरू अझै मिति अनुसार क्रमबद्ध हुन्छन्।", "presets.zip_result": "परिणामलाई ZIP को रूपमा सुरक्षित गर्नुहोस्", "presets.zip_result_hint": "परिणामका फाइलहरू (Shapefile को .shp, .shx, .dbf, .prj र .cpg) छुट्टाछुट्टै राख्नुको सट्टा स्रोतको छेउमा एउटै ZIP मा प्याक गरिन्छ।", + "presets.replace_original": "मूल फाइल बदल्नुहोस्", + "presets.replace_original_hint": "नतिजाले मूल फाइलको नाम र ठाउँ लिन्छ, र मूल फाइल रिसाइकल बिन / ट्र्यासमा जान्छ। नतिजाको ढाँचा उही हुँदा मात्र, वाच फोल्डर वा ब्राउजरबाट आएका फाइलका लागि कहिल्यै होइन। मूल फाइल त्यहाँ जान नसके, त्यो रहन्छ र नतिजा प्रतिलिपिको रूपमा सुरक्षित हुन्छ।", "presets.conflict": "यदि फाइल पहिले नै अवस्थित छ भने", "presets.conflict.Rename": "नम्बर थप्नुहोस्", "presets.conflict.Overwrite": "बदल्नुहोस् (अधिलेखन)", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index e07632dc3..5a3ee6eca 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -110,6 +110,8 @@ "error.geo_unsupported_shape": "Ten rodzaj kształtu nie jest obsługiwany", "error.geo_too_large": "Shapefile przekroczyłby limit 2 GB tego formatu", "notice.pdf_not_smaller": "Nie udało się zmniejszyć pliku, więc skopiowano go bez zmian", + "notice.original_kept": "Oryginał został zachowany, wynik zapisano jako kopię", + "notice.original_in_trash": "Oryginał jest w Koszu, wynik zachował własną nazwę", "notice.empty_spreadsheet_pages_removed": "Usunięto puste strony arkusza kalkulacyjnego", "notice.live_photo_pair": "Zapisano jako zdjęcie i plik MOV o tej samej nazwie. Zaimportuj je razem (Zdjęcia iCloud, AirDrop lub Zdjęcia na Macu), aby otrzymać Live Photo", "notice.geo_datum_assumed": "Datum nie jest WGS 84; pozycje przeliczono tak, jakby było, więc mogą się różnić nawet o setki metrów", @@ -619,6 +621,8 @@ "presets.keep_dates_hint": "Przekonwertowane pliki dostają daty utworzenia i modyfikacji oryginału, więc zdjęcia nadal sortują się według daty.", "presets.zip_result": "Zapisz wynik jako ZIP", "presets.zip_result_hint": "Pliki wyniku (.shp, .shx, .dbf, .prj i .cpg Shapefile) są pakowane do jednego ZIP obok źródła zamiast luźnych plików.", + "presets.replace_original": "Zastąp oryginał", + "presets.replace_original_hint": "Wynik przejmuje nazwę i miejsce oryginału, a oryginał trafia do Kosza. Tylko gdy wynik ma ten sam format, nigdy dla obserwowanych folderów ani plików z przeglądarki. Jeśli oryginał nie może trafić do Kosza, zostaje, a wynik jest zapisywany jako kopia.", "presets.conflict": "Jeśli plik istnieje", "presets.conflict.Rename": "Dodaj numer", "presets.conflict.Overwrite": "Zastąp", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 596141226..43d209aac 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -110,6 +110,8 @@ "error.geo_unsupported_shape": "Este tipo de forma não é compatível", "error.geo_too_large": "O Shapefile passaria do limite de 2 GB do formato", "notice.pdf_not_smaller": "Não deu para deixar menor, então foi copiado como está", + "notice.original_kept": "O original foi mantido, o resultado foi salvo como cópia", + "notice.original_in_trash": "O original está na Lixeira, o resultado manteve o próprio nome", "notice.empty_spreadsheet_pages_removed": "As páginas vazias da planilha foram removidas", "notice.live_photo_pair": "Salvo como uma foto e um MOV com o mesmo nome. Importe os dois juntos (Fotos do iCloud, AirDrop ou Fotos no Mac) para obter uma Live Photo", "notice.geo_datum_assumed": "O datum não é WGS 84; as posições foram convertidas como se fosse, e podem errar em até centenas de metros", @@ -619,6 +621,8 @@ "presets.keep_dates_hint": "Os arquivos convertidos recebem as datas de criação e modificação do original, então as fotos continuam ordenadas por data.", "presets.zip_result": "Salvar o resultado como ZIP", "presets.zip_result_hint": "Os arquivos do resultado (os .shp, .shx, .dbf, .prj e .cpg de um Shapefile) são compactados num ZIP ao lado da origem, no lugar de arquivos soltos.", + "presets.replace_original": "Substituir o original", + "presets.replace_original_hint": "O resultado assume o nome e o lugar do original, e o original vai para a Lixeira. Só quando o resultado tem o mesmo formato, nunca para pastas monitoradas ou arquivos vindos de um navegador. Se o original não puder ir para a Lixeira, ele fica e o resultado é salvo como cópia.", "presets.conflict": "Se o arquivo existir", "presets.conflict.Rename": "Adicionar um número", "presets.conflict.Overwrite": "Substituir", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 5e8d9564e..e496bcbf8 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -110,6 +110,8 @@ "error.geo_unsupported_shape": "Такой тип фигуры не поддерживается", "error.geo_too_large": "Shapefile превысил бы предел формата в 2 ГБ", "notice.pdf_not_smaller": "Уменьшить не получилось, файл скопирован как есть", + "notice.original_kept": "Оригинал оставлен, результат сохранён как копия", + "notice.original_in_trash": "Оригинал в Корзине, результат сохранил своё имя", "notice.empty_spreadsheet_pages_removed": "Пустые страницы таблицы удалены", "notice.live_photo_pair": "Сохранено как фото и MOV с одинаковым именем. Импортируйте их вместе (Фото iCloud, AirDrop или «Фото» на Mac), чтобы получить Live Photo", "notice.geo_datum_assumed": "Датум отличается от WGS 84; координаты пересчитаны как для WGS 84 и могут сместиться на сотни метров", @@ -619,6 +621,8 @@ "presets.keep_dates_hint": "Сконвертированные файлы получают даты создания и изменения оригинала, так что фото по-прежнему сортируются по дате.", "presets.zip_result": "Сохранять результат в ZIP", "presets.zip_result_hint": "Файлы результата (у Shapefile это .shp, .shx, .dbf, .prj и .cpg) упаковываются в один ZIP рядом с исходником вместо отдельных файлов.", + "presets.replace_original": "Заменить оригинал", + "presets.replace_original_hint": "Результат получает имя и место оригинала, а оригинал отправляется в Корзину. Только если у результата тот же формат, никогда для отслеживаемых папок и файлов из браузера. Если оригинал нельзя отправить в Корзину, он остаётся, а результат сохраняется как копия.", "presets.conflict": "Если файл уже есть", "presets.conflict.Rename": "Добавить номер", "presets.conflict.Overwrite": "Заменить", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 21a267b96..a87757d4c 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -104,6 +104,8 @@ "error.geo_unsupported_shape": "ไม่รองรับรูปทรงเรขาคณิตประเภทนี้", "error.geo_too_large": "Shapefile จะมีขนาดเกินขีดจำกัดของฟอร์แมตที่ 2 GB", "notice.pdf_not_smaller": "ไม่สามารถบีบอัดให้เล็กลงได้ จึงคัดลอกไฟล์ตามเดิม", + "notice.original_kept": "เก็บไฟล์ต้นฉบับไว้ และบันทึกผลลัพธ์เป็นสำเนา", + "notice.original_in_trash": "ไฟล์ต้นฉบับอยู่ในถังขยะ ผลลัพธ์ยังใช้ชื่อของตัวเอง", "notice.empty_spreadsheet_pages_removed": "ลบหน้าว่างของสเปรดชีตออกแล้ว", "notice.live_photo_pair": "บันทึกเป็นรูปภาพและไฟล์ MOV ที่มีชื่อเดียวกัน นำเข้าทั้งสองไฟล์พร้อมกัน (ผ่านรูปภาพ iCloud, AirDrop หรือ Photos บน Mac) เพื่อใช้งานเป็น Live Photo", "notice.geo_datum_assumed": "เดตัมไม่ใช่ WGS 84 ตำแหน่งถูกแปลงโดยถือว่าเป็น WGS 84 ซึ่งอาจคลาดเคลื่อนได้ถึงหลายร้อยเมตร", @@ -604,6 +606,8 @@ "presets.keep_dates_hint": "ไฟล์ที่แปลงแล้วจะได้วันที่สร้างและแก้ไขตามไฟล์ต้นฉบับ เพื่อให้ภาพถ่ายยังคงเรียงตามวันที่ได้ถูกต้อง", "presets.zip_result": "บันทึกผลลัพธ์เป็น ZIP", "presets.zip_result_hint": "ไฟล์ผลลัพธ์ทั้งหมด (เช่น .shp, .shx, .dbf, .prj และ .cpg ของ Shapefile) จะถูกรวมเป็น ZIP เดียวไว้ข้างไฟล์ต้นฉบับ แทนที่จะเป็นไฟล์แยกเดี่ยวๆ", + "presets.replace_original": "แทนที่ไฟล์ต้นฉบับ", + "presets.replace_original_hint": "ผลลัพธ์จะใช้ชื่อและตำแหน่งของไฟล์ต้นฉบับ และไฟล์ต้นฉบับจะถูกย้ายไปที่ถังขยะ เฉพาะเมื่อผลลัพธ์มีรูปแบบเดียวกัน และไม่ใช้กับโฟลเดอร์เฝ้าดูหรือไฟล์จากเบราว์เซอร์ หากย้ายไฟล์ต้นฉบับไปถังขยะไม่ได้ ไฟล์จะยังอยู่ และผลลัพธ์จะถูกบันทึกเป็นสำเนา", "presets.conflict": "หากมีไฟล์ชื่อนี้อยู่แล้ว", "presets.conflict.Rename": "เติมตัวเลขต่อท้าย", "presets.conflict.Overwrite": "เขียนทับ", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index cfa40e2bc..2eb4e2851 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -110,6 +110,8 @@ "error.geo_unsupported_shape": "不支持此类形状", "error.geo_too_large": "将超过 Shapefile 2 GB 的上限", "notice.pdf_not_smaller": "无法再缩小,已原样复制", + "notice.original_kept": "已保留原文件,结果另存为副本", + "notice.original_in_trash": "原文件已在回收站/废纸篓中,结果保留了自己的名称", "notice.empty_spreadsheet_pages_removed": "已移除电子表格中的空白页面", "notice.live_photo_pair": "已保存为同名的照片和 MOV。将两者一起导入(iCloud 照片、隔空投送或 Mac 上的“照片”)即可得到实况照片", "notice.geo_datum_assumed": "基准面不是 WGS 84,已按相同处理,位置可能偏差数百米", @@ -619,6 +621,8 @@ "presets.keep_dates_hint": "转换后的文件沿用原文件的创建和修改日期,按日期排序的照片顺序不变。", "presets.zip_result": "将结果保存为 ZIP", "presets.zip_result_hint": "结果文件(Shapefile 的 .shp、.shx、.dbf、.prj 和 .cpg)会打包成原文件旁的一个 ZIP,而不是零散的文件。", + "presets.replace_original": "替换原文件", + "presets.replace_original_hint": "结果会使用原文件的名称和位置,原文件移到回收站/废纸篓。仅在结果格式相同时生效,监视文件夹和来自浏览器的文件不会替换。若原文件无法移到回收站/废纸篓,则保留原文件,结果另存为副本。", "presets.conflict": "文件已存在时", "presets.conflict.Rename": "添加编号", "presets.conflict.Overwrite": "覆盖", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 3fc0e8cf4..836ab80f6 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -110,6 +110,8 @@ "error.geo_unsupported_shape": "不支援此類形狀", "error.geo_too_large": "將超過 Shapefile 2 GB 的上限", "notice.pdf_not_smaller": "無法再縮小,已原樣複製", + "notice.original_kept": "已保留原始檔案,結果另存為副本", + "notice.original_in_trash": "原始檔案已在資源回收筒/垃圾桶中,結果保留了自己的名稱", "notice.empty_spreadsheet_pages_removed": "已移除試算表中的空白頁面", "notice.live_photo_pair": "已儲存為同名的照片和 MOV。將兩者一起匯入(iCloud 照片、隔空投送或 Mac 上的「照片」)即可得到原況照片", "notice.geo_datum_assumed": "基準面不是 WGS 84,已按相同處理,位置可能偏差數百公尺", @@ -619,6 +621,8 @@ "presets.keep_dates_hint": "轉換後的檔案沿用原始檔案的建立和修改日期,按日期排序的照片順序不變。", "presets.zip_result": "將結果儲存為 ZIP", "presets.zip_result_hint": "結果檔案(Shapefile 的 .shp、.shx、.dbf、.prj 和 .cpg)會打包成原始檔案旁的一個 ZIP,而不是零散的檔案。", + "presets.replace_original": "取代原始檔案", + "presets.replace_original_hint": "結果會使用原始檔案的名稱和位置,原始檔案移到資源回收筒/垃圾桶。僅在結果格式相同時生效,監看資料夾和來自瀏覽器的檔案不會取代。若原始檔案無法移到資源回收筒/垃圾桶,則保留原始檔案,結果另存為副本。", "presets.conflict": "檔案已存在時", "presets.conflict.Rename": "新增編號", "presets.conflict.Overwrite": "覆蓋", diff --git a/src/Filee.App/Services/AppHost.cs b/src/Filee.App/Services/AppHost.cs index bb6164d70..a235b676b 100644 --- a/src/Filee.App/Services/AppHost.cs +++ b/src/Filee.App/Services/AppHost.cs @@ -57,7 +57,8 @@ public static IServiceProvider Build(string? dataDirectory = null, UserDataStore sp.GetRequiredService>(), EngineRegistry.FindFileCombiner(sp.GetRequiredService>()), EngineRegistry.FindTiffMerger(sp.GetRequiredService>()), - EngineRegistry.FindMediaJoiner(sp.GetRequiredService>()))); + EngineRegistry.FindMediaJoiner(sp.GetRequiredService>()), + sp.GetRequiredService().MoveToTrash)); // Files dragged from a browser with only an address are downloaded without the browser's cookies. services.AddSingleton(_ => new WebDownloader(userAgent: $"Filee/{typeof(AppHost).Assembly.GetName().Version?.ToString(3)}")); diff --git a/src/Filee.App/ViewModels/JobViewModel.cs b/src/Filee.App/ViewModels/JobViewModel.cs index 847abb668..0077e5e87 100644 --- a/src/Filee.App/ViewModels/JobViewModel.cs +++ b/src/Filee.App/ViewModels/JobViewModel.cs @@ -123,7 +123,7 @@ public void Refresh() var noted = Job.Files.Where(f => f.State == FileState.Done && f.NoticeKey is not null).ToList(); HasNotices = noted.Count > 0; Notices = noted.Count == 0 ? null : string.Join(Environment.NewLine, - noted.Select(f => $"{Path.GetFileName(f.SourcePath)} — {_loc[f.NoticeKey!]}")); + noted.Select(f => $"{Path.GetFileName(f.SourcePath)} — {FailureText.Reason(_loc, f.NoticeKey, f.NoticeDetail)}")); var missingFonts = Job.Files.SelectMany(f => f.MissingFonts) .Distinct(StringComparer.OrdinalIgnoreCase) diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index 5758e1c0c..7ad8ddced 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -166,6 +166,7 @@ .. FormatRegistry.Known _pattern = _edit.Output.FileNamePattern; _keepDates = _edit.Output.KeepDates; _zipResult = _edit.Output.ZipResult; + _replaceOriginal = _edit.Output.ReplaceOriginal; } public string PresetId => _original.Id; @@ -404,6 +405,8 @@ public bool ColorProfileChecked [ObservableProperty] private bool _keepDates; /// Geospatial targets: the result is saved as one ZIP (OutputRule.ZipResult). [ObservableProperty] private bool _zipResult; + /// The result takes the original's place, which goes to the Recycle Bin / Trash (OutputRule.ReplaceOriginal). + [ObservableProperty] private bool _replaceOriginal; [ObservableProperty] private Choice _mediaQuality; [ObservableProperty] private Choice _maxHeight; [ObservableProperty] private Choice _audioBitrate; @@ -655,6 +658,7 @@ static double Seconds(double value, double fallback, double min, double max) => output.KeepDates = KeepDates; // Only for geospatial targets, where it is offered: a box ticked for Shapefile must not zip a later PNG. output.ZipResult = ShowZipResult && ZipResult; + output.ReplaceOriginal = ReplaceOriginal; return true; } diff --git a/src/Filee.App/Views/PresetEditorView.axaml b/src/Filee.App/Views/PresetEditorView.axaml index 717ccbcc8..3ff1ddc98 100644 --- a/src/Filee.App/Views/PresetEditorView.axaml +++ b/src/Filee.App/Views/PresetEditorView.axaml @@ -405,6 +405,11 @@ ToolTip.Tip="{DynamicResource presets.keep_dates_hint}" /> + + + + diff --git a/src/Filee.Cli/Cli.cs b/src/Filee.Cli/Cli.cs index 6c804886d..d48fba0b6 100644 --- a/src/Filee.Cli/Cli.cs +++ b/src/Filee.Cli/Cli.cs @@ -156,7 +156,7 @@ private static string Describe(FileResult file, ILocalizer texts) return file.State switch { FileState.Done => $"ok {source} -> {string.Join(", ", file.Outputs.Select(Shorten))}" - + (file.NoticeKey is null ? "" : $" ({texts[file.NoticeKey]})") + + (file.NoticeKey is null ? "" : $" ({Notice(file, texts)})") + (file.MissingFonts.Count == 0 ? "" : $" (missing fonts: {string.Join(", ", file.MissingFonts)})"), FileState.Skipped => $"skip {source} (the output file already exists)", FileState.Cancelled => $"stop {source} (cancelled)", @@ -170,6 +170,9 @@ private static string Error(FileResult file, ILocalizer texts) return string.IsNullOrWhiteSpace(file.ErrorDetail) ? message : $"{message} ({file.ErrorDetail})"; } + private static string Notice(FileResult file, ILocalizer texts) => + string.IsNullOrWhiteSpace(file.NoticeDetail) ? texts[file.NoticeKey!] : $"{texts[file.NoticeKey!]} ({file.NoticeDetail})"; + /// Relative to the current folder when the file is inside it, else the full path. private static string Shorten(string path) { @@ -201,7 +204,7 @@ private static void WriteJson(ConversionJob job, ILocalizer texts, TextWriter ou if (file.State is FileState.Failed or FileState.Cancelled) json.WriteString("error", Error(file, texts)); else if (file.NoticeKey is not null) - json.WriteString("notice", texts[file.NoticeKey]); + json.WriteString("notice", Notice(file, texts)); if (file.MissingFonts.Count > 0) { json.WriteStartArray("missingFonts"); diff --git a/src/Filee.Core/Conversion/ConversionJob.cs b/src/Filee.Core/Conversion/ConversionJob.cs index c3d2434eb..d99a676a8 100644 --- a/src/Filee.Core/Conversion/ConversionJob.cs +++ b/src/Filee.Core/Conversion/ConversionJob.cs @@ -57,6 +57,9 @@ public sealed class FileResult /// public string? NoticeKey { get; set; } + /// Non-localized detail shown after the notice in brackets (why the original was kept), or null. + public string? NoticeDetail { get; set; } + /// /// Referenced document font families that weren't installed before rendering. Conversion still completes with the /// renderer's substitutions; the UI can offer trusted sources afterwards. diff --git a/src/Filee.Core/Conversion/JobQueue.cs b/src/Filee.Core/Conversion/JobQueue.cs index a9ccbd8a3..bd2f8269f 100644 --- a/src/Filee.Core/Conversion/JobQueue.cs +++ b/src/Filee.Core/Conversion/JobQueue.cs @@ -29,6 +29,7 @@ public sealed class JobQueue : IAsyncDisposable /// Output folders this queue made (a subfolder, a fixed folder that wasn't there): removed again when left empty. private readonly HashSet _madeFolders = new(Platform.FileSystemPaths.Comparer); private readonly Task _worker; + private readonly Func? _moveToTrash; private int _busy; /// Converters and their priority. @@ -37,9 +38,14 @@ public sealed class JobQueue : IAsyncDisposable /// Used by "Compress into one archive" presets (). /// Used by multi-page TIFF presets (). /// Used by "Merge videos", "Merge audio" and "Video from images" (). + /// + /// Moves a file to the Recycle Bin / Trash for : null when it worked, else + /// why not. Without it originals are always kept. + /// public JobQueue(ConverterCatalog catalog, IPdfMerger? pdfMerger = null, ILogger? log = null, IFileCombiner? combiner = null, - ITiffMerger? tiffMerger = null, IMediaJoiner? mediaJoiner = null) + ITiffMerger? tiffMerger = null, IMediaJoiner? mediaJoiner = null, Func? moveToTrash = null) { + _moveToTrash = moveToTrash; _catalog = catalog; _pdfMerger = pdfMerger; _combiner = combiner; @@ -766,6 +772,9 @@ private async Task> RunFileAsync( else if (finalOutput && inputs.Count == 1 && final is { Handed: 1 }) inputs = [NextToSource(job, file, index, inputs[0], final)]; + if (finalOutput && !zipResult && inputs.Count == 1) + inputs = [ReplaceOriginal(job, file, source.Id, inputs[0])]; + // A name the engine took but wrote nothing to is free again. final?.Release(except: inputs); if (finalOutput) @@ -985,6 +994,38 @@ private bool Reserve(string path) return true; } + /// + /// "Replace the original" (): moves the source to the Recycle Bin / Trash and + /// the finished to its path, which is returned. Anything that stops it keeps the source and + /// the output as they are, with a note saying why. + /// + private string ReplaceOriginal(ConversionJob job, FileResult file, string sourceFormat, string output) + { + if (!job.Preset.Output.ReplaceOriginal || job.Fetcher is not null || !File.Exists(output) + || FormatRegistry.Detect(output)?.Id != sourceFormat + || string.Equals(Path.GetFullPath(output), Path.GetFullPath(file.SourcePath), Platform.FileSystemPaths.Comparison)) + return output; + var reason = _moveToTrash is null ? "no Recycle Bin / Trash here" : _moveToTrash(file.SourcePath); + if (reason is null) + { + try + { + File.Move(output, file.SourcePath); + return file.SourcePath; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // The original is already in the Trash: the result stays where it was written. + _log.LogWarning(ex, "Could not move {Output} to {Source}", output, file.SourcePath); + reason = ex.Message; + } + } + _log.LogInformation("Kept the original {Source}: {Reason}", file.SourcePath, reason); + file.NoticeKey = File.Exists(file.SourcePath) ? "notice.original_kept" : "notice.original_in_trash"; + file.NoticeDetail = reason; + return output; + } + /// Lets go of (under the lock): free once nobody holds it. private void Unreserve(string path) { diff --git a/src/Filee.Core/Platform/IPlatformServices.cs b/src/Filee.Core/Platform/IPlatformServices.cs index ebb8d640f..e402e9426 100644 --- a/src/Filee.Core/Platform/IPlatformServices.cs +++ b/src/Filee.Core/Platform/IPlatformServices.cs @@ -21,6 +21,12 @@ public interface IPlatformServices /// True if the window at the screen point belongs to the file manager or desktop. Coordinates are pixels on Windows/Linux and desktop points on macOS. bool IsFileManagerAt(int x, int y); + /// + /// Moves a file to the Recycle Bin / Trash, where the user can restore it (). + /// Null when it worked; otherwise why not, in a few words, and the file is left where it is. + /// + string? MoveToTrash(string path) => "there is no Recycle Bin / Trash here"; + /// Process name (without extension) owning the window under the point, or null. string? ProcessNameAt(int x, int y); diff --git a/src/Filee.Core/Presets/OutputRule.cs b/src/Filee.Core/Presets/OutputRule.cs index 5fa4930a3..c9dc828b6 100644 --- a/src/Filee.Core/Presets/OutputRule.cs +++ b/src/Filee.Core/Presets/OutputRule.cs @@ -91,6 +91,14 @@ public sealed class OutputRule /// public bool OnlyIfSmaller { get; set; } + /// + /// The result takes the original's place and the original goes to the Recycle Bin / Trash (#332): only when the + /// result has the source's format, only after it is complete, never for watch folders or files from a browser. + /// When the original can't be moved there (a network drive, a file still open elsewhere, no Trash), it stays and + /// the result is saved as usual, with a note saying why. + /// + public bool ReplaceOriginal { get; set; } + public OutputRule Clone() { var clone = (OutputRule)MemberwiseClone(); diff --git a/src/Filee.Core/Watching/WatchRule.cs b/src/Filee.Core/Watching/WatchRule.cs index 8aa65134b..9f85821d5 100644 --- a/src/Filee.Core/Watching/WatchRule.cs +++ b/src/Filee.Core/Watching/WatchRule.cs @@ -69,6 +69,8 @@ public Preset Apply(Preset preset) applied.Output.Location = OutputLocation.CustomFolder; applied.Output.CustomFolder = ResolvedOutputFolder; applied.Output.KeepFoldersFrom = Folder; + // A watch folder never replaces what it watches: the result would be converted again. + applied.Output.ReplaceOriginal = false; if (!string.IsNullOrWhiteSpace(FileNamePattern)) applied.Output.FileNamePattern = FileNamePattern.Trim(); // Steps without a pattern are being typed; leaving them out keeps the name as it is. diff --git a/src/Filee.Platform.Linux/FreedesktopTrash.cs b/src/Filee.Platform.Linux/FreedesktopTrash.cs new file mode 100644 index 000000000..b3d6b5a44 --- /dev/null +++ b/src/Filee.Platform.Linux/FreedesktopTrash.cs @@ -0,0 +1,72 @@ +// The freedesktop.org Trash (Nautilus, Dolphin, Thunar, Nemo and `gio trash` all use it): the file moves into +// Trash/files and a Trash/info/.trashinfo records where it came from, so the file manager can restore it +// (OutputRule.ReplaceOriginal, #332). Only the home Trash is used, and only for files on the same filesystem; others +// are left where they are. + +using System.Globalization; +using System.Text; + +namespace Filee.Platform.Linux; + +internal static class FreedesktopTrash +{ + /// Null when is in , otherwise why it was left where it is. + public static string? Move(string path, string trash) + { + var full = Path.GetFullPath(path); + if (!File.Exists(full)) + return "the file is gone"; + var files = Path.Combine(trash, "files"); + var info = Path.Combine(trash, "info"); + try + { + Directory.CreateDirectory(files); + Directory.CreateDirectory(info); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return "there is no Trash folder"; + } + if (MountOf(full) != MountOf(files)) + return "the Trash is on another drive"; + + var name = Path.GetFileName(full); + for (var n = 2; File.Exists(Path.Combine(files, name)) || File.Exists(Path.Combine(info, name + ".trashinfo")); n++) + name = $"{Path.GetFileNameWithoutExtension(full)} {n}{Path.GetExtension(full)}"; + var record = Path.Combine(info, name + ".trashinfo"); + try + { + // The record first, as the spec asks: a crash in between leaves no file in the Trash without one. + using (var stream = new FileStream(record, FileMode.CreateNew, FileAccess.Write)) + using (var writer = new StreamWriter(stream, new UTF8Encoding(false))) + { + writer.Write($"[Trash Info]\nPath={Escape(full)}\nDeletionDate={DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture)}\n"); + } + File.Move(full, Path.Combine(files, name)); + return null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + try { File.Delete(record); } catch (IOException) { } + return File.Exists(full) ? "it couldn't be moved to the Trash" : null; + } + } + + /// The path in the record: each part URL-encoded, the slashes kept. + internal static string Escape(string path) => string.Join('/', path.Split('/').Select(Uri.EscapeDataString)); + + /// The mount point a path is on: the longest mounted root that contains it. + private static string MountOf(string path) + { + try + { + return DriveInfo.GetDrives().Select(d => d.RootDirectory.FullName) + .Where(r => path.StartsWith(r, StringComparison.Ordinal)) + .OrderByDescending(r => r.Length).FirstOrDefault() ?? "/"; + } + catch (IOException) + { + return "/"; + } + } +} diff --git a/src/Filee.Platform.Linux/LinuxPlatformServices.cs b/src/Filee.Platform.Linux/LinuxPlatformServices.cs index 15ae1143d..6d1e8252c 100644 --- a/src/Filee.Platform.Linux/LinuxPlatformServices.cs +++ b/src/Filee.Platform.Linux/LinuxPlatformServices.cs @@ -56,6 +56,8 @@ private static bool CanReadInputDevices() // Wayland can't say which window is under the cursor, so any Ctrl+drag may open the (invisible) donut window; // it only shows when files are dragged in, and excluded apps can't be told apart there either. + public string? MoveToTrash(string path) => FreedesktopTrash.Move(path, Path.Combine(XdgDirectory("XDG_DATA_HOME", ".local/share"), "Trash")); + public bool IsFileManagerAt(int x, int y) => WaylandGestures || ProcessNameAt(x, y) is { } name && FileManagers.Contains(name); public string? ProcessNameAt(int x, int y) => X11WindowSystem.ProcessNameAt(x, y); diff --git a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs index 4b762a062..ad3e42848 100644 --- a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs +++ b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs @@ -32,6 +32,8 @@ public sealed class MacOSPlatformServices : IPlatformServices public string? LastSelectionError { get; private set; } + public string? MoveToTrash(string path) => NativeMethods.MoveToTrash(path); + public bool IsFileManagerAt(int x, int y) => string.Equals(ProcessNameAt(x, y), "Finder", StringComparison.OrdinalIgnoreCase); diff --git a/src/Filee.Platform.MacOS/NativeMethods.cs b/src/Filee.Platform.MacOS/NativeMethods.cs index cb83919ae..466283674 100644 --- a/src/Filee.Platform.MacOS/NativeMethods.cs +++ b/src/Filee.Platform.MacOS/NativeMethods.cs @@ -181,6 +181,34 @@ internal static bool SetShowInDock(bool show) return succeeded; } + /// + /// NSFileManager trashItemAtURL: the file goes to the Trash of its volume, where Finder can put it back. Null when + /// it worked, else the reason macOS gives. + /// + public static string? MoveToTrash(string path) + { + using var pool = new AutoreleasePool(); + var text = CFStringCreateWithCString(0, Path.GetFullPath(path), 0x08000100); + try + { + var url = Send(objc_getClass("NSURL"), sel_registerName("fileURLWithPath:"), text); + var manager = Send(objc_getClass("NSFileManager"), sel_registerName("defaultManager")); + if (SendTrash(manager, sel_registerName("trashItemAtURL:resultingItemURL:error:"), url, 0, out var error)) + return null; + var description = error == 0 ? 0 : Send(error, sel_registerName("localizedDescription")); + return description == 0 ? "macOS couldn't move it to the Trash" + : Marshal.PtrToStringUTF8(Send(description, sel_registerName("UTF8String"))); + } + finally + { + CFRelease(text); + } + } + + [DllImport(ObjC, EntryPoint = "objc_msgSend")] + [return: MarshalAs(UnmanagedType.I1)] + private static extern bool SendTrash(nint receiver, nint selector, nint url, nint resultingUrl, out nint error); + private readonly struct AutoreleasePool : IDisposable { private readonly nint _pool = Send(Send(objc_getClass("NSAutoreleasePool"), sel_registerName("alloc")), sel_registerName("init")); diff --git a/src/Filee.Platform.Windows/RecycleBin.cs b/src/Filee.Platform.Windows/RecycleBin.cs new file mode 100644 index 000000000..7813e264b --- /dev/null +++ b/src/Filee.Platform.Windows/RecycleBin.cs @@ -0,0 +1,69 @@ +// Moves a file to the Recycle Bin (OutputRule.ReplaceOriginal, #332). SHFileOperation with FOF_ALLOWUNDO deletes a +// file for good on a drive without a Recycle Bin (network shares, most USB sticks), so only local fixed drives are +// tried, and a file another program holds open is refused before Windows is asked. + +using System.Runtime.InteropServices; + +namespace Filee.Platform.Windows; + +internal static class RecycleBin +{ + private const uint FO_DELETE = 3; + private const ushort FOF_SILENT = 0x4; + private const ushort FOF_NOCONFIRMATION = 0x10; + private const ushort FOF_ALLOWUNDO = 0x40; + private const ushort FOF_NOERRORUI = 0x400; + + /// Null when is in the Recycle Bin, otherwise why it was left where it is. + public static string? Move(string path) + { + var full = Path.GetFullPath(path); + if (!File.Exists(full)) + return "the file is gone"; + var root = Path.GetPathRoot(full); + if (string.IsNullOrEmpty(root) || root.StartsWith(@"\\", StringComparison.Ordinal) + || new DriveInfo(root).DriveType != DriveType.Fixed) + return "this drive has no Recycle Bin"; + try + { + using (new FileStream(full, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + } + } + catch (IOException) + { + return "the file is open in another program"; + } + catch (UnauthorizedAccessException) + { + return "the file is read-only or not yours to remove"; + } + + var operation = new SHFILEOPSTRUCTW + { + wFunc = FO_DELETE, + pFrom = full + "\0\0", + fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT, + }; + var result = SHFileOperationW(ref operation); + if (result != 0 || operation.fAnyOperationsAborted || File.Exists(full)) + return $"Windows couldn't move it to the Recycle Bin (0x{result:X})"; + return null; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct SHFILEOPSTRUCTW + { + public nint hwnd; + public uint wFunc; + public string pFrom; + public string? pTo; + public ushort fFlags; + [MarshalAs(UnmanagedType.Bool)] public bool fAnyOperationsAborted; + public nint hNameMappings; + public string? lpszProgressTitle; + } + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern int SHFileOperationW(ref SHFILEOPSTRUCTW operation); +} diff --git a/src/Filee.Platform.Windows/WindowsPlatformServices.cs b/src/Filee.Platform.Windows/WindowsPlatformServices.cs index 1c03fdd14..d580ba04e 100644 --- a/src/Filee.Platform.Windows/WindowsPlatformServices.cs +++ b/src/Filee.Platform.Windows/WindowsPlatformServices.cs @@ -49,6 +49,8 @@ public void RaiseTopmost(nint windowHandle) NativeMethods.SWP_NOMOVE | NativeMethods.SWP_NOSIZE | NativeMethods.SWP_NOACTIVATE | NativeMethods.SWP_NOOWNERZORDER); } + public string? MoveToTrash(string path) => RecycleBin.Move(path); + public bool IsFileManagerAt(int x, int y) { var root = RootWindowAt(x, y); diff --git a/tests/Filee.App.Tests/RenderTests.cs b/tests/Filee.App.Tests/RenderTests.cs index c3c85b97d..e4f96663b 100644 --- a/tests/Filee.App.Tests/RenderTests.cs +++ b/tests/Filee.App.Tests/RenderTests.cs @@ -1629,6 +1629,21 @@ public void Preset_editor_offers_save_as_zip_for_geospatial_targets_only() Assert.False(preset.Output.ZipResult); } + [AvaloniaFact] + public void Preset_editor_saves_replace_the_original_off_by_default() + { + TestServices.EnsureInitialized("en"); + var preset = new Filee.Core.Presets.Preset { TargetFormat = Filee.Core.Presets.BuiltInData.SameAsSource, Name = "Trim" }; + var vm = new PresetEditorViewModel(preset, AppHost.Get()); + Assert.False(vm.ReplaceOriginal); + + vm.ReplaceOriginal = true; + Assert.True(vm.Apply()); + Assert.True(preset.Output.ReplaceOriginal); + Assert.True(new PresetEditorViewModel(preset, AppHost.Get()).ReplaceOriginal); + Assert.Contains("Recycle Bin / Trash", AppHost.Get()["presets.replace_original_hint"]); + } + [AvaloniaFact] public void Preset_editor_saves_media_and_archive_options() { diff --git a/tests/Filee.Core.Tests/ReplaceOriginalTests.cs b/tests/Filee.Core.Tests/ReplaceOriginalTests.cs new file mode 100644 index 000000000..aafc30c01 --- /dev/null +++ b/tests/Filee.Core.Tests/ReplaceOriginalTests.cs @@ -0,0 +1,158 @@ +// "Replace the original" (#332): the finished result takes the source's place and the source goes to the Trash, or +// both are kept with a note saying why. + +using Filee.Core.Conversion; +using Filee.Core.Presets; +using Filee.Core.Watching; + +namespace Filee.Core.Tests; + +public class ReplaceOriginalTests +{ + private static readonly Preset Replacing = new() { TargetFormat = BuiltInData.SameAsSource, Output = { ReplaceOriginal = true } }; + + /// Moves files into a folder, or refuses with . + private sealed class FakeTrash(string folder, string? refuse = null) + { + public List Moved { get; } = []; + + public string? Move(string path) + { + if (refuse is not null) + return refuse; + File.Move(path, Path.Combine(folder, Path.GetFileName(path))); + Moved.Add(path); + return null; + } + } + + private static async Task Run(FakeTrash trash, string source, Preset preset, ISourceFetcher? fetcher = null) + { + await using var queue = new JobQueue(new ConverterCatalog([new FakeConverter("magick", new ConversionEdge("jpg", "jpg"), new ConversionEdge("jpg", "png"))]), + moveToTrash: trash.Move); + var job = new ConversionJob([source], preset, "Test", fetcher); + await queue.RunAsync(job); + return job; + } + + [Fact] + public async Task The_result_takes_the_place_of_the_original_which_goes_to_the_trash() + { + using var dir = new TempDir(); + using var bin = new TempDir(); + var source = dir.File("a.jpg"); + var trash = new FakeTrash(bin.Path); + + var job = await Run(trash, source, Replacing); + + Assert.Equal(JobState.Completed, job.State); + Assert.Equal([source], trash.Moved); + Assert.Equal([source], job.Outputs); + Assert.Equal("a.jpg→jpg by magick", File.ReadAllText(source)); + Assert.Equal("x", File.ReadAllText(Path.Combine(bin.Path, "a.jpg"))); + Assert.Equal([source], Directory.GetFiles(dir.Path)); + Assert.Null(job.Files[0].NoticeKey); + } + + [Fact] + public async Task When_the_trash_refuses_both_are_kept_with_the_reason() + { + using var dir = new TempDir(); + var source = dir.File("a.jpg"); + + var job = await Run(new FakeTrash(dir.Path, refuse: "the file is open in another program"), source, Replacing); + + Assert.Equal(JobState.Completed, job.State); + Assert.Equal("x", File.ReadAllText(source)); + var output = Assert.Single(job.Outputs); + Assert.NotEqual(source, output); + Assert.True(File.Exists(output)); + Assert.Equal("notice.original_kept", job.Files[0].NoticeKey); + Assert.Equal("the file is open in another program", job.Files[0].NoticeDetail); + } + + [Fact] + public async Task Without_a_trash_the_original_is_kept() + { + using var dir = new TempDir(); + var source = dir.File("a.jpg"); + await using var queue = new JobQueue(new ConverterCatalog([new FakeConverter("magick", new ConversionEdge("jpg", "jpg"))])); + var job = new ConversionJob([source], Replacing, "Test"); + + await queue.RunAsync(job); + + Assert.Equal("x", File.ReadAllText(source)); + Assert.Equal(2, Directory.GetFiles(dir.Path).Length); + Assert.Equal("notice.original_kept", job.Files[0].NoticeKey); + } + + [Fact] + public async Task Another_format_is_saved_beside_the_original() + { + using var dir = new TempDir(); + using var bin = new TempDir(); + var source = dir.File("a.jpg"); + var trash = new FakeTrash(bin.Path); + + var job = await Run(trash, source, new Preset { TargetFormat = "png", Output = { ReplaceOriginal = true } }); + + Assert.Empty(trash.Moved); + Assert.Equal("x", File.ReadAllText(source)); + Assert.Equal([Path.Combine(dir.Path, "a.png")], job.Outputs); + Assert.Null(job.Files[0].NoticeKey); + } + + [Fact] + public async Task Off_by_default() + { + using var dir = new TempDir(); + using var bin = new TempDir(); + var source = dir.File("a.jpg"); + var trash = new FakeTrash(bin.Path); + + await Run(trash, source, new Preset { TargetFormat = BuiltInData.SameAsSource }); + + Assert.False(new OutputRule().ReplaceOriginal); + Assert.Empty(trash.Moved); + Assert.Equal("x", File.ReadAllText(source)); + } + + [Fact] + public async Task Files_from_a_browser_are_never_replaced() + { + using var dir = new TempDir(); + using var bin = new TempDir(); + var source = dir.File("a.jpg"); + var trash = new FakeTrash(bin.Path); + + await Run(trash, source, Replacing, new LocalFetcher()); + + Assert.Empty(trash.Moved); + Assert.Equal("x", File.ReadAllText(source)); + } + + [Fact] + public void Watch_folders_never_replace_what_they_watch() + { + var rule = new WatchRule { Folder = "in", OutputFolder = "out" }; + + Assert.False(rule.Apply(Replacing).Output.ReplaceOriginal); + Assert.True(Replacing.Output.ReplaceOriginal); + } + + [Fact] + public void Survives_a_preset_round_trip() + { + var copy = Replacing.Clone(); + + Assert.True(copy.Output.ReplaceOriginal); + } + + /// A browser drop whose files are already on disk. + private sealed class LocalFetcher : ISourceFetcher + { + public bool Fetches(string source) => false; + public Task FetchAsync(string source, IProgress progress, CancellationToken cancellationToken) => Task.FromResult(source); + public void Release(ConversionJob job) { } + } +} diff --git a/tests/Filee.Platform.Tests/TrashTests.cs b/tests/Filee.Platform.Tests/TrashTests.cs new file mode 100644 index 000000000..e2add2940 --- /dev/null +++ b/tests/Filee.Platform.Tests/TrashTests.cs @@ -0,0 +1,85 @@ +// Moving an original to the Recycle Bin / Trash for "Replace the original" (#332): it works where there is one, and +// anything else leaves the file where it is with a reason. + +using Filee.Platform.Linux; +using Filee.Platform.MacOS; +using Filee.Platform.Windows; + +namespace Filee.Platform.Tests; + +public sealed class TrashTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "filee-trash-" + Guid.NewGuid().ToString("N")); + + public TrashTests() => Directory.CreateDirectory(_root); + + public void Dispose() => Directory.Delete(_root, recursive: true); + + [Fact] + public void A_file_open_elsewhere_stays_in_the_Recycle_Bins_place() + { + if (!OperatingSystem.IsWindows()) + { + Assert.Skip("Requires Windows."); + return; + } + var file = Path.Combine(_root, "open.png"); + File.WriteAllText(file, "x"); + + string? reason; + using (new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read)) + reason = new WindowsPlatformServices().MoveToTrash(file); + + Assert.Equal("the file is open in another program", reason); + Assert.Equal("x", File.ReadAllText(file)); + } + + [Fact] + public void A_missing_file_gives_a_reason() + { + var missing = Path.Combine(_root, "gone.png"); + if (OperatingSystem.IsWindows()) + Assert.NotNull(new WindowsPlatformServices().MoveToTrash(missing)); + else if (OperatingSystem.IsMacOS()) + Assert.NotNull(new MacOSPlatformServices().MoveToTrash(missing)); + Assert.NotNull(FreedesktopTrash.Move(missing, Path.Combine(_root, "Trash"))); + } + + [Fact] + public void The_freedesktop_Trash_records_where_the_file_came_from() + { + if (!OperatingSystem.IsLinux()) + { + Assert.Skip("Requires Linux paths."); + return; + } + var trash = Path.Combine(_root, "Trash"); + var first = Path.Combine(_root, "my photo.png"); + File.WriteAllText(first, "1"); + Assert.Null(FreedesktopTrash.Move(first, trash)); + File.WriteAllText(first, "2"); + Assert.Null(FreedesktopTrash.Move(first, trash)); + + Assert.False(File.Exists(first)); + Assert.Equal("1", File.ReadAllText(Path.Combine(trash, "files", "my photo.png"))); + Assert.Equal("2", File.ReadAllText(Path.Combine(trash, "files", "my photo 2.png"))); + var info = File.ReadAllText(Path.Combine(trash, "info", "my photo 2.png.trashinfo")); + Assert.StartsWith("[Trash Info]\nPath=" + FreedesktopTrash.Escape(first) + "\nDeletionDate=", info); + Assert.Contains("my%20photo.png", info); + } + + [Fact] + public void The_macOS_Trash_takes_the_file() + { + if (!OperatingSystem.IsMacOS()) + { + Assert.Skip("Requires macOS."); + return; + } + var file = Path.Combine(_root, "trash-me.png"); + File.WriteAllText(file, "x"); + + Assert.Null(new MacOSPlatformServices().MoveToTrash(file)); + Assert.False(File.Exists(file)); + } +} From 4364c27aa8f11974ee6455a1a287727c497d2683 Mon Sep 17 00:00:00 2001 From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:58:23 +0530 Subject: [PATCH 2/4] Replace the original: review fixes (Windows only recycles, safer order, CLI) Review of #481: 1. Windows: IFileOperation with FOFX_RECYCLEONDELETE on an STA thread and a progress sink whose PreDeleteItem refuses any delete Windows can't recycle, so a subst or network drive, a bin that is off or too small never deletes the original for good. Test with a subst drive. 2. "Keep dates" reads the source's dates before it goes to the Trash. 3. The result moves next to the source under a temporary name first, then the source goes to the Trash, then the result is renamed in that folder. When the Trash refuses, the result goes back to where it was saved. The hint says the result ends up in the original's folder. 4. TXT (scripts, logs) replaces only with the same extension. 5. "Only if smaller" that copied the source leaves the original alone, with no copy beside it (notice.original_unchanged). 6. Reasons are translated keys (trash.*) in all 14 languages; the system's own words stay as the detail. 7. Linux: a mount holds only its own folders (/home not /home2). 8. Linux: only a .trashinfo this call created is removed on failure. 9. Linux: Trash folders are created 0700; free names checked with Path.Exists. 10. filee convert replaces originals too, with the platform's Trash. --- docs/ARCHITECTURE.md | 11 +- src/Filee.App/Assets/i18n/ar.json | 10 +- src/Filee.App/Assets/i18n/en.json | 10 +- src/Filee.App/Assets/i18n/es.json | 10 +- src/Filee.App/Assets/i18n/fr.json | 10 +- src/Filee.App/Assets/i18n/hi.json | 10 +- src/Filee.App/Assets/i18n/ja.json | 10 +- src/Filee.App/Assets/i18n/ko.json | 10 +- src/Filee.App/Assets/i18n/ne.json | 10 +- src/Filee.App/Assets/i18n/pl.json | 10 +- src/Filee.App/Assets/i18n/pt-BR.json | 10 +- src/Filee.App/Assets/i18n/ru.json | 10 +- src/Filee.App/Assets/i18n/th.json | 10 +- src/Filee.App/Assets/i18n/zh-CN.json | 10 +- src/Filee.App/Assets/i18n/zh-TW.json | 10 +- src/Filee.App/ViewModels/JobViewModel.cs | 2 +- src/Filee.Cli/Cli.cs | 3 +- src/Filee.Cli/CliHost.cs | 11 +- src/Filee.Cli/Filee.Cli.csproj | 6 + src/Filee.Core/Conversion/ConversionJob.cs | 5 +- src/Filee.Core/Conversion/JobQueue.cs | 124 +++++++++--- src/Filee.Core/Localization/ILocalizer.cs | 12 ++ src/Filee.Core/Platform/IPlatformServices.cs | 12 +- src/Filee.Platform.Linux/FreedesktopTrash.cs | 45 +++-- .../LinuxPlatformServices.cs | 2 +- .../MacOSPlatformServices.cs | 2 +- src/Filee.Platform.MacOS/NativeMethods.cs | 6 +- src/Filee.Platform.Windows/RecycleBin.cs | 179 ++++++++++++++---- .../WindowsPlatformServices.cs | 2 +- .../Filee.Core.Tests/ReplaceOriginalTests.cs | 93 ++++++++- tests/Filee.Platform.Tests/TrashTests.cs | 70 ++++++- 31 files changed, 607 insertions(+), 118 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2de410787..ea72801d3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -49,10 +49,13 @@ writing, but only at a path that was its alone: nothing was there before and no other conversion was handed the same name ("Overwrite"). It frees its names and removes an output folder it made if that is left empty. *Replace the original* (`OutputRule.ReplaceOriginal`, off by default) comes after that, for a finished result of - the source's own format: the source goes to the Recycle Bin / Trash (`IPlatformServices.MoveToTrash`) and the - result is moved to its path. When it can't go there (no Recycle Bin on the drive, a network share, a file open - elsewhere, the command line), both stay and the file's notice says why. Watch folders and files from a browser - never replace. + the source's own format (for TXT, which also covers scripts and logs, the same extension). The result is first + moved next to the source under a temporary name, then the source goes to the Recycle Bin / Trash + (`IPlatformServices.MoveToTrash`, in the app and the command line), then the result takes its name. Windows only + ever recycles (IFileOperation with a sink that refuses a delete it can't recycle). When the source can't go there + (no Recycle Bin on the drive, a file open elsewhere, a read-only file), both stay and the file's notice says why, + with a translated reason (`trash.*`). A result "Only if smaller" copied from the source leaves the source alone. + Watch folders and files from a browser never replace. "Merge into one PDF" converts each file to PDF and merges with PDFsharp. "Compress into one archive" packs the dropped files, unconverted, with the archive engine (`IFileCombiner`). "Merge videos", "Merge audio" and "Video from images" join the files, in the order picked in the merge order diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 29780aaa9..129d84275 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -106,6 +106,14 @@ "notice.pdf_not_smaller": "تعذّر تقليل الحجم، لذا نُسخ الملف كما هو", "notice.original_kept": "تم الاحتفاظ بالأصل وحُفظت النتيجة كنسخة", "notice.original_in_trash": "الأصل في سلة المحذوفات، واحتفظت النتيجة باسمها", + "notice.original_unchanged": "تعذّر تصغيره، لذا تُرك الأصل كما هو", + "trash.no_recycle_bin": "لا توجد سلة محذوفات على هذا القرص", + "trash.in_use": "الملف مفتوح في برنامج آخر", + "trash.read_only": "الملف للقراءة فقط", + "trash.other_drive": "سلة المحذوفات على قرص آخر", + "trash.no_folder": "لا يوجد مجلد لسلة المحذوفات", + "trash.failed": "تعذّر نقله إلى سلة المحذوفات", + "trash.cannot_place": "تعذّر حفظ النتيجة في مجلد الأصل", "notice.empty_spreadsheet_pages_removed": "تمت إزالة صفحات جدول البيانات الفارغة", "notice.live_photo_pair": "حُفظ كصورة وملف MOV بالاسم نفسه. استوردهما معًا (صور iCloud أو AirDrop أو تطبيق الصور على Mac) لتحصل على Live Photo", "notice.geo_datum_assumed": "المرجع الجيوديسي ليس WGS 84؛ حُوّلت المواقع على افتراض أنه كذلك، وقد تنحرف بمقدار يصل إلى مئات الأمتار", @@ -607,7 +615,7 @@ "presets.zip_result": "حفظ النتيجة بصيغة ZIP", "presets.zip_result_hint": "تُجمَّع ملفات النتيجة (مثل ‎.shp و‎.shx و‎.dbf و‎.prj و‎.cpg في Shapefile) في ZIP واحد بجانب الأصل، بدلًا من ملفات منفصلة.", "presets.replace_original": "استبدال الأصل", - "presets.replace_original_hint": "تأخذ النتيجة اسم الأصل ومكانه، وينتقل الأصل إلى سلة المحذوفات. فقط عندما يكون للنتيجة التنسيق نفسه، ولا يحدث أبدًا لمجلدات المراقبة أو الملفات القادمة من متصفح. إذا تعذّر نقل الأصل، يبقى في مكانه وتُحفظ النتيجة كنسخة.", + "presets.replace_original_hint": "تأخذ النتيجة اسم الأصل ومكانه، وينتقل الأصل إلى سلة المحذوفات. فقط عندما يكون للنتيجة التنسيق نفسه، ولا يحدث أبدًا لمجلدات المراقبة أو الملفات القادمة من متصفح. إذا تعذّر نقل الأصل، يبقى في مكانه وتُحفظ النتيجة كنسخة. تُحفظ النتيجة في مجلد الأصل أيًّا كان إعداد «الحفظ في».", "presets.conflict": "إذا كان الملف موجودًا", "presets.conflict.Rename": "إضافة رقم", "presets.conflict.Overwrite": "استبداله", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index c20cf6fc5..19cec908c 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -112,6 +112,14 @@ "notice.pdf_not_smaller": "Couldn't make it smaller, so it was copied as it is", "notice.original_kept": "The original was kept, the result was saved as a copy", "notice.original_in_trash": "The original is in the Recycle Bin / Trash, the result kept its own name", + "notice.original_unchanged": "Couldn't make it smaller, so the original was left as it is", + "trash.no_recycle_bin": "this drive has no Recycle Bin / Trash", + "trash.in_use": "the file is open in another program", + "trash.read_only": "the file is read-only", + "trash.other_drive": "the Trash is on another drive", + "trash.no_folder": "there is no Trash folder", + "trash.failed": "it couldn't be moved to the Recycle Bin / Trash", + "trash.cannot_place": "the result couldn't be saved in the original's folder", "notice.empty_spreadsheet_pages_removed": "Empty spreadsheet pages were removed", "notice.live_photo_pair": "Saved as a photo and a MOV with the same name. Import both together (iCloud Photos, AirDrop or Photos on a Mac) to get a Live Photo", "notice.geo_datum_assumed": "The datum isn't WGS 84; positions were converted as if it were and can be off by up to hundreds of metres", @@ -622,7 +630,7 @@ "presets.zip_result": "Save the result as a ZIP", "presets.zip_result_hint": "The files of the result (a Shapefile's .shp, .shx, .dbf, .prj and .cpg) are packed into one ZIP next to the source, in place of loose files.", "presets.replace_original": "Replace the original", - "presets.replace_original_hint": "The result takes the original's name and place, and the original goes to the Recycle Bin / Trash. Only when the result has the same format, never for watch folders or files from a browser. When the original can't go there, it stays and the result is saved as a copy.", + "presets.replace_original_hint": "The result takes the original's name and place, and the original goes to the Recycle Bin / Trash. Only when the result has the same format, never for watch folders or files from a browser. When the original can't go there, it stays and the result is saved as a copy. The result is saved in the original's folder, whatever \"Save to\" says.", "presets.conflict": "If the file exists", "presets.conflict.Rename": "Add a number", "presets.conflict.Overwrite": "Replace it", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index cf48e0052..2c509d580 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -106,6 +106,14 @@ "notice.pdf_not_smaller": "No se pudo reducir su tamaño, por lo que se copió tal cual", "notice.original_kept": "Se conservó el original, el resultado se guardó como copia", "notice.original_in_trash": "El original está en la Papelera, el resultado conservó su propio nombre", + "notice.original_unchanged": "No se pudo hacer más pequeño, así que el original se dejó como estaba", + "trash.no_recycle_bin": "esta unidad no tiene Papelera de reciclaje", + "trash.in_use": "el archivo está abierto en otro programa", + "trash.read_only": "el archivo es de solo lectura", + "trash.other_drive": "la Papelera está en otra unidad", + "trash.no_folder": "no hay carpeta de Papelera", + "trash.failed": "no se pudo mover a la Papelera de reciclaje", + "trash.cannot_place": "no se pudo guardar el resultado en la carpeta del original", "notice.empty_spreadsheet_pages_removed": "Se eliminaron las páginas vacías de la hoja de cálculo", "notice.live_photo_pair": "Guardado como una foto y un MOV con el mismo nombre. Impórtalos juntos (Fotos de iCloud, AirDrop o Fotos en un Mac) para obtener una Live Photo", "notice.geo_datum_assumed": "El datum no es WGS 84; las posiciones se convirtieron como si lo fuera y pueden desviarse hasta cientos de metros", @@ -607,7 +615,7 @@ "presets.zip_result": "Guardar el resultado como ZIP", "presets.zip_result_hint": "Los archivos del resultado (.shp, .shx, .dbf, .prj y .cpg de un Shapefile) se empaquetan en un solo ZIP junto al origen, en lugar de archivos sueltos.", "presets.replace_original": "Reemplazar el original", - "presets.replace_original_hint": "El resultado toma el nombre y el lugar del original, y el original va a la Papelera de reciclaje. Solo cuando el resultado tiene el mismo formato, nunca en carpetas vigiladas ni con archivos de un navegador. Si el original no puede ir a la Papelera, se queda y el resultado se guarda como copia.", + "presets.replace_original_hint": "El resultado toma el nombre y el lugar del original, y el original va a la Papelera de reciclaje. Solo cuando el resultado tiene el mismo formato, nunca en carpetas vigiladas ni con archivos de un navegador. Si el original no puede ir a la Papelera, se queda y el resultado se guarda como copia. El resultado se guarda en la carpeta del original, diga lo que diga «Guardar en».", "presets.conflict": "Si el archivo existe", "presets.conflict.Rename": "Añadir un número", "presets.conflict.Overwrite": "Reemplazarlo", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 5d64b059a..be24ec953 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -112,6 +112,14 @@ "notice.pdf_not_smaller": "Impossible de réduire sa taille : le fichier a été copié tel quel", "notice.original_kept": "L'original a été conservé, le résultat a été enregistré en copie", "notice.original_in_trash": "L'original est dans la Corbeille, le résultat a gardé son propre nom", + "notice.original_unchanged": "Impossible de le rendre plus petit : l'original a été laissé tel quel", + "trash.no_recycle_bin": "ce lecteur n'a pas de Corbeille", + "trash.in_use": "le fichier est ouvert dans un autre programme", + "trash.read_only": "le fichier est en lecture seule", + "trash.other_drive": "la Corbeille est sur un autre lecteur", + "trash.no_folder": "il n'y a pas de dossier Corbeille", + "trash.failed": "impossible de le déplacer dans la Corbeille", + "trash.cannot_place": "impossible d'enregistrer le résultat dans le dossier de l'original", "notice.empty_spreadsheet_pages_removed": "Les pages vides de la feuille de calcul ont été supprimées", "notice.live_photo_pair": "Enregistré en une photo et un MOV de même nom. Importez-les ensemble (Photos iCloud, AirDrop ou Photos sur Mac) pour obtenir une Live Photo", "notice.geo_datum_assumed": "Le datum n'est pas WGS 84 ; les positions ont été converties comme s'il l'était et peuvent être décalées de plusieurs centaines de mètres", @@ -622,7 +630,7 @@ "presets.zip_result": "Enregistrer le résultat en ZIP", "presets.zip_result_hint": "Les fichiers du résultat (les .shp, .shx, .dbf, .prj et .cpg d'un Shapefile) sont regroupés dans un seul ZIP à côté de l'original, au lieu de fichiers séparés.", "presets.replace_original": "Remplacer l'original", - "presets.replace_original_hint": "Le résultat prend le nom et la place de l'original, et l'original va dans la Corbeille. Seulement quand le résultat a le même format, jamais pour les dossiers surveillés ni les fichiers venant d'un navigateur. Si l'original ne peut pas aller dans la Corbeille, il reste et le résultat est enregistré en copie.", + "presets.replace_original_hint": "Le résultat prend le nom et la place de l'original, et l'original va dans la Corbeille. Seulement quand le résultat a le même format, jamais pour les dossiers surveillés ni les fichiers venant d'un navigateur. Si l'original ne peut pas aller dans la Corbeille, il reste et le résultat est enregistré en copie. Le résultat est enregistré dans le dossier de l'original, quel que soit « Enregistrer dans ».", "presets.conflict": "Si le fichier existe", "presets.conflict.Rename": "Ajouter un numéro", "presets.conflict.Overwrite": "Le remplacer", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index c7fd149c3..07618ae1e 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -106,6 +106,14 @@ "notice.pdf_not_smaller": "इसे छोटा नहीं किया जा सका, इसलिए जैसा है वैसा ही कॉपी कर दिया गया", "notice.original_kept": "मूल फ़ाइल रखी गई, परिणाम कॉपी के रूप में सहेजा गया", "notice.original_in_trash": "मूल फ़ाइल रीसायकल बिन / ट्रैश में है, परिणाम ने अपना नाम रखा", + "notice.original_unchanged": "इसे छोटा नहीं किया जा सका, इसलिए मूल फ़ाइल जैसी थी वैसी छोड़ दी गई", + "trash.no_recycle_bin": "इस ड्राइव में रीसायकल बिन / ट्रैश नहीं है", + "trash.in_use": "फ़ाइल किसी दूसरे प्रोग्राम में खुली है", + "trash.read_only": "फ़ाइल केवल पढ़ने के लिए है", + "trash.other_drive": "ट्रैश किसी दूसरी ड्राइव पर है", + "trash.no_folder": "ट्रैश फ़ोल्डर नहीं है", + "trash.failed": "इसे रीसायकल बिन / ट्रैश में नहीं ले जाया जा सका", + "trash.cannot_place": "परिणाम मूल फ़ाइल के फ़ोल्डर में सेव नहीं हो सका", "notice.empty_spreadsheet_pages_removed": "स्प्रेडशीट के खाली पृष्ठ हटा दिए गए", "notice.live_photo_pair": "एक ही नाम की फ़ोटो और MOV के रूप में सहेजा गया। लाइव फ़ोटो पाने के लिए दोनों को साथ में इम्पोर्ट करें (iCloud Photos, AirDrop या Mac पर Photos)", "notice.geo_datum_assumed": "डेटम WGS 84 नहीं है। स्थितियाँ ऐसे कन्वर्ट की गईं मानो वह WGS 84 हो, इसलिए वे कुछ सौ मीटर तक खिसक सकती हैं", @@ -607,7 +615,7 @@ "presets.zip_result": "नतीजे को ZIP के रूप में सहेजें", "presets.zip_result_hint": "नतीजे की फ़ाइलें (Shapefile की .shp, .shx, .dbf, .prj और .cpg) अलग-अलग रखने के बजाय स्रोत के बगल में एक ZIP में पैक की जाती हैं।", "presets.replace_original": "मूल फ़ाइल बदलें", - "presets.replace_original_hint": "परिणाम मूल फ़ाइल का नाम और जगह ले लेता है, और मूल फ़ाइल रीसायकल बिन / ट्रैश में चली जाती है। केवल तब जब परिणाम का फ़ॉर्मैट वही हो, वॉच फ़ोल्डर या ब्राउज़र से आई फ़ाइलों के लिए कभी नहीं। अगर मूल फ़ाइल वहाँ नहीं जा सकती, तो वह रहती है और परिणाम एक कॉपी के रूप में सहेजा जाता है।", + "presets.replace_original_hint": "परिणाम मूल फ़ाइल का नाम और जगह ले लेता है, और मूल फ़ाइल रीसायकल बिन / ट्रैश में चली जाती है। केवल तब जब परिणाम का फ़ॉर्मैट वही हो, वॉच फ़ोल्डर या ब्राउज़र से आई फ़ाइलों के लिए कभी नहीं। अगर मूल फ़ाइल वहाँ नहीं जा सकती, तो वह रहती है और परिणाम एक कॉपी के रूप में सहेजा जाता है। परिणाम मूल फ़ाइल के फ़ोल्डर में सेव होता है, \"यहाँ सेव करें\" में चाहे जो हो।", "presets.conflict": "अगर फ़ाइल पहले से मौजूद हो", "presets.conflict.Rename": "नंबर जोड़ें", "presets.conflict.Overwrite": "बदल दें", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index e0893f912..cea5aa1fc 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -106,6 +106,14 @@ "notice.pdf_not_smaller": "サイズを小さくできなかったため、元のままコピーしました", "notice.original_kept": "元のファイルを残し、結果をコピーとして保存しました", "notice.original_in_trash": "元のファイルはごみ箱にあります。結果は別の名前のままです", + "notice.original_unchanged": "小さくできなかったため、元のファイルはそのままにしました", + "trash.no_recycle_bin": "このドライブにはごみ箱がありません", + "trash.in_use": "ファイルが別のプログラムで開かれています", + "trash.read_only": "ファイルが読み取り専用です", + "trash.other_drive": "ごみ箱が別のドライブにあります", + "trash.no_folder": "ごみ箱フォルダーがありません", + "trash.failed": "ごみ箱に移動できませんでした", + "trash.cannot_place": "元のファイルのフォルダーに結果を保存できませんでした", "notice.empty_spreadsheet_pages_removed": "スプレッドシートの空白ページを削除しました", "notice.live_photo_pair": "同じ名前の写真とMOVとして保存しました。両方を一緒に取り込むと(iCloud写真、AirDrop、Macの写真アプリ)Live Photoになります", "notice.geo_datum_assumed": "測地系がWGS 84ではありません。WGS 84とみなして変換したため、位置が最大で数百メートルずれることがあります", @@ -607,7 +615,7 @@ "presets.zip_result": "変換結果をZIPで保存", "presets.zip_result_hint": "変換結果のファイル(Shapefileの.shp、.shx、.dbf、.prj、.cpg)を、ばらばらに置く代わりに元ファイルの隣の1つのZIPにまとめます。", "presets.replace_original": "元のファイルを置き換える", - "presets.replace_original_hint": "変換結果が元のファイルの名前と場所を引き継ぎ、元のファイルはごみ箱に移動します。結果が同じ形式のときだけで、監視フォルダーやブラウザーからのファイルには使われません。元のファイルをごみ箱に移動できないときは残し、結果はコピーとして保存します。", + "presets.replace_original_hint": "変換結果が元のファイルの名前と場所を引き継ぎ、元のファイルはごみ箱に移動します。結果が同じ形式のときだけで、監視フォルダーやブラウザーからのファイルには使われません。元のファイルをごみ箱に移動できないときは残し、結果はコピーとして保存します。 結果は「保存先」の設定にかかわらず、元のファイルのフォルダーに保存されます。", "presets.conflict": "同名ファイルがある場合", "presets.conflict.Rename": "連番を付ける", "presets.conflict.Overwrite": "上書き", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 758408a56..ff0f578ef 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -112,6 +112,14 @@ "notice.pdf_not_smaller": "더 작게 만들 수 없어서 그대로 복사했어요", "notice.original_kept": "원본을 그대로 두고 결과를 사본으로 저장했습니다", "notice.original_in_trash": "원본은 휴지통에 있고 결과는 자기 이름을 유지했습니다", + "notice.original_unchanged": "더 작게 만들지 못해 원본을 그대로 두었습니다", + "trash.no_recycle_bin": "이 드라이브에는 휴지통이 없습니다", + "trash.in_use": "다른 프로그램에서 파일을 열고 있습니다", + "trash.read_only": "읽기 전용 파일입니다", + "trash.other_drive": "휴지통이 다른 드라이브에 있습니다", + "trash.no_folder": "휴지통 폴더가 없습니다", + "trash.failed": "휴지통으로 옮기지 못했습니다", + "trash.cannot_place": "원본 폴더에 결과를 저장하지 못했습니다", "notice.empty_spreadsheet_pages_removed": "스프레드시트의 빈 페이지를 제거했어요", "notice.live_photo_pair": "같은 이름의 사진과 MOV로 저장했어요. 두 파일을 함께 가져오면(iCloud 사진, AirDrop, Mac의 사진 앱) 라이브 포토가 돼요", "notice.geo_datum_assumed": "측지계가 WGS 84가 아니라서 같은 것으로 보고 변환했어요. 위치가 수백 m까지 어긋날 수 있어요", @@ -622,7 +630,7 @@ "presets.zip_result": "결과를 ZIP으로 저장", "presets.zip_result_hint": "결과 파일들(Shapefile의 .shp, .shx, .dbf, .prj, .cpg)을 낱개로 두지 않고 원본 옆에 ZIP 하나로 묶어요.", "presets.replace_original": "원본 바꾸기", - "presets.replace_original_hint": "결과가 원본의 이름과 위치를 가져가고 원본은 휴지통으로 이동합니다. 결과가 같은 형식일 때만 적용되며, 감시 폴더나 브라우저에서 가져온 파일에는 적용되지 않습니다. 원본을 휴지통으로 옮길 수 없으면 원본은 그대로 두고 결과를 사본으로 저장합니다.", + "presets.replace_original_hint": "결과가 원본의 이름과 위치를 가져가고 원본은 휴지통으로 이동합니다. 결과가 같은 형식일 때만 적용되며, 감시 폴더나 브라우저에서 가져온 파일에는 적용되지 않습니다. 원본을 휴지통으로 옮길 수 없으면 원본은 그대로 두고 결과를 사본으로 저장합니다. 결과는 '저장 위치'와 관계없이 원본 폴더에 저장됩니다.", "presets.conflict": "같은 파일이 있으면", "presets.conflict.Rename": "번호 붙이기", "presets.conflict.Overwrite": "덮어쓰기", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 8f56c2e4d..2bb2a2ebc 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -106,6 +106,14 @@ "notice.pdf_not_smaller": "यसलाई सानो बनाउन सकिएन, त्यसैले जस्तो छ त्यस्तै प्रतिलिपि गरियो", "notice.original_kept": "मूल फाइल राखियो, नतिजा प्रतिलिपिको रूपमा सुरक्षित भयो", "notice.original_in_trash": "मूल फाइल रिसाइकल बिन / ट्र्यासमा छ, नतिजाले आफ्नै नाम राख्यो", + "notice.original_unchanged": "यसलाई सानो बनाउन सकिएन, त्यसैले मूल फाइल जस्ताको तस्तै छोडियो", + "trash.no_recycle_bin": "यो ड्राइभमा रिसाइकल बिन / ट्र्यास छैन", + "trash.in_use": "फाइल अर्को प्रोग्राममा खुला छ", + "trash.read_only": "फाइल पढ्न मात्र मिल्ने छ", + "trash.other_drive": "ट्र्यास अर्को ड्राइभमा छ", + "trash.no_folder": "ट्र्यास फोल्डर छैन", + "trash.failed": "यसलाई रिसाइकल बिन / ट्र्यासमा सार्न सकिएन", + "trash.cannot_place": "नतिजा मूल फाइलको फोल्डरमा सेभ गर्न सकिएन", "notice.empty_spreadsheet_pages_removed": "स्प्रेडसिटका खाली पृष्ठहरू हटाइए", "notice.live_photo_pair": "एउटै नामको फोटो र MOV का रूपमा सुरक्षित गरियो। लाइभ फोटो पाउन दुवैलाई सँगै आयात गर्नुहोस् (iCloud Photos, AirDrop वा Mac मा Photos)", "notice.geo_datum_assumed": "डेटम WGS 84 होइन; स्थितिहरू यो WGS 84 भए जस्तै रूपान्तरण गरिए, जसले केही सय मिटरसम्म फरक पार्न सक्छ", @@ -607,7 +615,7 @@ "presets.zip_result": "परिणामलाई ZIP को रूपमा सुरक्षित गर्नुहोस्", "presets.zip_result_hint": "परिणामका फाइलहरू (Shapefile को .shp, .shx, .dbf, .prj र .cpg) छुट्टाछुट्टै राख्नुको सट्टा स्रोतको छेउमा एउटै ZIP मा प्याक गरिन्छ।", "presets.replace_original": "मूल फाइल बदल्नुहोस्", - "presets.replace_original_hint": "नतिजाले मूल फाइलको नाम र ठाउँ लिन्छ, र मूल फाइल रिसाइकल बिन / ट्र्यासमा जान्छ। नतिजाको ढाँचा उही हुँदा मात्र, वाच फोल्डर वा ब्राउजरबाट आएका फाइलका लागि कहिल्यै होइन। मूल फाइल त्यहाँ जान नसके, त्यो रहन्छ र नतिजा प्रतिलिपिको रूपमा सुरक्षित हुन्छ।", + "presets.replace_original_hint": "नतिजाले मूल फाइलको नाम र ठाउँ लिन्छ, र मूल फाइल रिसाइकल बिन / ट्र्यासमा जान्छ। नतिजाको ढाँचा उही हुँदा मात्र, वाच फोल्डर वा ब्राउजरबाट आएका फाइलका लागि कहिल्यै होइन। मूल फाइल त्यहाँ जान नसके, त्यो रहन्छ र नतिजा प्रतिलिपिको रूपमा सुरक्षित हुन्छ। \"यहाँ सेभ गर्नुहोस्\" मा जे भए पनि नतिजा मूल फाइलको फोल्डरमा सेभ हुन्छ।", "presets.conflict": "यदि फाइल पहिले नै अवस्थित छ भने", "presets.conflict.Rename": "नम्बर थप्नुहोस्", "presets.conflict.Overwrite": "बदल्नुहोस् (अधिलेखन)", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index faeb73d17..35e2b721e 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -112,6 +112,14 @@ "notice.pdf_not_smaller": "Nie udało się zmniejszyć pliku, więc skopiowano go bez zmian", "notice.original_kept": "Oryginał został zachowany, wynik zapisano jako kopię", "notice.original_in_trash": "Oryginał jest w Koszu, wynik zachował własną nazwę", + "notice.original_unchanged": "Nie udało się go zmniejszyć, więc oryginał pozostał bez zmian", + "trash.no_recycle_bin": "ten dysk nie ma Kosza", + "trash.in_use": "plik jest otwarty w innym programie", + "trash.read_only": "plik jest tylko do odczytu", + "trash.other_drive": "Kosz jest na innym dysku", + "trash.no_folder": "nie ma folderu Kosza", + "trash.failed": "nie udało się przenieść go do Kosza", + "trash.cannot_place": "nie udało się zapisać wyniku w folderze oryginału", "notice.empty_spreadsheet_pages_removed": "Usunięto puste strony arkusza kalkulacyjnego", "notice.live_photo_pair": "Zapisano jako zdjęcie i plik MOV o tej samej nazwie. Zaimportuj je razem (Zdjęcia iCloud, AirDrop lub Zdjęcia na Macu), aby otrzymać Live Photo", "notice.geo_datum_assumed": "Datum nie jest WGS 84; pozycje przeliczono tak, jakby było, więc mogą się różnić nawet o setki metrów", @@ -622,7 +630,7 @@ "presets.zip_result": "Zapisz wynik jako ZIP", "presets.zip_result_hint": "Pliki wyniku (.shp, .shx, .dbf, .prj i .cpg Shapefile) są pakowane do jednego ZIP obok źródła zamiast luźnych plików.", "presets.replace_original": "Zastąp oryginał", - "presets.replace_original_hint": "Wynik przejmuje nazwę i miejsce oryginału, a oryginał trafia do Kosza. Tylko gdy wynik ma ten sam format, nigdy dla obserwowanych folderów ani plików z przeglądarki. Jeśli oryginał nie może trafić do Kosza, zostaje, a wynik jest zapisywany jako kopia.", + "presets.replace_original_hint": "Wynik przejmuje nazwę i miejsce oryginału, a oryginał trafia do Kosza. Tylko gdy wynik ma ten sam format, nigdy dla obserwowanych folderów ani plików z przeglądarki. Jeśli oryginał nie może trafić do Kosza, zostaje, a wynik jest zapisywany jako kopia. Wynik jest zapisywany w folderze oryginału, niezależnie od ustawienia „Zapisz w”.", "presets.conflict": "Jeśli plik istnieje", "presets.conflict.Rename": "Dodaj numer", "presets.conflict.Overwrite": "Zastąp", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 69dcd201e..00a0e8b69 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -112,6 +112,14 @@ "notice.pdf_not_smaller": "Não deu para deixar menor, então foi copiado como está", "notice.original_kept": "O original foi mantido, o resultado foi salvo como cópia", "notice.original_in_trash": "O original está na Lixeira, o resultado manteve o próprio nome", + "notice.original_unchanged": "Não foi possível deixá-lo menor, então o original ficou como estava", + "trash.no_recycle_bin": "esta unidade não tem Lixeira", + "trash.in_use": "o arquivo está aberto em outro programa", + "trash.read_only": "o arquivo é somente leitura", + "trash.other_drive": "a Lixeira está em outra unidade", + "trash.no_folder": "não há pasta de Lixeira", + "trash.failed": "não foi possível movê-lo para a Lixeira", + "trash.cannot_place": "não foi possível salvar o resultado na pasta do original", "notice.empty_spreadsheet_pages_removed": "As páginas vazias da planilha foram removidas", "notice.live_photo_pair": "Salvo como uma foto e um MOV com o mesmo nome. Importe os dois juntos (Fotos do iCloud, AirDrop ou Fotos no Mac) para obter uma Live Photo", "notice.geo_datum_assumed": "O datum não é WGS 84; as posições foram convertidas como se fosse, e podem errar em até centenas de metros", @@ -622,7 +630,7 @@ "presets.zip_result": "Salvar o resultado como ZIP", "presets.zip_result_hint": "Os arquivos do resultado (os .shp, .shx, .dbf, .prj e .cpg de um Shapefile) são compactados num ZIP ao lado da origem, no lugar de arquivos soltos.", "presets.replace_original": "Substituir o original", - "presets.replace_original_hint": "O resultado assume o nome e o lugar do original, e o original vai para a Lixeira. Só quando o resultado tem o mesmo formato, nunca para pastas monitoradas ou arquivos vindos de um navegador. Se o original não puder ir para a Lixeira, ele fica e o resultado é salvo como cópia.", + "presets.replace_original_hint": "O resultado assume o nome e o lugar do original, e o original vai para a Lixeira. Só quando o resultado tem o mesmo formato, nunca para pastas monitoradas ou arquivos vindos de um navegador. Se o original não puder ir para a Lixeira, ele fica e o resultado é salvo como cópia. O resultado é salvo na pasta do original, seja qual for o \"Salvar em\".", "presets.conflict": "Se o arquivo existir", "presets.conflict.Rename": "Adicionar um número", "presets.conflict.Overwrite": "Substituir", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 19ddbc386..1db08a843 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -112,6 +112,14 @@ "notice.pdf_not_smaller": "Уменьшить не получилось, файл скопирован как есть", "notice.original_kept": "Оригинал оставлен, результат сохранён как копия", "notice.original_in_trash": "Оригинал в Корзине, результат сохранил своё имя", + "notice.original_unchanged": "Не удалось сделать его меньше, поэтому оригинал оставлен как есть", + "trash.no_recycle_bin": "на этом диске нет Корзины", + "trash.in_use": "файл открыт в другой программе", + "trash.read_only": "файл только для чтения", + "trash.other_drive": "Корзина на другом диске", + "trash.no_folder": "нет папки Корзины", + "trash.failed": "не удалось переместить его в Корзину", + "trash.cannot_place": "не удалось сохранить результат в папке оригинала", "notice.empty_spreadsheet_pages_removed": "Пустые страницы таблицы удалены", "notice.live_photo_pair": "Сохранено как фото и MOV с одинаковым именем. Импортируйте их вместе (Фото iCloud, AirDrop или «Фото» на Mac), чтобы получить Live Photo", "notice.geo_datum_assumed": "Датум отличается от WGS 84; координаты пересчитаны как для WGS 84 и могут сместиться на сотни метров", @@ -622,7 +630,7 @@ "presets.zip_result": "Сохранять результат в ZIP", "presets.zip_result_hint": "Файлы результата (у Shapefile это .shp, .shx, .dbf, .prj и .cpg) упаковываются в один ZIP рядом с исходником вместо отдельных файлов.", "presets.replace_original": "Заменить оригинал", - "presets.replace_original_hint": "Результат получает имя и место оригинала, а оригинал отправляется в Корзину. Только если у результата тот же формат, никогда для отслеживаемых папок и файлов из браузера. Если оригинал нельзя отправить в Корзину, он остаётся, а результат сохраняется как копия.", + "presets.replace_original_hint": "Результат получает имя и место оригинала, а оригинал отправляется в Корзину. Только если у результата тот же формат, никогда для отслеживаемых папок и файлов из браузера. Если оригинал нельзя отправить в Корзину, он остаётся, а результат сохраняется как копия. Результат сохраняется в папке оригинала, что бы ни было указано в «Сохранять в».", "presets.conflict": "Если файл уже есть", "presets.conflict.Rename": "Добавить номер", "presets.conflict.Overwrite": "Заменить", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 0a7d3f90b..be6a53fed 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -106,6 +106,14 @@ "notice.pdf_not_smaller": "ไม่สามารถบีบอัดให้เล็กลงได้ จึงคัดลอกไฟล์ตามเดิม", "notice.original_kept": "เก็บไฟล์ต้นฉบับไว้ และบันทึกผลลัพธ์เป็นสำเนา", "notice.original_in_trash": "ไฟล์ต้นฉบับอยู่ในถังขยะ ผลลัพธ์ยังใช้ชื่อของตัวเอง", + "notice.original_unchanged": "ทำให้เล็กลงไม่ได้ จึงปล่อยไฟล์ต้นฉบับไว้ตามเดิม", + "trash.no_recycle_bin": "ไดรฟ์นี้ไม่มีถังรีไซเคิล / ถังขยะ", + "trash.in_use": "ไฟล์เปิดอยู่ในโปรแกรมอื่น", + "trash.read_only": "ไฟล์เป็นแบบอ่านอย่างเดียว", + "trash.other_drive": "ถังขยะอยู่บนไดรฟ์อื่น", + "trash.no_folder": "ไม่มีโฟลเดอร์ถังขยะ", + "trash.failed": "ย้ายไปที่ถังรีไซเคิล / ถังขยะไม่ได้", + "trash.cannot_place": "บันทึกผลลัพธ์ในโฟลเดอร์ของไฟล์ต้นฉบับไม่ได้", "notice.empty_spreadsheet_pages_removed": "ลบหน้าว่างของสเปรดชีตออกแล้ว", "notice.live_photo_pair": "บันทึกเป็นรูปภาพและไฟล์ MOV ที่มีชื่อเดียวกัน นำเข้าทั้งสองไฟล์พร้อมกัน (ผ่านรูปภาพ iCloud, AirDrop หรือ Photos บน Mac) เพื่อใช้งานเป็น Live Photo", "notice.geo_datum_assumed": "เดตัมไม่ใช่ WGS 84 ตำแหน่งถูกแปลงโดยถือว่าเป็น WGS 84 ซึ่งอาจคลาดเคลื่อนได้ถึงหลายร้อยเมตร", @@ -607,7 +615,7 @@ "presets.zip_result": "บันทึกผลลัพธ์เป็น ZIP", "presets.zip_result_hint": "ไฟล์ผลลัพธ์ทั้งหมด (เช่น .shp, .shx, .dbf, .prj และ .cpg ของ Shapefile) จะถูกรวมเป็น ZIP เดียวไว้ข้างไฟล์ต้นฉบับ แทนที่จะเป็นไฟล์แยกเดี่ยวๆ", "presets.replace_original": "แทนที่ไฟล์ต้นฉบับ", - "presets.replace_original_hint": "ผลลัพธ์จะใช้ชื่อและตำแหน่งของไฟล์ต้นฉบับ และไฟล์ต้นฉบับจะถูกย้ายไปที่ถังขยะ เฉพาะเมื่อผลลัพธ์มีรูปแบบเดียวกัน และไม่ใช้กับโฟลเดอร์เฝ้าดูหรือไฟล์จากเบราว์เซอร์ หากย้ายไฟล์ต้นฉบับไปถังขยะไม่ได้ ไฟล์จะยังอยู่ และผลลัพธ์จะถูกบันทึกเป็นสำเนา", + "presets.replace_original_hint": "ผลลัพธ์จะใช้ชื่อและตำแหน่งของไฟล์ต้นฉบับ และไฟล์ต้นฉบับจะถูกย้ายไปที่ถังขยะ เฉพาะเมื่อผลลัพธ์มีรูปแบบเดียวกัน และไม่ใช้กับโฟลเดอร์เฝ้าดูหรือไฟล์จากเบราว์เซอร์ หากย้ายไฟล์ต้นฉบับไปถังขยะไม่ได้ ไฟล์จะยังอยู่ และผลลัพธ์จะถูกบันทึกเป็นสำเนา ผลลัพธ์จะถูกบันทึกในโฟลเดอร์ของไฟล์ต้นฉบับ ไม่ว่า \"บันทึกไปที่\" จะตั้งไว้อย่างไร", "presets.conflict": "หากมีไฟล์ชื่อนี้อยู่แล้ว", "presets.conflict.Rename": "เติมตัวเลขต่อท้าย", "presets.conflict.Overwrite": "เขียนทับ", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 6a2e33085..ff475c5b1 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -112,6 +112,14 @@ "notice.pdf_not_smaller": "无法再缩小,已原样复制", "notice.original_kept": "已保留原文件,结果另存为副本", "notice.original_in_trash": "原文件已在回收站/废纸篓中,结果保留了自己的名称", + "notice.original_unchanged": "无法变小,因此保留原文件不变", + "trash.no_recycle_bin": "此驱动器没有回收站/废纸篓", + "trash.in_use": "文件正在被其他程序使用", + "trash.read_only": "文件为只读", + "trash.other_drive": "废纸篓在另一个驱动器上", + "trash.no_folder": "没有废纸篓文件夹", + "trash.failed": "无法移到回收站/废纸篓", + "trash.cannot_place": "无法将结果保存到原文件所在的文件夹", "notice.empty_spreadsheet_pages_removed": "已移除电子表格中的空白页面", "notice.live_photo_pair": "已保存为同名的照片和 MOV。将两者一起导入(iCloud 照片、隔空投送或 Mac 上的“照片”)即可得到实况照片", "notice.geo_datum_assumed": "基准面不是 WGS 84,已按相同处理,位置可能偏差数百米", @@ -622,7 +630,7 @@ "presets.zip_result": "将结果保存为 ZIP", "presets.zip_result_hint": "结果文件(Shapefile 的 .shp、.shx、.dbf、.prj 和 .cpg)会打包成原文件旁的一个 ZIP,而不是零散的文件。", "presets.replace_original": "替换原文件", - "presets.replace_original_hint": "结果会使用原文件的名称和位置,原文件移到回收站/废纸篓。仅在结果格式相同时生效,监视文件夹和来自浏览器的文件不会替换。若原文件无法移到回收站/废纸篓,则保留原文件,结果另存为副本。", + "presets.replace_original_hint": "结果会使用原文件的名称和位置,原文件移到回收站/废纸篓。仅在结果格式相同时生效,监视文件夹和来自浏览器的文件不会替换。若原文件无法移到回收站/废纸篓,则保留原文件,结果另存为副本。无论“保存到”如何设置,结果都会保存在原文件所在的文件夹。", "presets.conflict": "文件已存在时", "presets.conflict.Rename": "添加编号", "presets.conflict.Overwrite": "覆盖", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 054efcacb..31a5e755a 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -112,6 +112,14 @@ "notice.pdf_not_smaller": "無法再縮小,已原樣複製", "notice.original_kept": "已保留原始檔案,結果另存為副本", "notice.original_in_trash": "原始檔案已在資源回收筒/垃圾桶中,結果保留了自己的名稱", + "notice.original_unchanged": "無法變小,因此保留原始檔案不變", + "trash.no_recycle_bin": "此磁碟機沒有資源回收筒/垃圾桶", + "trash.in_use": "檔案正由其他程式開啟", + "trash.read_only": "檔案為唯讀", + "trash.other_drive": "垃圾桶在另一個磁碟機上", + "trash.no_folder": "沒有垃圾桶資料夾", + "trash.failed": "無法移到資源回收筒/垃圾桶", + "trash.cannot_place": "無法將結果儲存到原始檔案所在的資料夾", "notice.empty_spreadsheet_pages_removed": "已移除試算表中的空白頁面", "notice.live_photo_pair": "已儲存為同名的照片和 MOV。將兩者一起匯入(iCloud 照片、隔空投送或 Mac 上的「照片」)即可得到原況照片", "notice.geo_datum_assumed": "基準面不是 WGS 84,已按相同處理,位置可能偏差數百公尺", @@ -622,7 +630,7 @@ "presets.zip_result": "將結果儲存為 ZIP", "presets.zip_result_hint": "結果檔案(Shapefile 的 .shp、.shx、.dbf、.prj 和 .cpg)會打包成原始檔案旁的一個 ZIP,而不是零散的檔案。", "presets.replace_original": "取代原始檔案", - "presets.replace_original_hint": "結果會使用原始檔案的名稱和位置,原始檔案移到資源回收筒/垃圾桶。僅在結果格式相同時生效,監看資料夾和來自瀏覽器的檔案不會取代。若原始檔案無法移到資源回收筒/垃圾桶,則保留原始檔案,結果另存為副本。", + "presets.replace_original_hint": "結果會使用原始檔案的名稱和位置,原始檔案移到資源回收筒/垃圾桶。僅在結果格式相同時生效,監看資料夾和來自瀏覽器的檔案不會取代。若原始檔案無法移到資源回收筒/垃圾桶,則保留原始檔案,結果另存為副本。無論「儲存到」如何設定,結果都會儲存在原始檔案所在的資料夾。", "presets.conflict": "檔案已存在時", "presets.conflict.Rename": "新增編號", "presets.conflict.Overwrite": "覆蓋", diff --git a/src/Filee.App/ViewModels/JobViewModel.cs b/src/Filee.App/ViewModels/JobViewModel.cs index 0077e5e87..a213dbf78 100644 --- a/src/Filee.App/ViewModels/JobViewModel.cs +++ b/src/Filee.App/ViewModels/JobViewModel.cs @@ -123,7 +123,7 @@ public void Refresh() var noted = Job.Files.Where(f => f.State == FileState.Done && f.NoticeKey is not null).ToList(); HasNotices = noted.Count > 0; Notices = noted.Count == 0 ? null : string.Join(Environment.NewLine, - noted.Select(f => $"{Path.GetFileName(f.SourcePath)} — {FailureText.Reason(_loc, f.NoticeKey, f.NoticeDetail)}")); + noted.Select(f => $"{Path.GetFileName(f.SourcePath)} — {_loc.Notice(f)}")); var missingFonts = Job.Files.SelectMany(f => f.MissingFonts) .Distinct(StringComparer.OrdinalIgnoreCase) diff --git a/src/Filee.Cli/Cli.cs b/src/Filee.Cli/Cli.cs index d48fba0b6..40baf69aa 100644 --- a/src/Filee.Cli/Cli.cs +++ b/src/Filee.Cli/Cli.cs @@ -170,8 +170,7 @@ private static string Error(FileResult file, ILocalizer texts) return string.IsNullOrWhiteSpace(file.ErrorDetail) ? message : $"{message} ({file.ErrorDetail})"; } - private static string Notice(FileResult file, ILocalizer texts) => - string.IsNullOrWhiteSpace(file.NoticeDetail) ? texts[file.NoticeKey!] : $"{texts[file.NoticeKey!]} ({file.NoticeDetail})"; + private static string Notice(FileResult file, ILocalizer texts) => texts.Notice(file); /// Relative to the current folder when the file is inside it, else the full path. private static string Shorten(string path) diff --git a/src/Filee.Cli/CliHost.cs b/src/Filee.Cli/CliHost.cs index 6dff13a28..c7bfa9e68 100644 --- a/src/Filee.Cli/CliHost.cs +++ b/src/Filee.Cli/CliHost.cs @@ -3,6 +3,7 @@ using Filee.Core.Conversion; using Filee.Core.Localization; +using Filee.Core.Platform; using Filee.Core.Settings; using Filee.Engines; using Filee.Engines.Infrastructure; @@ -36,9 +37,17 @@ public static CliHost Create(string? dataDirectory = null) var converters = EngineRegistry.CreateAll(new EngineEnvironment(store.Directory)); var catalog = new ConverterCatalog(converters) { Priority = store.Settings.EnginePriority }; var queue = new JobQueue(catalog, EngineRegistry.FindPdfMerger(converters), combiner: EngineRegistry.FindFileCombiner(converters), - tiffMerger: EngineRegistry.FindTiffMerger(converters), mediaJoiner: EngineRegistry.FindMediaJoiner(converters)); + tiffMerger: EngineRegistry.FindTiffMerger(converters), mediaJoiner: EngineRegistry.FindMediaJoiner(converters), + moveToTrash: Trash()); return new CliHost(store, catalog, queue); } + /// The system's Recycle Bin / Trash for "Replace the original", as in the app; none elsewhere. + private static Func? Trash() => + OperatingSystem.IsWindows() ? new Filee.Platform.Windows.WindowsPlatformServices().MoveToTrash + : OperatingSystem.IsMacOS() ? new Filee.Platform.MacOS.MacOSPlatformServices().MoveToTrash + : OperatingSystem.IsLinux() ? new Filee.Platform.Linux.LinuxPlatformServices().MoveToTrash + : null; + public ValueTask DisposeAsync() => Queue.DisposeAsync(); } diff --git a/src/Filee.Cli/Filee.Cli.csproj b/src/Filee.Cli/Filee.Cli.csproj index af728be3a..8fa0f9a13 100644 --- a/src/Filee.Cli/Filee.Cli.csproj +++ b/src/Filee.Cli/Filee.Cli.csproj @@ -9,11 +9,17 @@ filee-cli Filee.Cli ..\Filee.App\Assets\Icons\filee.ico + + true + + + + diff --git a/src/Filee.Core/Conversion/ConversionJob.cs b/src/Filee.Core/Conversion/ConversionJob.cs index d99a676a8..205ef0f55 100644 --- a/src/Filee.Core/Conversion/ConversionJob.cs +++ b/src/Filee.Core/Conversion/ConversionJob.cs @@ -57,7 +57,10 @@ public sealed class FileResult /// public string? NoticeKey { get; set; } - /// Non-localized detail shown after the notice in brackets (why the original was kept), or null. + /// Why, after the notice in brackets: a localization key (why the original was kept), or null. + public string? NoticeReasonKey { get; set; } + + /// The system's own words after (not localized), or null. public string? NoticeDetail { get; set; } /// diff --git a/src/Filee.Core/Conversion/JobQueue.cs b/src/Filee.Core/Conversion/JobQueue.cs index bd2f8269f..7386ff8b8 100644 --- a/src/Filee.Core/Conversion/JobQueue.cs +++ b/src/Filee.Core/Conversion/JobQueue.cs @@ -29,7 +29,7 @@ public sealed class JobQueue : IAsyncDisposable /// Output folders this queue made (a subfolder, a fixed folder that wasn't there): removed again when left empty. private readonly HashSet _madeFolders = new(Platform.FileSystemPaths.Comparer); private readonly Task _worker; - private readonly Func? _moveToTrash; + private readonly Func? _moveToTrash; private int _busy; /// Converters and their priority. @@ -43,7 +43,7 @@ public sealed class JobQueue : IAsyncDisposable /// why not. Without it originals are always kept. /// public JobQueue(ConverterCatalog catalog, IPdfMerger? pdfMerger = null, ILogger? log = null, IFileCombiner? combiner = null, - ITiffMerger? tiffMerger = null, IMediaJoiner? mediaJoiner = null, Func? moveToTrash = null) + ITiffMerger? tiffMerger = null, IMediaJoiner? mediaJoiner = null, Func? moveToTrash = null) { _moveToTrash = moveToTrash; _catalog = catalog; @@ -599,13 +599,25 @@ private static bool IsEmptyFile(string path) } } + /// The creation and modification dates of , or null when they can't be read. + private static (DateTime Created, DateTime Modified)? DatesOf(string source) + { + try + { + return (File.GetCreationTimeUtc(source), File.GetLastWriteTimeUtc(source)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } + /// Gives the outputs the creation and modification dates of the source (). - private void CopyDates(string source, IEnumerable outputs) + private void CopyDates((DateTime Created, DateTime Modified) dates, IEnumerable outputs) { try { - var created = File.GetCreationTimeUtc(source); - var modified = File.GetLastWriteTimeUtc(source); + var (created, modified) = dates; foreach (var output in outputs) { if (File.Exists(output)) @@ -623,7 +635,7 @@ private void CopyDates(string source, IEnumerable outputs) catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { // The conversion itself worked: keep the files, only the dates are new. - _log.LogWarning(ex, "Could not copy the dates of {Source}", source); + _log.LogWarning(ex, "Could not copy the source's dates to {Outputs}", string.Join(", ", outputs)); } } @@ -763,7 +775,8 @@ private async Task> RunFileAsync( } // Under "Save as ZIP" the output is still in the work folder: the source copied over it goes into the ZIP. - if (finalOutput && job.Preset.Output.OnlyIfSmaller && inputs.Count == 1 && CopyIfNotSmaller(file.SourcePath, inputs[0])) + var copiedSource = finalOutput && job.Preset.Output.OnlyIfSmaller && inputs.Count == 1 && CopyIfNotSmaller(file.SourcePath, inputs[0]); + if (copiedSource) file.NoticeKey = "notice.pdf_not_smaller"; if (zipResult && inputs.Count > 0) @@ -772,16 +785,18 @@ private async Task> RunFileAsync( else if (finalOutput && inputs.Count == 1 && final is { Handed: 1 }) inputs = [NextToSource(job, file, index, inputs[0], final)]; + // The source's dates are read now: "Replace the original" may send it to the Trash below. + var dates = finalOutput && job.Preset.Output.KeepDates ? DatesOf(file.SourcePath) : null; if (finalOutput && !zipResult && inputs.Count == 1) - inputs = [ReplaceOriginal(job, file, source.Id, inputs[0])]; + inputs = [ReplaceOriginal(job, file, source.Id, inputs[0], copiedSource)]; // A name the engine took but wrote nothing to is free again. final?.Release(except: inputs); if (finalOutput) { file.Outputs.AddRange(inputs); - if (job.Preset.Output.KeepDates) - CopyDates(file.SourcePath, inputs); + if (dates is { } kept) + CopyDates(kept, inputs); } file.State = inputs.Count == 0 && finalOutput ? FileState.Skipped : FileState.Done; file.Progress = ceiling; @@ -995,35 +1010,90 @@ private bool Reserve(string path) } /// - /// "Replace the original" (): moves the source to the Recycle Bin / Trash and - /// the finished to its path, which is returned. Anything that stops it keeps the source and - /// the output as they are, with a note saying why. + /// "Replace the original" (): the finished takes the + /// source's place and the source goes to the Recycle Bin / Trash. The path of the result is returned. The result is + /// first moved next to the source under a temporary name (a copy when it was saved on another drive), so a failure + /// there leaves the source alone; then the source goes to the Trash; then the result is renamed within the folder. + /// Anything that stops it keeps the source, with a note saying why. /// - private string ReplaceOriginal(ConversionJob job, FileResult file, string sourceFormat, string output) + /// "Only if smaller" copied the source as the result: nothing changed, so the source stays + /// where it is and the copy goes. + private string ReplaceOriginal(ConversionJob job, FileResult file, string sourceFormat, string output, bool copiedSource) { + var source = file.SourcePath; if (!job.Preset.Output.ReplaceOriginal || job.Fetcher is not null || !File.Exists(output) - || FormatRegistry.Detect(output)?.Id != sourceFormat - || string.Equals(Path.GetFullPath(output), Path.GetFullPath(file.SourcePath), Platform.FileSystemPaths.Comparison)) + || FormatRegistry.Detect(output) is not { } format || format.Id != sourceFormat + || string.Equals(Path.GetFullPath(output), Path.GetFullPath(source), Platform.FileSystemPaths.Comparison)) return output; - var reason = _moveToTrash is null ? "no Recycle Bin / Trash here" : _moveToTrash(file.SourcePath); - if (reason is null) + // TXT also covers scripts and logs (.bat, .py, .log): there only the same extension replaces. Elsewhere the + // extensions are spellings of one format (.jpeg / .jpg). + if (format.Id == "txt" && !string.Equals(Path.GetExtension(output), Path.GetExtension(source), StringComparison.OrdinalIgnoreCase)) + return output; + if (copiedSource) { try { - File.Move(output, file.SourcePath); - return file.SourcePath; + File.Delete(output); + file.NoticeKey = "notice.original_unchanged"; + return source; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - // The original is already in the Trash: the result stays where it was written. - _log.LogWarning(ex, "Could not move {Output} to {Source}", output, file.SourcePath); - reason = ex.Message; + return output; } } - _log.LogInformation("Kept the original {Source}: {Reason}", file.SourcePath, reason); - file.NoticeKey = File.Exists(file.SourcePath) ? "notice.original_kept" : "notice.original_in_trash"; - file.NoticeDetail = reason; - return output; + if (_moveToTrash is null) + return Kept(new Platform.TrashRefusal("trash.no_recycle_bin"), output); + + var staged = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(source))!, + $".{Path.GetFileName(source)}.{Guid.NewGuid().ToString("N")[..8]}.filee-tmp"); + try + { + File.Move(output, staged); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _log.LogWarning(ex, "Could not move {Output} next to {Source}", output, source); + return Kept(new Platform.TrashRefusal("trash.cannot_place", ex.Message), output); + } + + if (_moveToTrash(source) is { } refusal) + { + // The result goes back to where it was saved. + try + { + File.Move(staged, output); + return Kept(refusal, output); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _log.LogWarning(ex, "Could not move {Staged} back to {Output}", staged, output); + return Kept(refusal, staged); + } + } + try + { + File.Move(staged, source); + return source; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // The original is in the Trash already: the result stays next to where it was, under its temporary name. + _log.LogWarning(ex, "Could not rename {Staged} to {Source}", staged, source); + file.NoticeKey = "notice.original_in_trash"; + file.NoticeReasonKey = "trash.cannot_place"; + file.NoticeDetail = ex.Message; + return staged; + } + + string Kept(Platform.TrashRefusal why, string result) + { + _log.LogInformation("Kept the original {Source}: {Reason} {Detail}", source, why.ReasonKey, why.Detail); + file.NoticeKey = "notice.original_kept"; + file.NoticeReasonKey = why.ReasonKey; + file.NoticeDetail = why.Detail; + return result; + } } /// Lets go of (under the lock): free once nobody holds it. diff --git a/src/Filee.Core/Localization/ILocalizer.cs b/src/Filee.Core/Localization/ILocalizer.cs index d1186b884..ccc7763fc 100644 --- a/src/Filee.Core/Localization/ILocalizer.cs +++ b/src/Filee.Core/Localization/ILocalizer.cs @@ -25,6 +25,18 @@ public interface ILocalizer /// Helpers that resolve user-facing names of presets and profiles. public static class LocalizerExtensions { + /// + /// A file's notice with why in brackets, the reason translated and the system's own words after it: + /// "The original was kept… (the file is open in another program)". + /// + public static string Notice(this ILocalizer loc, Conversion.FileResult file) + { + var why = file.NoticeReasonKey is { } key ? loc[key] : null; + if (!string.IsNullOrWhiteSpace(file.NoticeDetail)) + why = why is null ? file.NoticeDetail : $"{why}: {file.NoticeDetail}"; + return why is null ? loc[file.NoticeKey!] : $"{loc[file.NoticeKey!]} ({why})"; + } + public static string DisplayName(this ILocalizer loc, Preset preset) => !string.IsNullOrWhiteSpace(preset.Name) ? preset.Name : preset.NameKey is not null ? loc[preset.NameKey] diff --git a/src/Filee.Core/Platform/IPlatformServices.cs b/src/Filee.Core/Platform/IPlatformServices.cs index e402e9426..a6db6bced 100644 --- a/src/Filee.Core/Platform/IPlatformServices.cs +++ b/src/Filee.Core/Platform/IPlatformServices.cs @@ -22,10 +22,10 @@ public interface IPlatformServices bool IsFileManagerAt(int x, int y); /// - /// Moves a file to the Recycle Bin / Trash, where the user can restore it (). - /// Null when it worked; otherwise why not, in a few words, and the file is left where it is. + /// Moves a file to the Recycle Bin / Trash, where the user can restore it (), + /// and never deletes it for good. Null when it worked; otherwise why not, and the file is left where it is. /// - string? MoveToTrash(string path) => "there is no Recycle Bin / Trash here"; + TrashRefusal? MoveToTrash(string path) => new TrashRefusal("trash.no_recycle_bin"); /// Process name (without extension) owning the window under the point, or null. string? ProcessNameAt(int x, int y); @@ -186,3 +186,9 @@ private static void Open(string path) System.Diagnostics.Process.Start(start)?.Dispose(); } } + +/// +/// Why a file wasn't moved to the Recycle Bin / Trash: a localization key (trash.*) and, when the system said +/// more, its own words. +/// +public sealed record TrashRefusal(string ReasonKey, string? Detail = null); diff --git a/src/Filee.Platform.Linux/FreedesktopTrash.cs b/src/Filee.Platform.Linux/FreedesktopTrash.cs index b3d6b5a44..9182dfcb2 100644 --- a/src/Filee.Platform.Linux/FreedesktopTrash.cs +++ b/src/Filee.Platform.Linux/FreedesktopTrash.cs @@ -5,41 +5,48 @@ using System.Globalization; using System.Text; +using Filee.Core.Platform; namespace Filee.Platform.Linux; internal static class FreedesktopTrash { + /// The spec asks for Trash folders only their owner can open. + private const UnixFileMode OwnerOnly = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + /// Null when is in , otherwise why it was left where it is. - public static string? Move(string path, string trash) + public static TrashRefusal? Move(string path, string trash) { var full = Path.GetFullPath(path); if (!File.Exists(full)) - return "the file is gone"; + return new TrashRefusal("trash.failed"); var files = Path.Combine(trash, "files"); var info = Path.Combine(trash, "info"); try { - Directory.CreateDirectory(files); - Directory.CreateDirectory(info); + foreach (var folder in new[] { trash, files, info }) + Directory.CreateDirectory(folder, OwnerOnly); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - return "there is no Trash folder"; + return new TrashRefusal("trash.no_folder", ex.Message); } if (MountOf(full) != MountOf(files)) - return "the Trash is on another drive"; + return new TrashRefusal("trash.other_drive"); + // Free in both: the Trash holds folders too. var name = Path.GetFileName(full); - for (var n = 2; File.Exists(Path.Combine(files, name)) || File.Exists(Path.Combine(info, name + ".trashinfo")); n++) + for (var n = 2; Path.Exists(Path.Combine(files, name)) || Path.Exists(Path.Combine(info, name + ".trashinfo")); n++) name = $"{Path.GetFileNameWithoutExtension(full)} {n}{Path.GetExtension(full)}"; var record = Path.Combine(info, name + ".trashinfo"); + var created = false; try { // The record first, as the spec asks: a crash in between leaves no file in the Trash without one. using (var stream = new FileStream(record, FileMode.CreateNew, FileAccess.Write)) - using (var writer = new StreamWriter(stream, new UTF8Encoding(false))) { + created = true; + using var writer = new StreamWriter(stream, new UTF8Encoding(false)); writer.Write($"[Trash Info]\nPath={Escape(full)}\nDeletionDate={DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture)}\n"); } File.Move(full, Path.Combine(files, name)); @@ -47,26 +54,36 @@ internal static class FreedesktopTrash } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - try { File.Delete(record); } catch (IOException) { } - return File.Exists(full) ? "it couldn't be moved to the Trash" : null; + // Only a record this call wrote: another program may have just made one of the same name. + if (created) + { + try { File.Delete(record); } catch (IOException) { } + } + return File.Exists(full) ? new TrashRefusal("trash.failed", ex.Message) : null; } } /// The path in the record: each part URL-encoded, the slashes kept. internal static string Escape(string path) => string.Join('/', path.Split('/').Select(Uri.EscapeDataString)); - /// The mount point a path is on: the longest mounted root that contains it. - private static string MountOf(string path) + /// The mount point a path is on: the longest mounted root that holds it (/home doesn't hold /home2). + internal static string MountOf(string path, IEnumerable? roots = null) { try { - return DriveInfo.GetDrives().Select(d => d.RootDirectory.FullName) - .Where(r => path.StartsWith(r, StringComparison.Ordinal)) + return (roots ?? DriveInfo.GetDrives().Select(d => d.RootDirectory.FullName)) + .Where(r => Holds(r, path)) .OrderByDescending(r => r.Length).FirstOrDefault() ?? "/"; } catch (IOException) { return "/"; } + + static bool Holds(string root, string path) + { + var folder = root.EndsWith('/') ? root : root + "/"; + return path.StartsWith(folder, StringComparison.Ordinal) || path == root; + } } } diff --git a/src/Filee.Platform.Linux/LinuxPlatformServices.cs b/src/Filee.Platform.Linux/LinuxPlatformServices.cs index 6d1e8252c..af92645b4 100644 --- a/src/Filee.Platform.Linux/LinuxPlatformServices.cs +++ b/src/Filee.Platform.Linux/LinuxPlatformServices.cs @@ -56,7 +56,7 @@ private static bool CanReadInputDevices() // Wayland can't say which window is under the cursor, so any Ctrl+drag may open the (invisible) donut window; // it only shows when files are dragged in, and excluded apps can't be told apart there either. - public string? MoveToTrash(string path) => FreedesktopTrash.Move(path, Path.Combine(XdgDirectory("XDG_DATA_HOME", ".local/share"), "Trash")); + public TrashRefusal? MoveToTrash(string path) => FreedesktopTrash.Move(path, Path.Combine(XdgDirectory("XDG_DATA_HOME", ".local/share"), "Trash")); public bool IsFileManagerAt(int x, int y) => WaylandGestures || ProcessNameAt(x, y) is { } name && FileManagers.Contains(name); diff --git a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs index ad3e42848..8fe002a5e 100644 --- a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs +++ b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs @@ -32,7 +32,7 @@ public sealed class MacOSPlatformServices : IPlatformServices public string? LastSelectionError { get; private set; } - public string? MoveToTrash(string path) => NativeMethods.MoveToTrash(path); + public TrashRefusal? MoveToTrash(string path) => NativeMethods.MoveToTrash(path); public bool IsFileManagerAt(int x, int y) => string.Equals(ProcessNameAt(x, y), "Finder", StringComparison.OrdinalIgnoreCase); diff --git a/src/Filee.Platform.MacOS/NativeMethods.cs b/src/Filee.Platform.MacOS/NativeMethods.cs index 466283674..2fe25218a 100644 --- a/src/Filee.Platform.MacOS/NativeMethods.cs +++ b/src/Filee.Platform.MacOS/NativeMethods.cs @@ -185,7 +185,7 @@ internal static bool SetShowInDock(bool show) /// NSFileManager trashItemAtURL: the file goes to the Trash of its volume, where Finder can put it back. Null when /// it worked, else the reason macOS gives. /// - public static string? MoveToTrash(string path) + public static Filee.Core.Platform.TrashRefusal? MoveToTrash(string path) { using var pool = new AutoreleasePool(); var text = CFStringCreateWithCString(0, Path.GetFullPath(path), 0x08000100); @@ -196,8 +196,8 @@ internal static bool SetShowInDock(bool show) if (SendTrash(manager, sel_registerName("trashItemAtURL:resultingItemURL:error:"), url, 0, out var error)) return null; var description = error == 0 ? 0 : Send(error, sel_registerName("localizedDescription")); - return description == 0 ? "macOS couldn't move it to the Trash" - : Marshal.PtrToStringUTF8(Send(description, sel_registerName("UTF8String"))); + return new Filee.Core.Platform.TrashRefusal("trash.failed", + description == 0 ? null : Marshal.PtrToStringUTF8(Send(description, sel_registerName("UTF8String")))); } finally { diff --git a/src/Filee.Platform.Windows/RecycleBin.cs b/src/Filee.Platform.Windows/RecycleBin.cs index 7813e264b..8691eef47 100644 --- a/src/Filee.Platform.Windows/RecycleBin.cs +++ b/src/Filee.Platform.Windows/RecycleBin.cs @@ -1,29 +1,32 @@ -// Moves a file to the Recycle Bin (OutputRule.ReplaceOriginal, #332). SHFileOperation with FOF_ALLOWUNDO deletes a -// file for good on a drive without a Recycle Bin (network shares, most USB sticks), so only local fixed drives are -// tried, and a file another program holds open is refused before Windows is asked. +// Moves a file to the Recycle Bin (OutputRule.ReplaceOriginal, #332), and never deletes it for good. SHFileOperation +// and IFileOperation both delete a file outright where it can't be recycled (a subst or network drive, a drive whose +// Recycle Bin is off, a file larger than the bin holds), without failing. So this uses IFileOperation with a progress +// sink that refuses every delete Windows says it can't recycle (PreDeleteItem without TSF_DELETE_RECYCLE_IF_POSSIBLE): +// the file then stays where it is. IFileOperation needs an STA thread. using System.Runtime.InteropServices; +using Filee.Core.Platform; namespace Filee.Platform.Windows; internal static class RecycleBin { - private const uint FO_DELETE = 3; - private const ushort FOF_SILENT = 0x4; - private const ushort FOF_NOCONFIRMATION = 0x10; - private const ushort FOF_ALLOWUNDO = 0x40; - private const ushort FOF_NOERRORUI = 0x400; + private const uint FOF_SILENT = 0x4; + private const uint FOF_NOCONFIRMATION = 0x10; + private const uint FOF_ALLOWUNDO = 0x40; + private const uint FOF_NOERRORUI = 0x400; + private const uint FOFX_RECYCLEONDELETE = 0x80000; + private const uint TSF_DELETE_RECYCLE_IF_POSSIBLE = 0x80; + private const int ERROR_CANCELLED = unchecked((int)0x800704C7); /// Null when is in the Recycle Bin, otherwise why it was left where it is. - public static string? Move(string path) + public static TrashRefusal? Move(string path) { var full = Path.GetFullPath(path); if (!File.Exists(full)) - return "the file is gone"; - var root = Path.GetPathRoot(full); - if (string.IsNullOrEmpty(root) || root.StartsWith(@"\\", StringComparison.Ordinal) - || new DriveInfo(root).DriveType != DriveType.Fixed) - return "this drive has no Recycle Bin"; + return new TrashRefusal("trash.failed"); + if ((File.GetAttributes(full) & FileAttributes.ReadOnly) != 0) + return new TrashRefusal("trash.read_only"); try { using (new FileStream(full, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) @@ -32,38 +35,138 @@ internal static class RecycleBin } catch (IOException) { - return "the file is open in another program"; + return new TrashRefusal("trash.in_use"); } - catch (UnauthorizedAccessException) + catch (UnauthorizedAccessException ex) { - return "the file is read-only or not yours to remove"; + return new TrashRefusal("trash.failed", ex.Message); } - var operation = new SHFILEOPSTRUCTW + TrashRefusal? refusal = null; + var thread = new Thread(() => refusal = Recycle(full)) { IsBackground = true, Name = "Filee Recycle Bin" }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + return refusal ?? (File.Exists(full) ? new TrashRefusal("trash.failed") : null); + } + + private static TrashRefusal? Recycle(string path) + { + var sink = new RecycleOnly(); + IFileOperation? operation = null; + try { - wFunc = FO_DELETE, - pFrom = full + "\0\0", - fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT, - }; - var result = SHFileOperationW(ref operation); - if (result != 0 || operation.fAnyOperationsAborted || File.Exists(full)) - return $"Windows couldn't move it to the Recycle Bin (0x{result:X})"; - return null; + operation = (IFileOperation)Activator.CreateInstance(Type.GetTypeFromCLSID(CLSID_FileOperation, throwOnError: true)!)!; + operation.SetOperationFlags(FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT | FOFX_RECYCLEONDELETE); + var item = SHCreateItemFromParsingName(path, 0, typeof(IShellItem).GUID); + operation.DeleteItem(item, sink); + var result = operation.PerformOperations(); + if (sink.Refused) + return new TrashRefusal("trash.no_recycle_bin"); + return result == 0 && !operation.GetAnyOperationsAborted() ? null : new TrashRefusal("trash.failed", $"0x{result:X8}"); + } + catch (COMException ex) + { + return sink.Refused ? new TrashRefusal("trash.no_recycle_bin") : new TrashRefusal("trash.failed", ex.Message); + } + finally + { + if (operation is not null) + Marshal.ReleaseComObject(operation); + } } - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - private struct SHFILEOPSTRUCTW + /// Lets a delete go ahead only when Windows will recycle it. + private sealed class RecycleOnly : IFileOperationProgressSink { - public nint hwnd; - public uint wFunc; - public string pFrom; - public string? pTo; - public ushort fFlags; - [MarshalAs(UnmanagedType.Bool)] public bool fAnyOperationsAborted; - public nint hNameMappings; - public string? lpszProgressTitle; + public bool Refused { get; private set; } + + public int PreDeleteItem(uint flags, IShellItem item) + { + if ((flags & TSF_DELETE_RECYCLE_IF_POSSIBLE) != 0) + return 0; + Refused = true; + return ERROR_CANCELLED; + } + + public int StartOperations() => 0; + public int FinishOperations(int result) => 0; + public int PreRenameItem(uint flags, IShellItem item, string? newName) => 0; + public int PostRenameItem(uint flags, IShellItem item, string? newName, int result, IShellItem? created) => 0; + public int PreMoveItem(uint flags, IShellItem item, IShellItem destination, string? newName) => 0; + public int PostMoveItem(uint flags, IShellItem item, IShellItem destination, string? newName, int result, IShellItem? created) => 0; + public int PreCopyItem(uint flags, IShellItem item, IShellItem destination, string? newName) => 0; + public int PostCopyItem(uint flags, IShellItem item, IShellItem destination, string? newName, int result, IShellItem? created) => 0; + public int PostDeleteItem(uint flags, IShellItem item, int result, IShellItem? created) => 0; + public int PreNewItem(uint flags, IShellItem destination, string? newName) => 0; + public int PostNewItem(uint flags, IShellItem destination, string? newName, string? template, uint attributes, int result, IShellItem? created) => 0; + public int UpdateProgress(uint total, uint done) => 0; + public int ResetTimer() => 0; + public int PauseTimer() => 0; + public int ResumeTimer() => 0; } - [DllImport("shell32.dll", CharSet = CharSet.Unicode)] - private static extern int SHFileOperationW(ref SHFILEOPSTRUCTW operation); + private static readonly Guid CLSID_FileOperation = new("3AD05575-8857-4850-9277-11B85BDB8E09"); + + [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)] + [return: MarshalAs(UnmanagedType.Interface, IidParameterIndex = 2)] + private static extern IShellItem SHCreateItemFromParsingName(string path, nint bindContext, [MarshalAs(UnmanagedType.LPStruct)] Guid riid); + + /// Passed through to IFileOperation only; its own methods are never called. + [ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface IShellItem + { + } + + /// IFileOperation in vtable order; only the methods Filee calls are typed, the others are never called. + [ComImport, Guid("947AAB5F-0A5C-4C13-B4D6-4BF7836FC9F8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IFileOperation + { + void Advise(); + void Unadvise(); + void SetOperationFlags(uint flags); + void SetProgressMessage(); + void SetProgressDialog(); + void SetProperties(); + void SetOwnerWindow(); + void ApplyPropertiesToItem(); + void ApplyPropertiesToItems(); + void RenameItem(); + void RenameItems(); + void MoveItem(); + void MoveItems(); + void CopyItem(); + void CopyItems(); + void DeleteItem(IShellItem item, IFileOperationProgressSink sink); + void DeleteItems(); + void NewItem(); + + [PreserveSig] + int PerformOperations(); + + [return: MarshalAs(UnmanagedType.Bool)] + bool GetAnyOperationsAborted(); + } + + [ComImport, Guid("04B0F1A7-9490-44BC-96E1-4296A31252E2"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface IFileOperationProgressSink + { + [PreserveSig] int StartOperations(); + [PreserveSig] int FinishOperations(int result); + [PreserveSig] int PreRenameItem(uint flags, IShellItem item, [MarshalAs(UnmanagedType.LPWStr)] string? newName); + [PreserveSig] int PostRenameItem(uint flags, IShellItem item, [MarshalAs(UnmanagedType.LPWStr)] string? newName, int result, IShellItem? created); + [PreserveSig] int PreMoveItem(uint flags, IShellItem item, IShellItem destination, [MarshalAs(UnmanagedType.LPWStr)] string? newName); + [PreserveSig] int PostMoveItem(uint flags, IShellItem item, IShellItem destination, [MarshalAs(UnmanagedType.LPWStr)] string? newName, int result, IShellItem? created); + [PreserveSig] int PreCopyItem(uint flags, IShellItem item, IShellItem destination, [MarshalAs(UnmanagedType.LPWStr)] string? newName); + [PreserveSig] int PostCopyItem(uint flags, IShellItem item, IShellItem destination, [MarshalAs(UnmanagedType.LPWStr)] string? newName, int result, IShellItem? created); + [PreserveSig] int PreDeleteItem(uint flags, IShellItem item); + [PreserveSig] int PostDeleteItem(uint flags, IShellItem item, int result, IShellItem? created); + [PreserveSig] int PreNewItem(uint flags, IShellItem destination, [MarshalAs(UnmanagedType.LPWStr)] string? newName); + [PreserveSig] int PostNewItem(uint flags, IShellItem destination, [MarshalAs(UnmanagedType.LPWStr)] string? newName, + [MarshalAs(UnmanagedType.LPWStr)] string? template, uint attributes, int result, IShellItem? created); + [PreserveSig] int UpdateProgress(uint total, uint done); + [PreserveSig] int ResetTimer(); + [PreserveSig] int PauseTimer(); + [PreserveSig] int ResumeTimer(); + } } diff --git a/src/Filee.Platform.Windows/WindowsPlatformServices.cs b/src/Filee.Platform.Windows/WindowsPlatformServices.cs index d580ba04e..ab6518d24 100644 --- a/src/Filee.Platform.Windows/WindowsPlatformServices.cs +++ b/src/Filee.Platform.Windows/WindowsPlatformServices.cs @@ -49,7 +49,7 @@ public void RaiseTopmost(nint windowHandle) NativeMethods.SWP_NOMOVE | NativeMethods.SWP_NOSIZE | NativeMethods.SWP_NOACTIVATE | NativeMethods.SWP_NOOWNERZORDER); } - public string? MoveToTrash(string path) => RecycleBin.Move(path); + public TrashRefusal? MoveToTrash(string path) => RecycleBin.Move(path); public bool IsFileManagerAt(int x, int y) { diff --git a/tests/Filee.Core.Tests/ReplaceOriginalTests.cs b/tests/Filee.Core.Tests/ReplaceOriginalTests.cs index aafc30c01..8826daacd 100644 --- a/tests/Filee.Core.Tests/ReplaceOriginalTests.cs +++ b/tests/Filee.Core.Tests/ReplaceOriginalTests.cs @@ -2,6 +2,7 @@ // both are kept with a note saying why. using Filee.Core.Conversion; +using Filee.Core.Platform; using Filee.Core.Presets; using Filee.Core.Watching; @@ -12,12 +13,16 @@ public class ReplaceOriginalTests private static readonly Preset Replacing = new() { TargetFormat = BuiltInData.SameAsSource, Output = { ReplaceOriginal = true } }; /// Moves files into a folder, or refuses with . - private sealed class FakeTrash(string folder, string? refuse = null) + private sealed class FakeTrash(string folder, TrashRefusal? refuse = null) { public List Moved { get; } = []; - public string? Move(string path) + /// What the source's folder held when the Trash was asked. + public List SeenBeside { get; } = []; + + public TrashRefusal? Move(string path) { + SeenBeside.AddRange(Directory.GetFiles(Path.GetDirectoryName(path)!).Select(Path.GetFileName)!); if (refuse is not null) return refuse; File.Move(path, Path.Combine(folder, Path.GetFileName(path))); @@ -60,7 +65,7 @@ public async Task When_the_trash_refuses_both_are_kept_with_the_reason() using var dir = new TempDir(); var source = dir.File("a.jpg"); - var job = await Run(new FakeTrash(dir.Path, refuse: "the file is open in another program"), source, Replacing); + var job = await Run(new FakeTrash(dir.Path, refuse: new TrashRefusal("trash.in_use")), source, Replacing); Assert.Equal(JobState.Completed, job.State); Assert.Equal("x", File.ReadAllText(source)); @@ -68,7 +73,8 @@ public async Task When_the_trash_refuses_both_are_kept_with_the_reason() Assert.NotEqual(source, output); Assert.True(File.Exists(output)); Assert.Equal("notice.original_kept", job.Files[0].NoticeKey); - Assert.Equal("the file is open in another program", job.Files[0].NoticeDetail); + Assert.Equal("trash.in_use", job.Files[0].NoticeReasonKey); + Assert.Null(job.Files[0].NoticeDetail); } [Fact] @@ -131,6 +137,85 @@ public async Task Files_from_a_browser_are_never_replaced() Assert.Equal("x", File.ReadAllText(source)); } + [Fact] + public async Task The_result_is_beside_the_original_before_it_goes_and_keeps_its_dates() + { + using var dir = new TempDir(); + using var other = new TempDir(); + using var bin = new TempDir(); + var source = dir.File("a.jpg"); + var then = new DateTime(2020, 5, 1, 12, 0, 0, DateTimeKind.Utc); + File.SetCreationTimeUtc(source, then); + File.SetLastWriteTimeUtc(source, then); + var trash = new FakeTrash(bin.Path); + var preset = new Preset + { + TargetFormat = BuiltInData.SameAsSource, + Output = { ReplaceOriginal = true, KeepDates = true, Location = OutputLocation.CustomFolder, CustomFolder = other.Path }, + }; + + var job = await Run(trash, source, preset); + + Assert.Equal([source], job.Outputs); + Assert.Equal(2, trash.SeenBeside.Count); // the original and the result under its temporary name + Assert.Empty(Directory.GetFiles(other.Path)); + Assert.Equal(then, File.GetLastWriteTimeUtc(source)); + Assert.Equal("a.jpg→jpg by magick", File.ReadAllText(source)); + } + + [Fact] + public async Task When_the_trash_refuses_the_result_stays_where_it_was_saved() + { + using var dir = new TempDir(); + using var other = new TempDir(); + var source = dir.File("a.jpg"); + var preset = new Preset + { + TargetFormat = BuiltInData.SameAsSource, + Output = { ReplaceOriginal = true, Location = OutputLocation.CustomFolder, CustomFolder = other.Path }, + }; + + var job = await Run(new FakeTrash(dir.Path, refuse: new TrashRefusal("trash.no_recycle_bin")), source, preset); + + Assert.Equal([Path.Combine(other.Path, "a.jpg")], job.Outputs); + Assert.Equal([source], Directory.GetFiles(dir.Path)); + Assert.Equal("x", File.ReadAllText(source)); + } + + [Fact] + public async Task A_script_is_not_replaced_by_text_of_another_kind() + { + using var dir = new TempDir(); + using var bin = new TempDir(); + var script = dir.File("run.bat"); + var trash = new FakeTrash(bin.Path); + await using var queue = new JobQueue(new ConverterCatalog([new FakeConverter("text", new ConversionEdge("txt", "txt"))]), moveToTrash: trash.Move); + var job = new ConversionJob([script], new Preset { TargetFormat = "txt", Output = { ReplaceOriginal = true } }, "Test"); + + await queue.RunAsync(job); + + Assert.Empty(trash.Moved); + Assert.Equal("x", File.ReadAllText(script)); + Assert.Equal([Path.Combine(dir.Path, "run.txt")], job.Outputs); + } + + [Fact] + public async Task Only_if_smaller_leaves_an_unchanged_original_alone() + { + using var dir = new TempDir(); + using var bin = new TempDir(); + var source = dir.File("a.jpg"); + var trash = new FakeTrash(bin.Path); + var preset = new Preset { TargetFormat = BuiltInData.SameAsSource, Output = { ReplaceOriginal = true, OnlyIfSmaller = true } }; + + var job = await Run(trash, source, preset); // the fake result is larger than "x" + + Assert.Empty(trash.Moved); + Assert.Equal([source], Directory.GetFiles(dir.Path)); // no copy beside it + Assert.Equal("x", File.ReadAllText(source)); + Assert.Equal("notice.original_unchanged", job.Files[0].NoticeKey); + } + [Fact] public void Watch_folders_never_replace_what_they_watch() { diff --git a/tests/Filee.Platform.Tests/TrashTests.cs b/tests/Filee.Platform.Tests/TrashTests.cs index e2add2940..cc2af22d5 100644 --- a/tests/Filee.Platform.Tests/TrashTests.cs +++ b/tests/Filee.Platform.Tests/TrashTests.cs @@ -26,14 +26,70 @@ public void A_file_open_elsewhere_stays_in_the_Recycle_Bins_place() var file = Path.Combine(_root, "open.png"); File.WriteAllText(file, "x"); - string? reason; + Filee.Core.Platform.TrashRefusal? refusal; using (new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read)) - reason = new WindowsPlatformServices().MoveToTrash(file); + refusal = new WindowsPlatformServices().MoveToTrash(file); - Assert.Equal("the file is open in another program", reason); + Assert.Equal("trash.in_use", refusal?.ReasonKey); Assert.Equal("x", File.ReadAllText(file)); } + [Fact] + public void Windows_recycles_and_never_deletes_for_good() + { + if (!OperatingSystem.IsWindows()) + { + Assert.Skip("Requires Windows."); + return; + } + // A subst drive reports itself as fixed, but has no Recycle Bin: Windows would delete the file outright. + var letter = "ZYXWVUTSRQPONMLKJ".Select(c => $"{c}:").First(d => !Directory.Exists(d + "\\")); + Assert.Equal(0, Subst(letter, _root)); + try + { + var file = Path.Combine(letter + "\\", "kept.png"); + File.WriteAllText(file, "x"); + + var refusal = new WindowsPlatformServices().MoveToTrash(file); + + Assert.Equal("trash.no_recycle_bin", refusal?.ReasonKey); + Assert.Equal("x", File.ReadAllText(file)); + } + finally + { + Subst(letter, null); + } + + // On the real drive it goes to the Recycle Bin. + var recycled = Path.Combine(_root, $"filee-test-recycle-{Guid.NewGuid():N}.txt"); + File.WriteAllText(recycled, "x"); + Assert.Null(new WindowsPlatformServices().MoveToTrash(recycled)); + Assert.False(File.Exists(recycled)); + + static int Subst(string drive, string? folder) + { + using var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("subst.exe") + { + ArgumentList = { drive, folder ?? "/D" }, + CreateNoWindow = true, + UseShellExecute = false, + })!; + process.WaitForExit(); + return process.ExitCode; + } + } + + [Fact] + public void A_mount_holds_only_its_own_folders() + { + string[] roots = ["/", "/home", "/media/usb"]; + + Assert.Equal("/home", FreedesktopTrash.MountOf("/home/ana/a.png", roots)); + Assert.Equal("/", FreedesktopTrash.MountOf("/home2/ana/a.png", roots)); + Assert.Equal("/media/usb", FreedesktopTrash.MountOf("/media/usb/a.png", roots)); + Assert.Equal("/", FreedesktopTrash.MountOf("/media/usb2/a.png", roots)); + } + [Fact] public void A_missing_file_gives_a_reason() { @@ -66,6 +122,14 @@ public void The_freedesktop_Trash_records_where_the_file_came_from() var info = File.ReadAllText(Path.Combine(trash, "info", "my photo 2.png.trashinfo")); Assert.StartsWith("[Trash Info]\nPath=" + FreedesktopTrash.Escape(first) + "\nDeletionDate=", info); Assert.Contains("my%20photo.png", info); + foreach (var folder in new[] { trash, Path.Combine(trash, "files"), Path.Combine(trash, "info") }) + Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute, File.GetUnixFileMode(folder)); + + // A folder of that name in the Trash is in the way too. + Directory.CreateDirectory(Path.Combine(trash, "files", "my photo 3.png")); + File.WriteAllText(first, "3"); + Assert.Null(FreedesktopTrash.Move(first, trash)); + Assert.Equal("3", File.ReadAllText(Path.Combine(trash, "files", "my photo 4.png"))); } [Fact] From d8a56c0a2d380ae369547b8f5a5b376f7a234edb Mon Sep 17 00:00:00 2001 From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:02:14 +0530 Subject: [PATCH 3/4] Replace the original: mark the Recycle Bin and freedesktop Trash code by platform --- src/Filee.Platform.Linux/FreedesktopTrash.cs | 2 ++ src/Filee.Platform.Windows/RecycleBin.cs | 5 ++++- tests/Filee.Platform.Tests/TrashTests.cs | 3 +++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Filee.Platform.Linux/FreedesktopTrash.cs b/src/Filee.Platform.Linux/FreedesktopTrash.cs index 9182dfcb2..08d61d61d 100644 --- a/src/Filee.Platform.Linux/FreedesktopTrash.cs +++ b/src/Filee.Platform.Linux/FreedesktopTrash.cs @@ -4,11 +4,13 @@ // are left where they are. using System.Globalization; +using System.Runtime.Versioning; using System.Text; using Filee.Core.Platform; namespace Filee.Platform.Linux; +[UnsupportedOSPlatform("windows")] internal static class FreedesktopTrash { /// The spec asks for Trash folders only their owner can open. diff --git a/src/Filee.Platform.Windows/RecycleBin.cs b/src/Filee.Platform.Windows/RecycleBin.cs index 8691eef47..c9bb9fcd3 100644 --- a/src/Filee.Platform.Windows/RecycleBin.cs +++ b/src/Filee.Platform.Windows/RecycleBin.cs @@ -5,10 +5,12 @@ // the file then stays where it is. IFileOperation needs an STA thread. using System.Runtime.InteropServices; +using System.Runtime.Versioning; using Filee.Core.Platform; namespace Filee.Platform.Windows; +[SupportedOSPlatform("windows")] internal static class RecycleBin { private const uint FOF_SILENT = 0x4; @@ -162,7 +164,8 @@ internal interface IFileOperationProgressSink [PreserveSig] int PreDeleteItem(uint flags, IShellItem item); [PreserveSig] int PostDeleteItem(uint flags, IShellItem item, int result, IShellItem? created); [PreserveSig] int PreNewItem(uint flags, IShellItem destination, [MarshalAs(UnmanagedType.LPWStr)] string? newName); - [PreserveSig] int PostNewItem(uint flags, IShellItem destination, [MarshalAs(UnmanagedType.LPWStr)] string? newName, + [PreserveSig] + int PostNewItem(uint flags, IShellItem destination, [MarshalAs(UnmanagedType.LPWStr)] string? newName, [MarshalAs(UnmanagedType.LPWStr)] string? template, uint attributes, int result, IShellItem? created); [PreserveSig] int UpdateProgress(uint total, uint done); [PreserveSig] int ResetTimer(); diff --git a/tests/Filee.Platform.Tests/TrashTests.cs b/tests/Filee.Platform.Tests/TrashTests.cs index cc2af22d5..d6deb56c6 100644 --- a/tests/Filee.Platform.Tests/TrashTests.cs +++ b/tests/Filee.Platform.Tests/TrashTests.cs @@ -5,6 +5,9 @@ using Filee.Platform.MacOS; using Filee.Platform.Windows; +// Each test checks the OS it needs; the mount and missing-file checks are plain string and file work. +#pragma warning disable CA1416 + namespace Filee.Platform.Tests; public sealed class TrashTests : IDisposable From 45a9dcf315b4d48a6e390f64eff0eefa94989573 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 23 Oct 2026 19:57:44 +0900 Subject: [PATCH 4/4] Replace the original: same extension or a spelling of it; Korean in the app's tone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One format can cover different kinds of file (PPT also .pps and .pot, AIFF also .aifc, HTML also .xhtml, TXT also scripts and logs), and the result takes the original's name. So only the same extension, or another spelling of it (.jpeg / .jpg, .tif / .tiff), replaces; the TXT-only rule becomes the general one. When the result can't take the original's name after the original went to the Trash, it goes back to the name it was saved under instead of keeping the temporary one. Korean: the new notices, reasons and hint in 해요체, like the rest of the app. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 3 +- src/Filee.App/Assets/i18n/ko.json | 22 ++++++------- src/Filee.Core/Conversion/JobQueue.cs | 31 ++++++++++++++++--- .../Filee.Core.Tests/ReplaceOriginalTests.cs | 23 ++++++++++++++ 4 files changed, 62 insertions(+), 17 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ea72801d3..470619bf1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -49,7 +49,8 @@ writing, but only at a path that was its alone: nothing was there before and no other conversion was handed the same name ("Overwrite"). It frees its names and removes an output folder it made if that is left empty. *Replace the original* (`OutputRule.ReplaceOriginal`, off by default) comes after that, for a finished result of - the source's own format (for TXT, which also covers scripts and logs, the same extension). The result is first + the source's own format and extension, or another spelling of it (`.jpeg` / `.jpg`; TXT also covers scripts and + logs, PPT also `.pps` and `.pot`, so those need the same extension). The result is first moved next to the source under a temporary name, then the source goes to the Recycle Bin / Trash (`IPlatformServices.MoveToTrash`, in the app and the command line), then the result takes its name. Windows only ever recycles (IFileOperation with a sink that refuses a delete it can't recycle). When the source can't go there diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 653a55d35..9b9923949 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -110,16 +110,16 @@ "error.geo_unsupported_shape": "지원하지 않는 도형 종류예요", "error.geo_too_large": "Shapefile의 한계인 2GB를 넘어요", "notice.pdf_not_smaller": "더 작게 만들 수 없어서 그대로 복사했어요", - "notice.original_kept": "원본을 그대로 두고 결과를 사본으로 저장했습니다", - "notice.original_in_trash": "원본은 휴지통에 있고 결과는 자기 이름을 유지했습니다", - "notice.original_unchanged": "더 작게 만들지 못해 원본을 그대로 두었습니다", - "trash.no_recycle_bin": "이 드라이브에는 휴지통이 없습니다", - "trash.in_use": "다른 프로그램에서 파일을 열고 있습니다", - "trash.read_only": "읽기 전용 파일입니다", - "trash.other_drive": "휴지통이 다른 드라이브에 있습니다", - "trash.no_folder": "휴지통 폴더가 없습니다", - "trash.failed": "휴지통으로 옮기지 못했습니다", - "trash.cannot_place": "원본 폴더에 결과를 저장하지 못했습니다", + "notice.original_kept": "원본은 그대로 두고 결과를 사본으로 저장했어요", + "notice.original_in_trash": "원본은 휴지통으로 옮겼고, 결과는 저장된 이름 그대로예요", + "notice.original_unchanged": "더 작게 만들지 못해서 원본을 그대로 두었어요", + "trash.no_recycle_bin": "이 드라이브에는 휴지통이 없어요", + "trash.in_use": "다른 프로그램에서 파일을 열고 있어요", + "trash.read_only": "읽기 전용 파일이에요", + "trash.other_drive": "휴지통이 다른 드라이브에 있어요", + "trash.no_folder": "휴지통 폴더가 없어요", + "trash.failed": "휴지통으로 옮기지 못했어요", + "trash.cannot_place": "원본 폴더에 결과를 저장하지 못했어요", "notice.empty_spreadsheet_pages_removed": "스프레드시트의 빈 페이지를 제거했어요", "notice.live_photo_pair": "같은 이름의 사진과 MOV로 저장했어요. 두 파일을 함께 가져오면(iCloud 사진, AirDrop, Mac의 사진 앱) 라이브 포토가 돼요", "notice.geo_datum_assumed": "측지계가 WGS 84가 아니라서 같은 것으로 보고 변환했어요. 위치가 수백 m까지 어긋날 수 있어요", @@ -634,7 +634,7 @@ "presets.zip_result": "결과를 ZIP으로 저장", "presets.zip_result_hint": "결과 파일들(Shapefile의 .shp, .shx, .dbf, .prj, .cpg)을 낱개로 두지 않고 원본 옆에 ZIP 하나로 묶어요.", "presets.replace_original": "원본 바꾸기", - "presets.replace_original_hint": "결과가 원본의 이름과 위치를 가져가고 원본은 휴지통으로 이동합니다. 결과가 같은 형식일 때만 적용되며, 감시 폴더나 브라우저에서 가져온 파일에는 적용되지 않습니다. 원본을 휴지통으로 옮길 수 없으면 원본은 그대로 두고 결과를 사본으로 저장합니다. 결과는 '저장 위치'와 관계없이 원본 폴더에 저장됩니다.", + "presets.replace_original_hint": "결과가 원본의 이름과 자리를 이어받고, 원본은 휴지통으로 옮겨요. 결과가 같은 형식일 때만 바꾸고, 감시 폴더나 브라우저에서 가져온 파일은 바꾸지 않아요. 원본을 휴지통으로 옮길 수 없으면 원본은 그대로 두고 결과를 사본으로 저장해요. 결과는 '저장 위치'와 상관없이 원본 폴더에 저장돼요.", "presets.conflict": "같은 파일이 있으면", "presets.conflict.Rename": "번호 붙이기", "presets.conflict.Overwrite": "덮어쓰기", diff --git a/src/Filee.Core/Conversion/JobQueue.cs b/src/Filee.Core/Conversion/JobQueue.cs index 7386ff8b8..3453d186b 100644 --- a/src/Filee.Core/Conversion/JobQueue.cs +++ b/src/Filee.Core/Conversion/JobQueue.cs @@ -1025,9 +1025,9 @@ private string ReplaceOriginal(ConversionJob job, FileResult file, string source || FormatRegistry.Detect(output) is not { } format || format.Id != sourceFormat || string.Equals(Path.GetFullPath(output), Path.GetFullPath(source), Platform.FileSystemPaths.Comparison)) return output; - // TXT also covers scripts and logs (.bat, .py, .log): there only the same extension replaces. Elsewhere the - // extensions are spellings of one format (.jpeg / .jpg). - if (format.Id == "txt" && !string.Equals(Path.GetExtension(output), Path.GetExtension(source), StringComparison.OrdinalIgnoreCase)) + // One format can cover different kinds of file (TXT: .bat, .py, .log; PPT: .pot; AIFF: .aifc), and the result + // takes the original's name: only the same extension, or another spelling of it (.jpeg / .jpg), replaces. + if (!SameKindOfFile(Path.GetExtension(output), Path.GetExtension(source))) return output; if (copiedSource) { @@ -1078,12 +1078,21 @@ private string ReplaceOriginal(ConversionJob job, FileResult file, string source } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - // The original is in the Trash already: the result stays next to where it was, under its temporary name. + // The original is in the Trash already: the result goes back to the name it was saved under. _log.LogWarning(ex, "Could not rename {Staged} to {Source}", staged, source); file.NoticeKey = "notice.original_in_trash"; file.NoticeReasonKey = "trash.cannot_place"; file.NoticeDetail = ex.Message; - return staged; + try + { + File.Move(staged, output); + return output; + } + catch (Exception again) when (again is IOException or UnauthorizedAccessException) + { + _log.LogWarning(again, "Could not move {Staged} back to {Output}", staged, output); + return staged; + } } string Kept(Platform.TrashRefusal why, string result) @@ -1096,6 +1105,18 @@ string Kept(Platform.TrashRefusal why, string result) } } + /// Extensions that only spell the same kind of file differently ("Replace the original" may swap them). + private static readonly string[][] Spellings = + [ + [".jpg", ".jpeg", ".jpe", ".jfif"], [".tiff", ".tif"], [".html", ".htm"], [".md", ".markdown"], [".yaml", ".yml"], + [".mpg", ".mpeg"], [".wav", ".wave"], [".aiff", ".aif"], [".ogg", ".oga"], [".3gp", ".3gpp"], [".m2ts", ".mts"], + [".mov", ".qt"], [".tex", ".latex"], + ]; + + private static bool SameKindOfFile(string a, string b) => + string.Equals(a, b, StringComparison.OrdinalIgnoreCase) + || Spellings.Any(s => s.Contains(a, StringComparer.OrdinalIgnoreCase) && s.Contains(b, StringComparer.OrdinalIgnoreCase)); + /// Lets go of (under the lock): free once nobody holds it. private void Unreserve(string path) { diff --git a/tests/Filee.Core.Tests/ReplaceOriginalTests.cs b/tests/Filee.Core.Tests/ReplaceOriginalTests.cs index 8826daacd..e7ed86271 100644 --- a/tests/Filee.Core.Tests/ReplaceOriginalTests.cs +++ b/tests/Filee.Core.Tests/ReplaceOriginalTests.cs @@ -199,6 +199,29 @@ public async Task A_script_is_not_replaced_by_text_of_another_kind() Assert.Equal([Path.Combine(dir.Path, "run.txt")], job.Outputs); } + [Fact] + public async Task Another_kind_of_file_in_the_same_format_is_not_replaced_but_another_spelling_is() + { + using var dir = new TempDir(); + using var bin = new TempDir(); + var show = dir.File("talk.pps"); + var trash = new FakeTrash(bin.Path); + await using (var queue = new JobQueue(new ConverterCatalog([new FakeConverter("office", new ConversionEdge("ppt", "ppt"))]), moveToTrash: trash.Move)) + { + var job = new ConversionJob([show], new Preset { TargetFormat = "ppt", Output = { ReplaceOriginal = true } }, "Test"); + await queue.RunAsync(job); + Assert.Equal([Path.Combine(dir.Path, "talk.ppt")], job.Outputs); + } + Assert.Empty(trash.Moved); + Assert.Equal("x", File.ReadAllText(show)); + + var photo = dir.File("photo.jpeg"); + var replaced = await Run(trash, photo, new Preset { TargetFormat = "jpg", Output = { ReplaceOriginal = true } }); + + Assert.Equal([photo], trash.Moved); + Assert.Equal([photo], replaced.Outputs); + } + [Fact] public async Task Only_if_smaller_leaves_an_unchanged_original_alone() {