From 55c185be27ceee934f872bca49e10a6335933e42 Mon Sep 17 00:00:00 2001 From: Amanvir Singh <134874017+amanvir-singh@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:10:27 -0500 Subject: [PATCH 1/5] add file sorter for name and date orders --- src/Filee.Core/Platform/FileSorter.cs | 66 +++++++++++++++++++ tests/Filee.Core.Tests/FileSorterTests.cs | 79 +++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 src/Filee.Core/Platform/FileSorter.cs create mode 100644 tests/Filee.Core.Tests/FileSorterTests.cs diff --git a/src/Filee.Core/Platform/FileSorter.cs b/src/Filee.Core/Platform/FileSorter.cs new file mode 100644 index 00000000..8d7d26fd --- /dev/null +++ b/src/Filee.Core/Platform/FileSorter.cs @@ -0,0 +1,66 @@ +// Sorts files by name or date, for the order they merge in: the merge order window and "filee convert --sort". + +namespace Filee.Core.Platform; + +/// An order files can be sorted in. Ascending is A to Z or oldest first. +public enum FileSortOrder +{ + NameAscending, + NameDescending, + ModifiedAscending, + ModifiedDescending, + CreatedAscending, + CreatedDescending, +} + +/// A file and its dates in UTC; a date is null when it can't be known (the file isn't there or can't be read). +public sealed record FileStamp(string FilePath, DateTime? Modified = null, DateTime? Created = null) +{ + /// Reads the dates of . Never throws for a missing or unreadable file. + public static FileStamp Read(string path) + { + try + { + // A missing file reports the year 1601 rather than failing, which would sort it as the oldest. + var info = new FileInfo(path); + return info.Exists ? new FileStamp(path, info.LastWriteTimeUtc, info.CreationTimeUtc) : new FileStamp(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + return new FileStamp(path); + } + } +} + +/// Sorts files; the same input always gives the same order, also when names or dates are equal. +public static class FileSorter +{ + /// + /// Sorts . Names sort the way people count ("page 2" before "page 10", see + /// ), with the full path deciding between equal names. Files with equal dates sort by + /// name, and files without a date come last in both directions: they have no place on the timeline. + /// + public static IReadOnlyList Sort(IEnumerable items, Func stamp, FileSortOrder order) + { + string Name(T item) => Path.GetFileName(stamp(item).FilePath); + string Full(T item) => stamp(item).FilePath; + + IOrderedEnumerable ByDate(Func date, bool newestFirst) + { + var known = items.OrderBy(item => date(item) is null); + var dated = newestFirst ? known.ThenByDescending(date) : known.ThenBy(date); + return dated.ThenBy(Name, NaturalComparer.Instance).ThenBy(Full, NaturalComparer.Instance); + } + + var sorted = order switch + { + FileSortOrder.NameDescending => items.OrderByDescending(Name, NaturalComparer.Instance).ThenByDescending(Full, NaturalComparer.Instance), + FileSortOrder.ModifiedAscending => ByDate(item => stamp(item).Modified, newestFirst: false), + FileSortOrder.ModifiedDescending => ByDate(item => stamp(item).Modified, newestFirst: true), + FileSortOrder.CreatedAscending => ByDate(item => stamp(item).Created, newestFirst: false), + FileSortOrder.CreatedDescending => ByDate(item => stamp(item).Created, newestFirst: true), + _ => items.OrderBy(Name, NaturalComparer.Instance).ThenBy(Full, NaturalComparer.Instance), + }; + return sorted.ToArray(); + } +} diff --git a/tests/Filee.Core.Tests/FileSorterTests.cs b/tests/Filee.Core.Tests/FileSorterTests.cs new file mode 100644 index 00000000..f75620a6 --- /dev/null +++ b/tests/Filee.Core.Tests/FileSorterTests.cs @@ -0,0 +1,79 @@ +// The orders files merge in: by name the way people count, by the dates on disk, and what happens without a date. + +using Filee.Core.Platform; + +namespace Filee.Core.Tests; + +public class FileSorterTests +{ + private static readonly DateTime Early = new(2024, 1, 1, 8, 0, 0, DateTimeKind.Utc); + private static readonly DateTime Later = new(2024, 1, 1, 9, 30, 0, DateTimeKind.Utc); // same day: the time decides + private static readonly DateTime Latest = new(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc); + + private static string[] Sorted(FileSortOrder order, params FileStamp[] files) => + FileSorter.Sort(files, file => file, order).Select(file => file.FilePath).ToArray(); + + [Fact] + public void Names_sort_the_way_people_count_in_both_directions() + { + FileStamp[] files = [new("page 10.pdf"), new("Page 2.pdf"), new("page 1.pdf")]; + Assert.Equal(["page 1.pdf", "Page 2.pdf", "page 10.pdf"], Sorted(FileSortOrder.NameAscending, files)); + Assert.Equal(["page 10.pdf", "Page 2.pdf", "page 1.pdf"], Sorted(FileSortOrder.NameDescending, files)); + } + + [Fact] + public void Equal_names_in_different_folders_sort_by_folder() + { + string a = Path.Combine("a", "x.pdf"), b = Path.Combine("b", "x.pdf"); + Assert.Equal([a, b], Sorted(FileSortOrder.NameAscending, new(b), new(a))); + Assert.Equal([b, a], Sorted(FileSortOrder.NameDescending, new(a), new(b))); + } + + [Fact] + public void Each_date_sorts_on_its_own_down_to_the_time_of_day() + { + // The two dates disagree on purpose: a copied file is often "created" after it was last modified. + FileStamp[] files = + [ + new("a.pdf", Modified: Latest, Created: Early), + new("b.pdf", Modified: Early, Created: Later), + new("c.pdf", Modified: Later, Created: Latest), + ]; + Assert.Equal(["b.pdf", "c.pdf", "a.pdf"], Sorted(FileSortOrder.ModifiedAscending, files)); + Assert.Equal(["a.pdf", "c.pdf", "b.pdf"], Sorted(FileSortOrder.ModifiedDescending, files)); + Assert.Equal(["a.pdf", "b.pdf", "c.pdf"], Sorted(FileSortOrder.CreatedAscending, files)); + Assert.Equal(["c.pdf", "b.pdf", "a.pdf"], Sorted(FileSortOrder.CreatedDescending, files)); + } + + [Theory] + [InlineData(FileSortOrder.ModifiedAscending)] + [InlineData(FileSortOrder.ModifiedDescending)] + public void Equal_dates_sort_by_name_and_files_without_a_date_come_last(FileSortOrder order) + { + FileStamp[] files = [new("z unknown.pdf"), new("page 10.pdf", Early), new("a unknown.pdf"), new("page 2.pdf", Early)]; + Assert.Equal(["page 2.pdf", "page 10.pdf", "a unknown.pdf", "z unknown.pdf"], Sorted(order, files)); + } + + [Fact] + public void Reads_the_dates_on_disk_and_none_for_a_missing_file() + { + var folder = Directory.CreateTempSubdirectory("filee-sort-").FullName; + try + { + var path = Path.Combine(folder, "real.pdf"); + File.WriteAllBytes(path, [1]); + File.SetLastWriteTimeUtc(path, Later); + File.SetCreationTimeUtc(path, Early); + + var stamp = FileStamp.Read(path); + Assert.Equal(Later, stamp.Modified); + if (OperatingSystem.IsWindows()) // Linux can't set a file's creation time + Assert.Equal(Early, stamp.Created); + + var missing = FileStamp.Read(Path.Combine(folder, "missing.pdf")); + Assert.Null(missing.Modified); + Assert.Null(missing.Created); + } + finally { Directory.Delete(folder, true); } + } +} From eebb599c57ce583272a0f214afa2841ab4ea63b7 Mon Sep 17 00:00:00 2001 From: Amanvir Singh <134874017+amanvir-singh@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:10:27 -0500 Subject: [PATCH 2/5] sort merge order window by name or date --- src/Filee.App/Assets/i18n/ar.json | 8 ++ src/Filee.App/Assets/i18n/de.json | 8 ++ src/Filee.App/Assets/i18n/en.json | 8 ++ src/Filee.App/Assets/i18n/es.json | 8 ++ src/Filee.App/Assets/i18n/fr.json | 8 ++ src/Filee.App/Assets/i18n/hi.json | 8 ++ src/Filee.App/Assets/i18n/id.json | 8 ++ src/Filee.App/Assets/i18n/it.json | 8 ++ src/Filee.App/Assets/i18n/ja.json | 8 ++ src/Filee.App/Assets/i18n/ko.json | 8 ++ src/Filee.App/Assets/i18n/ne.json | 8 ++ src/Filee.App/Assets/i18n/pl.json | 8 ++ src/Filee.App/Assets/i18n/pt-BR.json | 8 ++ src/Filee.App/Assets/i18n/pt-PT.json | 8 ++ src/Filee.App/Assets/i18n/ru.json | 8 ++ src/Filee.App/Assets/i18n/th.json | 8 ++ src/Filee.App/Assets/i18n/tr.json | 8 ++ src/Filee.App/Assets/i18n/zh-CN.json | 8 ++ src/Filee.App/Assets/i18n/zh-TW.json | 8 ++ src/Filee.App/Services/WindowService.cs | 2 +- .../ViewModels/MergeOrderViewModel.cs | 62 +++++++-- src/Filee.App/Views/MergeOrderWindow.axaml | 30 ++++- tests/Filee.App.Tests/MergeOrderTests.cs | 125 +++++++++++++++++- 23 files changed, 354 insertions(+), 17 deletions(-) diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index ae5e9d33..405cde66 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -380,6 +380,14 @@ "preset.merge_pdf": "دمج PDF", "merge.order_title": "اختيار ترتيب الدمج", "merge.order_hint": "حدّد ملفًا وانقله إلى أعلى أو أسفل. تُدمج الملفات بالترتيب من الأعلى إلى الأسفل. (‎Alt+↑ / Alt+↓‎)", + "merge.sort_by": "ترتيب حسب", + "merge.sort_name_asc": "الاسم (أ إلى ي)", + "merge.sort_name_desc": "الاسم (ي إلى أ)", + "merge.sort_modified_asc": "تاريخ التعديل (الأقدم أولاً)", + "merge.sort_modified_desc": "تاريخ التعديل (الأحدث أولاً)", + "merge.sort_created_asc": "تاريخ الإنشاء (الأقدم أولاً)", + "merge.sort_created_desc": "تاريخ الإنشاء (الأحدث أولاً)", + "merge.sort_custom": "ترتيب مخصص", "merge.images_hint": "حدّد صورة وانقلها إلى أعلى أو أسفل. تأتي الأولى في الأعلى، أو على اليسار عند وضع الصور جنبًا إلى جنب. (‎Alt+↑ / Alt+↓‎)", "merge.vertical": "من الأعلى إلى الأسفل", "merge.horizontal": "جنبًا إلى جنب", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index c047aa51..785c03dd 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -380,6 +380,14 @@ "preset.merge_pdf": "PDF zusammenführen", "merge.order_title": "Reihenfolge festlegen", "merge.order_hint": "Wähle eine Datei und verschiebe sie nach oben oder unten. Dateien werden von oben nach unten zusammengeführt. (Alt+↑ / Alt+↓)", + "merge.sort_by": "Sortieren nach", + "merge.sort_name_asc": "Name (A bis Z)", + "merge.sort_name_desc": "Name (Z bis A)", + "merge.sort_modified_asc": "Änderungsdatum (älteste zuerst)", + "merge.sort_modified_desc": "Änderungsdatum (neueste zuerst)", + "merge.sort_created_asc": "Erstelldatum (älteste zuerst)", + "merge.sort_created_desc": "Erstelldatum (neueste zuerst)", + "merge.sort_custom": "Eigene Reihenfolge", "merge.images_hint": "Wähle ein Bild und verschiebe es nach oben oder unten. Das erste kommt nach oben, nebeneinander nach links. (Alt+↑ / Alt+↓)", "merge.vertical": "Untereinander", "merge.horizontal": "Nebeneinander", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index d7426584..bf35bdd9 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -390,6 +390,14 @@ "preset.merge_pdf": "Merge PDF", "merge.order_title": "Choose merge order", "merge.order_hint": "Select a file and move it up or down. Files are merged from top to bottom. (Alt+↑ / Alt+↓)", + "merge.sort_by": "Sort by", + "merge.sort_name_asc": "Name (A to Z)", + "merge.sort_name_desc": "Name (Z to A)", + "merge.sort_modified_asc": "Date modified (oldest first)", + "merge.sort_modified_desc": "Date modified (newest first)", + "merge.sort_created_asc": "Date created (oldest first)", + "merge.sort_created_desc": "Date created (newest first)", + "merge.sort_custom": "Custom order", "merge.images_hint": "Select a picture and move it up or down. The first one goes at the top, or on the left side by side. (Alt+↑ / Alt+↓)", "merge.vertical": "Top to bottom", "merge.horizontal": "Side by side", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index bd389715..8c5a6b7d 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -380,6 +380,14 @@ "preset.merge_pdf": "Combinar PDF", "merge.order_title": "Elegir el orden de combinación", "merge.order_hint": "Selecciona un archivo y muévelo arriba o abajo. Los archivos se combinan de arriba abajo. (Alt+↑ / Alt+↓)", + "merge.sort_by": "Ordenar por", + "merge.sort_name_asc": "Nombre (A a Z)", + "merge.sort_name_desc": "Nombre (Z a A)", + "merge.sort_modified_asc": "Modificación (antiguos primero)", + "merge.sort_modified_desc": "Modificación (recientes primero)", + "merge.sort_created_asc": "Creación (antiguos primero)", + "merge.sort_created_desc": "Creación (recientes primero)", + "merge.sort_custom": "Orden personalizado", "merge.images_hint": "Selecciona una imagen y muévela arriba o abajo. La primera va arriba, o a la izquierda si van lado a lado. (Alt+↑ / Alt+↓)", "merge.vertical": "De arriba abajo", "merge.horizontal": "Lado a lado", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 9488fe34..a1e32fff 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -390,6 +390,14 @@ "preset.merge_pdf": "Fusionner les PDF", "merge.order_title": "Choisir l'ordre de fusion", "merge.order_hint": "Sélectionnez un fichier et déplacez-le vers le haut ou le bas. Les fichiers sont fusionnés de haut en bas. (Alt+↑ / Alt+↓)", + "merge.sort_by": "Trier par", + "merge.sort_name_asc": "Nom (A à Z)", + "merge.sort_name_desc": "Nom (Z à A)", + "merge.sort_modified_asc": "Date de modification (anciens d'abord)", + "merge.sort_modified_desc": "Date de modification (récents d'abord)", + "merge.sort_created_asc": "Date de création (anciens d'abord)", + "merge.sort_created_desc": "Date de création (récents d'abord)", + "merge.sort_custom": "Ordre personnalisé", "merge.images_hint": "Sélectionnez une image et déplacez-la vers le haut ou le bas. La première va en haut, ou à gauche côte à côte. (Alt+↑ / Alt+↓)", "merge.vertical": "De haut en bas", "merge.horizontal": "Côte à côte", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 525f6bfe..72684bad 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -380,6 +380,14 @@ "preset.merge_pdf": "PDF मर्ज करें", "merge.order_title": "मर्ज करने का क्रम चुनें", "merge.order_hint": "फ़ाइल चुनकर उसे ऊपर या नीचे ले जाएँ। फ़ाइलें ऊपर से नीचे के क्रम में मर्ज होंगी। (Alt+↑ / Alt+↓)", + "merge.sort_by": "क्रमबद्ध करें", + "merge.sort_name_asc": "नाम (A से Z)", + "merge.sort_name_desc": "नाम (Z से A)", + "merge.sort_modified_asc": "बदलने की तिथि (पुरानी पहले)", + "merge.sort_modified_desc": "बदलने की तिथि (नई पहले)", + "merge.sort_created_asc": "बनाने की तिथि (पुरानी पहले)", + "merge.sort_created_desc": "बनाने की तिथि (नई पहले)", + "merge.sort_custom": "कस्टम क्रम", "merge.images_hint": "तस्वीर चुनकर उसे ऊपर या नीचे ले जाएँ। पहली तस्वीर सबसे ऊपर आएगी, या साथ-साथ जोड़ने पर सबसे बाईं ओर। (Alt+↑ / Alt+↓)", "merge.vertical": "ऊपर से नीचे", "merge.horizontal": "साथ-साथ", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index 32505be4..08060560 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -380,6 +380,14 @@ "preset.merge_pdf": "Gabungkan PDF", "merge.order_title": "Pilih urutan penggabungan", "merge.order_hint": "Pilih file lalu pindahkan ke atas atau ke bawah. File digabungkan dari atas ke bawah. (Alt+↑ / Alt+↓)", + "merge.sort_by": "Urutkan menurut", + "merge.sort_name_asc": "Nama (A ke Z)", + "merge.sort_name_desc": "Nama (Z ke A)", + "merge.sort_modified_asc": "Tanggal diubah (terlama dahulu)", + "merge.sort_modified_desc": "Tanggal diubah (terbaru dahulu)", + "merge.sort_created_asc": "Tanggal dibuat (terlama dahulu)", + "merge.sort_created_desc": "Tanggal dibuat (terbaru dahulu)", + "merge.sort_custom": "Urutan khusus", "merge.images_hint": "Pilih gambar lalu pindahkan ke atas atau ke bawah. Gambar pertama berada di atas, atau di sebelah kiri bila berdampingan. (Alt+↑ / Alt+↓)", "merge.vertical": "Atas ke bawah (vertikal)", "merge.horizontal": "Berdampingan (horizontal)", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index 2bb46009..b14f3f9c 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -380,6 +380,14 @@ "preset.merge_pdf": "Unisci PDF", "merge.order_title": "Scegli l'ordine di unione", "merge.order_hint": "Seleziona un file e spostalo su o giù. I file vengono uniti dall'alto verso il basso. (Alt+↑ / Alt+↓)", +"merge.sort_by": "Ordina per", +"merge.sort_name_asc": "Nome (A-Z)", +"merge.sort_name_desc": "Nome (Z-A)", +"merge.sort_modified_asc": "Data di modifica (prima i vecchi)", +"merge.sort_modified_desc": "Data di modifica (prima i recenti)", +"merge.sort_created_asc": "Data di creazione (prima i vecchi)", +"merge.sort_created_desc": "Data di creazione (prima i recenti)", +"merge.sort_custom": "Ordine personalizzato", "merge.images_hint": "Seleziona un'immagine e spostala su o giù. La prima va in alto, o a sinistra nella disposizione affiancata. (Alt+↑ / Alt+↓)", "merge.vertical": "Dall'alto verso il basso", "merge.horizontal": "Affiancate", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 40674bae..4697eea3 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -380,6 +380,14 @@ "preset.merge_pdf": "結合", "merge.order_title": "結合順序を選択", "merge.order_hint": "ファイルを選択して上下に移動します。上から順に結合されます。(Alt+↑ / Alt+↓)", + "merge.sort_by": "並べ替え", + "merge.sort_name_asc": "名前 (昇順)", + "merge.sort_name_desc": "名前 (降順)", + "merge.sort_modified_asc": "更新日時 (古い順)", + "merge.sort_modified_desc": "更新日時 (新しい順)", + "merge.sort_created_asc": "作成日時 (古い順)", + "merge.sort_created_desc": "作成日時 (新しい順)", + "merge.sort_custom": "カスタム順", "merge.images_hint": "画像を選択して上下に移動します。最初の画像が一番上(横に並べるときは一番左)になります。(Alt+↑ / Alt+↓)", "merge.vertical": "縦に並べる", "merge.horizontal": "横に並べる", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 82bd41c7..3517a071 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -390,6 +390,14 @@ "preset.merge_pdf": "PDF 병합", "merge.order_title": "병합 순서 선택", "merge.order_hint": "파일을 골라 위아래로 옮기세요. 위에서부터 순서대로 병합돼요. (Alt+↑ / Alt+↓)", + "merge.sort_by": "정렬 기준", + "merge.sort_name_asc": "이름 (오름차순)", + "merge.sort_name_desc": "이름 (내림차순)", + "merge.sort_modified_asc": "수정한 날짜 (오래된 순)", + "merge.sort_modified_desc": "수정한 날짜 (최신 순)", + "merge.sort_created_asc": "만든 날짜 (오래된 순)", + "merge.sort_created_desc": "만든 날짜 (최신 순)", + "merge.sort_custom": "사용자 지정 순서", "merge.images_hint": "사진을 골라 위아래로 옮기세요. 첫 번째 사진이 맨 위에, 나란히 붙일 때는 맨 왼쪽에 와요. (Alt+↑ / Alt+↓)", "merge.vertical": "위아래로", "merge.horizontal": "나란히", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 4dc7a4ef..6670e9f2 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -380,6 +380,14 @@ "preset.merge_pdf": "PDF मर्ज गर्नुहोस्", "merge.order_title": "मर्ज क्रम मिलाउनुहोस्", "merge.order_hint": "फाइलहरूलाई इच्छित क्रममा मिलाउन तान्नुहोस् र छाड्नुहोस्", + "merge.sort_by": "क्रमबद्ध गर्नुहोस्", + "merge.sort_name_asc": "नाम (A देखि Z)", + "merge.sort_name_desc": "नाम (Z देखि A)", + "merge.sort_modified_asc": "परिमार्जन मिति (पुरानो पहिले)", + "merge.sort_modified_desc": "परिमार्जन मिति (नयाँ पहिले)", + "merge.sort_created_asc": "सिर्जना मिति (पुरानो पहिले)", + "merge.sort_created_desc": "सिर्जना मिति (नयाँ पहिले)", + "merge.sort_custom": "आफ्नै क्रम", "merge.images_hint": "तस्बिर छानेर माथि वा तल सार्नुहोस्। पहिलो तस्बिर सबैभन्दा माथि, वा छेउछेउमा राख्दा सबैभन्दा बायाँ आउँछ। (Alt+↑ / Alt+↓)", "merge.vertical": "माथिदेखि तल", "merge.horizontal": "छेउछेउमा", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 14c3ff32..080ed62f 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -390,6 +390,14 @@ "preset.merge_pdf": "Połącz PDF", "merge.order_title": "Wybierz kolejność scalania", "merge.order_hint": "Wybierz plik i przesuń go w górę lub w dół. Pliki są scalane od góry do dołu. (Alt+↑ / Alt+↓)", + "merge.sort_by": "Sortuj według", + "merge.sort_name_asc": "Nazwa (A do Z)", + "merge.sort_name_desc": "Nazwa (Z do A)", + "merge.sort_modified_asc": "Data modyfikacji (najpierw najstarsze)", + "merge.sort_modified_desc": "Data modyfikacji (najpierw najnowsze)", + "merge.sort_created_asc": "Data utworzenia (najpierw najstarsze)", + "merge.sort_created_desc": "Data utworzenia (najpierw najnowsze)", + "merge.sort_custom": "Własna kolejność", "merge.images_hint": "Wybierz obraz i przesuń go w górę lub w dół. Pierwszy trafia na górę, a przy układzie obok siebie na lewo. (Alt+↑ / Alt+↓)", "merge.vertical": "Jeden pod drugim", "merge.horizontal": "Obok siebie", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index d898e703..f16ca02e 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -390,6 +390,14 @@ "preset.merge_pdf": "Mesclar PDF", "merge.order_title": "Escolha a ordem da mesclagem", "merge.order_hint": "Selecione um arquivo e mova para cima ou para baixo. Os arquivos são mesclados de cima para baixo. (Alt+↑ / Alt+↓)", + "merge.sort_by": "Ordenar por", + "merge.sort_name_asc": "Nome (A a Z)", + "merge.sort_name_desc": "Nome (Z a A)", + "merge.sort_modified_asc": "Data de modificação (antigos primeiro)", + "merge.sort_modified_desc": "Data de modificação (recentes primeiro)", + "merge.sort_created_asc": "Data de criação (antigos primeiro)", + "merge.sort_created_desc": "Data de criação (recentes primeiro)", + "merge.sort_custom": "Ordem personalizada", "merge.images_hint": "Selecione uma imagem e mova para cima ou para baixo. A primeira fica no topo, ou à esquerda lado a lado. (Alt+↑ / Alt+↓)", "merge.vertical": "De cima para baixo", "merge.horizontal": "Lado a lado", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 642555c3..347589b0 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -390,6 +390,14 @@ "preset.merge_pdf": "Juntar PDF", "merge.order_title": "Escolher a ordem dos ficheiros", "merge.order_hint": "Seleciona um ficheiro e move-o para cima ou para baixo. Os ficheiros são combinados pela ordem da lista, de cima para baixo. (Alt+↑ / Alt+↓)", + "merge.sort_by": "Ordenar por", + "merge.sort_name_asc": "Nome (A a Z)", + "merge.sort_name_desc": "Nome (Z a A)", + "merge.sort_modified_asc": "Data de modificação (antigos primeiro)", + "merge.sort_modified_desc": "Data de modificação (recentes primeiro)", + "merge.sort_created_asc": "Data de criação (antigos primeiro)", + "merge.sort_created_desc": "Data de criação (recentes primeiro)", + "merge.sort_custom": "Ordem personalizada", "merge.images_hint": "Seleciona uma imagem e move-a para cima ou para baixo. A primeira fica no topo ou, lado a lado, à esquerda. (Alt+↑ / Alt+↓)", "merge.vertical": "De cima para baixo", "merge.horizontal": "Lado a lado", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 6dde8d90..f95f60ac 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -390,6 +390,14 @@ "preset.merge_pdf": "Объединить PDF", "merge.order_title": "Выберите порядок объединения", "merge.order_hint": "Выберите файл и переместите его вверх или вниз. Файлы объединяются сверху вниз. (Alt+↑ / Alt+↓)", + "merge.sort_by": "Сортировка", + "merge.sort_name_asc": "Имя (от А до Я)", + "merge.sort_name_desc": "Имя (от Я до А)", + "merge.sort_modified_asc": "Дата изменения (сначала старые)", + "merge.sort_modified_desc": "Дата изменения (сначала новые)", + "merge.sort_created_asc": "Дата создания (сначала старые)", + "merge.sort_created_desc": "Дата создания (сначала новые)", + "merge.sort_custom": "Свой порядок", "merge.images_hint": "Выберите изображение и переместите его вверх или вниз. Первое окажется сверху, а при размещении рядом — слева. (Alt+↑ / Alt+↓)", "merge.vertical": "Сверху вниз", "merge.horizontal": "Рядом", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 0e6cab38..5858e042 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -380,6 +380,14 @@ "preset.merge_pdf": "รวมไฟล์ PDF", "merge.order_title": "เลือกลำดับการรวมไฟล์", "merge.order_hint": "เลือกไฟล์แล้วเลื่อนขึ้นหรือลง ไฟล์จะรวมจากบนลงล่าง (Alt+↑ / Alt+↓)", + "merge.sort_by": "เรียงตาม", + "merge.sort_name_asc": "ชื่อ (A ถึง Z)", + "merge.sort_name_desc": "ชื่อ (Z ถึง A)", + "merge.sort_modified_asc": "วันที่แก้ไข (เก่าสุดก่อน)", + "merge.sort_modified_desc": "วันที่แก้ไข (ใหม่สุดก่อน)", + "merge.sort_created_asc": "วันที่สร้าง (เก่าสุดก่อน)", + "merge.sort_created_desc": "วันที่สร้าง (ใหม่สุดก่อน)", + "merge.sort_custom": "ลำดับที่กำหนดเอง", "merge.images_hint": "เลือกรูปแล้วเลื่อนขึ้นหรือลง รูปแรกจะอยู่บนสุด หรืออยู่ซ้ายสุดเมื่อวางเรียงข้างกัน (Alt+↑ / Alt+↓)", "merge.vertical": "บนลงล่าง", "merge.horizontal": "เรียงข้างกัน", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 2c286a28..00a08c22 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -380,6 +380,14 @@ "preset.merge_pdf": "PDF birleştir", "merge.order_title": "Birleştirme sırasını seçin", "merge.order_hint": "Bir dosya seçip yukarı veya aşağı taşıyın. Dosyalar yukarıdan aşağıya birleştirilir. (Alt+↑ / Alt+↓)", + "merge.sort_by": "Sıralama", + "merge.sort_name_asc": "Ad (A'dan Z'ye)", + "merge.sort_name_desc": "Ad (Z'den A'ya)", + "merge.sort_modified_asc": "Değiştirme tarihi (önce en eski)", + "merge.sort_modified_desc": "Değiştirme tarihi (önce en yeni)", + "merge.sort_created_asc": "Oluşturma tarihi (önce en eski)", + "merge.sort_created_desc": "Oluşturma tarihi (önce en yeni)", + "merge.sort_custom": "Özel sıralama", "merge.images_hint": "Bir görsel seçip yukarı veya aşağı taşıyın. İlk görsel en üste, yan yana dizilince en sola gelir. (Alt+↑ / Alt+↓)", "merge.vertical": "Alt alta", "merge.horizontal": "Yan yana", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index c5c08c8e..917002da 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -390,6 +390,14 @@ "preset.merge_pdf": "合并 PDF", "merge.order_title": "选择合并顺序", "merge.order_hint": "选择文件并上下移动。文件将按从上到下的顺序合并。(Alt+↑ / Alt+↓)", + "merge.sort_by": "排序方式", + "merge.sort_name_asc": "名称 (A 到 Z)", + "merge.sort_name_desc": "名称 (Z 到 A)", + "merge.sort_modified_asc": "修改日期 (最早优先)", + "merge.sort_modified_desc": "修改日期 (最新优先)", + "merge.sort_created_asc": "创建日期 (最早优先)", + "merge.sort_created_desc": "创建日期 (最新优先)", + "merge.sort_custom": "自定义顺序", "merge.images_hint": "选择图片并上下移动。第一张放在最上面(左右排列时放在最左边)。(Alt+↑ / Alt+↓)", "merge.vertical": "上下排列", "merge.horizontal": "左右排列", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index ae074709..d3e20007 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -390,6 +390,14 @@ "preset.merge_pdf": "合併 PDF", "merge.order_title": "選擇合併順序", "merge.order_hint": "選擇檔案並上下移動。檔案將按從上到下的順序合併。(Alt+↑ / Alt+↓)", + "merge.sort_by": "排序方式", + "merge.sort_name_asc": "名稱 (A 到 Z)", + "merge.sort_name_desc": "名稱 (Z 到 A)", + "merge.sort_modified_asc": "修改日期 (最舊優先)", + "merge.sort_modified_desc": "修改日期 (最新優先)", + "merge.sort_created_asc": "建立日期 (最舊優先)", + "merge.sort_created_desc": "建立日期 (最新優先)", + "merge.sort_custom": "自訂順序", "merge.images_hint": "選擇圖片並上下移動。第一張放在最上面(左右排列時放在最左邊)。(Alt+↑ / Alt+↓)", "merge.vertical": "上下排列", "merge.horizontal": "左右排列", diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index 955b0851..4dd6fc8d 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -68,7 +68,7 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem if (images) { // Room for the layout below the list. - dialog.Height = 640; + dialog.Height = 690; dialog.MinHeight = 540; } PrepareWindow(dialog); diff --git a/src/Filee.App/ViewModels/MergeOrderViewModel.cs b/src/Filee.App/ViewModels/MergeOrderViewModel.cs index 7710534b..b4576615 100644 --- a/src/Filee.App/ViewModels/MergeOrderViewModel.cs +++ b/src/Filee.App/ViewModels/MergeOrderViewModel.cs @@ -1,9 +1,10 @@ // The order of a merge into one PDF (issue #268), one video or one image (#645): the files start sorted by name the way -// people count ("page 2" before "page 10"), and the user moves them before the merge starts. Moving a row changes only -// this copy of the selection. Pictures merged into one image also get their layout here: one under another or side by -// side, the same width or their own sizes, the gap and the background. +// people count ("page 2" before "page 10"); the user can sort them by name or date, and move them, before the merge +// starts. Moving a row changes only this copy of the selection. Pictures merged into one image also get their layout +// here: one under another or side by side, the same width or their own sizes, the gap and the background. using System.Collections.ObjectModel; +using System.Globalization; using Avalonia.Media; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -13,12 +14,26 @@ namespace Filee.App.ViewModels; -/// One file of the merge: its name, and the folder it is in (none for a file from a browser, not saved yet). -public sealed class MergeSource(string path) +/// +/// One file of the merge: its name, its date and the folder it is in (no date or folder for a file from a browser, not +/// saved yet). +/// +public sealed partial class MergeSource(string path) : ObservableObject { public string Path { get; } = path; public string Name => System.IO.Path.GetFileName(Path); public string Folder => WebDrop.IsPlaceholder(Path) ? "" : System.IO.Path.GetDirectoryName(Path) ?? ""; + + /// The dates, read once: the order and the dates shown stay the same while the window is open. + public FileStamp Stamp { get; } = WebDrop.IsPlaceholder(path) ? new FileStamp(path) : FileStamp.Read(path); + + /// The row shows the date created rather than the date modified (while sorted by it). + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Time))] + private bool _showsCreated; + + /// The date and time shown, in the user's format. + public string Time => (ShowsCreated ? Stamp.Created : Stamp.Modified)?.ToLocalTime().ToString("g", CultureInfo.CurrentCulture) ?? ""; } public sealed partial class MergeOrderViewModel : ObservableObject @@ -30,10 +45,7 @@ public sealed partial class MergeOrderViewModel : ObservableObject /// public MergeOrderViewModel(IReadOnlyList files, ImageOptions? images = null) { - Files = new(files - .OrderBy(file => System.IO.Path.GetFileName(file), NaturalComparer.Instance) - .ThenBy(file => file, NaturalComparer.Instance) - .Select(file => new MergeSource(file))); + Files = new(FileSorter.Sort(files.Select(file => new MergeSource(file)), file => file.Stamp, FileSortOrder.NameAscending)); SelectedFile = Files.FirstOrDefault(); if (images is null) return; @@ -48,6 +60,37 @@ public MergeOrderViewModel(IReadOnlyList files, ImageOptions? images = n public ObservableCollection Files { get; } + /// The list entry after the sort orders: the files were moved by hand. + public const int CustomSortIndex = (int)FileSortOrder.CreatedDescending + 1; + + /// The entry chosen in the sort list: a , or . + [ObservableProperty] + private int _sortIndex = (int)FileSortOrder.NameAscending; + + partial void OnSortIndexChanged(int value) + { + // "Custom order" keeps the order as it is; nothing is selected (-1) only in passing. + if (value is >= 0 and < CustomSortIndex) + ApplySort((FileSortOrder)value); + } + + private void ApplySort(FileSortOrder order) + { + var selected = SelectedFile; + var sorted = FileSorter.Sort(Files, file => file.Stamp, order); + for (var i = 0; i < sorted.Count; i++) + { + var from = Files.IndexOf(sorted[i]); + if (from != i) + Files.Move(from, i); + sorted[i].ShowsCreated = order is FileSortOrder.CreatedAscending or FileSortOrder.CreatedDescending; + } + // The list box can drop its selection while it moves the row. + SelectedFile = selected; + MoveUpCommand.NotifyCanExecuteChanged(); + MoveDownCommand.NotifyCanExecuteChanged(); + } + /// The files are pictures that become one image: the window shows the layout too. public bool MergesImages { get; } @@ -136,6 +179,7 @@ private void Move(int direction) Files.Move(index, target); // The list box can drop its selection while it moves the row. SelectedFile = selected; + SortIndex = CustomSortIndex; MoveUpCommand.NotifyCanExecuteChanged(); MoveDownCommand.NotifyCanExecuteChanged(); } diff --git a/src/Filee.App/Views/MergeOrderWindow.axaml b/src/Filee.App/Views/MergeOrderWindow.axaml index 5f5cb502..66e3b7a3 100644 --- a/src/Filee.App/Views/MergeOrderWindow.axaml +++ b/src/Filee.App/Views/MergeOrderWindow.axaml @@ -8,7 +8,7 @@ x:DataType="vm:MergeOrderViewModel" Title="{DynamicResource merge.order_title}" Icon="avares://Filee/Assets/Icons/filee.ico" - Width="560" Height="480" MinWidth="440" MinHeight="320" + Width="560" Height="530" MinWidth="440" MinHeight="320" WindowStartupLocation="CenterScreen"> @@ -87,6 +87,20 @@ + + + + + + + + + + + + + @@ -137,11 +151,15 @@ - - - - + + + + + + + diff --git a/tests/Filee.App.Tests/MergeOrderTests.cs b/tests/Filee.App.Tests/MergeOrderTests.cs index 080193d8..fefea597 100644 --- a/tests/Filee.App.Tests/MergeOrderTests.cs +++ b/tests/Filee.App.Tests/MergeOrderTests.cs @@ -120,6 +120,117 @@ public async Task Donut_merge_waits_for_review_and_uses_the_chosen_order_or_canc } } + /// The sort list in the real chooser: files of real dates, sorted, then merged in the order it shows. + [AvaloniaFact] + public async Task Sort_list_orders_the_merge_by_date_and_name_and_a_move_makes_it_custom() + { + TestServices.EnsureInitialized(); + var folder = Directory.CreateTempSubdirectory("filee-merge-sort-").FullName; + var store = AppHost.Get(); + var profile = store.Profiles.Single(p => p.Id == "pdf"); + var previous = profile.PresetIds; + var preset = store.FindPreset("merge-pdf")!; + var previousOutput = preset.Output; + var conversions = AppHost.Get(); + var before = conversions.Jobs.Select(j => j.Job.Id).ToHashSet(); + var radial = AppHost.Get(); + var windows = AppHost.Get(); + try + { + // The name order, the modified order and the created order are all different; the page width tells the file. + var day = new DateTime(2024, 3, 1, 9, 0, 0, DateTimeKind.Utc); + string Pdf(string name, int width, double modifiedHours, double createdHours) + { + var path = Path.Combine(folder, name); + using (var document = new PdfDocument()) + { + var page = document.AddPage(); + page.Width = PdfSharp.Drawing.XUnit.FromPoint(width); + page.Height = PdfSharp.Drawing.XUnit.FromPoint(500); + document.Save(path); + } + File.SetLastWriteTimeUtc(path, day.AddHours(modifiedHours)); + File.SetCreationTimeUtc(path, day.AddHours(createdHours)); + return path; + } + var a = Pdf("page 2.pdf", 200, modifiedHours: 2, createdHours: 1); + var b = Pdf("page 10.pdf", 300, modifiedHours: 1, createdHours: 3); + var c = Pdf("page 30.pdf", 400, modifiedHours: 3, createdHours: 2); + + profile.PresetIds = ["merge-pdf"]; + preset.Output = new() { Location = Filee.Core.Presets.OutputLocation.CustomFolder, CustomFolder = folder }; + radial.ShowForFiles(450, 400, [c, a, b]); + var donut = radial.Window!.DonutControl; + Assert.True(donut.HandleKey(Key.Right)); + Assert.True(donut.HandleKey(Key.Enter)); + RenderTests.Pump(); + + var dialog = Assert.IsType(windows.MergeOrder); + var vm = Assert.IsType(dialog.DataContext); + var sort = dialog.FindControl("SortOrder")!; + void Choose(Filee.Core.Platform.FileSortOrder order) + { + sort.SelectedIndex = (int)order; + RenderTests.Pump(); + } + + // Opens on name order, with the list saying so. + Assert.Equal(0, sort.SelectedIndex); + Assert.Equal("Name (A to Z)", sort.SelectionBoxItem); + Assert.Equal([a, b, c], vm.OrderedFiles); + + Choose(Filee.Core.Platform.FileSortOrder.NameDescending); + Assert.Equal([c, b, a], vm.OrderedFiles); + Choose(Filee.Core.Platform.FileSortOrder.ModifiedAscending); + Assert.Equal([b, a, c], vm.OrderedFiles); + Assert.Equal(day.AddHours(1).ToLocalTime().ToString("g", System.Globalization.CultureInfo.CurrentCulture), vm.Files[0].Time); + Choose(Filee.Core.Platform.FileSortOrder.CreatedAscending); + Assert.Equal([a, c, b], vm.OrderedFiles); + // Sorted by date created, the rows show that date, not the one modified. + Assert.Equal(day.AddHours(1).ToLocalTime().ToString("g", System.Globalization.CultureInfo.CurrentCulture), vm.Files[0].Time); + Choose(Filee.Core.Platform.FileSortOrder.CreatedDescending); + Assert.Equal([b, c, a], vm.OrderedFiles); + + // A move keeps the order it made and the list says it's custom; the selection and the arrows follow the row. + Choose(Filee.Core.Platform.FileSortOrder.ModifiedDescending); + Assert.Equal([c, a, b], vm.OrderedFiles); + vm.SelectedFile = vm.Files[0]; + RenderTests.Pump(); + Click(dialog, "MoveDown"); + Assert.Equal([a, c, b], vm.OrderedFiles); + Assert.Equal(MergeOrderViewModel.CustomSortIndex, sort.SelectedIndex); + Assert.Equal("Custom order", sort.SelectionBoxItem); + Assert.Equal(c, vm.SelectedFile!.Path); + // Choosing an order again sorts again, and the selected file stays selected. + Choose(Filee.Core.Platform.FileSortOrder.ModifiedDescending); + Assert.Equal([c, a, b], vm.OrderedFiles); + Assert.Equal(c, vm.SelectedFile!.Path); + Assert.False(dialog.FindControl