diff --git a/src/custom-sort/custom-sort-getComparator.spec.ts b/src/custom-sort/custom-sort-getComparator.spec.ts index 35f039d..1afb404 100644 --- a/src/custom-sort/custom-sort-getComparator.spec.ts +++ b/src/custom-sort/custom-sort-getComparator.spec.ts @@ -144,19 +144,7 @@ describe('getComparator', () => { expect(sp).toHaveBeenNthCalledWith(1, CustomSortOrder.byMetadataFieldAlphabeticalReverse, OS_byModifiedTime, SortingLevelId.forPrimary) expect(sp).toHaveBeenNthCalledWith(2, CustomSortOrder.byCreatedTime, OS_byModifiedTime, SortingLevelId.forDerivedPrimary) }) - it( 'in simple case - group-level comparison fails, folder-level fails, ui-selected in effect', () => { - const a = getBaseItemForSorting() - const b= getBaseItemForSorting({ - mtime: a.mtime + 100 // Make be fresher than a - }) - const result = Math.sign(comparator(a,b)) - expect(result).toBe(B_GOES_FIRST) - expect(sp).toBeCalledTimes(3) - expect(sp).toHaveBeenNthCalledWith(1, CustomSortOrder.byMetadataFieldAlphabeticalReverse, OS_byModifiedTime, SortingLevelId.forPrimary) - expect(sp).toHaveBeenNthCalledWith(2, CustomSortOrder.byCreatedTime, OS_byModifiedTime, SortingLevelId.forDerivedPrimary) - expect(sp).toHaveBeenNthCalledWith(3, CustomSortOrder.standardObsidian, OS_byModifiedTime, SortingLevelId.forUISelected) - }) - it( 'in simple case - group-level comparison fails, folder-level fails, ui-selected fails, the last resort default comes into play - case A', () => { + it( 'in simple case - group-level comparison fails, folder-level fails, the last resort default comes into play - case A', () => { const a = getBaseItemForSorting({ sortString: 'Second' }) @@ -165,11 +153,10 @@ describe('getComparator', () => { }) const result = comparator(a,b) expect(result).toBe(B_GOES_FIRST) - expect(sp).toBeCalledTimes(4) + expect(sp).toBeCalledTimes(3) expect(sp).toHaveBeenNthCalledWith(1, CustomSortOrder.byMetadataFieldAlphabeticalReverse, OS_byModifiedTime, SortingLevelId.forPrimary) expect(sp).toHaveBeenNthCalledWith(2, CustomSortOrder.byCreatedTime, OS_byModifiedTime, SortingLevelId.forDerivedPrimary) - expect(sp).toHaveBeenNthCalledWith(3, CustomSortOrder.standardObsidian, OS_byModifiedTime, SortingLevelId.forUISelected) - expect(sp).toHaveBeenNthCalledWith(4, CustomSortOrder.default, undefined, SortingLevelId.forLastResort) + expect(sp).toHaveBeenNthCalledWith(3, CustomSortOrder.default, undefined, SortingLevelId.forDefaultWhenUnspecified) }) }) describe('should correctly handle secondary sorting spec', () => { @@ -191,7 +178,7 @@ describe('getComparator', () => { expect(sp).toHaveBeenNthCalledWith(1, CustomSortOrder.byMetadataFieldAlphabeticalReverse, OS_byModifiedTimeReverse, SortingLevelId.forPrimary) expect(sp).toHaveBeenNthCalledWith(2, CustomSortOrder.byMetadataFieldTrueAlphabetical, OS_byModifiedTimeReverse, SortingLevelId.forSecondary) }) - it( 'in complex case - secondary sort comparison fails, last resort comes into play', () => { + it( 'in complex case - secondary sort comparison fails, last resort default comes into play', () => { const a = getBaseItemForSorting({ sortString: 'Second' }) @@ -200,12 +187,11 @@ describe('getComparator', () => { }) const result = comparator(a,b) expect(result).toBe(B_GOES_FIRST) - expect(sp).toBeCalledTimes(5) + expect(sp).toBeCalledTimes(4) expect(sp).toHaveBeenNthCalledWith(1, CustomSortOrder.byMetadataFieldAlphabeticalReverse, OS_byModifiedTimeReverse, SortingLevelId.forPrimary) expect(sp).toHaveBeenNthCalledWith(2, CustomSortOrder.byMetadataFieldTrueAlphabetical, OS_byModifiedTimeReverse, SortingLevelId.forSecondary) expect(sp).toHaveBeenNthCalledWith(3, CustomSortOrder.byCreatedTime, OS_byModifiedTimeReverse, SortingLevelId.forDerivedPrimary ) - expect(sp).toHaveBeenNthCalledWith(4, CustomSortOrder.standardObsidian, OS_byModifiedTimeReverse, SortingLevelId.forUISelected) - expect(sp).toHaveBeenNthCalledWith(5, CustomSortOrder.default, undefined, SortingLevelId.forLastResort) + expect(sp).toHaveBeenNthCalledWith(4, CustomSortOrder.default, undefined, SortingLevelId.forDefaultWhenUnspecified) }) }) describe('at target folder level (aka derived)', () => { @@ -224,7 +210,7 @@ describe('getComparator', () => { expect(sp).toHaveBeenNthCalledWith(2, CustomSortOrder.byCreatedTime, OS_byModifiedTimeReverse, SortingLevelId.forDerivedPrimary) expect(sp).toHaveBeenNthCalledWith(3, CustomSortOrder.byMetadataFieldTrueAlphabeticalReverse, OS_byModifiedTimeReverse, SortingLevelId.forDerivedSecondary) }) - it( 'in complex case - secondary sort comparison fails, last resort comes into play', () => { + it( 'in complex case - secondary sort comparison fails, last resort default comes into play', () => { const a = getBaseItemForSorting({ sortString: 'Second' }) @@ -233,12 +219,11 @@ describe('getComparator', () => { }) const result = comparator(a,b) expect(result).toBe(B_GOES_FIRST) - expect(sp).toBeCalledTimes(5) + expect(sp).toBeCalledTimes(4) expect(sp).toHaveBeenNthCalledWith(1, CustomSortOrder.byMetadataFieldAlphabeticalReverse, OS_byModifiedTimeReverse, SortingLevelId.forPrimary) expect(sp).toHaveBeenNthCalledWith(2, CustomSortOrder.byCreatedTime, OS_byModifiedTimeReverse, SortingLevelId.forDerivedPrimary) expect(sp).toHaveBeenNthCalledWith(3, CustomSortOrder.byMetadataFieldTrueAlphabeticalReverse, OS_byModifiedTimeReverse, SortingLevelId.forDerivedSecondary) - expect(sp).toHaveBeenNthCalledWith(4, CustomSortOrder.standardObsidian, OS_byModifiedTimeReverse, SortingLevelId.forUISelected) - expect(sp).toHaveBeenNthCalledWith(5, CustomSortOrder.default, undefined, SortingLevelId.forLastResort) + expect(sp).toHaveBeenNthCalledWith(4, CustomSortOrder.default, undefined, SortingLevelId.forDefaultWhenUnspecified) }) }) describe('at group and at target folder level (aka derived)', () => { @@ -247,7 +232,7 @@ describe('getComparator', () => { beforeEach(() => { mdataGetter.mockClear() }) - it('most complex case - last resort comest into play, all sort levels present, all involve metadata', () => { + it('most complex case - last resort default comes into play, all sort levels present, all involve metadata', () => { const a = getBaseItemForSorting({ path: 'test 1', // Not used in comparisons, used only to identify source of compared metadata metadataFieldValue: 'm', @@ -264,13 +249,12 @@ describe('getComparator', () => { }) const result = Math.sign(comparator(a,b)) expect(result).toBe(AB_EQUAL) - expect(sp).toBeCalledTimes(6) + expect(sp).toBeCalledTimes(5) expect(sp).toHaveBeenNthCalledWith(1, CustomSortOrder.byMetadataFieldAlphabetical, OS_byCreatedTime, SortingLevelId.forPrimary) expect(sp).toHaveBeenNthCalledWith(2, CustomSortOrder.byMetadataFieldAlphabeticalReverse, OS_byCreatedTime, SortingLevelId.forSecondary) expect(sp).toHaveBeenNthCalledWith(3, CustomSortOrder.byMetadataFieldTrueAlphabetical, OS_byCreatedTime, SortingLevelId.forDerivedPrimary) expect(sp).toHaveBeenNthCalledWith(4, CustomSortOrder.byMetadataFieldTrueAlphabeticalReverse, OS_byCreatedTime, SortingLevelId.forDerivedSecondary) - expect(sp).toHaveBeenNthCalledWith(5, CustomSortOrder.standardObsidian, OS_byCreatedTime, SortingLevelId.forUISelected) - expect(sp).toHaveBeenNthCalledWith(6, CustomSortOrder.default, undefined, SortingLevelId.forLastResort) + expect(sp).toHaveBeenNthCalledWith(5, CustomSortOrder.default, undefined, SortingLevelId.forDefaultWhenUnspecified) expect(mdataGetter).toHaveBeenCalledTimes(8) expect(mdataGetter).toHaveBeenNthCalledWith(1, expect.objectContaining({path: 'test 1'}), SortingLevelId.forPrimary) expect(mdataGetter).toHaveNthReturnedWith(1, 'm') diff --git a/src/custom-sort/custom-sort-types.ts b/src/custom-sort/custom-sort-types.ts index 996076a..6bafbb1 100644 --- a/src/custom-sort/custom-sort-types.ts +++ b/src/custom-sort/custom-sort-types.ts @@ -35,8 +35,9 @@ export enum CustomSortOrder { export interface RecognizedOrderValue { order: CustomSortOrder - secondaryOrder?: CustomSortOrder applyToMetadataField?: string + secondaryOrder?: CustomSortOrder + secondaryApplyToMetadataField?: string } export type NormalizerFn = (s: string) => string | null @@ -57,8 +58,8 @@ export interface CustomSortGroup { 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 - byMetadataFieldSecondary?: string // for 'by-metadata:' sorting if the order is by metadata alphabetical or reverse secondaryOrder?: CustomSortOrder + byMetadataFieldSecondary?: string // for 'by-metadata:' sorting if the order is by metadata alphabetical or reverse filesOnly?: boolean matchFilenameWithExt?: boolean foldersOnly?: boolean diff --git a/src/custom-sort/custom-sort.ts b/src/custom-sort/custom-sort.ts index 0158503..82051bc 100644 --- a/src/custom-sort/custom-sort.ts +++ b/src/custom-sort/custom-sort.ts @@ -71,8 +71,7 @@ export enum SortingLevelId { forSecondary, forDerivedPrimary, forDerivedSecondary, - forUISelected, - forLastResort + forDefaultWhenUnspecified } export type SorterFn = (a: FolderItemForSorting, b: FolderItemForSorting) => number @@ -116,7 +115,7 @@ export const sorterByMetadataField = (reverseOrder?: boolean, trueAlphabetical?: } } -let Sorters: { [key in CustomSortOrder]: SorterFn } = { +const Sorters: { [key in CustomSortOrder]: SorterFn } = { [CustomSortOrder.alphabetical]: (a: FolderItemForSorting, b: FolderItemForSorting) => CollatorCompare(a.sortString, b.sortString), [CustomSortOrder.trueAlphabetical]: (a: FolderItemForSorting, b: FolderItemForSorting) => CollatorTrueAlphabeticalCompare(a.sortString, b.sortString), [CustomSortOrder.alphabeticalReverse]: (a: FolderItemForSorting, b: FolderItemForSorting) => CollatorCompare(b.sortString, a.sortString), @@ -139,21 +138,21 @@ let Sorters: { [key in CustomSortOrder]: SorterFn } = { }; // Some sorters are different when used in primary vs. secondary sorting order -let SortersForSecondary: { [key in CustomSortOrder]?: SorterFn } = { +const SortersForSecondary: { [key in CustomSortOrder]?: SorterFn } = { [CustomSortOrder.byMetadataFieldAlphabetical]: sorterByMetadataField(StraightOrder, !TrueAlphabetical, SortingLevelId.forSecondary), [CustomSortOrder.byMetadataFieldTrueAlphabetical]: sorterByMetadataField(StraightOrder, TrueAlphabetical, SortingLevelId.forSecondary), [CustomSortOrder.byMetadataFieldAlphabeticalReverse]: sorterByMetadataField(ReverseOrder, !TrueAlphabetical, SortingLevelId.forSecondary), [CustomSortOrder.byMetadataFieldTrueAlphabeticalReverse]: sorterByMetadataField(ReverseOrder, TrueAlphabetical, SortingLevelId.forSecondary) }; -let SortersForDerivedPrimary: { [key in CustomSortOrder]?: SorterFn } = { +const SortersForDerivedPrimary: { [key in CustomSortOrder]?: SorterFn } = { [CustomSortOrder.byMetadataFieldAlphabetical]: sorterByMetadataField(StraightOrder, !TrueAlphabetical, SortingLevelId.forDerivedPrimary), [CustomSortOrder.byMetadataFieldTrueAlphabetical]: sorterByMetadataField(StraightOrder, TrueAlphabetical, SortingLevelId.forDerivedPrimary), [CustomSortOrder.byMetadataFieldAlphabeticalReverse]: sorterByMetadataField(ReverseOrder, !TrueAlphabetical, SortingLevelId.forDerivedPrimary), [CustomSortOrder.byMetadataFieldTrueAlphabeticalReverse]: sorterByMetadataField(ReverseOrder, TrueAlphabetical, SortingLevelId.forDerivedPrimary) }; -let SortersForDerivedSecondary: { [key in CustomSortOrder]?: SorterFn } = { +const SortersForDerivedSecondary: { [key in CustomSortOrder]?: SorterFn } = { [CustomSortOrder.byMetadataFieldAlphabetical]: sorterByMetadataField(StraightOrder, !TrueAlphabetical, SortingLevelId.forDerivedSecondary), [CustomSortOrder.byMetadataFieldTrueAlphabetical]: sorterByMetadataField(StraightOrder, TrueAlphabetical, SortingLevelId.forDerivedSecondary), [CustomSortOrder.byMetadataFieldAlphabeticalReverse]: sorterByMetadataField(ReverseOrder, !TrueAlphabetical, SortingLevelId.forDerivedSecondary), @@ -243,10 +242,8 @@ export const getComparator = (sortSpec: CustomSortSpec, currentUIselectedSorting if (folderLevel !== EQUAL_OR_UNCOMPARABLE) return folderLevel const folderLevelSecondary: number = sortSpec.defaultSecondaryOrder ? getSorterFnFor(sortSpec.defaultSecondaryOrder, currentUIselectedSorting, SortingLevelId.forDerivedSecondary)(itA, itB) : EQUAL_OR_UNCOMPARABLE if (folderLevelSecondary !== EQUAL_OR_UNCOMPARABLE) return folderLevelSecondary - const uiSelected: number = currentUIselectedSorting ? getSorterFnFor(CustomSortOrder.standardObsidian, currentUIselectedSorting, SortingLevelId.forUISelected)(itA, itB) : EQUAL_OR_UNCOMPARABLE - if (uiSelected !== EQUAL_OR_UNCOMPARABLE) return uiSelected - const lastResort: number = getSorterFnFor(CustomSortOrder.default, undefined, SortingLevelId.forLastResort)(itA, itB) - return lastResort + const defaultForUnspecified: number = getSorterFnFor(CustomSortOrder.default, undefined, SortingLevelId.forDefaultWhenUnspecified)(itA, itB) + return defaultForUnspecified } else { return itA.groupIdx - itB.groupIdx; } @@ -538,7 +535,7 @@ export const determineFolderDatesIfNeeded = (folderItems: Array { 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, @@ -1243,7 +1160,7 @@ describe('SortingSpecProcessor', () => { }) expect(result?.sortSpecByWildcard).toBeUndefined() }) - it('should correctly parse combine operator, apply default sorting and explicit sorting', () => { + it('should correctly parse combine operator, apply explicit sorting to combined groups', () => { const inputTxtArr: Array = ` target-folder: / Nothing @@ -1268,17 +1185,14 @@ describe('SortingSpecProcessor', () => { 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, @@ -1296,10 +1210,8 @@ describe('SortingSpecProcessor', () => { type: CustomSortGroupType.MatchAll }, { exactText: "Unreachable line", - order: CustomSortOrder.alphabetical, type: CustomSortGroupType.ExactName }, { - order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders }], outsidersGroupIdx: 8, @@ -1308,7 +1220,534 @@ describe('SortingSpecProcessor', () => { }) expect(result?.sortSpecByWildcard).toBeUndefined() }) + it('should correctly parse combine operator, apply explicit complex sorting to combined groups', () => { + const inputTxtArr: Array = ` + target-folder: / + < created, a-z desc by-metadata: someMdataFld // intentionally folder-level, to confirm no inheritance folder->groups + Nothing + > a-z, a-z + /+ /:files Fi... + /+ /folders Fo... + ... Separator + /+ Abc... + /+ ...Def + /+ ... + < a-z by-metadata: abc-def, true a-z asc by-metadata: ghi-jkl1 + Unreachable line + `.replace(/\t/gi, '').split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result?.sortSpecByPath).toEqual({ + "/": { + defaultOrder: CustomSortOrder.byCreatedTime, + defaultSecondaryOrder: CustomSortOrder.byMetadataFieldAlphabeticalReverse, + byMetadataFieldSecondary: "someMdataFld", + groups: [{ + exactText: "Nothing", + order: CustomSortOrder.alphabeticalReverse, + secondaryOrder: CustomSortOrder.alphabetical, + type: CustomSortGroupType.ExactName + }, { + combineWithIdx: 1, + exactPrefix: "Fi", + filesOnly: true, + type: CustomSortGroupType.ExactPrefix + }, { + combineWithIdx: 1, + exactPrefix: "Fo", + foldersOnly: true, + type: CustomSortGroupType.ExactPrefix + }, { + exactSuffix: " Separator", + type: CustomSortGroupType.ExactSuffix + }, { + combineWithIdx: 4, + exactPrefix: "Abc", + order: CustomSortOrder.byMetadataFieldAlphabetical, + byMetadataField: "abc-def", + secondaryOrder: CustomSortOrder.byMetadataFieldTrueAlphabetical, + byMetadataFieldSecondary: "ghi-jkl1", + type: CustomSortGroupType.ExactPrefix + }, { + combineWithIdx: 4, + exactSuffix: "Def", + order: CustomSortOrder.byMetadataFieldAlphabetical, + byMetadataField: "abc-def", + secondaryOrder: CustomSortOrder.byMetadataFieldTrueAlphabetical, + byMetadataFieldSecondary: "ghi-jkl1", + type: CustomSortGroupType.ExactSuffix + }, { + combineWithIdx: 4, + order: CustomSortOrder.byMetadataFieldAlphabetical, + byMetadataField: "abc-def", + secondaryOrder: CustomSortOrder.byMetadataFieldTrueAlphabetical, + byMetadataFieldSecondary: "ghi-jkl1", + type: CustomSortGroupType.MatchAll + }, { + exactText: "Unreachable line", + type: CustomSortGroupType.ExactName + }, { + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 8, + targetFoldersPaths: ['/'] + } + }) + expect(result?.sortSpecByWildcard).toBeUndefined() + }) +}) +const txtInputStandardSortingVariants1: string = ` +sorting: standard, sorting: ui selected +/folders:files + > ui selected desc, standard asc +` + +const txtInputStandardSortingVariants2: string = ` +order-desc: standard desc, < ui selected asc +/folders:files + sorting: ui selected asc, order-desc: standard desc +` + +const expectedSortSpecsStandardSortingVariants: { [key: string]: CustomSortSpec } = { + "mock-folder": { + defaultOrder: CustomSortOrder.standardObsidian, + defaultSecondaryOrder: CustomSortOrder.standardObsidian, + groups: [{ + order: CustomSortOrder.standardObsidian, + secondaryOrder: CustomSortOrder.standardObsidian, + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 0, + targetFoldersPaths: ['mock-folder'] + } +} + +describe('standard sorting (aka Obsidian UI selected)', () => { + let processor: SortingSpecProcessor; + beforeEach(() => { + processor = new SortingSpecProcessor(); + }); + it('should recognize variants of the syntax', () => { + const inputTxtArr: Array = txtInputStandardSortingVariants1.split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result?.sortSpecByPath).toEqual(expectedSortSpecsStandardSortingVariants) + }) + it('ignores any direction specified in any way', () => { + const inputTxtArr: Array = txtInputStandardSortingVariants2.split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result?.sortSpecByPath).toEqual(expectedSortSpecsStandardSortingVariants) + }) +}) + +describe('comments and higher level specs in sorting order spec', () => { + let processor: SortingSpecProcessor; + beforeEach(() => { + processor = new SortingSpecProcessor(); + }); + it('can occur at the end of line', () => { + const inputTxtArr: Array = ` + > created // An inline comment in sorting order specification + `.replace(/\t/gi, '').split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result?.sortSpecByPath).toEqual({ + "mock-folder": { + defaultOrder: CustomSortOrder.byCreatedTimeReverse, + groups: [{ + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 0, + targetFoldersPaths: ['mock-folder'] + } + }) + expect(result?.sortSpecByWildcard).toBeUndefined() + }) + it('inline always span to the end of line', () => { + const inputTxtArr: Array = ` + > created //, > modified <-- he he, the // take it all + `.replace(/\t/gi, '').split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result?.sortSpecByPath).toEqual({ + "mock-folder": { + defaultOrder: CustomSortOrder.byCreatedTimeReverse, + groups: [{ + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 0, + targetFoldersPaths: ['mock-folder'] + } + }) + expect(result?.sortSpecByWildcard).toBeUndefined() + }) + it('ignore 3rd+ sorting level specs', () => { + const inputTxtArr: Array = ` + > created, < modified, a-z, < true a-z + `.replace(/\t/gi, '').split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result?.sortSpecByPath).toEqual({ + "mock-folder": { + defaultOrder: CustomSortOrder.byCreatedTimeReverse, + defaultSecondaryOrder: CustomSortOrder.byModifiedTime, + groups: [{ + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 0, + targetFoldersPaths: ['mock-folder'] + } + }) + expect(result?.sortSpecByWildcard).toBeUndefined() + }) +}) + +describe('multi-level sorting', () => { + let processor: SortingSpecProcessor; + beforeEach(() => { + processor = new SortingSpecProcessor(); + }); + it('should accept direction lexemes in prefix and postfix notations, various variants', () => { + const inputTxtArr: Array = ` + a pre + order-asc: true a-z, order-asc: modified + a pre 2 + order-asc: true a-z, < modified + a post 1 + order-asc: true a-z, modified order-asc + a post 2 + order-asc: true a-z, modified asc + a post + order-asc: true a-z, modified < + a none + order-asc: true a-z, modified + a unspecified + order-asc: true a-z, sorting: modified + a dbl specified + order-asc: true a-z asc, < modified < + d pre + order-desc: true a-z, order-desc: modified + d pre 2 + order-desc: true a-z, > modified + d post 1 + order-desc: true a-z, modified order-desc + d post 2 + order-desc: true a-z, modified desc + d post + order-desc: true a-z, modified > + d none + order-desc: true a-z, modified + d unspecified + order-desc: true a-z, sorting: modified + d dbl specified + order-desc: true a-z desc, > modified > + `.replace(/\t/gi, '').split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result?.sortSpecByPath).toEqual({ + "mock-folder": { + groups: [{ + exactText: "a pre", + order: CustomSortOrder.trueAlphabetical, + secondaryOrder: CustomSortOrder.byModifiedTime, + type: CustomSortGroupType.ExactName + },{ + exactText: "a pre 2", + order: CustomSortOrder.trueAlphabetical, + secondaryOrder: CustomSortOrder.byModifiedTime, + type: CustomSortGroupType.ExactName + },{ + exactText: "a post 1", + order: CustomSortOrder.trueAlphabetical, + secondaryOrder: CustomSortOrder.byModifiedTime, + type: CustomSortGroupType.ExactName + },{ + exactText: "a post 2", + order: CustomSortOrder.trueAlphabetical, + secondaryOrder: CustomSortOrder.byModifiedTime, + type: CustomSortGroupType.ExactName + },{ + exactText: "a post", + order: CustomSortOrder.trueAlphabetical, + secondaryOrder: CustomSortOrder.byModifiedTime, + type: CustomSortGroupType.ExactName + },{ + exactText: "a none", + order: CustomSortOrder.trueAlphabetical, + secondaryOrder: CustomSortOrder.byModifiedTime, + type: CustomSortGroupType.ExactName + },{ + exactText: "a unspecified", + order: CustomSortOrder.trueAlphabetical, + secondaryOrder: CustomSortOrder.byModifiedTime, + type: CustomSortGroupType.ExactName + }, { + exactText: "a dbl specified", + order: CustomSortOrder.trueAlphabetical, + secondaryOrder: CustomSortOrder.byModifiedTime, + type: CustomSortGroupType.ExactName + },{ + exactText: "d pre", + order: CustomSortOrder.trueAlphabeticalReverse, + secondaryOrder: CustomSortOrder.byModifiedTimeReverse, + type: CustomSortGroupType.ExactName + },{ + exactText: "d pre 2", + order: CustomSortOrder.trueAlphabeticalReverse, + secondaryOrder: CustomSortOrder.byModifiedTimeReverse, + type: CustomSortGroupType.ExactName + },{ + exactText: "d post 1", + order: CustomSortOrder.trueAlphabeticalReverse, + secondaryOrder: CustomSortOrder.byModifiedTimeReverse, + type: CustomSortGroupType.ExactName + },{ + exactText: "d post 2", + order: CustomSortOrder.trueAlphabeticalReverse, + secondaryOrder: CustomSortOrder.byModifiedTimeReverse, + type: CustomSortGroupType.ExactName + },{ + exactText: "d post", + order: CustomSortOrder.trueAlphabeticalReverse, + secondaryOrder: CustomSortOrder.byModifiedTimeReverse, + type: CustomSortGroupType.ExactName + },{ + exactText: "d none", + order: CustomSortOrder.trueAlphabeticalReverse, + secondaryOrder: CustomSortOrder.byModifiedTime, + type: CustomSortGroupType.ExactName + },{ + exactText: "d unspecified", + order: CustomSortOrder.trueAlphabeticalReverse, + secondaryOrder: CustomSortOrder.byModifiedTime, + type: CustomSortGroupType.ExactName + }, { + exactText: "d dbl specified", + order: CustomSortOrder.trueAlphabeticalReverse, + secondaryOrder: CustomSortOrder.byModifiedTimeReverse, + type: CustomSortGroupType.ExactName + },{ + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 16, + targetFoldersPaths: ['mock-folder'] + } + }) + expect(result?.sortSpecByWildcard).toBeUndefined() + }) + it('should correctly parse legacy examples', () => { + const inputTxtArr: Array = ` + a c a + < a-z, created asc + a c d + < a-z, created desc + a ac a + < a-z, advanced created asc + a ac d + < a-z, advanced created desc + a m a + < a-z, modified asc + a m d + < a-z, modified desc + a am a + < a-z, advanced modified asc + a am d + < a-z, advanced modified desc + d c a + > a-z, created asc + d c d + > a-z, created desc + d ac a + > a-z, advanced created asc + d ac d + > a-z, advanced created desc + d m a + > a-z, modified asc + d m d + > a-z, modified desc + d am a + > a-z, advanced modified asc + d am d + > a-z, advanced modified desc + `.replace(/\t/gi, '').split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result?.sortSpecByPath).toEqual({ + "mock-folder": { + groups: [{ + exactText: "a c a", + order: CustomSortOrder.alphabetical, + secondaryOrder: CustomSortOrder.byCreatedTime, + type: CustomSortGroupType.ExactName + }, { + exactText: "a c d", + order: CustomSortOrder.alphabetical, + secondaryOrder: CustomSortOrder.byCreatedTimeReverse, + type: CustomSortGroupType.ExactName + },{ + exactText: "a ac a", + order: CustomSortOrder.alphabetical, + secondaryOrder: CustomSortOrder.byCreatedTimeAdvanced, + type: CustomSortGroupType.ExactName + },{ + exactText: "a ac d", + order: CustomSortOrder.alphabetical, + secondaryOrder: CustomSortOrder.byCreatedTimeReverseAdvanced, + type: CustomSortGroupType.ExactName + },{ + exactText: "a m a", + order: CustomSortOrder.alphabetical, + secondaryOrder: CustomSortOrder.byModifiedTime, + type: CustomSortGroupType.ExactName + },{ + exactText: "a m d", + order: CustomSortOrder.alphabetical, + secondaryOrder: CustomSortOrder.byModifiedTimeReverse, + type: CustomSortGroupType.ExactName + },{ + exactText: "a am a", + order: CustomSortOrder.alphabetical, + secondaryOrder: CustomSortOrder.byModifiedTimeAdvanced, + type: CustomSortGroupType.ExactName + },{ + exactText: "a am d", + order: CustomSortOrder.alphabetical, + secondaryOrder: CustomSortOrder.byModifiedTimeReverseAdvanced, + type: CustomSortGroupType.ExactName + },{ + exactText: "d c a", + order: CustomSortOrder.alphabeticalReverse, + secondaryOrder: CustomSortOrder.byCreatedTime, + type: CustomSortGroupType.ExactName + }, { + exactText: "d c d", + order: CustomSortOrder.alphabeticalReverse, + secondaryOrder: CustomSortOrder.byCreatedTimeReverse, + type: CustomSortGroupType.ExactName + },{ + exactText: "d ac a", + order: CustomSortOrder.alphabeticalReverse, + secondaryOrder: CustomSortOrder.byCreatedTimeAdvanced, + type: CustomSortGroupType.ExactName + },{ + exactText: "d ac d", + order: CustomSortOrder.alphabeticalReverse, + secondaryOrder: CustomSortOrder.byCreatedTimeReverseAdvanced, + type: CustomSortGroupType.ExactName + },{ + exactText: "d m a", + order: CustomSortOrder.alphabeticalReverse, + secondaryOrder: CustomSortOrder.byModifiedTime, + type: CustomSortGroupType.ExactName + },{ + exactText: "d m d", + order: CustomSortOrder.alphabeticalReverse, + secondaryOrder: CustomSortOrder.byModifiedTimeReverse, + type: CustomSortGroupType.ExactName + },{ + exactText: "d am a", + order: CustomSortOrder.alphabeticalReverse, + secondaryOrder: CustomSortOrder.byModifiedTimeAdvanced, + type: CustomSortGroupType.ExactName + },{ + exactText: "d am d", + order: CustomSortOrder.alphabeticalReverse, + secondaryOrder: CustomSortOrder.byModifiedTimeReverseAdvanced, + type: CustomSortGroupType.ExactName + },{ + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 16, + targetFoldersPaths: ['mock-folder'] + } + }) + expect(result?.sortSpecByWildcard).toBeUndefined() + }) +}) + +describe('the sorting: prefix', () => { + let processor: SortingSpecProcessor; + beforeEach(() => { + processor = new SortingSpecProcessor(); + }); + it('should default to ascending, if not specified (primary only)', () => { + const inputTxtArr: Array = ` + sorting: a-z + `.replace(/\t/gi, '').split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result?.sortSpecByPath).toEqual({ + 'mock-folder': { + defaultOrder: CustomSortOrder.alphabetical, + groups: [{ + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 0, + targetFoldersPaths: ['mock-folder'] + } + }) + }) + it('should default to ascending, if not specified (primary and secondary levels)', () => { + const inputTxtArr: Array = ` + sorting: true a-z, sorting: advanced modified + `.replace(/\t/gi, '').split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result?.sortSpecByPath).toEqual({ + 'mock-folder': { + defaultOrder: CustomSortOrder.trueAlphabetical, + defaultSecondaryOrder: CustomSortOrder.byModifiedTimeAdvanced, + groups: [{ + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 0, + targetFoldersPaths: ['mock-folder'] + } + }) + }) + it('should use postfix-specified direction (primary only)', () => { + const inputTxtArr: Array = ` + sorting: a-z desc + `.replace(/\t/gi, '').split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result?.sortSpecByPath).toEqual({ + 'mock-folder': { + defaultOrder: CustomSortOrder.alphabeticalReverse, + groups: [{ + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 0, + targetFoldersPaths: ['mock-folder'] + } + }) + }) + it('should use postfix-specified direction (primary and secondary levels)', () => { + const inputTxtArr: Array = ` + sorting: true a-z order-desc, sorting: advanced modified desc + `.replace(/\t/gi, '').split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result?.sortSpecByPath).toEqual({ + 'mock-folder': { + defaultOrder: CustomSortOrder.trueAlphabeticalReverse, + defaultSecondaryOrder: CustomSortOrder.byModifiedTimeReverseAdvanced, + groups: [{ + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 0, + targetFoldersPaths: ['mock-folder'] + } + }) + }) + it('secondary should not inherit direction from primary', () => { + const inputTxtArr: Array = ` + /folders:files + sorting: a-z desc, sorting: advanced modified + `.replace(/\t/gi, '').split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result?.sortSpecByPath).toEqual({ + 'mock-folder': { + groups: [{ + order: CustomSortOrder.alphabeticalReverse, + secondaryOrder: CustomSortOrder.byModifiedTimeAdvanced, + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 0, + targetFoldersPaths: ['mock-folder'] + } + }) + }) }) const txtInputTargetFolderMultiSpecA: string = ` @@ -1333,7 +1772,6 @@ const expectedSortSpecForMultiSpecAandB: { [key: string]: CustomSortSpec } = { 'mock-folder': { defaultOrder: CustomSortOrder.alphabetical, groups: [{ - order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders }], outsidersGroupIdx: 0, @@ -1347,7 +1785,6 @@ const expectedWildcardMatchingTreeForMultiSpecAandB: FolderMatchingTreeNode { expect(result).toBeNull() expect(errorsLogger).toHaveBeenCalledTimes(2) expect(errorsLogger).toHaveBeenNthCalledWith(1, - `${ERR_PREFIX} 5:MissingAttributeValue Attribute "TARGET-FOLDER:" requires a value to follow ${ERR_SUFFIX_IN_LINE(2)}`) + `${ERR_PREFIX} 5:MissingAttributeValue Invalid target folder specification: "TARGET-FOLDER:" requires a value to follow ${ERR_SUFFIX_IN_LINE(2)}`) expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('TARGET-FOLDER: ')) }) it('should recognize error: no value for ascending sorting attr (space only)', () => { @@ -1894,7 +2316,7 @@ describe('SortingSpecProcessor error detection and reporting', () => { expect(result).toBeNull() expect(errorsLogger).toHaveBeenCalledTimes(2) expect(errorsLogger).toHaveBeenNthCalledWith(1, - `${ERR_PREFIX} 5:MissingAttributeValue Attribute "ORDER-ASC:" requires a value to follow ${ERR_SUFFIX_IN_LINE(2)}`) + `${ERR_PREFIX} 5:MissingAttributeValue Invalid sorting order: "ORDER-ASC:" requires a value to follow ${ERR_SUFFIX_IN_LINE(2)}`) expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('ORDER-ASC: ')) }) it('should recognize error: invalid value for descending sorting attr (space only)', () => { @@ -1903,7 +2325,7 @@ describe('SortingSpecProcessor error detection and reporting', () => { expect(result).toBeNull() expect(errorsLogger).toHaveBeenCalledTimes(2) expect(errorsLogger).toHaveBeenNthCalledWith(1, - `${ERR_PREFIX} 7:InvalidAttributeValue Invalid value of the attribute ">" ${ERR_SUFFIX_IN_LINE(3)}`) + `${ERR_PREFIX} 7:InvalidAttributeValue Primary sorting order contains unrecognized text: >>> definitely not correct <<< ${ERR_SUFFIX_IN_LINE(3)}`) expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT(' > definitely not correct')) }) it('should recognize error: no space before value for descending sorting attr (space only)', () => { @@ -2102,12 +2524,6 @@ describe('SortingSpecProcessor error detection and reporting', () => { expect(result).toBeNull() expect(errorsLogger).toHaveBeenCalledTimes(0) }) - it('should recognize empty spec', () => { - const inputTxtArr: Array = txtInputOnlyCommentsSpec.split('\n') - const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') - expect(result).toBeNull() - expect(errorsLogger).toHaveBeenCalledTimes(0) - }) it.each([ '% \\.d+...', '% ...\\d+', @@ -2175,6 +2591,94 @@ describe('SortingSpecProcessor error detection and reporting', () => { expect(errorsLogger).toHaveBeenNthCalledWith(1, `${ERR_PREFIX} 25:DuplicateByNameSortSpecForFolder Duplicate 'target-folder: name:' definition for the same name <123> ${ERR_SUFFIX}`) }) + it('should recognize unsupported order for by-metadata: (regular orders)', () => { + const inputTxtArr: Array = ` + < modified by-metadata: + `.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} 7:InvalidAttributeValue Sorting by metadata requires one of alphabetical orders ${ERR_SUFFIX_IN_LINE(2)}`) + expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('< modified by-metadata:')) + }) + it('should recognize unsupported order for by-metadata: (ui selected order)', () => { + const inputTxtArr: Array = ` + < ui selected by-metadata: + `.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} 7:InvalidAttributeValue Sorting by metadata requires one of alphabetical orders ${ERR_SUFFIX_IN_LINE(2)}`) + expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('< ui selected by-metadata:')) + }) + it('should reject superfluous unrecognized text case A', () => { + const inputTxtArr: Array = ` + sorting: standard, sorting: ui-selected + `.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} 7:InvalidAttributeValue Secondary sorting order contains unrecognized text: >>> ui-selected <<< ${ERR_SUFFIX_IN_LINE(2)}`) + expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('sorting: standard, sorting: ui-selected')) + }) + it('should reject "sorting" as postfix (comment ignored)', () => { + const inputTxtArr: Array = ` + sorting: a-z, a-z sorting // <- reject postfix notation + `.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} 7:InvalidAttributeValue Secondary sorting order contains unrecognized text: >>> sorting <<< ${ERR_SUFFIX_IN_LINE(2)}`) + expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('sorting: a-z, a-z sorting // <- reject postfix notation')) + }) + it('should reject "order-desc:" as postfix (colon is not needed)', () => { + const inputTxtArr: Array = ` + order-desc: true a-z order-desc: by-metadata: + `.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} 7:InvalidAttributeValue Primary sorting order contains unrecognized text: >>> : by-metadata: <<< ${ERR_SUFFIX_IN_LINE(2)}`) + expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('order-desc: true a-z order-desc: by-metadata:')) + }) + it('should reject "order-asc:" as postfix (colon is not needed)', () => { + const inputTxtArr: Array = ` + order-desc: modified, a-z order-asc: + `.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} 7:InvalidAttributeValue Secondary sorting order contains unrecognized text: >>> : <<< ${ERR_SUFFIX_IN_LINE(2)}`) + expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('order-desc: modified, a-z order-asc:')) + }) + it('should reject "order-asc:" as postfix (colon is not needed) - comment is involved', () => { + const inputTxtArr: Array = ` + order-desc: modified, a-z order-asc: // Comment intentionally here, some spaces, some comma + `.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} 7:InvalidAttributeValue Secondary sorting order contains unrecognized text: >>> : <<< ${ERR_SUFFIX_IN_LINE(2)}`) + expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('order-desc: modified, a-z order-asc: // Comment intentionally here, some spaces, some comma')) + }) + it('should reject inconsistent prefix and postfix orders', () => { + const inputTxtArr: Array = ` + sorting: standard, order-asc: modified desc by-metadata: xyz // <-- and it is checked earlier than the by-metadata incompatible order + `.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} 7:InvalidAttributeValue Secondary sorting direction order-asc: and desc are contradicting ${ERR_SUFFIX_IN_LINE(2)}`) + expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('sorting: standard, order-asc: modified desc by-metadata: xyz // <-- and it is checked earlier than the by-metadata incompatible order')) + }) }) const txtInputTargetFolderCCC: string = ` diff --git a/src/custom-sort/sorting-spec-processor.ts b/src/custom-sort/sorting-spec-processor.ts index f5c085f..bef3c97 100644 --- a/src/custom-sort/sorting-spec-processor.ts +++ b/src/custom-sort/sorting-spec-processor.ts @@ -98,15 +98,18 @@ const ContextFreeProblems = new Set([ const ThreeDots = '...'; const ThreeDotsLength = ThreeDots.length; -const DEFAULT_SORT_ORDER = CustomSortOrder.alphabetical - interface CustomSortOrderAscDescPair { - asc: CustomSortOrder, - desc: CustomSortOrder, - secondary?: CustomSortOrder - applyToMetadataField?: string + asc: CustomSortOrder + desc: CustomSortOrder } +interface CustomSortOrderSpec { + order: CustomSortOrder + byMetadataField?: string +} + +const MAX_SORT_LEVEL: number = 1 + // remember about .toLowerCase() before comparison! const OrderLiterals: { [key: string]: CustomSortOrderAscDescPair } = { 'a-z': {asc: CustomSortOrder.alphabetical, desc: CustomSortOrder.alphabeticalReverse}, @@ -115,75 +118,105 @@ const OrderLiterals: { [key: string]: CustomSortOrderAscDescPair } = { 'modified': {asc: CustomSortOrder.byModifiedTime, desc: CustomSortOrder.byModifiedTimeReverse}, 'advanced modified': {asc: CustomSortOrder.byModifiedTimeAdvanced, desc: CustomSortOrder.byModifiedTimeReverseAdvanced}, 'advanced created': {asc: CustomSortOrder.byCreatedTimeAdvanced, desc: CustomSortOrder.byCreatedTimeReverseAdvanced}, - - // Advanced, for edge cases of secondary sorting, when if regexp match is the same, override the alphabetical sorting by full name - 'a-z, created': { - asc: CustomSortOrder.alphabetical, - desc: CustomSortOrder.alphabeticalReverse, - secondary: CustomSortOrder.byCreatedTime - }, - 'a-z, created desc': { - asc: CustomSortOrder.alphabetical, - desc: CustomSortOrder.alphabeticalReverse, - secondary: CustomSortOrder.byCreatedTimeReverse - }, - 'a-z, modified': { - asc: CustomSortOrder.alphabetical, - desc: CustomSortOrder.alphabeticalReverse, - secondary: CustomSortOrder.byModifiedTime - }, - 'a-z, modified desc': { - asc: CustomSortOrder.alphabetical, - desc: CustomSortOrder.alphabeticalReverse, - secondary: CustomSortOrder.byModifiedTimeReverse - }, - 'a-z, advanced created': { - asc: CustomSortOrder.alphabetical, - desc: CustomSortOrder.alphabeticalReverse, - secondary: CustomSortOrder.byCreatedTimeAdvanced - }, - 'a-z, advanced created desc': { - asc: CustomSortOrder.alphabetical, - desc: CustomSortOrder.alphabeticalReverse, - secondary: CustomSortOrder.byCreatedTimeReverseAdvanced - }, - 'a-z, advanced modified': { - asc: CustomSortOrder.alphabetical, - desc: CustomSortOrder.alphabeticalReverse, - secondary: CustomSortOrder.byModifiedTimeAdvanced - }, - 'a-z, advanced modified desc': { - asc: CustomSortOrder.alphabetical, - desc: CustomSortOrder.alphabeticalReverse, - secondary: CustomSortOrder.byModifiedTimeReverseAdvanced - } + 'standard': {asc: CustomSortOrder.standardObsidian, desc: CustomSortOrder.standardObsidian}, + 'ui selected': {asc: CustomSortOrder.standardObsidian, desc: CustomSortOrder.standardObsidian}, } const OrderByMetadataLexeme: string = 'by-metadata:' +const OrderLevelsSeparator: string = ',' + enum Attribute { TargetFolder = 1, // Starting from 1 to allow: if (attribute) { ... OrderAsc, OrderDesc, - OrderStandardObsidian + OrderUnspecified +} + +type OrderAttribute = Exclude + +const SortingOrderSpecInvalid: string = 'Invalid sorting order' + +const ErrorMsgForAttribute: { [key in Attribute]: string } = { + [Attribute.TargetFolder]: 'Invalid target folder specification', + [Attribute.OrderAsc]: SortingOrderSpecInvalid, + [Attribute.OrderDesc]: SortingOrderSpecInvalid, + [Attribute.OrderUnspecified]: SortingOrderSpecInvalid } const TargetFolderLexeme: string = 'target-folder:' -const AttrLexems: { [key: string]: Attribute } = { - // Verbose attr names - [TargetFolderLexeme]: Attribute.TargetFolder, - 'order-asc:': Attribute.OrderAsc, - 'order-desc:': Attribute.OrderDesc, - 'sorting:': Attribute.OrderStandardObsidian, - // Concise abbreviated equivalents - '::::': Attribute.TargetFolder, +const OrderDirectionAttrLexemes: { [key: string]: OrderAttribute } = { '<': Attribute.OrderAsc, '\\<': Attribute.OrderAsc, // to allow single-liners in YAML '>': Attribute.OrderDesc, '\\>': Attribute.OrderDesc // to allow single-liners in YAML } +const OrderDirectionPrefixAttrLexemes: { [key: string]: OrderAttribute } = { + ...OrderDirectionAttrLexemes, + 'order-asc:': Attribute.OrderAsc, + 'order-desc:': Attribute.OrderDesc, + 'sorting:': Attribute.OrderUnspecified, +} + +const OrderDirectionPostfixAttrLexemes: { [key: string]: OrderAttribute } = { + ...OrderDirectionAttrLexemes, + 'order-asc': Attribute.OrderAsc, + 'order-desc': Attribute.OrderDesc, + 'asc': Attribute.OrderAsc, + 'desc': Attribute.OrderDesc, +} + +const TargetFolderLexemes: { [key: string]: Attribute } = { + [TargetFolderLexeme]: Attribute.TargetFolder, + '::::': Attribute.TargetFolder +} + +const AttrLexemes: { [key: string]: Attribute } = { + ...OrderDirectionPrefixAttrLexemes, + ...OrderDirectionPostfixAttrLexemes, + ...TargetFolderLexemes +} + +interface HasOrderAttrLexeme { + lexeme: string + attr: OrderAttribute +} + +const startsWithOrderAttrLexeme = (s: string, postfixLexemes?: boolean): HasOrderAttrLexeme|undefined => { + const hasLexeme= Object.keys(postfixLexemes ? OrderDirectionPostfixAttrLexemes : OrderDirectionPrefixAttrLexemes) + .find((lexeme) => { + return s?.toLowerCase().startsWith(lexeme) + }) + return hasLexeme ? + {lexeme: hasLexeme, attr: postfixLexemes ? OrderDirectionPostfixAttrLexemes[hasLexeme] : OrderDirectionPrefixAttrLexemes[hasLexeme]} + : + undefined +} + +interface HasOrderNameLiteral { + literal: string + order: CustomSortOrderAscDescPair +} + +const startsWithOrderNameLiteral = (s: string): HasOrderNameLiteral|undefined => { + const hasLiteral= Object.keys(OrderLiterals).find((literal) => { + return s?.toLowerCase().startsWith(literal) + }) + return hasLiteral ? + {literal: hasLiteral, order: OrderLiterals[hasLiteral]} + : + undefined +} + +const OrdersSupportedByMetadata: { [key in CustomSortOrder]?: CustomSortOrder} = { + [CustomSortOrder.alphabetical]: CustomSortOrder.byMetadataFieldAlphabetical, + [CustomSortOrder.alphabeticalReverse]: CustomSortOrder.byMetadataFieldAlphabeticalReverse, + [CustomSortOrder.trueAlphabetical]: CustomSortOrder.byMetadataFieldTrueAlphabetical, + [CustomSortOrder.trueAlphabeticalReverse]: CustomSortOrder.byMetadataFieldTrueAlphabeticalReverse +} + const CURRENT_FOLDER_SYMBOL: string = '.' interface ParsedSortingAttribute { @@ -192,7 +225,7 @@ interface ParsedSortingAttribute { value?: any } -type AttrValueValidatorFn = (v: string) => any | null; +type AttrValueValidatorFn = (v: string, attr: Attribute, attrLexeme: string) => any|AttrError|null; const FilesGroupVerboseLexeme: string = '/:files' const FilesGroupShortLexeme: string = '/:' @@ -707,6 +740,11 @@ export const consumeFolderByRegexpExpression = (expression: string): ConsumedFol } } +class AttrError { + constructor(public errorMsg: string) { + } +} + // Simplistic const extractIdentifier = (text: string, defaultResult?: string): string | undefined => { const identifier: string = text.trim().split(' ')?.[0]?.trim() @@ -911,22 +949,24 @@ export class SortingSpecProcessor { } const firstLexeme: string = lineTrimmedStart.substring(0, indexOfSpace) const firstLexemeLowerCase: string = firstLexeme.toLowerCase() - const recognizedAttr: Attribute = AttrLexems[firstLexemeLowerCase] + const recognizedAttr: Attribute = AttrLexemes[firstLexemeLowerCase] if (recognizedAttr) { const attrValue: string = lineTrimmedStart.substring(indexOfSpace).trim() if (attrValue) { const validator: AttrValueValidatorFn = this.attrValueValidators[recognizedAttr] if (validator) { - const validValue = validator(attrValue); - if (validValue) { + const validValue = validator(attrValue, recognizedAttr, firstLexeme); + if (validValue instanceof AttrError) { + this.problem(ProblemCode.InvalidAttributeValue, validValue.errorMsg || ErrorMsgForAttribute[recognizedAttr]) + } else if (validValue) { return { nesting: nestingLevel, attribute: recognizedAttr, value: validValue } } else { - this.problem(ProblemCode.InvalidAttributeValue, `Invalid value of the attribute "${firstLexeme}"`) + this.problem(ProblemCode.InvalidAttributeValue, ErrorMsgForAttribute[recognizedAttr]) } } else { return { @@ -936,7 +976,7 @@ export class SortingSpecProcessor { } } } else { - this.problem(ProblemCode.MissingAttributeValue, `Attribute "${firstLexeme}" requires a value to follow`) + this.problem(ProblemCode.MissingAttributeValue, `${ErrorMsgForAttribute[recognizedAttr]}: "${firstLexeme}" requires a value to follow`) } } return null; // Seemingly not an attribute or not a valid attribute expression (respective syntax error could have been logged) @@ -960,7 +1000,7 @@ export class SortingSpecProcessor { this.problem(ProblemCode.TargetFolderNestedSpec, `Nested (indented) specification of target folder is not allowed`) return false } - } else if (attr.attribute === Attribute.OrderAsc || attr.attribute === Attribute.OrderDesc || attr.attribute === Attribute.OrderStandardObsidian) { + } else if (attr.attribute === Attribute.OrderAsc || attr.attribute === Attribute.OrderDesc || attr.attribute === Attribute.OrderUnspecified) { if (attr.nesting === 0) { if (!this.ctx.currentSpec) { this.ctx.currentSpec = this.putNewSpecForNewTargetFolder() @@ -972,6 +1012,8 @@ export class SortingSpecProcessor { } this.ctx.currentSpec.defaultOrder = (attr.value as RecognizedOrderValue).order this.ctx.currentSpec.byMetadataField = (attr.value as RecognizedOrderValue).applyToMetadataField + this.ctx.currentSpec.defaultSecondaryOrder = (attr.value as RecognizedOrderValue).secondaryOrder + this.ctx.currentSpec.byMetadataFieldSecondary = (attr.value as RecognizedOrderValue).secondaryApplyToMetadataField return true; } else if (attr.nesting > 0) { // For now only distinguishing nested (indented) and not-nested (not-indented), the depth doesn't matter if (!this.ctx.currentSpec || !this.ctx.currentSpecGroup) { @@ -986,6 +1028,7 @@ export class SortingSpecProcessor { this.ctx.currentSpecGroup.order = (attr.value as RecognizedOrderValue).order this.ctx.currentSpecGroup.byMetadataField = (attr.value as RecognizedOrderValue).applyToMetadataField this.ctx.currentSpecGroup.secondaryOrder = (attr.value as RecognizedOrderValue).secondaryOrder + this.ctx.currentSpecGroup.byMetadataFieldSecondary = (attr.value as RecognizedOrderValue).secondaryApplyToMetadataField return true; } } @@ -996,7 +1039,7 @@ export class SortingSpecProcessor { const lineTrimmedStart: string = line.trimStart() const lineTrimmedStartLowerCase: string = lineTrimmedStart.toLowerCase() // no space present, check for potential syntax errors - for (let attrLexeme of Object.keys(AttrLexems)) { + for (let attrLexeme of Object.keys(AttrLexemes)) { if (lineTrimmedStartLowerCase.startsWith(attrLexeme)) { const originalAttrLexeme: string = lineTrimmedStart.substring(0, attrLexeme.length) if (lineTrimmedStartLowerCase.length === attrLexeme.length) { @@ -1291,6 +1334,8 @@ export class SortingSpecProcessor { if (anyCombinedGroupPresent) { let orderForCombinedGroup: CustomSortOrder | undefined let byMetadataFieldForCombinedGroup: string | undefined + let secondaryOrderForCombinedGroup: CustomSortOrder | undefined + let secondaryByMetadataFieldForCombinedGroup: string | undefined let idxOfCurrentCombinedGroup: number | undefined = undefined for (let i = spec.groups.length - 1; i >= 0; i--) { const group: CustomSortGroup = spec.groups[i] @@ -1299,28 +1344,26 @@ export class SortingSpecProcessor { if (group.combineWithIdx === idxOfCurrentCombinedGroup) { // a subsequent (2nd, 3rd, ...) group of combined (counting from the end) group.order = orderForCombinedGroup group.byMetadataField = byMetadataFieldForCombinedGroup + group.secondaryOrder = secondaryOrderForCombinedGroup + group.byMetadataFieldSecondary = secondaryByMetadataFieldForCombinedGroup } 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 + secondaryOrderForCombinedGroup = group.secondaryOrder // could be undefined + secondaryByMetadataFieldForCombinedGroup = group.byMetadataFieldSecondary // could be undefined } } else { // for sanity idxOfCurrentCombinedGroup = undefined orderForCombinedGroup = undefined byMetadataFieldForCombinedGroup = undefined + secondaryOrderForCombinedGroup = undefined + secondaryByMetadataFieldForCombinedGroup = undefined } } } - // 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 - group.byMetadataField = spec.byMetadataField - } - } - // 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 @@ -1349,85 +1392,119 @@ export class SortingSpecProcessor { // level 2 parser functions defined in order of occurrence and dependency - private validateTargetFolderAttrValue = (v: string): string | null => { + private validateTargetFolderAttrValue: AttrValueValidatorFn = (v: string, attr: Attribute, attrLexeme: string): string | null => { if (v) { const trimmed: string = v.trim(); - return trimmed ? trimmed : null; // Can't use ?? - it treats '' as a valid value + return trimmed || null; } else { return null; } } - private internalValidateOrderAttrValue = (v: string): CustomSortOrderAscDescPair | null => { - v = v.trim(); - let orderLiteral: string = v - let metadataSpec: Partial = {} - let applyToMetadata: boolean = false - - if (v.indexOf(OrderByMetadataLexeme) > 0) { // Intentionally > 0 -> not allow the metadata lexeme alone - const pieces: Array = v.split(OrderByMetadataLexeme) - // there are at least two pieces by definition, prefix and suffix of the metadata lexeme - orderLiteral = pieces[0]?.trim() - let metadataFieldName: string = pieces[1]?.trim() - if (metadataFieldName) { - metadataSpec.applyToMetadataField = metadataFieldName - } - applyToMetadata = true + private internalValidateOrderAttrValue = (sortOrderSpecText: string, prefixLexeme: string): Array|AttrError|null => { + if (sortOrderSpecText.indexOf(CommentPrefix) >= 0) { + sortOrderSpecText = sortOrderSpecText.substring(0, sortOrderSpecText.indexOf(CommentPrefix)) } - let attr: CustomSortOrderAscDescPair | null = orderLiteral ? OrderLiterals[orderLiteral.toLowerCase()] : null - if (attr) { - if (applyToMetadata && - (attr.asc === CustomSortOrder.alphabetical || attr.desc === CustomSortOrder.alphabeticalReverse || - attr.asc === CustomSortOrder.trueAlphabetical || attr.desc === CustomSortOrder.trueAlphabeticalReverse )) { + const sortLevels: Array = `${prefixLexeme||''} ${sortOrderSpecText}`.trim().split(OrderLevelsSeparator) + let sortOrderSpec: Array = [] - const trueAlphabetical: boolean = attr.asc === CustomSortOrder.trueAlphabetical || attr.desc === CustomSortOrder.trueAlphabeticalReverse + // Max two levels are supported, excess levels specs are ignored + for (let level: number = 0; level <= MAX_SORT_LEVEL && level < sortLevels.length; level++) { + let orderNameForErrorMsg = level === 0 ? 'Primary' : 'Secondary' + let orderSpec: string = sortLevels[level].trim() + let applyToMetadata: boolean = false - // Create adjusted copy - attr = { - ...attr, - asc: trueAlphabetical ? CustomSortOrder.byMetadataFieldTrueAlphabetical : CustomSortOrder.byMetadataFieldAlphabetical, - desc: trueAlphabetical ? CustomSortOrder.byMetadataFieldTrueAlphabeticalReverse : CustomSortOrder.byMetadataFieldAlphabeticalReverse + // The direction (asc or desc lexeme) can come before the order literal + // and for level 0 it always comes first (otherwise this validator would not be invoked) + const hasDirectionPrefix: HasOrderAttrLexeme|undefined = startsWithOrderAttrLexeme(orderSpec) + orderSpec = hasDirectionPrefix ? orderSpec.substring(hasDirectionPrefix.lexeme.length).trim() : orderSpec + + let orderName: HasOrderNameLiteral|undefined = startsWithOrderNameLiteral(orderSpec) + orderSpec = orderName ? orderSpec.substring(orderName.literal.length).trim() : orderSpec + + // Order direction, for level > 0 can also occur after order name or can be omitted + const hasDirectionPostfix: HasOrderAttrLexeme|undefined = (orderName) ? startsWithOrderAttrLexeme(orderSpec, true) : undefined + orderSpec = hasDirectionPostfix ? orderSpec.substring(hasDirectionPostfix.lexeme.length).trim() : orderSpec + + let metadataName: string|undefined + if (orderSpec.startsWith(OrderByMetadataLexeme)) { + applyToMetadata = true + metadataName = orderSpec.substring(OrderByMetadataLexeme.length).trim() || undefined + orderSpec = '' // metadataName is unparsed, consumes the remainder string, even if malformed, e.g. with infix spaces + } + + // check for any superfluous text + const superfluousText = orderSpec.trim()||undefined + if (superfluousText) { + return new AttrError(`${orderNameForErrorMsg} sorting order contains unrecognized text: >>> ${superfluousText} <<<`) + } + + // check consistency of prefix and postfix orders, if both are present + if (hasDirectionPrefix && hasDirectionPostfix) { + if (hasDirectionPrefix.attr !== Attribute.OrderUnspecified && hasDirectionPostfix.attr !== Attribute.OrderUnspecified) + if (hasDirectionPrefix.attr !== hasDirectionPostfix.attr) + { + return new AttrError(`${orderNameForErrorMsg} sorting direction ${hasDirectionPrefix.lexeme} and ${hasDirectionPostfix.lexeme} are contradicting`) + } + } + + let order: CustomSortOrder|undefined + if (orderName) { + const direction: OrderAttribute = hasDirectionPrefix ? hasDirectionPrefix.attr : ( + hasDirectionPostfix ? hasDirectionPostfix.attr : Attribute.OrderAsc + ) + switch (direction) { + case Attribute.OrderAsc: order = orderName.order.asc + break + case Attribute.OrderDesc: order = orderName.order.desc + break + case Attribute.OrderUnspecified: + if (hasDirectionPostfix) { + order = hasDirectionPostfix.attr === Attribute.OrderAsc ? orderName.order.asc : orderName.order.desc + } else { + order = orderName.order.asc + } + break + default: + order = undefined } - } else { // For orders different from alphabetical (and reverse) a reference to metadata is not supported - metadataSpec.applyToMetadataField = undefined + + if (applyToMetadata) { + if (order) { + order = OrdersSupportedByMetadata[order] + } + if (!order) { + return new AttrError(`Sorting by metadata requires one of alphabetical orders`) + } + } + } else { + // order name not specified, this is a general syntax error + return null + } + sortOrderSpec[level] = { + order: order!, + byMetadataField: metadataName } } - - return attr ? {...attr, ...metadataSpec} : null + return sortOrderSpec } - private validateOrderAscAttrValue = (v: string): RecognizedOrderValue | null => { - const recognized: CustomSortOrderAscDescPair | null = this.internalValidateOrderAttrValue(v) - return recognized ? { - order: recognized.asc, - secondaryOrder: recognized.secondary, - applyToMetadataField: recognized.applyToMetadataField - } : null; - } - - private validateOrderDescAttrValue = (v: string): RecognizedOrderValue | null => { - const recognized: CustomSortOrderAscDescPair | null = this.internalValidateOrderAttrValue(v) - return recognized ? { - order: recognized.desc, - secondaryOrder: recognized.secondary, - applyToMetadataField: recognized.applyToMetadataField - } : null; - } - - private validateSortingAttrValue = (v: string): RecognizedOrderValue | null => { - // for now only a single fixed lexem - const recognized: boolean = v.trim().toLowerCase() === 'standard' - return recognized ? { - order: CustomSortOrder.standardObsidian - } : null; + private validateOrderAttrValue: AttrValueValidatorFn = (v: string, attr: Attribute, attrLexeme: string): RecognizedOrderValue|AttrError|null => { + const recognized: Array|AttrError|null = this.internalValidateOrderAttrValue(v, attrLexeme) + return recognized ? (recognized instanceof AttrError ? recognized : { + order: recognized[0].order, + applyToMetadataField: recognized[0].byMetadataField, + secondaryOrder: recognized[1]?.order, + secondaryApplyToMetadataField: recognized[1]?.byMetadataField + }) : null; } attrValueValidators: { [key in Attribute]: AttrValueValidatorFn } = { [Attribute.TargetFolder]: this.validateTargetFolderAttrValue.bind(this), - [Attribute.OrderAsc]: this.validateOrderAscAttrValue.bind(this), - [Attribute.OrderDesc]: this.validateOrderDescAttrValue.bind(this), - [Attribute.OrderStandardObsidian]: this.validateSortingAttrValue.bind(this) + [Attribute.OrderAsc]: this.validateOrderAttrValue.bind(this), + [Attribute.OrderDesc]: this.validateOrderAttrValue.bind(this), + [Attribute.OrderUnspecified]: this.validateOrderAttrValue.bind(this) } convertPlainStringSortingGroupSpecToArraySpec = (spec: string): Array => {