diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5cd601a26..470619bf1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -48,6 +48,15 @@ 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 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 + (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 d5f344858..9a4671608 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -105,6 +105,16 @@ "error.geo_unsupported_shape": "هذا النوع من الأشكال غير مدعوم", "error.geo_too_large": "سيتجاوز ملف Shapefile حد الصيغة البالغ 2 غيغابايت", "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.ocr_has_text": "كل الصفحات تحتوي على نص بالفعل، لذا نُسخ الملف كما هو", "notice.live_photo_pair": "حُفظ كصورة وملف MOV بالاسم نفسه. استوردهما معًا (صور iCloud أو AirDrop أو تطبيق الصور على Mac) لتحصل على Live Photo", @@ -617,6 +627,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 08c33f554..a45088aad 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -111,6 +111,16 @@ "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.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.ocr_has_text": "Every page already has text, so it was copied as it is", "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", @@ -632,6 +642,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. 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 fbc629f41..5aa4fb0b9 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -105,6 +105,16 @@ "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.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.ocr_has_text": "Todas las páginas ya tienen texto, así que se copió tal cual", "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", @@ -617,6 +627,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. 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 fbc0a30bd..4c9ebef4e 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -111,6 +111,16 @@ "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.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.ocr_has_text": "Toutes les pages ont déjà du texte : copié tel quel", "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", @@ -632,6 +642,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. 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 aa2b97dd5..5cc338d50 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -105,6 +105,16 @@ "error.geo_unsupported_shape": "इस तरह का आकार समर्थित नहीं है", "error.geo_too_large": "Shapefile फ़ॉर्मैट की 2 GB सीमा से बड़ी हो जाएगी", "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.ocr_has_text": "हर पेज में पहले से टेक्स्ट है, इसलिए फ़ाइल जैसी थी वैसी कॉपी की गई", "notice.live_photo_pair": "एक ही नाम की फ़ोटो और MOV के रूप में सहेजा गया। लाइव फ़ोटो पाने के लिए दोनों को साथ में इम्पोर्ट करें (iCloud Photos, AirDrop या Mac पर Photos)", @@ -617,6 +627,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 ede662939..fe1db4b61 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -105,6 +105,16 @@ "error.geo_unsupported_shape": "この種類の図形には対応していません", "error.geo_too_large": "Shapefile形式の上限(2 GB)を超えてしまいます", "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.ocr_has_text": "すべてのページにテキストがあるため、元のままコピーしました", "notice.live_photo_pair": "同じ名前の写真とMOVとして保存しました。両方を一緒に取り込むと(iCloud写真、AirDrop、Macの写真アプリ)Live Photoになります", @@ -617,6 +627,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 91d11c7ed..6708b4263 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -111,6 +111,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.empty_spreadsheet_pages_removed": "스프레드시트의 빈 페이지를 제거했어요", "notice.ocr_has_text": "모든 페이지에 이미 텍스트가 있어서 그대로 복사했어요", "notice.live_photo_pair": "같은 이름의 사진과 MOV로 저장했어요. 두 파일을 함께 가져오면(iCloud 사진, AirDrop, Mac의 사진 앱) 라이브 포토가 돼요", @@ -632,6 +642,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 17e5fd992..d469fcc1a 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -105,6 +105,16 @@ "error.geo_unsupported_shape": "यस प्रकारको आकार समर्थित छैन", "error.geo_too_large": "Shapefile ढाँचाको २ GB सीमा नाघ्नेछ", "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.ocr_has_text": "हरेक पृष्ठमा पहिले नै पाठ छ, त्यसैले जस्तो छ त्यस्तै प्रतिलिपि गरियो", "notice.empty_spreadsheet_pages_removed": "स्प्रेडसिटका खाली पृष्ठहरू हटाइए", "notice.live_photo_pair": "एउटै नामको फोटो र MOV का रूपमा सुरक्षित गरियो। लाइभ फोटो पाउन दुवैलाई सँगै आयात गर्नुहोस् (iCloud Photos, AirDrop वा Mac मा Photos)", @@ -617,6 +627,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 9f88452c6..97acb64f3 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -111,6 +111,16 @@ "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.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.ocr_has_text": "Każda strona ma już tekst, więc skopiowano bez zmian", "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", @@ -632,6 +642,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. 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 ab9da6f1b..c1bc5e152 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -111,6 +111,16 @@ "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.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.ocr_has_text": "Todas as páginas já têm texto, então foi copiado como está", "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", @@ -632,6 +642,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. 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 8aacb6aa2..715bf7fa6 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -111,6 +111,16 @@ "error.geo_unsupported_shape": "Такой тип фигуры не поддерживается", "error.geo_too_large": "Shapefile превысил бы предел формата в 2 ГБ", "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.ocr_has_text": "На всех страницах уже есть текст, поэтому файл скопирован как есть", "notice.live_photo_pair": "Сохранено как фото и MOV с одинаковым именем. Импортируйте их вместе (Фото iCloud, AirDrop или «Фото» на Mac), чтобы получить Live Photo", @@ -632,6 +642,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 013e31ff8..adcc8012b 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -105,6 +105,16 @@ "error.geo_unsupported_shape": "ไม่รองรับรูปทรงเรขาคณิตประเภทนี้", "error.geo_too_large": "Shapefile จะมีขนาดเกินขีดจำกัดของฟอร์แมตที่ 2 GB", "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.ocr_has_text": "ทุกหน้ามีข้อความอยู่แล้ว จึงคัดลอกไฟล์ตามเดิม", "notice.empty_spreadsheet_pages_removed": "ลบหน้าว่างของสเปรดชีตออกแล้ว", "notice.live_photo_pair": "บันทึกเป็นรูปภาพและไฟล์ MOV ที่มีชื่อเดียวกัน นำเข้าทั้งสองไฟล์พร้อมกัน (ผ่านรูปภาพ iCloud, AirDrop หรือ Photos บน Mac) เพื่อใช้งานเป็น Live Photo", @@ -617,6 +627,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 a880dc4cb..3edb431e4 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -111,6 +111,16 @@ "error.geo_unsupported_shape": "不支持此类形状", "error.geo_too_large": "将超过 Shapefile 2 GB 的上限", "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.ocr_has_text": "每页都已有文字,已原样复制", "notice.live_photo_pair": "已保存为同名的照片和 MOV。将两者一起导入(iCloud 照片、隔空投送或 Mac 上的“照片”)即可得到实况照片", @@ -632,6 +642,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 2e36d0d17..05406af77 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -111,6 +111,16 @@ "error.geo_unsupported_shape": "不支援此類形狀", "error.geo_too_large": "將超過 Shapefile 2 GB 的上限", "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.ocr_has_text": "每頁都已有文字,已原樣複製", "notice.live_photo_pair": "已儲存為同名的照片和 MOV。將兩者一起匯入(iCloud 照片、隔空投送或 Mac 上的「照片」)即可得到原況照片", @@ -632,6 +642,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..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)} — {_loc[f.NoticeKey!]}")); + 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.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index 1fee4e9bf..6bc46ca9b 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -174,6 +174,7 @@ .. FormatRegistry.Known _pattern = _edit.Output.FileNamePattern; _keepDates = _edit.Output.KeepDates; _zipResult = _edit.Output.ZipResult; + _replaceOriginal = _edit.Output.ReplaceOriginal; } public string PresetId => _original.Id; @@ -427,6 +428,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; @@ -696,6 +699,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 81a1db4db..5eee90629 100644 --- a/src/Filee.App/Views/PresetEditorView.axaml +++ b/src/Filee.App/Views/PresetEditorView.axaml @@ -426,6 +426,11 @@ ToolTip.Tip="{DynamicResource presets.keep_dates_hint}" /> + + + + diff --git a/src/Filee.Cli/Cli.cs b/src/Filee.Cli/Cli.cs index 6c804886d..40baf69aa 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,8 @@ 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) => texts.Notice(file); + /// Relative to the current folder when the file is inside it, else the full path. private static string Shorten(string path) { @@ -201,7 +203,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.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 c3d2434eb..205ef0f55 100644 --- a/src/Filee.Core/Conversion/ConversionJob.cs +++ b/src/Filee.Core/Conversion/ConversionJob.cs @@ -57,6 +57,12 @@ public sealed class FileResult /// public string? NoticeKey { get; set; } + /// 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; } + /// /// 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 0c5a0c198..cce812339 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; @@ -594,13 +600,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)) @@ -618,7 +636,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)); } } @@ -761,7 +779,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) @@ -770,13 +789,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], 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; @@ -989,6 +1013,114 @@ private bool Reserve(string path) return true; } + /// + /// "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. + /// + /// "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) is not { } format || format.Id != sourceFormat + || string.Equals(Path.GetFullPath(output), Path.GetFullPath(source), Platform.FileSystemPaths.Comparison)) + return output; + // 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) + { + try + { + File.Delete(output); + file.NoticeKey = "notice.original_unchanged"; + return source; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + 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 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; + 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) + { + _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; + } + } + + /// 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/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 109d2e1d1..049f6fb23 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 (), + /// and never deletes it for good. Null when it worked; otherwise why not, and the file is left where it is. + /// + 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); @@ -198,3 +204,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.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..08d61d61d --- /dev/null +++ b/src/Filee.Platform.Linux/FreedesktopTrash.cs @@ -0,0 +1,91 @@ +// 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.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. + private const UnixFileMode OwnerOnly = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + + /// Null when is in , otherwise why it was left where it is. + public static TrashRefusal? Move(string path, string trash) + { + var full = Path.GetFullPath(path); + if (!File.Exists(full)) + return new TrashRefusal("trash.failed"); + var files = Path.Combine(trash, "files"); + var info = Path.Combine(trash, "info"); + try + { + foreach (var folder in new[] { trash, files, info }) + Directory.CreateDirectory(folder, OwnerOnly); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return new TrashRefusal("trash.no_folder", ex.Message); + } + if (MountOf(full) != MountOf(files)) + return new TrashRefusal("trash.other_drive"); + + // Free in both: the Trash holds folders too. + var name = Path.GetFileName(full); + 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)) + { + 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)); + return null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // 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 holds it (/home doesn't hold /home2). + internal static string MountOf(string path, IEnumerable? roots = null) + { + try + { + 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 15ae1143d..af92645b4 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 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); 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..8fe002a5e 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 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 cb83919ae..2fe25218a 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 Filee.Core.Platform.TrashRefusal? 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 new Filee.Core.Platform.TrashRefusal("trash.failed", + description == 0 ? null : 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..c9bb9fcd3 --- /dev/null +++ b/src/Filee.Platform.Windows/RecycleBin.cs @@ -0,0 +1,175 @@ +// 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 System.Runtime.Versioning; +using Filee.Core.Platform; + +namespace Filee.Platform.Windows; + +[SupportedOSPlatform("windows")] +internal static class RecycleBin +{ + 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 TrashRefusal? Move(string path) + { + var full = Path.GetFullPath(path); + if (!File.Exists(full)) + 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)) + { + } + } + catch (IOException) + { + return new TrashRefusal("trash.in_use"); + } + catch (UnauthorizedAccessException ex) + { + return new TrashRefusal("trash.failed", ex.Message); + } + + 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 + { + 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); + } + } + + /// Lets a delete go ahead only when Windows will recycle it. + private sealed class RecycleOnly : IFileOperationProgressSink + { + 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; + } + + 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 38dde8fcd..f38b99068 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 TrashRefusal? 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 94c085833..35d64af95 100644 --- a/tests/Filee.App.Tests/RenderTests.cs +++ b/tests/Filee.App.Tests/RenderTests.cs @@ -1735,6 +1735,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..e7ed86271 --- /dev/null +++ b/tests/Filee.Core.Tests/ReplaceOriginalTests.cs @@ -0,0 +1,266 @@ +// "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.Platform; +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, TrashRefusal? refuse = null) + { + public List Moved { get; } = []; + + /// 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))); + 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: new TrashRefusal("trash.in_use")), 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("trash.in_use", job.Files[0].NoticeReasonKey); + Assert.Null(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 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 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() + { + 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() + { + 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..d6deb56c6 --- /dev/null +++ b/tests/Filee.Platform.Tests/TrashTests.cs @@ -0,0 +1,152 @@ +// 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; + +// 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 +{ + 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"); + + Filee.Core.Platform.TrashRefusal? refusal; + using (new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read)) + refusal = new WindowsPlatformServices().MoveToTrash(file); + + 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() + { + 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); + 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] + 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)); + } +}