diff --git a/src/custom-sort/custom-sort-types.ts b/src/custom-sort/custom-sort-types.ts index d4ce774..9b8741b 100644 --- a/src/custom-sort/custom-sort-types.ts +++ b/src/custom-sort/custom-sort-types.ts @@ -55,6 +55,7 @@ export interface CustomSortGroup { regexPrefix?: RegExpSpec exactSuffix?: string regexSuffix?: RegExpSpec + overrideTitle?: boolean // instead of title, use a derived text for sorting (e.g. regexp matching group). order?: CustomSortOrder byMetadataField?: string // for 'by-metadata:' sorting if the order is by metadata alphabetical or reverse secondaryOrder?: CustomSortOrder diff --git a/src/custom-sort/custom-sort.spec.ts b/src/custom-sort/custom-sort.spec.ts index b6a6133..5a2bc6b 100644 --- a/src/custom-sort/custom-sort.spec.ts +++ b/src/custom-sort/custom-sort.spec.ts @@ -4,6 +4,7 @@ import { DEFAULT_FOLDER_MTIME, determineFolderDatesIfNeeded, determineSortingGroup, + EQUAL_OR_UNCOMPARABLE, FolderItemForSorting, matchGroupRegex, sorterByBookmarkOrder, @@ -225,7 +226,6 @@ describe('determineSortingGroup', () => { groupIdx: 0, // Matched! isFolder: false, sortString: "00000123////Part123:-icle.md", - matchGroup: '00000123//', ctime: MOCK_TIMESTAMP + 555, mtime: MOCK_TIMESTAMP + 666, path: 'Some parent folder/Part123:-icle.md' @@ -288,6 +288,36 @@ describe('determineSortingGroup', () => { path: 'Some parent folder/Part:123-icle.md' }); }); + it('should match head and tail, when simple regexp in head and tail, overrideTitle', () => { + // given + const file: TFile = mockTFile('Part:123-icle', 'md', 444, MOCK_TIMESTAMP + 555, MOCK_TIMESTAMP + 666); + const sortSpec: CustomSortSpec = { + targetFoldersPaths: ['Some parent folder'], + groups: [{ + type: CustomSortGroupType.ExactHeadAndTail, + regexPrefix: { + regex: /^Part:\d/i + }, + regexSuffix: { + regex: /\d-icle$/i + }, + overrideTitle: true // Should be ignored when no advanced regexp + }] + } + + // when + const result = determineSortingGroup(file, sortSpec) + + // then + expect(result).toEqual({ + groupIdx: 0, // Matched! + isFolder: false, + sortString: "Part:123-icle.md", + ctime: MOCK_TIMESTAMP + 555, + mtime: MOCK_TIMESTAMP + 666, + path: 'Some parent folder/Part:123-icle.md' + }); + }); it('should match head and tail, when simple regexp in head and and mixed in tail', () => { // given const file: TFile = mockTFile('Part:1 1-23.456-icle', 'md', 444, MOCK_TIMESTAMP + 555, MOCK_TIMESTAMP + 666); @@ -313,7 +343,37 @@ describe('determineSortingGroup', () => { groupIdx: 0, // Matched! isFolder: false, sortString: "00000001|00000023////Part:1 1-23.456-icle.md", - matchGroup: '00000001|00000023//', + ctime: MOCK_TIMESTAMP + 555, + mtime: MOCK_TIMESTAMP + 666, + path: 'Some parent folder/Part:1 1-23.456-icle.md' + }); + }); + it('should match head and tail, when simple regexp in head and and mixed in tail, with overrideTitle', () => { + // given + const file: TFile = mockTFile('Part:1 1-23.456-icle', 'md', 444, MOCK_TIMESTAMP + 555, MOCK_TIMESTAMP + 666); + const sortSpec: CustomSortSpec = { + targetFoldersPaths: ['Some parent folder'], + groups: [{ + type: CustomSortGroupType.ExactHeadAndTail, + regexPrefix: { + regex: /^Part:\d/i + }, + regexSuffix: { + regex: / *(\d+(?:-\d+)*).\d\d\d-icle$/i, + normalizerFn: CompoundDashNumberNormalizerFn + }, + overrideTitle: true + }] + } + + // when + const result = determineSortingGroup(file, sortSpec) + + // then + expect(result).toEqual({ + groupIdx: 0, // Matched! + isFolder: false, + sortString: "00000001|00000023//", ctime: MOCK_TIMESTAMP + 555, mtime: MOCK_TIMESTAMP + 666, path: 'Some parent folder/Part:1 1-23.456-icle.md' @@ -342,12 +402,74 @@ describe('determineSortingGroup', () => { groupIdx: 0, // Matched! isFolder: false, sortString: "00000123////Part:123-icle.md", - matchGroup: '00000123//', ctime: MOCK_TIMESTAMP + 555, mtime: MOCK_TIMESTAMP + 666, path: 'Some parent folder/Part:123-icle.md' }); }); + it('should match head and tail, when advanced regexp in both, head and tail', () => { + // given + const file: TFile = mockTFile('Part 555-6 123-icle', 'md', 444, MOCK_TIMESTAMP + 555, MOCK_TIMESTAMP + 666); + const sortSpec: CustomSortSpec = { + targetFoldersPaths: ['Some parent folder'], + groups: [{ + type: CustomSortGroupType.ExactHeadAndTail, + regexPrefix: { + regex: /^Part *(\d+(?:-\d+)*)/i, + normalizerFn: CompoundDashNumberNormalizerFn + }, + regexSuffix: { + regex: / *(\d+(?:-\d+)*)-icle$/i, + normalizerFn: CompoundDashNumberNormalizerFn + } + }] + } + + // when + const result = determineSortingGroup(file, sortSpec) + + // then + expect(result).toEqual({ + groupIdx: 0, // Matched! + isFolder: false, + sortString: "00000555|00000006//00000123////Part 555-6 123-icle.md", + ctime: MOCK_TIMESTAMP + 555, + mtime: MOCK_TIMESTAMP + 666, + path: 'Some parent folder/Part 555-6 123-icle.md' + }); + }); + it('should match head and tail, when advanced regexp in both, head and tail, with overrideTitle', () => { + // given + const file: TFile = mockTFile('Part 555-6 123-icle', 'md', 444, MOCK_TIMESTAMP + 555, MOCK_TIMESTAMP + 666); + const sortSpec: CustomSortSpec = { + targetFoldersPaths: ['Some parent folder'], + groups: [{ + type: CustomSortGroupType.ExactHeadAndTail, + regexPrefix: { + regex: /^Part *(\d+(?:-\d+)*)/i, + normalizerFn: CompoundDashNumberNormalizerFn + }, + regexSuffix: { + regex: / *(\d+(?:-\d+)*)-icle$/i, + normalizerFn: CompoundDashNumberNormalizerFn + }, + overrideTitle: true + }] + } + + // when + const result = determineSortingGroup(file, sortSpec) + + // then + expect(result).toEqual({ + groupIdx: 0, // Matched! + isFolder: false, + sortString: "00000555|00000006//00000123//", + ctime: MOCK_TIMESTAMP + 555, + mtime: MOCK_TIMESTAMP + 666, + path: 'Some parent folder/Part 555-6 123-icle.md' + }); + }); }) describe('CustomSortGroupType.ExactPrefix', () => { it('should correctly recognize exact prefix', () => { @@ -422,7 +544,6 @@ describe('determineSortingGroup', () => { groupIdx: 0, isFolder: false, sortString: '00000001|00000030|00000006|00001900////Reference i.xxx.vi.mcm.md', - matchGroup: "00000001|00000030|00000006|00001900//", ctime: MOCK_TIMESTAMP + 222, mtime: MOCK_TIMESTAMP + 333, path: 'Some parent folder/Reference i.xxx.vi.mcm.md' @@ -525,7 +646,6 @@ describe('determineSortingGroup', () => { groupIdx: 0, isFolder: false, sortString: '00000001|00000030|00000006|00001900////Reference i.xxx.vi.mcm.md', - matchGroup: "00000001|00000030|00000006|00001900//", ctime: MOCK_TIMESTAMP + 222, mtime: MOCK_TIMESTAMP + 333, path: 'Some parent folder/Reference i.xxx.vi.mcm.md' @@ -653,7 +773,6 @@ describe('determineSortingGroup', () => { groupIdx: 0, isFolder: false, sortString: '00000001|00000030|00000006|00001900////Reference i.xxx.vi.mcm.md', - matchGroup: "00000001|00000030|00000006|00001900//", ctime: MOCK_TIMESTAMP + 222, mtime: MOCK_TIMESTAMP + 333, path: 'Some parent folder/Reference i.xxx.vi.mcm.md' @@ -2198,7 +2317,7 @@ describe('CustomSortOrder.byMetadataFieldAlphabetical', () => { expect(result1).toBe(SORT_FIRST_GOES_EARLIER) expect(result2).toBe(SORT_FIRST_GOES_LATER) }) - it('should correctly fallback to alphabetical if no metadata on both items', () => { + it('should refuse comparison if no metadata on both items', () => { // given const itemA: Partial = { sortString: 'ccc' @@ -2214,9 +2333,9 @@ describe('CustomSortOrder.byMetadataFieldAlphabetical', () => { const result3: number = sorter(itemB as FolderItemForSorting, itemB as FolderItemForSorting) // then - expect(result1).toBe(SORT_FIRST_GOES_EARLIER) - expect(result2).toBe(SORT_FIRST_GOES_LATER) - expect(result3).toBe(SORT_ITEMS_ARE_EQUAL) + expect(result1).toBe(EQUAL_OR_UNCOMPARABLE) + expect(result2).toBe(EQUAL_OR_UNCOMPARABLE) + expect(result3).toBe(EQUAL_OR_UNCOMPARABLE) }) }) @@ -2280,7 +2399,7 @@ describe('CustomSortOrder.byMetadataFieldAlphabeticalReverse', () => { expect(result1).toBe(SORT_FIRST_GOES_LATER) expect(result2).toBe(SORT_FIRST_GOES_EARLIER) }) - it('should correctly fallback to alphabetical reverse if no metadata on both items', () => { + it('should refrain from comparing if no metadata on both items', () => { // given const itemA: Partial = { sortString: 'ccc' @@ -2296,9 +2415,9 @@ describe('CustomSortOrder.byMetadataFieldAlphabeticalReverse', () => { const result3: number = sorter(itemB as FolderItemForSorting, itemB as FolderItemForSorting) // then - expect(result1).toBe(SORT_FIRST_GOES_LATER) - expect(result2).toBe(SORT_FIRST_GOES_EARLIER) - expect(result3).toBe(SORT_ITEMS_ARE_EQUAL) + expect(result1).toBe(EQUAL_OR_UNCOMPARABLE) + expect(result2).toBe(EQUAL_OR_UNCOMPARABLE) + expect(result3).toBe(EQUAL_OR_UNCOMPARABLE) }) }) @@ -2311,9 +2430,9 @@ describe('sorterByMetadataField', () => { [true,'mmm','mmm',1, 'e', 'd'], [true,'abc',undefined,-1, 'a','a'], [true,undefined,'klm',1, 'b','b'], - [true,undefined,undefined,0, 'a','a'], - [true,undefined,undefined,-1, 'a','b'], - [true,undefined,undefined,1, 'd','c'], + [true,undefined,undefined,EQUAL_OR_UNCOMPARABLE, 'a','a'], + [true,undefined,undefined,EQUAL_OR_UNCOMPARABLE, 'a','b'], + [true,undefined,undefined,EQUAL_OR_UNCOMPARABLE, 'd','c'], [false,'abc','def',1, 'a', 'a'], [false,'xyz','klm',-1, 'b', 'b'], [false,'mmm','mmm',0, 'c', 'c'], @@ -2321,9 +2440,9 @@ describe('sorterByMetadataField', () => { [false,'mmm','mmm',-1, 'e', 'd'], [false,'abc',undefined,1, 'a','a'], [false,undefined,'klm',-1, 'b','b'], - [false,undefined,undefined,0, 'a','a'], - [false,undefined,undefined,1, 'a','b'], - [false,undefined,undefined,-1, 'd','c'], + [false,undefined,undefined,EQUAL_OR_UNCOMPARABLE, 'a','a'], + [false,undefined,undefined,EQUAL_OR_UNCOMPARABLE, 'a','b'], + [false,undefined,undefined,EQUAL_OR_UNCOMPARABLE, 'd','c'], ])('straight order %s, comparing %s and %s should return %s for sortStrings %s and %s', (straight: boolean, metadataA: string|undefined, metadataB: string|undefined, order: number, sortStringA: string, sortStringB) => { diff --git a/src/custom-sort/custom-sort.ts b/src/custom-sort/custom-sort.ts index afbe76e..7cd98b9 100644 --- a/src/custom-sort/custom-sort.ts +++ b/src/custom-sort/custom-sort.ts @@ -59,7 +59,6 @@ export interface FolderItemForSorting { groupIdx?: number // the index itself represents order for groups sortString: string // fragment (or full name) to be used for sorting metadataFieldValue?: string // relevant to metadata-based sorting only - matchGroup?: string // advanced - used for secondary sorting rule, to recognize 'same regex match' ctime: number // for a file ctime is obvious, for a folder = ctime of the oldest child file mtime: number // for a file mtime is obvious, for a folder = date of most recently modified child file isFolder: boolean @@ -77,6 +76,8 @@ const TrueAlphabetical: boolean = true const ReverseOrder: boolean = true const StraightOrder: boolean = false +export const EQUAL_OR_UNCOMPARABLE: number = 0 + export const sorterByMetadataField:(reverseOrder?: boolean, trueAlphabetical?: boolean) => SorterFn = (reverseOrder: boolean, trueAlphabetical?: boolean) => { const collatorCompareFn: CollatorCompareFn = trueAlphabetical ? CollatorTrueAlphabeticalCompare : CollatorCompare return (a: FolderItemForSorting, b: FolderItemForSorting) => { @@ -95,8 +96,8 @@ export const sorterByMetadataField:(reverseOrder?: boolean, trueAlphabetical?: b // Item with metadata goes before the w/o metadata if (a.metadataFieldValue) return -1 if (b.metadataFieldValue) return 1 - // Fallback -> requested sort by metadata, yet none of two items contain it, use alphabetical by name - return collatorCompareFn(a.sortString, b.sortString) + + return EQUAL_OR_UNCOMPARABLE } } @@ -210,19 +211,25 @@ function getComparator(sortSpec: CustomSortSpec, currentUIselectedSorting?: stri if (itA.groupIdx != undefined && itB.groupIdx != undefined) { if (itA.groupIdx === itB.groupIdx) { const group: CustomSortGroup | undefined = sortSpec.groups[itA.groupIdx] - const matchingGroupPresentOnBothSidesAndEqual: boolean = itA.matchGroup !== undefined && itA.matchGroup === itB.matchGroup - if (matchingGroupPresentOnBothSidesAndEqual && group.secondaryOrder) { - return getSorterFnFor(group.secondaryOrder ?? CustomSortOrder.default, currentUIselectedSorting)(itA, itB) - } else { - return getSorterFnFor(group?.order ?? CustomSortOrder.default, currentUIselectedSorting)(itA, itB) - } + const primary: number = group?.order ? getSorterFnFor(group.order, currentUIselectedSorting)(itA, itB) : EQUAL_OR_UNCOMPARABLE + if (primary !== EQUAL_OR_UNCOMPARABLE) return primary + const secondary: number = group?.secondaryOrder ? getSorterFnFor(group.secondaryOrder, currentUIselectedSorting)(itA, itB) : EQUAL_OR_UNCOMPARABLE + if (secondary !== EQUAL_OR_UNCOMPARABLE) return secondary + const folderLevel: number = sortSpec.defaultOrder ? getSorterFnFor(sortSpec.defaultOrder, currentUIselectedSorting)(itA, itB) : EQUAL_OR_UNCOMPARABLE + if (folderLevel !== EQUAL_OR_UNCOMPARABLE) return folderLevel + const uiSelected: number = currentUIselectedSorting ? getSorterFnFor(CustomSortOrder.standardObsidian, currentUIselectedSorting)(itA, itB) : EQUAL_OR_UNCOMPARABLE + if (uiSelected !== EQUAL_OR_UNCOMPARABLE) return uiSelected + const lastResort: number = getSorterFnFor(CustomSortOrder.default)(itA, itB) + return lastResort } else { return itA.groupIdx - itB.groupIdx; } } else { // should never happen - groupIdx is not known for at least one of items to compare. // The logic of determining the index always sets some idx - // Yet for sanity and to satisfy TS code analyzer a fallback to default behavior below + // Yet for sanity and to satisfy TS code analyzer some valid behavior below + if (itA.groupIdx !== undefined) return -1 + if (itB.groupIdx !== undefined) return 1 return getSorterFnFor(CustomSortOrder.default, currentUIselectedSorting)(itA, itB) } } @@ -263,7 +270,7 @@ export const matchGroupRegex = (theRegex: RegExpSpec, nameForMatching: string): export const determineSortingGroup = function (entry: TFile | TFolder, spec: CustomSortSpec, ctx?: ProcessingContext): FolderItemForSorting { let groupIdx: number let determined: boolean = false - let matchedGroup: string | null | undefined + let derivedText: string | null | undefined let bookmarkedIdx: number | undefined let metadataValueToSortBy: string | undefined const aFolder: boolean = isFolder(entry) @@ -275,8 +282,8 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus // than the actual full set of defined groups, because the outsiders group are not // in the ordered list (aka priorityOrder array) const numOfGroupsToCheck: number = spec.priorityOrder ? spec.priorityOrder.length : spec.groups.length - for (let idx = 0; idx < numOfGroupsToCheck; idx++) { - matchedGroup = null + for (let idx = 0; idx < numOfGroupsToCheck && !determined; idx++) { + derivedText = null groupIdx = spec.priorityOrder ? spec.priorityOrder[idx] : idx const group: CustomSortGroup = spec.groupsShadow ? spec.groupsShadow[groupIdx] : spec.groups[groupIdx]; if (group.foldersOnly && aFile) continue; @@ -289,7 +296,9 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus determined = true; } } else { // regexp is involved - [determined, matchedGroup] = matchGroupRegex(group.regexPrefix!, nameForMatching) + const [matched, matchedGroup] = matchGroupRegex(group.regexPrefix!, nameForMatching) + determined = matched + derivedText = matchedGroup ?? derivedText } break; case CustomSortGroupType.ExactSuffix: @@ -298,7 +307,9 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus determined = true; } } else { // regexp is involved - [determined, matchedGroup] = matchGroupRegex(group.regexSuffix!, nameForMatching) + const [matched, matchedGroup] = matchGroupRegex(group.regexSuffix!, nameForMatching) + determined = matched + derivedText = matchedGroup ?? derivedText } break; case CustomSortGroupType.ExactHeadAndTail: @@ -311,13 +322,12 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus } else if (group.exactPrefix || group.exactSuffix) { // regexp is involved as the prefix or as the suffix (not both) if ((group.exactPrefix && nameForMatching.startsWith(group.exactPrefix)) || (group.exactSuffix && nameForMatching.endsWith(group.exactSuffix))) { - let fullMatch: string | undefined - [determined, matchedGroup, fullMatch] = matchGroupRegex(group.exactPrefix ? group.regexSuffix! : group.regexPrefix!, nameForMatching) - if (determined) { + const [matched, matchedGroup, fullMatch] = matchGroupRegex(group.exactPrefix ? group.regexSuffix! : group.regexPrefix!, nameForMatching) + if (matched) { // check for overlapping of prefix and suffix match (not allowed) - if ((fullMatch!.length + (group.exactPrefix?.length ?? 0) + (group.exactSuffix?.length ?? 0)) > nameForMatching.length) { - determined = false - matchedGroup = null // if it falls into Outsiders group, let it use title to sort + if ((fullMatch!.length + (group.exactPrefix?.length ?? 0) + (group.exactSuffix?.length ?? 0)) <= nameForMatching.length) { + determined = true + derivedText = matchedGroup ?? derivedText } } } @@ -328,7 +338,9 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus // check for overlapping of prefix and suffix match (not allowed) if ((fullMatchLeft!.length + fullMatchRight!.length) <= nameForMatching.length) { determined = true - matchedGroup = matchedGroupLeft ?? matchedGroupRight + if (matchedGroupLeft || matchedGroupRight) { + derivedText = ((matchedGroupLeft || '') + (matchedGroupRight || '')) || derivedText + } } } } @@ -339,7 +351,11 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus determined = true; } } else { // regexp is involved - [determined, matchedGroup] = matchGroupRegex(group.regexPrefix!, nameForMatching) + const [matched, matchedGroup] = matchGroupRegex(group.regexPrefix!, nameForMatching) + if (matched) { + determined = true + derivedText = matchedGroup ?? derivedText + } } break case CustomSortGroupType.HasMetadataField: @@ -388,8 +404,10 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus determined = true; break } - if (determined) { - break; // No need to check other sorting groups + if (determined && derivedText) { + if (!group.overrideTitle) { + derivedText = derivedText + '//' + entry.name + } } } @@ -458,9 +476,8 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus return { // idx of the matched group or idx of Outsiders group or the largest index (= groups count+1) groupIdx: determinedGroupIdx, - sortString: matchedGroup ? (matchedGroup + '//' + entry.name) : entry.name, + sortString: derivedText ?? entry.name, metadataFieldValue: metadataValueToSortBy, - matchGroup: matchedGroup ?? undefined, isFolder: aFolder, folder: aFolder ? (entry as TFolder) : undefined, path: entry.path,