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