diff --git a/src/common/Utils.ts b/src/common/Utils.ts index 710a43f8..e7dcb62d 100644 --- a/src/common/Utils.ts +++ b/src/common/Utils.ts @@ -173,7 +173,7 @@ export class Utils { } static getOffsetMinutes(offsetString: string) { //Convert offset string (+HH:MM or -HH:MM) into a minute value - const regex = /^([+-](0[0-9]|1[0-4]):[0-5][0-9])$/; //checks if offset is between -14:00 and +14:00. + const regex = /^([+-](0[0-9]|1[0-4]):[0-5][0-9])$/; //checks if offset is between -14:00 and +14:00. //-12:00 is the lowest valid UTC-offset, but we allow down to -14 for efficiency if (regex.test(offsetString)) { const hhmm = offsetString.split(":"); @@ -389,6 +389,19 @@ export class Utils { const sign = (parts[3] === "N" || parts[3] === "E") ? 1 : -1; return sign * (degrees + (minutes / 60.0)) } + + + public static sortableFilename(filename: string): string { + const lastDot = filename.lastIndexOf("."); + + // Avoid 0 as well as -1 to prevent empty names for extensionless dot-files + if (lastDot > 0) { + return filename.substring(0, lastDot); + } + + // Fallback to the full name + return filename; + } } export class LRU { diff --git a/src/frontend/app/ui/gallery/navigator/sorting.service.ts b/src/frontend/app/ui/gallery/navigator/sorting.service.ts index b5718c5a..16e42d51 100644 --- a/src/frontend/app/ui/gallery/navigator/sorting.service.ts +++ b/src/frontend/app/ui/gallery/navigator/sorting.service.ts @@ -132,8 +132,20 @@ export class GallerySortingService { } switch (sorting.method) { case SortByTypes.Name: - media.sort((a: PhotoDTO, b: PhotoDTO) => - this.collator.compare(a.name, b.name) + media.sort((a: PhotoDTO, b: PhotoDTO) => { + const aSortable = Utils.sortableFilename(a.name) + const bSortable = Utils.sortableFilename(b.name) + + if (aSortable === bSortable) { + // If the trimmed filenames match, use the full name as tie breaker + // This preserves a consistent final position for files named e.g., + // 10.jpg and 10.png, even if their starting position in the list + // changes based on any previous sorting that's happened under different heuristics + return this.collator.compare(a.name, b.name) + } + + return this.collator.compare(aSortable, bSortable) + } ); break; case SortByTypes.Date: diff --git a/test/common/unit/Utils.spec.ts b/test/common/unit/Utils.spec.ts index 01367ad7..9e42a438 100644 --- a/test/common/unit/Utils.spec.ts +++ b/test/common/unit/Utils.spec.ts @@ -58,4 +58,22 @@ describe('Utils', () => { expect(Utils.equalsFilter({a: 0}, {b: 0})).to.be.equal(false); expect(Utils.equalsFilter({a: 0}, {a: 0})).to.be.equal(true); }); + + describe('sortableFilename', () => { + it('should trim extensions', () => { + expect(Utils.sortableFilename("10.jpg")).to.be.equal("10") + }) + + it('should not trim dotfiles to empty strings', () => { + expect(Utils.sortableFilename(".file")).to.be.equal(".file") + }) + + it('should trim dotfiles with extensions', () => { + expect(Utils.sortableFilename(".favourite.jpg")).to.be.equal(".favourite") + }) + + it('should not trim without dots', () => { + expect(Utils.sortableFilename("hello_world")).to.be.equal("hello_world") + }) + }) });