From e5371396bbeb29627fd5340bb7706d9ac13a810d Mon Sep 17 00:00:00 2001 From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:49:04 +0530 Subject: [PATCH 1/3] Presets: save to "Ask every time" (#521) A preset can ask where to save: a save dialog that starts in the source's folder, once per batch. One result (a single file, or a merge) is saved under the name chosen; several go into the chosen folder under their own names. Watch folders and the command line can't ask: they save next to the source. Files from a browser keep saving into the download folder. --- src/Filee.App/Assets/i18n/ar.json | 3 + src/Filee.App/Assets/i18n/en.json | 3 + src/Filee.App/Assets/i18n/es.json | 3 + src/Filee.App/Assets/i18n/fr.json | 3 + src/Filee.App/Assets/i18n/hi.json | 3 + src/Filee.App/Assets/i18n/ja.json | 3 + src/Filee.App/Assets/i18n/ko.json | 3 + src/Filee.App/Assets/i18n/ne.json | 3 + src/Filee.App/Assets/i18n/pl.json | 3 + src/Filee.App/Assets/i18n/pt-BR.json | 3 + src/Filee.App/Assets/i18n/pt-PT.json | 3 + src/Filee.App/Assets/i18n/ru.json | 3 + src/Filee.App/Assets/i18n/th.json | 3 + src/Filee.App/Assets/i18n/tr.json | 3 + src/Filee.App/Assets/i18n/zh-CN.json | 3 + src/Filee.App/Assets/i18n/zh-TW.json | 3 + src/Filee.App/Services/RadialController.cs | 29 +++++++++- src/Filee.App/Services/WindowService.cs | 51 +++++++++++++++++ src/Filee.Core/Presets/AskedLocation.cs | 55 +++++++++++++++++++ src/Filee.Core/Presets/OutputRule.cs | 5 ++ tests/Filee.Core.Tests/AskedLocationTests.cs | 58 ++++++++++++++++++++ 21 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 src/Filee.Core/Presets/AskedLocation.cs create mode 100644 tests/Filee.Core.Tests/AskedLocationTests.cs diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 3de1151ec..695d99363 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -745,6 +745,9 @@ "presets.location.SameFolder": "مجلد الملف الأصلي نفسه", "presets.location.Subfolder": "مجلد فرعي", "presets.location.CustomFolder": "مجلد ثابت", + "presets.location.Ask": "السؤال في كل مرة", + "ask.save_title": "حفظ النتيجة باسم", + "ask.folder_title": "اختر مكان حفظ النتائج", "presets.subfolder": "اسم المجلد الفرعي", "presets.subfolder_only_several": "فقط عندما ينتج أكثر من ملف", "presets.subfolder_only_several_hint": "إذا نتج عن التحويل ملف واحد فإنه يُحفظ بجانب الأصل؛ ولا يُنشأ المجلد الفرعي إلا عندما ينتج عدة ملفات (مثل تقسيم PDF إلى صفحات).", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 75d0ee92a..122500b29 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -760,6 +760,9 @@ "presets.location.SameFolder": "Same folder as the source", "presets.location.Subfolder": "A subfolder", "presets.location.CustomFolder": "A fixed folder", + "presets.location.Ask": "Ask every time", + "ask.save_title": "Save the result as", + "ask.folder_title": "Choose where to save the results", "presets.subfolder": "Subfolder name", "presets.subfolder_only_several": "Only when a file becomes several", "presets.subfolder_only_several_hint": "A file converted to one file is saved next to the original; only one that becomes several files (a PDF split into pages) gets the subfolder.", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index c074a17f9..cc44ee173 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -745,6 +745,9 @@ "presets.location.SameFolder": "La misma carpeta que el original", "presets.location.Subfolder": "Una subcarpeta", "presets.location.CustomFolder": "Una carpeta fija", + "presets.location.Ask": "Preguntar cada vez", + "ask.save_title": "Guardar el resultado como", + "ask.folder_title": "Elige dónde guardar los resultados", "presets.subfolder": "Nombre de la subcarpeta", "presets.subfolder_only_several": "Solo si salen varios archivos", "presets.subfolder_only_several_hint": "Si un archivo se convierte en uno solo, se guarda junto al original; la subcarpeta solo se crea cuando salen varios archivos (un PDF dividido en páginas).", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 68101164f..75400e0c2 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -760,6 +760,9 @@ "presets.location.SameFolder": "Même dossier que l'original", "presets.location.Subfolder": "Un sous-dossier", "presets.location.CustomFolder": "Un dossier fixe", + "presets.location.Ask": "Demander à chaque fois", + "ask.save_title": "Enregistrer le résultat sous", + "ask.folder_title": "Choisissez où enregistrer les résultats", "presets.subfolder": "Nom du sous-dossier", "presets.subfolder_only_several": "Seulement si un fichier en produit plusieurs", "presets.subfolder_only_several_hint": "Un fichier converti en un seul fichier est enregistré à côté de l'original ; seul celui qui en produit plusieurs (un PDF découpé en pages) va dans le sous-dossier.", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 957153f43..fff88313f 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -745,6 +745,9 @@ "presets.location.SameFolder": "स्रोत वाला ही फ़ोल्डर", "presets.location.Subfolder": "एक सबफ़ोल्डर", "presets.location.CustomFolder": "एक तय फ़ोल्डर", + "presets.location.Ask": "हर बार पूछें", + "ask.save_title": "परिणाम को इस रूप में सहेजें", + "ask.folder_title": "चुनें कि परिणाम कहाँ सहेजे जाएँ", "presets.subfolder": "सबफ़ोल्डर का नाम", "presets.subfolder_only_several": "सिर्फ़ तब, जब कई फ़ाइलें बनें", "presets.subfolder_only_several_hint": "एक फ़ाइल बनने पर वह मूल फ़ाइल के पास सेव होती है; सबफ़ोल्डर सिर्फ़ तब बनता है जब कई फ़ाइलें बनें (जैसे PDF को पेजों में बाँटने पर)।", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 5dcc03f93..4e567d174 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -745,6 +745,9 @@ "presets.location.SameFolder": "元ファイルと同じフォルダー", "presets.location.Subfolder": "サブフォルダー", "presets.location.CustomFolder": "指定したフォルダー", + "presets.location.Ask": "毎回確認する", + "ask.save_title": "変換結果を保存", + "ask.folder_title": "変換結果の保存先を選択", "presets.subfolder": "サブフォルダー名", "presets.subfolder_only_several": "複数のファイルになるときだけ", "presets.subfolder_only_several_hint": "1つのファイルに変換されたときは元のファイルの隣に保存し、複数のファイルになるとき(PDFをページごとに分けるときなど)だけサブフォルダーを作ります。", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 5e899b549..8ec8b0872 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -760,6 +760,9 @@ "presets.location.SameFolder": "원본과 같은 폴더", "presets.location.Subfolder": "하위 폴더", "presets.location.CustomFolder": "지정한 폴더", + "presets.location.Ask": "매번 묻기", + "ask.save_title": "결과를 다른 이름으로 저장", + "ask.folder_title": "결과를 저장할 곳을 고르세요", "presets.subfolder": "하위 폴더 이름", "presets.subfolder_only_several": "여러 파일이 될 때만", "presets.subfolder_only_several_hint": "한 파일로 변환되면 원본 옆에 저장하고, 여러 파일이 될 때(PDF를 페이지별로 나눌 때 등)만 하위 폴더를 만듭니다.", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index a9a85890c..9653b5bce 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -745,6 +745,9 @@ "presets.location.SameFolder": "स्रोत जस्तै उही फोल्डर", "presets.location.Subfolder": "एउटा सबफोल्डर", "presets.location.CustomFolder": "एउटा निश्चित फोल्डर", + "presets.location.Ask": "हरेक पटक सोध्नुहोस्", + "ask.save_title": "नतिजालाई यस रूपमा सेभ गर्नुहोस्", + "ask.folder_title": "नतिजाहरू कहाँ सेभ गर्ने छान्नुहोस्", "presets.subfolder": "सबफोल्डर नाम", "presets.subfolder_only_several": "धेरै फाइलहरू बन्दा मात्र", "presets.subfolder_only_several_hint": "एउटा फाइल रूपान्तरण हुँदा मूल फाइलकै छेउमा सुरक्षित हुन्छ; धेरै फाइलहरू बन्दा मात्र सबफोल्डर बन्छ (जस्तै पृष्ठहरूमा विभाजित गरिएको PDF)।", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 72f4aa02f..2cc735b74 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -760,6 +760,9 @@ "presets.location.SameFolder": "Ten sam folder co źródło", "presets.location.Subfolder": "Podfolder", "presets.location.CustomFolder": "Stały folder", + "presets.location.Ask": "Pytaj za każdym razem", + "ask.save_title": "Zapisz wynik jako", + "ask.folder_title": "Wybierz, gdzie zapisać wyniki", "presets.subfolder": "Nazwa podfolderu", "presets.subfolder_only_several": "Tylko gdy powstaje kilka plików", "presets.subfolder_only_several_hint": "Plik przekonwertowany na jeden plik zostaje zapisany obok oryginału; podfolder powstaje tylko wtedy, gdy powstaje kilka plików (PDF podzielony na strony).", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 750998136..97c6a9fb6 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -760,6 +760,9 @@ "presets.location.SameFolder": "A mesma pasta da origem", "presets.location.Subfolder": "Uma subpasta", "presets.location.CustomFolder": "Uma pasta fixa", + "presets.location.Ask": "Perguntar sempre", + "ask.save_title": "Salvar o resultado como", + "ask.folder_title": "Escolha onde salvar os resultados", "presets.subfolder": "Nome da subpasta", "presets.subfolder_only_several": "Só quando um arquivo vira vários", "presets.subfolder_only_several_hint": "Um arquivo convertido em um único arquivo é salvo ao lado do original; só um que vira vários arquivos (um PDF dividido em páginas) vai para a subpasta.", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index e6ca7c15b..bf2db301c 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -760,6 +760,9 @@ "presets.location.SameFolder": "Mesma pasta da origem", "presets.location.Subfolder": "Uma subpasta", "presets.location.CustomFolder": "Uma pasta fixa", + "presets.location.Ask": "Perguntar sempre", + "ask.save_title": "Guardar o resultado como", + "ask.folder_title": "Escolha onde guardar os resultados", "presets.subfolder": "Nome da subpasta", "presets.subfolder_only_several": "Só se resultar em vários ficheiros", "presets.subfolder_only_several_hint": "Se a conversão gerar um só ficheiro, este é guardado junto ao original. A subpasta só é criada quando gerar vários ficheiros (por exemplo, ao dividir um PDF em páginas).", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 29acdd1e0..69668b818 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -760,6 +760,9 @@ "presets.location.SameFolder": "В ту же папку, что исходник", "presets.location.Subfolder": "Во вложенную папку", "presets.location.CustomFolder": "В заданную папку", + "presets.location.Ask": "Спрашивать каждый раз", + "ask.save_title": "Сохранить результат как", + "ask.folder_title": "Выберите, куда сохранить результаты", "presets.subfolder": "Имя вложенной папки", "presets.subfolder_only_several": "Только если файлов получается несколько", "presets.subfolder_only_several_hint": "Если из файла получается один файл, он сохраняется рядом с оригиналом; вложенная папка создаётся, только когда файлов несколько (например, PDF разбит на страницы).", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 8c419e6b3..caf39ce4e 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -745,6 +745,9 @@ "presets.location.SameFolder": "โฟลเดอร์เดียวกับต้นฉบับ", "presets.location.Subfolder": "โฟลเดอร์ย่อย", "presets.location.CustomFolder": "โฟลเดอร์ที่กำหนดเอง", + "presets.location.Ask": "ถามทุกครั้ง", + "ask.save_title": "บันทึกผลลัพธ์เป็น", + "ask.folder_title": "เลือกที่บันทึกผลลัพธ์", "presets.subfolder": "ชื่อโฟลเดอร์ย่อย", "presets.subfolder_only_several": "เฉพาะเมื่อหนึ่งไฟล์แยกเป็นหลายไฟล์", "presets.subfolder_only_several_hint": "ไฟล์ที่แปลงแล้วได้ไฟล์เดียวจะบันทึกข้างไฟล์ต้นฉบับ มีเฉพาะไฟล์ที่แตกออกเป็นหลายไฟล์ (เช่น PDF ที่แยกหน้า) ที่จะบันทึกลงในโฟลเดอร์ย่อย", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 62a8e5e1d..1bf29e89a 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -745,6 +745,9 @@ "presets.location.SameFolder": "Kaynakla aynı klasör", "presets.location.Subfolder": "Alt klasör", "presets.location.CustomFolder": "Sabit klasör", + "presets.location.Ask": "Her seferinde sor", + "ask.save_title": "Sonucu farklı kaydet", + "ask.folder_title": "Sonuçların kaydedileceği yeri seçin", "presets.subfolder": "Alt klasör adı", "presets.subfolder_only_several": "Yalnızca tek dosya birden fazla dosyaya dönüşürse", "presets.subfolder_only_several_hint": "Tek dosyaya dönüştürülen dosya özgünün yanına kaydedilir; yalnızca birkaç dosyaya dönüşenler (sayfalara bölünen PDF gibi) alt klasöre kaydedilir.", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 340de83b0..0d252fa33 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -760,6 +760,9 @@ "presets.location.SameFolder": "与源文件相同的文件夹", "presets.location.Subfolder": "子文件夹", "presets.location.CustomFolder": "指定文件夹", + "presets.location.Ask": "每次询问", + "ask.save_title": "将结果另存为", + "ask.folder_title": "选择结果的保存位置", "presets.subfolder": "子文件夹名称", "presets.subfolder_only_several": "仅在生成多个文件时", "presets.subfolder_only_several_hint": "只生成一个文件时保存在原文件旁边,生成多个文件时(例如 PDF 拆分成页面)才新建子文件夹。", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index ee508a742..3c1f2c992 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -760,6 +760,9 @@ "presets.location.SameFolder": "與來源檔案相同的資料夾", "presets.location.Subfolder": "子資料夾", "presets.location.CustomFolder": "指定資料夾", + "presets.location.Ask": "每次詢問", + "ask.save_title": "將結果另存為", + "ask.folder_title": "選擇結果的儲存位置", "presets.subfolder": "子資料夾名稱", "presets.subfolder_only_several": "僅在產生多個檔案時", "presets.subfolder_only_several_hint": "只產生一個檔案時儲存在原始檔案旁邊,產生多個檔案時(例如 PDF 拆分成頁面)才新增子資料夾。", diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index b8fb4174d..b518bb0a6 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -346,6 +346,15 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w return; } + // "Ask every time" (#521): the save dialog first; the toast shows the job, the donut has closed for the dialog. + // shortcut: files from a browser aren't asked about, they save into the download folder as "next to the source". + if (preset.Output.Location == OutputLocation.Ask && web is null) + { + Close(animate: false); + _ = AskThenConvertAsync(sources, preset, oneResult: sources.Count == 1); + return; + } + log.LogInformation("Converting {Count} file(s) with preset {Preset}{Web}", sources.Count, preset.Id, web is null ? "" : " (from a browser)"); var job = conversions.Start(sources, preset, showInToast: false, web); @@ -363,7 +372,9 @@ private async Task MergeInOrderAsync(IReadOnlyList files, Preset preset, try { var ordered = await windows.ChooseMergeOrderAsync(files); - if (ordered is not null) + if (ordered is not null && preset.Output.Location == OutputLocation.Ask && web is null) + await AskThenConvertAsync(ordered, preset, oneResult: true); // the merge is one file + else if (ordered is not null) { conversions.Start(ordered, preset, web: web); web = null; // the conversion owns the browser drop now @@ -379,6 +390,22 @@ private async Task MergeInOrderAsync(IReadOnlyList files, Preset preset, } } + private async Task AskThenConvertAsync(IReadOnlyList sources, Preset preset, bool oneResult) + { + try + { + if (await windows.AskSaveLocationAsync(sources, preset, oneResult) is { } asked) + { + log.LogInformation("Converting {Count} file(s) with preset {Preset} where the user chose", sources.Count, preset.Id); + conversions.Start(sources, asked); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + log.LogError(ex, "Could not ask where to save"); + } + } + /// A tool (crop, clip) on files from a browser: they are saved first, then it opens on them. private async Task ShowToolForBrowserFilesAsync(WebDrop web, Filee.Core.Presets.Preset preset, PixelPoint? near) { diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index 20ed7e986..90a21e0f0 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -5,6 +5,7 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Layout; using Avalonia.Media; +using Avalonia.Platform.Storage; using Filee.App.ViewModels; using Filee.App.Views; using Filee.Core.Conversion; @@ -67,6 +68,56 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem return dialog.Result; } + /// + /// "Ask every time" (#521): one save dialog for the batch, starting in the first source's folder. One result is + /// saved under a name (), several go into a folder. Null when cancelled. + /// + /// The batch makes one file (a single source, or a merge of several). + public async Task AskSaveLocationAsync(IReadOnlyList sources, Preset preset, bool oneResult) + { + // The donut has closed and the main window may be in the tray: a small window of its own owns the dialog. + var host = new Window + { + Width = 1, + Height = 1, + Opacity = 0, + ShowInTaskbar = false, + WindowDecorations = WindowDecorations.None, + WindowStartupLocation = WindowStartupLocation.CenterScreen, + Title = "Filee", + }; + host.Show(); + try + { + var storage = host.StorageProvider; + var start = await storage.TryGetFolderFromPathAsync(Path.GetDirectoryName(Path.GetFullPath(sources[0]))!); + if (oneResult) + { + var extension = AskedLocation.Extension(preset, sources[0]); + var file = await storage.SaveFilePickerAsync(new Avalonia.Platform.Storage.FilePickerSaveOptions + { + Title = loc["ask.save_title"], + SuggestedStartLocation = start, + SuggestedFileName = AskedLocation.SuggestedName(preset, loc.DisplayName(preset), sources[0]), + DefaultExtension = extension.Length == 0 ? null : extension, + ShowOverwritePrompt = true, + }); + return file?.TryGetLocalPath() is { } path ? AskedLocation.ForFile(preset, path) : null; + } + var folders = await storage.OpenFolderPickerAsync(new Avalonia.Platform.Storage.FolderPickerOpenOptions + { + Title = loc["ask.folder_title"], + SuggestedStartLocation = start, + AllowMultiple = false, + }); + return folders.FirstOrDefault()?.TryGetLocalPath() is { } folder ? AskedLocation.ForFolder(preset, folder) : null; + } + finally + { + host.Close(); + } + } + public MainWindow? Main => _main; /// diff --git a/src/Filee.Core/Presets/AskedLocation.cs b/src/Filee.Core/Presets/AskedLocation.cs new file mode 100644 index 000000000..4bc582015 --- /dev/null +++ b/src/Filee.Core/Presets/AskedLocation.cs @@ -0,0 +1,55 @@ +// "Ask every time" (#521): what the save dialog suggests, and the preset as it runs once the user has answered it. + +using Filee.Core.Formats; + +namespace Filee.Core.Presets; + +public static class AskedLocation +{ + /// + /// The name the save dialog suggests for : the preset's name pattern and renames, and the + /// target's extension (the source's own for "same as source"). + /// + public static string SuggestedName(Preset preset, string presetName, string source) + { + var tokens = new OutputPathResolver.Tokens(source, presetName, 1, DateTime.Now); + var name = OutputPathResolver.Sanitize( + OutputPathResolver.Rename(OutputPathResolver.ExpandPattern(preset.Output.FileNamePattern, tokens), preset.Output.Renames), + FormatRegistry.NameWithoutExtension(source)); + var extension = Extension(preset, source); + return extension.Length == 0 ? name : $"{name}.{extension}"; + } + + /// The extension the result gets, without the dot; empty for a folder ("Extract"). + public static string Extension(Preset preset, string source) => + preset.TargetFormat == BuiltInData.SameAsSource + ? FormatRegistry.ExtensionOf(source) + : FormatRegistry.FindById(preset.TargetFormat)?.Extensions.FirstOrDefault() ?? ""; + + /// + /// One result, saved as : that folder and that name, without the pattern or renames + /// on top. The dialog has already asked about replacing a file there, so it is replaced. + /// + public static Preset ForFile(Preset preset, string chosenPath) + { + var copy = InFolder(preset, Path.GetDirectoryName(Path.GetFullPath(chosenPath))!); + // shortcut: a chosen name with a pattern token in it ("{date}") is expanded, there is no escape; rare enough. + copy.Output.FileNamePattern = Path.GetFileNameWithoutExtension(chosenPath); + copy.Output.Renames = []; + copy.Output.Conflict = ConflictPolicy.Overwrite; + return copy; + } + + /// Several results, all saved in under the preset's own names. + public static Preset ForFolder(Preset preset, string folder) => InFolder(preset, folder); + + private static Preset InFolder(Preset preset, string folder) + { + var copy = preset.Clone(); + copy.Output.Location = OutputLocation.CustomFolder; + copy.Output.CustomFolder = folder; + copy.Output.KeepFoldersFrom = ""; + copy.Output.SubfolderOnlyForSeveralFiles = false; + return copy; + } +} diff --git a/src/Filee.Core/Presets/OutputRule.cs b/src/Filee.Core/Presets/OutputRule.cs index c9dc828b6..fe8714948 100644 --- a/src/Filee.Core/Presets/OutputRule.cs +++ b/src/Filee.Core/Presets/OutputRule.cs @@ -16,6 +16,11 @@ public enum OutputLocation Subfolder, /// In a fixed folder (see ). CustomFolder, + /// + /// "Ask every time" (#521): a save dialog that starts in the source's folder, once per batch (see + /// ). Where nothing can ask (watch folders, the command line), next to the source. + /// + Ask, } /// What to do when the output file already exists. diff --git a/tests/Filee.Core.Tests/AskedLocationTests.cs b/tests/Filee.Core.Tests/AskedLocationTests.cs new file mode 100644 index 000000000..c964fb0fe --- /dev/null +++ b/tests/Filee.Core.Tests/AskedLocationTests.cs @@ -0,0 +1,58 @@ +// "Ask every time" (#521): what the save dialog suggests, and where the files go once it is answered. + +using Filee.Core.Presets; + +namespace Filee.Core.Tests; + +public class AskedLocationTests +{ + private static readonly OutputPathResolver.Tokens At = new(Path.Combine(Path.GetTempPath(), "photos", "IMG_0412.heic"), "JPG", 1, DateTime.Now); + + private static Preset Asking(string target = "jpg") => new() + { + TargetFormat = target, + Output = { Location = OutputLocation.Ask, FileNamePattern = "{name}_small", Renames = [new RenameStep { Kind = RenameKind.Replace, Find = "IMG_", Replace = "Photo_" }] }, + }; + + [Fact] + public void The_dialog_suggests_the_name_the_preset_would_give() + { + Assert.Equal("Photo_0412_small.jpg", AskedLocation.SuggestedName(Asking(), "JPG", At.SourcePath)); + Assert.Equal("Photo_0412_small.heic", AskedLocation.SuggestedName(Asking(BuiltInData.SameAsSource), "Same", At.SourcePath)); + } + + [Fact] + public void Where_nothing_asks_the_result_goes_next_to_the_source() + { + var path = OutputPathResolver.Resolve(Asking().Output, At, "jpg", _ => false); + Assert.Equal(Path.Combine(Path.GetDirectoryName(At.SourcePath)!, "Photo_0412_small.jpg"), path); + } + + [Fact] + public void One_result_is_saved_exactly_as_chosen_replacing_what_the_dialog_asked_about() + { + var chosen = Path.Combine(Path.GetTempPath(), "out", "holiday.jpg"); + var preset = Asking(); + + var asked = AskedLocation.ForFile(preset, chosen); + + // The name typed in the dialog, without the pattern or renames on top, even though the file exists. + Assert.Equal(chosen, OutputPathResolver.Resolve(asked.Output, At, "jpg", _ => true)); + Assert.Equal(OutputLocation.Ask, preset.Output.Location); // the preset itself keeps asking + Assert.Single(preset.Output.Renames); + } + + [Fact] + public void Several_results_go_into_the_chosen_folder_under_their_own_names() + { + var folder = Path.Combine(Path.GetTempPath(), "chosen"); + var preset = Asking(); + preset.Output.Conflict = ConflictPolicy.Rename; + + var asked = AskedLocation.ForFolder(preset, folder); + + Assert.Equal(Path.Combine(folder, "Photo_0412_small.jpg"), OutputPathResolver.Resolve(asked.Output, At, "jpg", _ => false)); + // An existing file there isn't replaced: only one picked by name in the dialog is. + Assert.Equal(Path.Combine(folder, "Photo_0412_small (2).jpg"), OutputPathResolver.Resolve(asked.Output, At, "jpg", p => !p.Contains("(2)"))); + } +} From 7e3d2a13efc3b6ab5cdb22ae1fd2c56158a80cf3 Mon Sep 17 00:00:00 2001 From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:54:57 +0530 Subject: [PATCH 2/3] Ask every time: German strings (#541 came in on main) --- src/Filee.App/Assets/i18n/de.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index fc742a82b..58b33ae34 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -745,6 +745,9 @@ "presets.location.SameFolder": "Gleicher Ordner wie die Quelldatei", "presets.location.Subfolder": "Unterordner", "presets.location.CustomFolder": "Fester Ordner", + "presets.location.Ask": "Jedes Mal fragen", + "ask.save_title": "Ergebnis speichern unter", + "ask.folder_title": "Wählen Sie, wo die Ergebnisse gespeichert werden", "presets.subfolder": "Name des Unterordners", "presets.subfolder_only_several": "Nur wenn aus einer Datei mehrere werden", "presets.subfolder_only_several_hint": "Wird eine Datei in eine einzelne Datei konvertiert, wird diese neben dem Original gespeichert. Nur wenn mehrere Dateien entstehen (etwa einzelne PDF-Seiten), wird der Unterordner verwendet.", From 4e26f8f9fb54b4f30c504ac2e8fbd30595c6e3fe Mon Sep 17 00:00:00 2001 From: cre8ive-marketing <168519979+cre8ive-marketing@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:23:23 +0530 Subject: [PATCH 3/3] Ask every time: replace only the confirmed file, a bool of its own, one-file batches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the review of #572: - The job keeps the preset's own conflict policy; only the exact path the save dialog confirmed may replace a file (OutputRule.ConfirmedPath, runtime only). Pages, a typed extension other than the result's, and ZIPs get numbered as usual. The dialog is asked with Settings › General applied, and suggests .zip for a ZIP. - "Ask every time" is OutputRule.AskEveryTime, not a Location value, so an older Filee keeps the preset and uses its Location. Location reads an unknown name as Default (LenientEnumJsonConverter). - Several sources that make one file (merged PDF, multi-page TIFF, one archive, joins) are asked for by name (AskedLocation.MakesOneResult). - The chosen place turns "Replace the original" off for that run. --- src/Filee.App/Services/RadialController.cs | 14 ++- .../ViewModels/PresetEditorViewModel.cs | 17 ++- src/Filee.Core/Presets/AskedLocation.cs | 32 +++-- src/Filee.Core/Presets/OutputPathResolver.cs | 3 + src/Filee.Core/Presets/OutputRule.cs | 21 +++- tests/Filee.Core.Tests/AskedLocationTests.cs | 116 ++++++++++++++++-- 6 files changed, 169 insertions(+), 34 deletions(-) diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index b518bb0a6..d689f25d7 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -348,10 +348,10 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w // "Ask every time" (#521): the save dialog first; the toast shows the job, the donut has closed for the dialog. // shortcut: files from a browser aren't asked about, they save into the download folder as "next to the source". - if (preset.Output.Location == OutputLocation.Ask && web is null) + if (preset.Output.AskEveryTime && web is null) { Close(animate: false); - _ = AskThenConvertAsync(sources, preset, oneResult: sources.Count == 1); + _ = AskThenConvertAsync(sources, preset); return; } @@ -372,8 +372,8 @@ private async Task MergeInOrderAsync(IReadOnlyList files, Preset preset, try { var ordered = await windows.ChooseMergeOrderAsync(files); - if (ordered is not null && preset.Output.Location == OutputLocation.Ask && web is null) - await AskThenConvertAsync(ordered, preset, oneResult: true); // the merge is one file + if (ordered is not null && preset.Output.AskEveryTime && web is null) + await AskThenConvertAsync(ordered, preset); else if (ordered is not null) { conversions.Start(ordered, preset, web: web); @@ -390,11 +390,13 @@ private async Task MergeInOrderAsync(IReadOnlyList files, Preset preset, } } - private async Task AskThenConvertAsync(IReadOnlyList sources, Preset preset, bool oneResult) + private async Task AskThenConvertAsync(IReadOnlyList sources, Preset preset) { try { - if (await windows.AskSaveLocationAsync(sources, preset, oneResult) is { } asked) + // Asked as it will run (a ZIP from Settings › General is a .zip), for one file or a folder as the job makes them. + var running = store.Settings.WithDefaultOutput(preset); + if (await windows.AskSaveLocationAsync(sources, running, AskedLocation.MakesOneResult(running, sources.Count)) is { } asked) { log.LogInformation("Converting {Count} file(s) with preset {Preset} where the user chose", sources.Count, preset.Id); conversions.Start(sources, asked); diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index 44cb482cf..1940480a8 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -91,7 +91,9 @@ .. FormatRegistry.Known .Select(l => new Choice(l.Code, l.NativeName, OcrLanguages.IsInstalled(l.Code) || vision.Reads(l.Code) ? null : loc.Format("presets.ocr_language_size", $"{l.Size / 1_000_000.0:0.#} MB")))]; - Locations = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.location.{v}"])).ToList(); + // "Ask every time" (#521) is a bool of its own on the preset (OutputRule.AskEveryTime): its choice has no location. + Locations = [.. Enum.GetValues().Select(v => new Choice(v, loc[$"presets.location.{v}"])), + new Choice(null, loc["presets.location.Ask"])]; Conflicts = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.conflict.{v}"])).ToList(); TiffCompressions = Enum.GetValues().Select(v => new Choice(v, v.ToString().ToUpperInvariant())).ToList(); FitsTiffModes = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.fits_tiff.{v}"])).ToList(); @@ -124,7 +126,7 @@ .. FormatRegistry.Known _pdfPassword = _edit.Pdf.Password; _ocr = _edit.Pdf.Ocr; _ocrLanguage = OcrLanguageChoices.FirstOrDefault(l => l.Value == _edit.Pdf.OcrLanguage) ?? OcrLanguageChoices[0]; - _location = Locations.First(l => l.Value == _edit.Output.Location); + _location = _edit.Output.AskEveryTime ? Locations[^1] : Locations.First(l => l.Value == _edit.Output.Location); _conflict = Conflicts.First(c => c.Value == _edit.Output.Conflict); _tiffCompression = TiffCompressions.First(t => t.Value == _edit.Image.TiffCompression); _fitsTiffMode = FitsTiffModes.FirstOrDefault(m => m.Value == _edit.Image.FitsTiff) ?? FitsTiffModes[0]; @@ -211,7 +213,7 @@ .. FormatRegistry.Known /// PDF → PDF: don't compress, or the level (pictures at 72, 150 or 300 dpi). public IReadOnlyList> CompressionLevels { get; } - public IReadOnlyList> Locations { get; } + public IReadOnlyList> Locations { get; } public IReadOnlyList> Conflicts { get; } public IReadOnlyList> TiffCompressions { get; } @@ -373,7 +375,7 @@ public Choice AudioCodecChoice private Choice _pageSize; [ObservableProperty, NotifyPropertyChangedFor(nameof(ShowSubfolder), nameof(ShowCustomFolder))] - private Choice _location; + private Choice _location; [ObservableProperty] private Choice _conflict; [ObservableProperty] private Choice _tiffCompression; @@ -516,7 +518,7 @@ partial void OnPdfPasswordChanged(string value) partial void OnRotationChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Rotation = v); partial void OnFlipChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Flip = v); partial void OnPageSizeChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => PageSize = v); - partial void OnLocationChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Location = v); + partial void OnLocationChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Location = v); partial void OnConflictChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Conflict = v); partial void OnTiffCompressionChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => TiffCompression = v); @@ -759,7 +761,10 @@ static double Seconds(double value, double fallback, double min, double max) => _original.Cad.Version = ShowCad ? CadVersion.Value : default; var output = _original.Output; - output.Location = Location.Value; + // Asking keeps the location it had: a Filee that can't ask (or one from before it) saves there. + output.AskEveryTime = Location.Value is null; + if (Location.Value is { } location) + output.Location = location; output.SubfolderName = string.IsNullOrWhiteSpace(SubfolderName) ? "converted" : SubfolderName.Trim(); // Only with a subfolder, where it is offered: a box ticked there must not stay on hidden behind another location. output.SubfolderOnlyForSeveralFiles = ShowSubfolder && SubfolderOnlyForSeveralFiles; diff --git a/src/Filee.Core/Presets/AskedLocation.cs b/src/Filee.Core/Presets/AskedLocation.cs index 4bc582015..f5af3b52c 100644 --- a/src/Filee.Core/Presets/AskedLocation.cs +++ b/src/Filee.Core/Presets/AskedLocation.cs @@ -6,9 +6,20 @@ namespace Filee.Core.Presets; public static class AskedLocation { + /// + /// The batch makes one file, so the dialog asks for its name: one source, or several that become one (a merged + /// PDF, a multi-page TIFF, one archive, joined videos). Otherwise it asks for a folder. + /// + public static bool MakesOneResult(Preset preset, int sources) => + sources == 1 + || preset.TargetFormat == "pdf" && preset.Pdf.MergeIntoSingle + || preset.Image.MultiPageTiff && preset.TargetFormat == "tiff" + || preset.PacksIntoOneArchive + || preset.JoinsIntoOneMedia; + /// /// The name the save dialog suggests for : the preset's name pattern and renames, and the - /// target's extension (the source's own for "same as source"). + /// extension the result gets. /// public static string SuggestedName(Preset preset, string presetName, string source) { @@ -20,15 +31,19 @@ public static string SuggestedName(Preset preset, string presetName, string sour return extension.Length == 0 ? name : $"{name}.{extension}"; } - /// The extension the result gets, without the dot; empty for a folder ("Extract"). + /// + /// The extension the result gets, without the dot: "zip" when it's saved as a ZIP, the source's own for "same as + /// source", empty for a folder ("Extract"). The preset is the one that runs, with Settings › General applied. + /// public static string Extension(Preset preset, string source) => - preset.TargetFormat == BuiltInData.SameAsSource - ? FormatRegistry.ExtensionOf(source) - : FormatRegistry.FindById(preset.TargetFormat)?.Extensions.FirstOrDefault() ?? ""; + preset.Output.ZipResult ? "zip" + : preset.TargetFormat == BuiltInData.SameAsSource ? FormatRegistry.ExtensionOf(source) + : FormatRegistry.FindById(preset.TargetFormat)?.Extensions.FirstOrDefault() ?? ""; /// /// One result, saved as : that folder and that name, without the pattern or renames - /// on top. The dialog has already asked about replacing a file there, so it is replaced. + /// on top. Only that exact path, which the dialog asked about, may replace a file there; pages or a result with + /// another extension keep the preset's own conflict policy. /// public static Preset ForFile(Preset preset, string chosenPath) { @@ -36,7 +51,7 @@ public static Preset ForFile(Preset preset, string chosenPath) // shortcut: a chosen name with a pattern token in it ("{date}") is expanded, there is no escape; rare enough. copy.Output.FileNamePattern = Path.GetFileNameWithoutExtension(chosenPath); copy.Output.Renames = []; - copy.Output.Conflict = ConflictPolicy.Overwrite; + copy.Output.ConfirmedPath = Path.GetFullPath(chosenPath); return copy; } @@ -46,10 +61,13 @@ public static Preset ForFile(Preset preset, string chosenPath) private static Preset InFolder(Preset preset, string folder) { var copy = preset.Clone(); + copy.Output.AskEveryTime = false; copy.Output.Location = OutputLocation.CustomFolder; copy.Output.CustomFolder = folder; copy.Output.KeepFoldersFrom = ""; copy.Output.SubfolderOnlyForSeveralFiles = false; + // The place the user picked is where the result goes, not back over the original. + copy.Output.ReplaceOriginal = false; return copy; } } diff --git a/src/Filee.Core/Presets/OutputPathResolver.cs b/src/Filee.Core/Presets/OutputPathResolver.cs index 57fa8d126..bfb5ded70 100644 --- a/src/Filee.Core/Presets/OutputPathResolver.cs +++ b/src/Filee.Core/Presets/OutputPathResolver.cs @@ -47,6 +47,9 @@ public static partial class OutputPathResolver var isSource = PathsEqual(candidate, tokens.SourcePath); if (!isSource && !exists(candidate)) return candidate; + // The save dialog asked about replacing exactly this file ("Ask every time"); any other keeps the policy. + if (!isSource && rule.ConfirmedPath is { } confirmed && PathsEqual(candidate, confirmed)) + return candidate; if (!isSource && rule.Conflict == ConflictPolicy.Overwrite) return candidate; diff --git a/src/Filee.Core/Presets/OutputRule.cs b/src/Filee.Core/Presets/OutputRule.cs index fe8714948..ae79a68bd 100644 --- a/src/Filee.Core/Presets/OutputRule.cs +++ b/src/Filee.Core/Presets/OutputRule.cs @@ -16,11 +16,6 @@ public enum OutputLocation Subfolder, /// In a fixed folder (see ). CustomFolder, - /// - /// "Ask every time" (#521): a save dialog that starts in the source's folder, once per batch (see - /// ). Where nothing can ask (watch folders, the command line), next to the source. - /// - Ask, } /// What to do when the output file already exists. @@ -37,8 +32,24 @@ public enum ConflictPolicy /// Output placement and naming rules. public sealed class OutputRule { + /// A name this version doesn't know (from a newer Filee) reads as . + [System.Text.Json.Serialization.JsonConverter(typeof(LenientEnumJsonConverter))] public OutputLocation Location { get; set; } = OutputLocation.Default; + /// + /// "Ask every time" (#521): the donut asks where to save first, once per batch, in the source's folder + /// (). Where nothing can ask (the command line, files from a browser, other tools), + /// applies. A bool of its own, so a Filee from before it keeps the preset and uses Location. + /// + public bool AskEveryTime { get; set; } + + /// + /// The one path the user picked and confirmed in the save dialog: it may replace a file there whatever + /// says. Set per job, never saved with a preset. + /// + [System.Text.Json.Serialization.JsonIgnore] + public string? ConfirmedPath { get; set; } + public string SubfolderName { get; set; } = "converted"; /// diff --git a/tests/Filee.Core.Tests/AskedLocationTests.cs b/tests/Filee.Core.Tests/AskedLocationTests.cs index c964fb0fe..a35f459aa 100644 --- a/tests/Filee.Core.Tests/AskedLocationTests.cs +++ b/tests/Filee.Core.Tests/AskedLocationTests.cs @@ -1,6 +1,10 @@ -// "Ask every time" (#521): what the save dialog suggests, and where the files go once it is answered. +// "Ask every time" (#521): what the save dialog suggests, and where the files go once it is answered: only the file +// the dialog asked about may replace one, and a preset that asks still loads in a Filee from before it. +using System.Text.Json; +using Filee.Core.Conversion; using Filee.Core.Presets; +using Filee.Core.Settings; namespace Filee.Core.Tests; @@ -11,7 +15,7 @@ public class AskedLocationTests private static Preset Asking(string target = "jpg") => new() { TargetFormat = target, - Output = { Location = OutputLocation.Ask, FileNamePattern = "{name}_small", Renames = [new RenameStep { Kind = RenameKind.Replace, Find = "IMG_", Replace = "Photo_" }] }, + Output = { AskEveryTime = true, FileNamePattern = "{name}_small", Renames = [new RenameStep { Kind = RenameKind.Replace, Find = "IMG_", Replace = "Photo_" }] }, }; [Fact] @@ -19,40 +23,132 @@ public void The_dialog_suggests_the_name_the_preset_would_give() { Assert.Equal("Photo_0412_small.jpg", AskedLocation.SuggestedName(Asking(), "JPG", At.SourcePath)); Assert.Equal("Photo_0412_small.heic", AskedLocation.SuggestedName(Asking(BuiltInData.SameAsSource), "Same", At.SourcePath)); + // Saved as a ZIP (the preset's or Settings › General's): the result is a .zip. + var zipped = Asking(); + zipped.Output.ZipResult = true; + Assert.Equal("Photo_0412_small.zip", AskedLocation.SuggestedName(zipped, "JPG", At.SourcePath)); } [Fact] - public void Where_nothing_asks_the_result_goes_next_to_the_source() + public void One_file_is_asked_for_by_name_also_when_several_sources_make_it() { - var path = OutputPathResolver.Resolve(Asking().Output, At, "jpg", _ => false); + Assert.True(AskedLocation.MakesOneResult(Asking(), 1)); + Assert.False(AskedLocation.MakesOneResult(Asking(), 3)); + Assert.True(AskedLocation.MakesOneResult(new Preset { TargetFormat = "pdf", Pdf = { MergeIntoSingle = true } }, 3)); + Assert.True(AskedLocation.MakesOneResult(new Preset { TargetFormat = "tiff", Image = { MultiPageTiff = true } }, 3)); + Assert.True(AskedLocation.MakesOneResult(BuiltInData.CreatePresets().First(p => p.PacksIntoOneArchive), 3)); + Assert.True(AskedLocation.MakesOneResult(BuiltInData.CreatePresets().First(p => p.JoinsIntoOneMedia), 3)); + } + + [Fact] + public void Where_nothing_asks_the_presets_location_applies() + { + var preset = Asking(); + preset.Output.Location = OutputLocation.SameFolder; + var path = OutputPathResolver.Resolve(preset.Output, At, "jpg", _ => false); Assert.Equal(Path.Combine(Path.GetDirectoryName(At.SourcePath)!, "Photo_0412_small.jpg"), path); } [Fact] - public void One_result_is_saved_exactly_as_chosen_replacing_what_the_dialog_asked_about() + public void Only_the_file_the_dialog_asked_about_is_replaced() { var chosen = Path.Combine(Path.GetTempPath(), "out", "holiday.jpg"); var preset = Asking(); var asked = AskedLocation.ForFile(preset, chosen); - // The name typed in the dialog, without the pattern or renames on top, even though the file exists. + // The name typed in the dialog, without the pattern or renames on top, replacing what's there. Assert.Equal(chosen, OutputPathResolver.Resolve(asked.Output, At, "jpg", _ => true)); - Assert.Equal(OutputLocation.Ask, preset.Output.Location); // the preset itself keeps asking + // Pages of it, or a result with another extension than the one typed, keep the preset's own policy. + Assert.Equal(Path.Combine(Path.GetTempPath(), "out", "holiday_p1 (2).jpg"), + OutputPathResolver.Resolve(asked.Output, At, "jpg", p => !p.Contains("(2)"), "_p1")); + Assert.Equal(Path.Combine(Path.GetTempPath(), "out", "holiday (2).zip"), + OutputPathResolver.Resolve(asked.Output, At, "zip", p => !p.Contains("(2)"))); + // The preset itself keeps asking. + Assert.True(preset.Output.AskEveryTime); Assert.Single(preset.Output.Renames); + Assert.False(asked.Output.AskEveryTime); + } + + [Fact] + public async Task Pages_never_replace_files_the_dialog_didnt_ask_about() + { + using var dir = new TempDir(); + var pdf = dir.File("book.pdf"); + var outFolder = Path.Combine(dir.Path, "out"); + Directory.CreateDirectory(outFolder); + var existing = Path.Combine(outFolder, "holiday_p1.png"); + File.WriteAllText(existing, "keep me"); + await using var queue = new JobQueue(new ConverterCatalog([new FakeConverter("pdfium", new ConversionEdge("pdf", "png")) { OutputsPerInput = 2 }])); + var asked = AskedLocation.ForFile(new Preset { TargetFormat = "png", Output = { AskEveryTime = true } }, Path.Combine(outFolder, "holiday.png")); + + var job = new ConversionJob([pdf], asked, "PNG"); + await queue.RunAsync(job); + + Assert.Equal("keep me", File.ReadAllText(existing)); + Assert.Contains(Path.Combine(outFolder, "holiday_p1 (2).png"), job.Outputs); } [Fact] public void Several_results_go_into_the_chosen_folder_under_their_own_names() { var folder = Path.Combine(Path.GetTempPath(), "chosen"); - var preset = Asking(); - preset.Output.Conflict = ConflictPolicy.Rename; - var asked = AskedLocation.ForFolder(preset, folder); + var asked = AskedLocation.ForFolder(Asking(), folder); Assert.Equal(Path.Combine(folder, "Photo_0412_small.jpg"), OutputPathResolver.Resolve(asked.Output, At, "jpg", _ => false)); // An existing file there isn't replaced: only one picked by name in the dialog is. Assert.Equal(Path.Combine(folder, "Photo_0412_small (2).jpg"), OutputPathResolver.Resolve(asked.Output, At, "jpg", p => !p.Contains("(2)"))); } + + [Fact] + public void The_chosen_place_is_where_the_result_goes_not_back_over_the_original() + { + var preset = Asking(BuiltInData.SameAsSource); + preset.Output.ReplaceOriginal = true; + + Assert.False(AskedLocation.ForFolder(preset, Path.GetTempPath()).Output.ReplaceOriginal); + Assert.False(AskedLocation.ForFile(preset, Path.Combine(Path.GetTempPath(), "x.heic")).Output.ReplaceOriginal); + Assert.True(preset.Output.ReplaceOriginal); + } + + [Fact] + public void A_preset_that_asks_keeps_its_location_for_a_filee_that_cant() + { + using var dir = new TempDir(); + var store = new UserDataStore(dir.Path); + store.Load(); + var preset = new Preset { Id = "asks", TargetFormat = "png", Output = { AskEveryTime = true, Location = OutputLocation.SameFolder } }; + store.Presets.Add(preset); + store.SaveLibrary(); + + var json = File.ReadAllText(Path.Combine(dir.Path, "presets.json")); + // Only names every version knows: 2.1.0 reads "SameFolder" and skips "AskEveryTime". + Assert.DoesNotContain("\"Ask\"", json); + var reloaded = new UserDataStore(dir.Path); + reloaded.Load(); + var back = reloaded.FindPreset("asks")!; + Assert.True(back.Output.AskEveryTime); + Assert.Equal(OutputLocation.SameFolder, back.Output.Location); + } + + [Fact] + public void A_location_from_a_newer_filee_reads_as_the_default() + { + using var dir = new TempDir(); + var store = new UserDataStore(dir.Path); + store.Load(); + store.Presets.Add(new Preset { Id = "future", TargetFormat = "png" }); + store.SaveLibrary(); + var path = Path.Combine(dir.Path, "presets.json"); + var json = File.ReadAllText(path); + File.WriteAllText(path, json.Replace("\"location\": \"Default\"", "\"location\": \"Somewhere\"")); + Assert.Contains("Somewhere", File.ReadAllText(path)); + + var reloaded = new UserDataStore(dir.Path); + reloaded.Load(); + + // The user's presets survive (a broken file would be replaced by the factory ones). + Assert.Equal(OutputLocation.Default, reloaded.FindPreset("future")!.Output.Location); + } }