diff --git a/src/custom-sort/custom-sort-types.ts b/src/custom-sort/custom-sort-types.ts index 1ce15b3..5515373 100644 --- a/src/custom-sort/custom-sort-types.ts +++ b/src/custom-sort/custom-sort-types.ts @@ -57,6 +57,7 @@ export interface CustomSortGroup { matchFilenameWithExt?: boolean foldersOnly?: boolean withMetadataFieldName?: string // for 'with-metadata:' + priority?: number } export interface CustomSortSpec { @@ -68,9 +69,10 @@ export interface CustomSortSpec { outsidersFilesGroupIdx?: number outsidersFoldersGroupIdx?: number itemsToHide?: Set - plugin?: Plugin // to hand over the access to App instance to the sorting engine + priorityOrder?: Array // Indexes of groups in evaluation order // For internal transient use + plugin?: Plugin // to hand over the access to App instance to the sorting engine _mCache?: MetadataCache } diff --git a/src/custom-sort/custom-sort.spec.ts b/src/custom-sort/custom-sort.spec.ts index 3809eb3..5ae7501 100644 --- a/src/custom-sort/custom-sort.spec.ts +++ b/src/custom-sort/custom-sort.spec.ts @@ -788,6 +788,52 @@ describe('determineSortingGroup', () => { } as FolderItemForSorting); }) }) + + it('should correctly apply priority group', () => { + // given + const file: TFile = mockTFile('Abcdef!', 'md', 111, MOCK_TIMESTAMP + 222, MOCK_TIMESTAMP + 333); + const sortSpec: CustomSortSpec = { + groups: [{ + filesOnly: true, + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.MatchAll + }, { + foldersOnly: true, + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.MatchAll + }, { + exactSuffix: "def!", + priority: 2, + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.ExactSuffix + }, { + exactText: "Abcdef!", + order: CustomSortOrder.alphabetical, + priority: 3, + type: CustomSortGroupType.ExactName + }, { + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 4, + targetFoldersPaths: ['/'], + priorityOrder: [3,2,0,1] + } + + // when + const result = determineSortingGroup(file, sortSpec) + + // then + expect(result).toEqual({ + groupIdx: 3, + isFolder: false, + sortString: "Abcdef!.md", + ctimeNewest: MOCK_TIMESTAMP + 222, + ctimeOldest: MOCK_TIMESTAMP + 222, + mtime: MOCK_TIMESTAMP + 333, + path: 'Some parent folder/Abcdef!.md' + }); + }) }) describe('determineFolderDatesIfNeeded', () => { diff --git a/src/custom-sort/custom-sort.ts b/src/custom-sort/custom-sort.ts index 4f3f6e8..48e8f45 100644 --- a/src/custom-sort/custom-sort.ts +++ b/src/custom-sort/custom-sort.ts @@ -129,8 +129,10 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus const entryAsTFile: TFile = entry as TFile const basename: string = aFolder ? entry.name : entryAsTFile.basename - for (groupIdx = 0; groupIdx < spec.groups.length; groupIdx++) { + const numOfGroupsToCheck: number = spec.priorityOrder ? spec.priorityOrder.length : spec.groups.length + for (let idx = 0; idx < numOfGroupsToCheck; idx++) { matchedGroup = null + groupIdx = spec.priorityOrder ? spec.priorityOrder[idx] : idx const group: CustomSortGroup = spec.groups[groupIdx]; if (group.foldersOnly && aFile) continue; if (group.filesOnly && aFolder) continue; @@ -218,12 +220,12 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus break } if (determined) { - break; + break; // No need to check other sorting groups } } - // the final groupIdx for undetermined folder entry is either the last+1 groupIdx or idx of explicitly defined outsiders group - let determinedGroupIdx: number | undefined = groupIdx; + const idxAfterLastGroupIdx: number = spec.groups.length + let determinedGroupIdx: number | undefined = determined ? groupIdx! : idxAfterLastGroupIdx if (!determined) { // Automatically assign the index to outsiders group, if relevant was configured diff --git a/src/custom-sort/sorting-spec-processor.spec.ts b/src/custom-sort/sorting-spec-processor.spec.ts index 34c899d..7085c96 100644 --- a/src/custom-sort/sorting-spec-processor.spec.ts +++ b/src/custom-sort/sorting-spec-processor.spec.ts @@ -7,7 +7,7 @@ import { escapeRegexUnsafeCharacters, extractNumericSortingSymbol, hasMoreThanOneNumericSortingSymbol, - NumberNormalizerFn, + NumberNormalizerFn, ProblemCode, RegexpUsedAs, RomanNumberNormalizerFn, SortingSpecProcessor @@ -535,7 +535,7 @@ describe('SortingSpecProcessor', () => { const txtInputSimplistic1: string = ` target-folder: /* /:files -//folders +/folders ` const expectedSortSpecForSimplistic1: { [key: string]: CustomSortSpec } = { @@ -545,11 +545,12 @@ const expectedSortSpecForSimplistic1: { [key: string]: CustomSortSpec } = { order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders }, { + foldersOnly: true, order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders }], outsidersFilesGroupIdx: 0, - outsidersGroupIdx: 1, + outsidersFoldersGroupIdx: 1, targetFoldersPaths: ['/*'] } } @@ -561,11 +562,12 @@ const expectedWildcardMatchingTreeForSimplistic1 = { order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders }, { + foldersOnly: true, order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders }], outsidersFilesGroupIdx: 0, - outsidersGroupIdx: 1, + outsidersFoldersGroupIdx: 1, targetFoldersPaths: ['/*'] }, "subtree": {} @@ -574,7 +576,7 @@ const expectedWildcardMatchingTreeForSimplistic1 = { const txtInputSimplistic2: string = ` target-folder: / /:files -//folders +/folders ` const expectedSortSpecForSimplistic2: { [key: string]: CustomSortSpec } = { @@ -584,11 +586,12 @@ const expectedSortSpecForSimplistic2: { [key: string]: CustomSortSpec } = { order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders }, { + foldersOnly: true, order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders }], outsidersFilesGroupIdx: 0, - outsidersGroupIdx: 1, + outsidersFoldersGroupIdx: 1, targetFoldersPaths: ['/'] } } @@ -760,6 +763,123 @@ describe('SortingSpecProcessor edge case', () => { }) }) +const txtInputPriorityGroups1: string = ` +target-folder: / +/:files +/folders +/! /:files Fi... +/!! /folders Fo... +/!!! ...def! +Plain text +/! % Anything +` + +const txtInputPriorityGroups2: string = ` +target-folder: / +/! /:files Fi... +/!! /folders Fo... +/!!! ...def! +/! Anything +` + +const expectedSortSpecForPriorityGroups1: { [key: string]: CustomSortSpec } = { + "/": { + groups: [{ + filesOnly: true, + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.Outsiders + }, { + foldersOnly: true, + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.Outsiders + }, { + exactPrefix: "Fi", + filesOnly: true, + order: CustomSortOrder.alphabetical, + priority: 1, + type: CustomSortGroupType.ExactPrefix + }, { + exactPrefix: "Fo", + foldersOnly: true, + order: CustomSortOrder.alphabetical, + priority: 2, + type: CustomSortGroupType.ExactPrefix + }, { + exactSuffix: "def!", + priority: 3, + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.ExactSuffix + }, { + exactText: "Plain text", + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.ExactName + },{ + exactText: "Anything", + order: CustomSortOrder.alphabetical, + priority: 1, + type: CustomSortGroupType.ExactName + }], + outsidersFilesGroupIdx: 0, + outsidersFoldersGroupIdx: 1, + targetFoldersPaths: ['/'], + priorityOrder: [4,3,2,6,5] + } +} + +const expectedSortSpecForPriorityGroups2: { [key: string]: CustomSortSpec } = { + "/": { + groups: [{ + exactPrefix: "Fi", + filesOnly: true, + order: CustomSortOrder.alphabetical, + priority: 1, + type: CustomSortGroupType.ExactPrefix + }, { + exactPrefix: "Fo", + foldersOnly: true, + order: CustomSortOrder.alphabetical, + priority: 2, + type: CustomSortGroupType.ExactPrefix + }, { + exactSuffix: "def!", + priority: 3, + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.ExactSuffix + }, { + exactText: "Anything", + order: CustomSortOrder.alphabetical, + priority: 1, + type: CustomSortGroupType.ExactName + }, { + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 4, + targetFoldersPaths: ['/'], + priorityOrder: [2,1,0,3] + } +} + +describe('SortingSpecProcessor', () => { + let processor: SortingSpecProcessor; + beforeEach(() => { + processor = new SortingSpecProcessor(); + }); + it('should recognize the sorting groups with priority example 1', () => { + const inputTxtArr: Array = txtInputPriorityGroups1.split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result?.sortSpecByPath).toEqual(expectedSortSpecForPriorityGroups1) + expect(result?.sortSpecByWildcard).toBeUndefined() + }) + it('should recognize the sorting groups with priority example 2', () => { + const inputTxtArr: Array = txtInputPriorityGroups2.split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result?.sortSpecByPath).toEqual(expectedSortSpecForPriorityGroups2) + expect(result?.sortSpecByWildcard).toBeUndefined() + }) +}) + + const txtInputTargetFolderMultiSpecA: string = ` target-folder: . < a-z @@ -1111,6 +1231,22 @@ target-folder: AAA sorting: standard ` +const txtInputErrorPriorityAlone: string = ` +/! +` + +const txtInputErrorPriorityEmptyFilePattern: string = ` +/!! /: +` + +const txtInputErrorPriorityEmptyFolderPattern: string = ` +/!!! / +` + +const txtInputErrorPriorityEmptyPattern: string = ` +/! % +` + const txtInputEmptySpec: string = `` describe('SortingSpecProcessor error detection and reporting', () => { @@ -1207,6 +1343,42 @@ describe('SortingSpecProcessor error detection and reporting', () => { `${ERR_PREFIX} 14:StandardObsidianSortAllowedOnlyAtFolderLevel The standard Obsidian sort order is only allowed at a folder level (not nested syntax) ${ERR_SUFFIX_IN_LINE(4)}`) expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT(' sorting: standard')) }) + it('should recognize error: priority indicator alone', () => { + const inputTxtArr: Array = txtInputErrorPriorityAlone.split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result).toBeNull() + expect(errorsLogger).toHaveBeenCalledTimes(2) + expect(errorsLogger).toHaveBeenNthCalledWith(1, + `${ERR_PREFIX} 15:PriorityNotAllowedOnOutsidersGroup Priority is not allowed for sorting group with empty match-pattern ${ERR_SUFFIX_IN_LINE(2)}`) + expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('/!')) + }) + it('should recognize error: priority indicator with empty file pattern', () => { + const inputTxtArr: Array = txtInputErrorPriorityEmptyFilePattern.split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result).toBeNull() + expect(errorsLogger).toHaveBeenCalledTimes(2) + expect(errorsLogger).toHaveBeenNthCalledWith(1, + `${ERR_PREFIX} 15:PriorityNotAllowedOnOutsidersGroup Priority is not allowed for sorting group with empty match-pattern ${ERR_SUFFIX_IN_LINE(2)}`) + expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('/!! /:')) + }) + it('should recognize error: priority indicator with empty folder pattern', () => { + const inputTxtArr: Array = txtInputErrorPriorityEmptyFolderPattern.split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result).toBeNull() + expect(errorsLogger).toHaveBeenCalledTimes(2) + expect(errorsLogger).toHaveBeenNthCalledWith(1, + `${ERR_PREFIX} 15:PriorityNotAllowedOnOutsidersGroup Priority is not allowed for sorting group with empty match-pattern ${ERR_SUFFIX_IN_LINE(2)}`) + expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('/!!! /')) + }) + it('should recognize error: priority indicator with empty pattern', () => { + const inputTxtArr: Array = txtInputErrorPriorityEmptyPattern.split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result).toBeNull() + expect(errorsLogger).toHaveBeenCalledTimes(2) + expect(errorsLogger).toHaveBeenNthCalledWith(1, + `${ERR_PREFIX} 15:PriorityNotAllowedOnOutsidersGroup Priority is not allowed for sorting group with empty match-pattern ${ERR_SUFFIX_IN_LINE(2)}`) + expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('/! %')) + }) it('should recognize empty spec', () => { const inputTxtArr: Array = txtInputEmptySpec.split('\n') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') diff --git a/src/custom-sort/sorting-spec-processor.ts b/src/custom-sort/sorting-spec-processor.ts index f4aa3f1..b96528c 100644 --- a/src/custom-sort/sorting-spec-processor.ts +++ b/src/custom-sort/sorting-spec-processor.ts @@ -46,6 +46,7 @@ interface ParsedSortingGroup { arraySpec?: Array outsidersGroup?: boolean // Mutually exclusive with plainSpec and arraySpec itemToHide?: boolean + priority?: number } export enum ProblemCode { @@ -63,7 +64,8 @@ export enum ProblemCode { ItemToHideExactNameWithExtRequired, ItemToHideNoSupportForThreeDots, DuplicateWildcardSortSpecForSameFolder, - StandardObsidianSortAllowedOnlyAtFolderLevel + StandardObsidianSortAllowedOnlyAtFolderLevel, + PriorityNotAllowedOnOutsidersGroup } const ContextFreeProblems = new Set([ @@ -174,7 +176,8 @@ const FilesWithExtGroupVerboseLexeme: string = '/:files.' const FilesWithExtGroupShortLexeme: string = '/:.' const FoldersGroupVerboseLexeme: string = '/folders' const FoldersGroupShortLexeme: string = '/' -const AnyTypeGroupLexeme: string = '%' // See % as a combination of / and : +const AnyTypeGroupLexemeShort: string = '%' // See % as a combination of / and : +const AnyTypeGroupLexeme: string = '/%' // See % as a combination of / and : const HideItemShortLexeme: string = '--%' // See % as a combination of / and : const HideItemVerboseLexeme: string = '/--hide:' @@ -182,11 +185,26 @@ const MetadataFieldIndicatorLexeme: string = 'with-metadata:' const CommentPrefix: string = '//' +const FileGroupModifierPrio1Lexeme: string = '/!' +const FileGroupModifierPrio2Lexeme: string = '/!!' +const FileGroupModifierPrio3Lexeme: string = '/!!!' + +const PRIO_1: number = 1 +const PRIO_2: number = 2 +const PRIO_3: number = 3 + +const SortingGroupPriorityPrefixes: { [key: string]: number } = { + [FileGroupModifierPrio1Lexeme]: PRIO_1, + [FileGroupModifierPrio2Lexeme]: PRIO_2, + [FileGroupModifierPrio3Lexeme]: PRIO_3 +} + interface SortingGroupType { filesOnly?: boolean filenameWithExt?: boolean // The text matching criteria should apply to filename + extension foldersOnly?: boolean itemToHide?: boolean + priority?: number } const SortingGroupPrefixes: { [key: string]: SortingGroupType } = { @@ -196,6 +214,7 @@ const SortingGroupPrefixes: { [key: string]: SortingGroupType } = { [FilesWithExtGroupVerboseLexeme]: {filesOnly: true, filenameWithExt: true}, [FoldersGroupShortLexeme]: {foldersOnly: true}, [FoldersGroupVerboseLexeme]: {foldersOnly: true}, + [AnyTypeGroupLexemeShort]: {}, [AnyTypeGroupLexeme]: {}, [HideItemShortLexeme]: {itemToHide: true}, [HideItemVerboseLexeme]: {itemToHide: true} @@ -664,23 +683,43 @@ export class SortingSpecProcessor { } private parseSortingGroupSpec = (line: string): ParsedSortingGroup | null => { - const s: string = line.trim() + let s: string = line.trim() if (hasMoreThanOneNumericSortingSymbol(s)) { this.problem(ProblemCode.TooManyNumericSortingSymbols, 'Maximum one numeric sorting indicator allowed per line') return null } + const priorityPrefixAlone: number = SortingGroupPriorityPrefixes[s] + if (priorityPrefixAlone) { + this.problem(ProblemCode.PriorityNotAllowedOnOutsidersGroup, 'Priority is not allowed for sorting group with empty match-pattern') + return null + } + + let groupPriority: number | undefined = undefined + for (const priorityPrefix of Object.keys(SortingGroupPriorityPrefixes)) { + if (s.startsWith(priorityPrefix + ' ')) { + groupPriority = SortingGroupPriorityPrefixes[priorityPrefix] + s = s.substring(priorityPrefix.length).trim() + break + } + } + const prefixAlone: SortingGroupType = SortingGroupPrefixes[s] if (prefixAlone) { if (prefixAlone.itemToHide) { this.problem(ProblemCode.ItemToHideExactNameWithExtRequired, 'Exact name with ext of file or folders to hide is required') return null } else { // !prefixAlone.itemToHide - return { - outsidersGroup: true, - filesOnly: prefixAlone.filesOnly, - foldersOnly: prefixAlone.foldersOnly + if (groupPriority) { + this.problem(ProblemCode.PriorityNotAllowedOnOutsidersGroup, 'Priority is not allowed for sorting group with empty match-pattern') + return null + } else { + return { + outsidersGroup: true, + filesOnly: prefixAlone.filesOnly, + foldersOnly: prefixAlone.foldersOnly + } } } } @@ -700,12 +739,26 @@ export class SortingSpecProcessor { plainSpec: s.substring(prefix.length + 1), filesOnly: sortingGroupType.filesOnly, foldersOnly: sortingGroupType.foldersOnly, - matchFilenameWithExt: sortingGroupType.filenameWithExt + matchFilenameWithExt: sortingGroupType.filenameWithExt, + priority: groupPriority ?? undefined } } } } + if (groupPriority) { + if (s === '') { + // Edge case: line with only priority prefix and no other content + this.problem(ProblemCode.PriorityNotAllowedOnOutsidersGroup, 'Priority is not allowed for sorting group with empty match-pattern') + return null + } else { + // Edge case: line with only priority prefix and no other known syntax, yet some content + return { + plainSpec: s, + priority: groupPriority + } + } + } return null; } @@ -731,8 +784,14 @@ export class SortingSpecProcessor { if (newGroup) { if (this.adjustSortingGroupForNumericSortingSymbol(newGroup)) { if (this.ctx.currentSpec) { - this.ctx.currentSpec.groups.push(newGroup) + const groupIdx = this.ctx.currentSpec.groups.push(newGroup) - 1 this.ctx.currentSpecGroup = newGroup + // Consume group with priority + if (group.priority && group.priority > 0) { + newGroup.priority = group.priority + this.addExpediteGroupInfo(this.ctx.currentSpec, group.priority, groupIdx) + } + return true; } else { return false @@ -794,7 +853,7 @@ export class SortingSpecProcessor { }) } - // Populate sorting order for a bit more efficient sorting later on + // Populate sorting order down the hierarchy for more clean sorting logic later on for (let group of spec.groups) { if (!group.order) { group.order = spec.defaultOrder ?? DEFAULT_SORT_ORDER @@ -802,6 +861,18 @@ export class SortingSpecProcessor { } } + // If any priority sorting group was present in the spec, determine the groups evaluation order + if (spec.priorityOrder) { + // priorityOrder array already contains at least one priority group, so append all non-priority groups for the final order + // (Outsiders groups are ignored intentionally) + for (let idx=0; idx < spec.groups.length; idx++) { + const group: CustomSortGroup = spec.groups[idx] + if (group.priority === undefined && group.type !== CustomSortGroupType.Outsiders) { + spec.priorityOrder.push(idx) + } + } + } + const CURRENT_FOLDER_PREFIX: string = `${CURRENT_FOLDER_SYMBOL}/` // Replace the dot-folder names (coming from: 'target-folder: .') with actual folder names @@ -1108,4 +1179,21 @@ export class SortingSpecProcessor { } return true } + + private addExpediteGroupInfo = (spec: CustomSortSpec, groupPriority: number, groupIdx: number) => { + if (!spec.priorityOrder) { + spec.priorityOrder = [] + } + let inserted: boolean = false + for (let idx=0; idx spec.groups[spec.priorityOrder[idx]].priority!) { + spec.priorityOrder.splice(idx, 0, groupIdx) + inserted = true + break + } + } + if (!inserted) { + spec.priorityOrder.push(groupIdx) + } + } }