From 84a52388148880f4f03ed585d414ae176b1c81df Mon Sep 17 00:00:00 2001 From: SebastianMC <23032356+SebastianMC@users.noreply.github.com> Date: Tue, 29 Nov 2022 11:17:02 +0100 Subject: [PATCH] #35 - Feature: combining of sorting rules (#36) - full unit tests coverage of the new functionality - refactor of the parser to allow more flexible syntax and be able to detect more errors - introduced many new errors recognized by the parser --- docs/manual.md | 2 +- src/custom-sort/custom-sort-types.ts | 5 +- src/custom-sort/custom-sort.spec.ts | 105 +++++++ src/custom-sort/custom-sort.ts | 8 + .../sorting-spec-processor.spec.ts | 281 +++++++++++++++++- src/custom-sort/sorting-spec-processor.ts | 255 ++++++++++++---- 6 files changed, 590 insertions(+), 66 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 7f92291..b256943 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -30,7 +30,7 @@ sorting-spec: | The resulting order of notes would be: -![Order of notes w/o priorities](./svg/priorities-example-a.svg) +![Order of notes w/o priorites](./svg/priorities-example-a.svg) However, a group can be assigned a higher priority in the sorting spec. In result, folder items will be matched against them _before_ any other rules. To impose a priority on a group use the prefix `/!` or `/!!` or `/!!!` diff --git a/src/custom-sort/custom-sort-types.ts b/src/custom-sort/custom-sort-types.ts index 5515373..dd39d72 100644 --- a/src/custom-sort/custom-sort-types.ts +++ b/src/custom-sort/custom-sort-types.ts @@ -51,13 +51,14 @@ export interface CustomSortGroup { exactPrefix?: string exactSuffix?: string order?: CustomSortOrder - byMetadataField?: string // for 'by-metadata:' if the order is by metadata alphabetical or reverse + byMetadataField?: string // for 'by-metadata:' sorting if the order is by metadata alphabetical or reverse secondaryOrder?: CustomSortOrder filesOnly?: boolean matchFilenameWithExt?: boolean foldersOnly?: boolean - withMetadataFieldName?: string // for 'with-metadata:' + withMetadataFieldName?: string // for 'with-metadata:' grouping priority?: number + combineWithIdx?: number } export interface CustomSortSpec { diff --git a/src/custom-sort/custom-sort.spec.ts b/src/custom-sort/custom-sort.spec.ts index 5ae7501..8495416 100644 --- a/src/custom-sort/custom-sort.spec.ts +++ b/src/custom-sort/custom-sort.spec.ts @@ -834,6 +834,111 @@ describe('determineSortingGroup', () => { path: 'Some parent folder/Abcdef!.md' }); }) + it('should correctly recognize and apply combined group', () => { + // given + const file1: TFile = mockTFile('Hello :-) ha', 'md', 111, MOCK_TIMESTAMP + 222, MOCK_TIMESTAMP + 333); + const file2: TFile = mockTFile('Hello World :-)', 'md', 111, MOCK_TIMESTAMP + 222, MOCK_TIMESTAMP + 333); + const sortSpec: CustomSortSpec = { + groups: [{ + exactSuffix: "def!", + order: CustomSortOrder.alphabeticalReverse, + type: CustomSortGroupType.ExactSuffix + }, { + exactPrefix: "Hello :-)", + order: CustomSortOrder.alphabeticalReverse, + type: CustomSortGroupType.ExactPrefix, + combineWithIdx: 1 + }, { + exactText: "Hello World :-)", + order: CustomSortOrder.alphabeticalReverse, + type: CustomSortGroupType.ExactName, + combineWithIdx: 1 + }, { + filesOnly: true, + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.MatchAll + }, { + foldersOnly: true, + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.MatchAll + }, { + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 5, + targetFoldersPaths: ['/'] + } + + // when + const result1 = determineSortingGroup(file1, sortSpec) + const result2 = determineSortingGroup(file2, sortSpec) + + // then + expect(result1).toEqual({ + groupIdx: 1, // Imposed by combined groups + isFolder: false, + sortString: "Hello :-) ha.md", + ctimeNewest: MOCK_TIMESTAMP + 222, + ctimeOldest: MOCK_TIMESTAMP + 222, + mtime: MOCK_TIMESTAMP + 333, + path: 'Some parent folder/Hello :-) ha.md' + }); + expect(result2).toEqual({ + groupIdx: 1, // Imposed by combined groups + isFolder: false, + sortString: "Hello World :-).md", + ctimeNewest: MOCK_TIMESTAMP + 222, + ctimeOldest: MOCK_TIMESTAMP + 222, + mtime: MOCK_TIMESTAMP + 333, + path: 'Some parent folder/Hello World :-).md' + }); + }) + it('should correctly recognize and apply combined group in connection with priorities', () => { + // given + const file: TFile = mockTFile('Hello :-)', '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!", + order: CustomSortOrder.alphabeticalReverse, + type: CustomSortGroupType.ExactSuffix, + combineWithIdx: 2 + }, { + exactText: "Hello :-)", + order: CustomSortOrder.alphabeticalReverse, + type: CustomSortGroupType.ExactName, + priority: 1, + combineWithIdx: 2 + }, { + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 4, + priorityOrder: [3,0,1,2], + targetFoldersPaths: ['/'] + } + + // when + const result = determineSortingGroup(file, sortSpec) + + // then + expect(result).toEqual({ + groupIdx: 2, // Imposed by combined groups + isFolder: false, + sortString: "Hello :-).md", + ctimeNewest: MOCK_TIMESTAMP + 222, + ctimeOldest: MOCK_TIMESTAMP + 222, + mtime: MOCK_TIMESTAMP + 333, + path: 'Some parent folder/Hello :-).md' + }); + }) }) describe('determineFolderDatesIfNeeded', () => { diff --git a/src/custom-sort/custom-sort.ts b/src/custom-sort/custom-sort.ts index 1fe007c..8b7ee46 100644 --- a/src/custom-sort/custom-sort.ts +++ b/src/custom-sort/custom-sort.ts @@ -230,6 +230,14 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus const idxAfterLastGroupIdx: number = spec.groups.length let determinedGroupIdx: number | undefined = determined ? groupIdx! : idxAfterLastGroupIdx + // Redirection to the first group of combined, if detected + if (determined) { + const combinedGroupIdx: number | undefined = spec.groups[determinedGroupIdx].combineWithIdx + if (combinedGroupIdx !== undefined) { + determinedGroupIdx = combinedGroupIdx + } + } + if (!determined) { // Automatically assign the index to outsiders group, if relevant was configured if (isDefined(spec.outsidersFilesGroupIdx) && aFile) { diff --git a/src/custom-sort/sorting-spec-processor.spec.ts b/src/custom-sort/sorting-spec-processor.spec.ts index 7085c96..a7bce01 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, ProblemCode, + NumberNormalizerFn, RegexpUsedAs, RomanNumberNormalizerFn, SortingSpecProcessor @@ -860,6 +860,42 @@ const expectedSortSpecForPriorityGroups2: { [key: string]: CustomSortSpec } = { } } +const expectedSortSpecForPriorityAndCombineGroups: { [key: string]: CustomSortSpec } = { + "/": { + groups: [{ + combineWithIdx: 0, + exactPrefix: "Fi", + filesOnly: true, + order: CustomSortOrder.alphabetical, + priority: 1, + type: CustomSortGroupType.ExactPrefix + }, { + combineWithIdx: 0, + 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(() => { @@ -877,8 +913,127 @@ describe('SortingSpecProcessor', () => { expect(result?.sortSpecByPath).toEqual(expectedSortSpecForPriorityGroups2) expect(result?.sortSpecByWildcard).toBeUndefined() }) -}) + it('should recognize the combine and priority prefixes in any order example 1', () => { + const inputTxtArr: Array = ` + target-folder: / + /! /+ /:files Fi... + /!! /+ /folders Fo... + /!!! ...def! + /! Anything + `.replace(/\t/gi, '').split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result?.sortSpecByPath).toEqual(expectedSortSpecForPriorityAndCombineGroups) + expect(result?.sortSpecByWildcard).toBeUndefined() + }) + it('should recognize the combine and priority prefixes in any order example 2', () => { + const inputTxtArr: Array = ` + target-folder: / + /+ /! /:files Fi... + /+ /!! /folders Fo... + /!!! ...def! + /! Anything + `.replace(/\t/gi, '').split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result?.sortSpecByPath).toEqual(expectedSortSpecForPriorityAndCombineGroups) + expect(result?.sortSpecByWildcard).toBeUndefined() + }) + it('should accept the combine operator in single line only', () => { + const inputTxtArr: Array = ` + target-folder: / + /+ /:files Fi... + /folders Fo... + `.replace(/\t/gi, '').split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result?.sortSpecByPath).toEqual({ + "/": { + groups: [{ + combineWithIdx: 0, + exactPrefix: "Fi", + filesOnly: true, + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.ExactPrefix + }, { + exactPrefix: "Fo", + foldersOnly: true, + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.ExactPrefix + }, { + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 2, + targetFoldersPaths: ['/'] + } + }) + expect(result?.sortSpecByWildcard).toBeUndefined() + }) + it('should correctly parse combine operator, apply default sorting and explicit sorting', () => { + const inputTxtArr: Array = ` + target-folder: / + Nothing + > a-z + /+ /:files Fi... + /+ /folders Fo... + ... Separator + /+ Abc... + /+ ...Def + /+ ... + > modified + Unreachable line + `.replace(/\t/gi, '').split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result?.sortSpecByPath).toEqual({ + "/": { + groups: [{ + exactText: "Nothing", + order: CustomSortOrder.alphabeticalReverse, + type: CustomSortGroupType.ExactName + }, { + combineWithIdx: 1, + exactPrefix: "Fi", + filesOnly: true, + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.ExactPrefix + }, { + combineWithIdx: 1, + exactPrefix: "Fo", + foldersOnly: true, + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.ExactPrefix + }, { + exactSuffix: " Separator", + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.ExactSuffix + }, { + combineWithIdx: 4, + exactPrefix: "Abc", + order: CustomSortOrder.byModifiedTimeReverse, + type: CustomSortGroupType.ExactPrefix + }, { + combineWithIdx: 4, + exactSuffix: "Def", + order: CustomSortOrder.byModifiedTimeReverse, + type: CustomSortGroupType.ExactSuffix + }, { + combineWithIdx: 4, + order: CustomSortOrder.byModifiedTimeReverse, + type: CustomSortGroupType.MatchAll + }, { + exactText: "Unreachable line", + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.ExactName + }, { + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 8, + targetFoldersPaths: ['/'] + } + }) + expect(result?.sortSpecByWildcard).toBeUndefined() + }) +}) const txtInputTargetFolderMultiSpecA: string = ` target-folder: . @@ -1231,10 +1386,6 @@ target-folder: AAA sorting: standard ` -const txtInputErrorPriorityAlone: string = ` -/! -` - const txtInputErrorPriorityEmptyFilePattern: string = ` /!! /: ` @@ -1344,7 +1495,9 @@ describe('SortingSpecProcessor error detection and reporting', () => { expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT(' sorting: standard')) }) it('should recognize error: priority indicator alone', () => { - const inputTxtArr: Array = txtInputErrorPriorityAlone.split('\n') + const inputTxtArr: Array = ` + /! + `.replace(/\t/gi, '').split('\n') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') expect(result).toBeNull() expect(errorsLogger).toHaveBeenCalledTimes(2) @@ -1352,6 +1505,28 @@ describe('SortingSpecProcessor error detection and reporting', () => { `${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: multiple priority indicators alone', () => { + const inputTxtArr: Array = ` + /! /!! /!!! + `.replace(/\t/gi, '').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} 16:TooManyPriorityPrefixes Only one priority prefix allowed on sorting group ${ERR_SUFFIX_IN_LINE(2)}`) + expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('/! /!! /!!!')) + }) + it('should recognize error: multiple priority indicators', () => { + const inputTxtArr: Array = ` + /!!! /!!! Abc\.d+ ... + `.replace(/\t/gi, '').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} 16:TooManyPriorityPrefixes Only one priority prefix allowed on sorting group ${ERR_SUFFIX_IN_LINE(2)}`) + expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('/!!! /!!! Abc\.d+ ...')) + }) 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') @@ -1379,6 +1554,98 @@ describe('SortingSpecProcessor error detection and reporting', () => { `${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 of combining: sorting order on first group', () => { + const inputTxtArr: Array = ` + /+ Abc + > modified + /+ /:files def + `.replace(/\t/gi, '').split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result).toBeNull() + expect(errorsLogger).toHaveBeenCalledTimes(1) + expect(errorsLogger).toHaveBeenNthCalledWith(1, + `${ERR_PREFIX} 20:OnlyLastCombinedGroupCanSpecifyOrder Predecessor group of combined group cannot contain order specification. Put it at the last of group in combined groups ${ERR_SUFFIX}`) + }) + it('should recognize error of combining: sorting order not on last group', () => { + const inputTxtArr: Array = ` + /+ Abc + /+ ...Def + /+ Ghi... + > modified + /+ /:files def + `.replace(/\t/gi, '').split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result).toBeNull() + expect(errorsLogger).toHaveBeenCalledTimes(1) + expect(errorsLogger).toHaveBeenNthCalledWith(1, + `${ERR_PREFIX} 20:OnlyLastCombinedGroupCanSpecifyOrder Predecessor group of combined group cannot contain order specification. Put it at the last of group in combined groups ${ERR_SUFFIX}`) + }) + it('should recognize error of combining: combining not allowed for outsiders group', () => { + const inputTxtArr: Array = ` + /+ % + `.replace(/\t/gi, '').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} 17:CombiningNotAllowedOnOutsidersGroup Combining 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 of combining: combining not allowed for outsiders priority group', () => { + const inputTxtArr: Array = ` + /+ /! / + `.replace(/\t/gi, '').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 of combining: multiple combine operators', () => { + const inputTxtArr: Array = ` + /+ /! /+ /: Something + `.replace(/\t/gi, '').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} 18:TooManyCombinePrefixes Only one combining prefix allowed on sorting group ${ERR_SUFFIX_IN_LINE(2)}`) + expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('/+ /! /+ /: Something')) + }) + it('should recognize error: too many sorting group type prefixes', () => { + const inputTxtArr: Array = ` + /folders /:files Hello + `.replace(/\t/gi, '').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} 21:TooManyGroupTypePrefixes Only one sorting group type prefix allowed on sorting group ${ERR_SUFFIX_IN_LINE(2)}`) + expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('/folders /:files Hello')) + }) + it('should recognize error: priority prefix after sorting group type prefixe', () => { + const inputTxtArr: Array = ` + /folders /+ /! Hello + `.replace(/\t/gi, '').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} 22:PriorityPrefixAfterGroupTypePrefix Priority prefix must be used before sorting group type indicator ${ERR_SUFFIX_IN_LINE(2)}`) + expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('/folders /+ /! Hello')) + }) + it('should recognize error: combine prefix after sorting group type prefixe', () => { + const inputTxtArr: Array = ` + /folders /+ Hello + `.replace(/\t/gi, '').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} 23:CombinePrefixAfterGroupTypePrefix Combining prefix must be used before sorting group type indicator ${ERR_SUFFIX_IN_LINE(2)}`) + expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('/folders /+ Hello')) + }) 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 da1f85a..72c6906 100644 --- a/src/custom-sort/sorting-spec-processor.ts +++ b/src/custom-sort/sorting-spec-processor.ts @@ -47,6 +47,7 @@ interface ParsedSortingGroup { outsidersGroup?: boolean // Mutually exclusive with plainSpec and arraySpec itemToHide?: boolean priority?: number + combine?: boolean } export enum ProblemCode { @@ -65,12 +66,21 @@ export enum ProblemCode { ItemToHideNoSupportForThreeDots, DuplicateWildcardSortSpecForSameFolder, StandardObsidianSortAllowedOnlyAtFolderLevel, - PriorityNotAllowedOnOutsidersGroup + PriorityNotAllowedOnOutsidersGroup, + TooManyPriorityPrefixes, + CombiningNotAllowedOnOutsidersGroup, + TooManyCombinePrefixes, + ModifierPrefixesOnlyOnOutsidersGroup, + OnlyLastCombinedGroupCanSpecifyOrder, + TooManyGroupTypePrefixes, + PriorityPrefixAfterGroupTypePrefix, + CombinePrefixAfterGroupTypePrefix } const ContextFreeProblems = new Set([ ProblemCode.DuplicateSortSpecForSameFolder, - ProblemCode.DuplicateWildcardSortSpecForSameFolder + ProblemCode.DuplicateWildcardSortSpecForSameFolder, + ProblemCode.OnlyLastCombinedGroupCanSpecifyOrder ]) const ThreeDots = '...'; @@ -199,6 +209,12 @@ const SortingGroupPriorityPrefixes: { [key: string]: number } = { [PriorityModifierPrio3Lexeme]: PRIO_3 } +const CombineGroupLexeme: string = '/+' + +const CombiningGroupPrefixes: Array = [ + CombineGroupLexeme +] + interface SortingGroupType { filesOnly?: boolean filenameWithExt?: boolean // The text matching criteria should apply to filename + extension @@ -490,7 +506,9 @@ export class SortingSpecProcessor { if (success) { if (this.ctx.specs.length > 0) { for (let spec of this.ctx.specs) { - this.postprocessSortSpec(spec) + if (!this.postprocessSortSpec(spec)) { + return null + } } let sortspecByWildcard: FolderWildcardMatching | undefined @@ -690,78 +708,149 @@ export class SortingSpecProcessor { return null } - const priorityPrefixAlone: number = SortingGroupPriorityPrefixes[s] - if (priorityPrefixAlone) { + let groupPriority: number | undefined = undefined + let groupPriorityPrefixesCount: number = 0 + let combineGroup: boolean | undefined = undefined + let combineGroupPrefixesCount: number = 0 + let groupType: SortingGroupType | undefined = undefined + let groupTypePrefixesCount: number = 0 + let priorityPrefixAfterGroupTypePrefix: boolean = false + let combinePrefixAfterGroupTypePrefix: boolean = false + + let prefixRecognized: boolean | undefined = undefined + while (prefixRecognized === undefined || prefixRecognized) { + let doContinue: boolean = false // to support 'continue' on external loop from nested loop + + for (const priorityPrefix of Object.keys(SortingGroupPriorityPrefixes)) { + if (s === priorityPrefix || s.startsWith(priorityPrefix + ' ')) { + groupPriority = SortingGroupPriorityPrefixes[priorityPrefix] + groupPriorityPrefixesCount ++ + prefixRecognized = true + doContinue = true + if (groupType) { + priorityPrefixAfterGroupTypePrefix = true + } + s = s.substring(priorityPrefix.length).trim() + break + } + } + + if (doContinue) continue + + for (let combinePrefix of CombiningGroupPrefixes) { + if (s === combinePrefix || s.startsWith(combinePrefix + ' ')) { + combineGroup = true + combineGroupPrefixesCount ++ + prefixRecognized = true + doContinue = true + if (groupType) { + combinePrefixAfterGroupTypePrefix = true + } + s = s.substring(combinePrefix.length).trim() + break + } + } + + if (doContinue) continue + + for (const sortingGroupTypePrefix of Object.keys(SortingGroupPrefixes)) { + if (s === sortingGroupTypePrefix || s.startsWith(sortingGroupTypePrefix + ' ')) { + groupType = SortingGroupPrefixes[sortingGroupTypePrefix] + groupTypePrefixesCount++ + prefixRecognized = true + doContinue = true + s = s.substring(sortingGroupTypePrefix.length).trim() + break + } + } + + if (doContinue) continue + + prefixRecognized = false + } + + if (groupPriorityPrefixesCount > 1) { + this.problem(ProblemCode.TooManyPriorityPrefixes, 'Only one priority prefix allowed on sorting group') + return null + } + + if (s === '' && groupPriority) { 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 - } + if (combineGroupPrefixesCount > 1) { + this.problem(ProblemCode.TooManyCombinePrefixes, 'Only one combining prefix allowed on sorting group') + return null } - const prefixAlone: SortingGroupType = SortingGroupPrefixes[s] - if (prefixAlone) { - if (prefixAlone.itemToHide) { + if (s === '' && combineGroup) { + this.problem(ProblemCode.CombiningNotAllowedOnOutsidersGroup, 'Combining is not allowed for sorting group with empty match-pattern') + return null + } + + if (groupTypePrefixesCount > 1) { + this.problem(ProblemCode.TooManyGroupTypePrefixes, 'Only one sorting group type prefix allowed on sorting group') + return null + } + + if (priorityPrefixAfterGroupTypePrefix) { + this.problem(ProblemCode.PriorityPrefixAfterGroupTypePrefix, 'Priority prefix must be used before sorting group type indicator') + return null + } + + if (combinePrefixAfterGroupTypePrefix) { + this.problem(ProblemCode.CombinePrefixAfterGroupTypePrefix, 'Combining prefix must be used before sorting group type indicator') + return null + } + + if (s === '' && groupType) { // alone alone alone + if (groupType.itemToHide) { this.problem(ProblemCode.ItemToHideExactNameWithExtRequired, 'Exact name with ext of file or folders to hide is required') return null - } else { // !prefixAlone.itemToHide - 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 - } + } else { // !sortingGroupIndicatorPrefixAlone.itemToHide + return { + outsidersGroup: true, + filesOnly: groupType.filesOnly, + foldersOnly: groupType.foldersOnly } } } - for (const prefix of Object.keys(SortingGroupPrefixes)) { - if (s.startsWith(prefix + ' ')) { - const sortingGroupType: SortingGroupType = SortingGroupPrefixes[prefix] - if (sortingGroupType.itemToHide) { - return { - itemToHide: true, - plainSpec: s.substring(prefix.length + 1), - filesOnly: sortingGroupType.filesOnly, - foldersOnly: sortingGroupType.foldersOnly - } - } else { // !sortingGroupType.itemToHide - return { - plainSpec: s.substring(prefix.length + 1), - filesOnly: sortingGroupType.filesOnly, - foldersOnly: sortingGroupType.foldersOnly, - matchFilenameWithExt: sortingGroupType.filenameWithExt, - priority: groupPriority ?? undefined - } + if (groupType) { + if (groupType.itemToHide) { + return { + itemToHide: true, + plainSpec: s, + filesOnly: groupType.filesOnly, + foldersOnly: groupType.foldersOnly } - } - } - - 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 + } else { // !sortingGroupType.itemToHide return { plainSpec: s, - priority: groupPriority + filesOnly: groupType.filesOnly, + foldersOnly: groupType.foldersOnly, + matchFilenameWithExt: groupType.filenameWithExt, + priority: groupPriority ?? undefined, + combine: combineGroup } } } + + if ((groupPriority || combineGroup) && s !== '' ) { + // Edge case: line with only priority prefix or combine prefix and no other known syntax, yet some content + return { + plainSpec: s, + priority: groupPriority, + combine: combineGroup + } + } return null; } + // Artificial value used to indicate not-undefined value in if (COMBINING_INDICATOR_IDX) { ... } + COMBINING_INDICATOR_IDX: number = -1 + private processParsedSortGroupSpec(group: ParsedSortingGroup): boolean { if (!this.ctx.currentSpec) { this.ctx.currentSpec = this.putNewSpecForNewTargetFolder() @@ -791,7 +880,10 @@ export class SortingSpecProcessor { newGroup.priority = group.priority this.addExpediteGroupInfo(this.ctx.currentSpec, group.priority, groupIdx) } - + // Consume combined group + if (group.combine) { + newGroup.combineWithIdx = this.COMBINING_INDICATOR_IDX + } return true; } else { return false @@ -805,7 +897,7 @@ export class SortingSpecProcessor { } } - private postprocessSortSpec(spec: CustomSortSpec): void { + private postprocessSortSpec(spec: CustomSortSpec): boolean { // clean up to prevent false warnings in console spec.outsidersGroupIdx = undefined spec.outsidersFilesGroupIdx = undefined @@ -853,6 +945,55 @@ export class SortingSpecProcessor { }) } + // Process 'combined groups' + let anyCombinedGroupPresent: boolean = false + let currentCombinedGroupIdx: number | undefined = undefined + for (let i=0; i= 0; i--) { + const group: CustomSortGroup = spec.groups[i] + + if (group.combineWithIdx !== undefined) { + if (group.combineWithIdx === idxOfCurrentCombinedGroup) { // a subsequent (2nd, 3rd, ...) group of combined (counting from the end) + group.order = orderForCombinedGroup + group.byMetadataField = byMetadataFieldForCombinedGroup + } else { // the first group of combined (counting from the end) + idxOfCurrentCombinedGroup = group.combineWithIdx + orderForCombinedGroup = group.order // could be undefined + byMetadataFieldForCombinedGroup = group.byMetadataField // could be undefined + } + } else { + // for sanity + idxOfCurrentCombinedGroup = undefined + orderForCombinedGroup = undefined + byMetadataFieldForCombinedGroup = undefined + } + } + } + // Populate sorting order down the hierarchy for more clean sorting logic later on for (let group of spec.groups) { if (!group.order) { @@ -883,6 +1024,8 @@ export class SortingSpecProcessor { spec.targetFoldersPaths[idx] = `${this.ctx.folderPath}/${path.substring(CURRENT_FOLDER_PREFIX.length)}` } }); + + return true // success indicator } // level 2 parser functions defined in order of occurrence and dependency