import { CompoundDashNumberNormalizerFn, CompoundDashRomanNumberNormalizerFn, CompoundDotNumberNormalizerFn, convertPlainStringWithNumericSortingSymbolToRegex, detectNumericSortingSymbols, escapeRegexUnsafeCharacters, extractNumericSortingSymbol, hasMoreThanOneNumericSortingSymbol, NumberNormalizerFn, RegexpUsedAs, RomanNumberNormalizerFn, SortingSpecProcessor } from "./sorting-spec-processor" import {CustomSortGroupType, CustomSortOrder, CustomSortSpec} from "./custom-sort-types"; import {FolderMatchingTreeNode} from "./folder-matching-rules"; const txtInputExampleA: string = ` order-asc: a-z / ... /: ... target-folder: tricky folder / /: :::: Conceptual model /: Entities % target-folder: / /: Con... / > modified /: < modified /: Ref... /: Att...ch sort...spec /:. sortspec.md target-folder: Sandbox > modified /: adfsasda / sdsadasdsa > a-z /folders fdsfdsfdsfs > created target-folder: Abcd efgh ijk > a-z Plain text spec bla bla bla (matches files and folders)... /: files only matching > a-z / folders only matching < a-z some-file (or folder) /:. sort....md Trailer item :::: References :::: Same rules as for References Recently... `; const txtInputExampleAVerbose: string = ` order-asc: a-z /folders ... /:files ... target-folder: tricky folder /folders /:files :::: Conceptual model /:files Entities % target-folder: / /:files Con... /folders > modified /:files < modified /:files Ref... /:files Att...ch % sort...spec /:files. sortspec.md target-folder: Sandbox > modified /:files adfsasda /folders sdsadasdsa > a-z / fdsfdsfdsfs > created target-folder: Abcd efgh ijk > a-z Plain text spec bla bla bla (matches files and folders)... /:files files only matching > a-z /folders folders only matching < a-z % some-file (or folder) /:files. sort....md % Trailer item target-folder: References target-folder: Same rules as for References % Recently... `; const expectedSortSpecsExampleA: { [key: string]: CustomSortSpec } = { "mock-folder": { defaultOrder: CustomSortOrder.alphabetical, groups: [{ foldersOnly: true, order: CustomSortOrder.alphabetical, type: CustomSortGroupType.MatchAll }, { filesOnly: true, order: CustomSortOrder.alphabetical, type: CustomSortGroupType.MatchAll }, { type: CustomSortGroupType.Outsiders, order: CustomSortOrder.alphabetical, }], targetFoldersPaths: ['mock-folder'], outsidersGroupIdx: 2 }, "tricky folder": { groups: [{ foldersOnly: true, order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders }, { filesOnly: true, order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders }], outsidersFilesGroupIdx: 1, outsidersFoldersGroupIdx: 0, targetFoldersPaths: ['tricky folder'] }, "Conceptual model": { groups: [{ exactText: "Entities", filesOnly: true, order: CustomSortOrder.alphabetical, type: CustomSortGroupType.ExactName }, { type: CustomSortGroupType.Outsiders, order: CustomSortOrder.alphabetical, }], outsidersGroupIdx: 1, targetFoldersPaths: ['Conceptual model'] }, "/": { groups: [{ exactPrefix: "Con", filesOnly: true, order: CustomSortOrder.alphabetical, type: CustomSortGroupType.ExactPrefix }, { foldersOnly: true, order: CustomSortOrder.byModifiedTimeReverse, type: CustomSortGroupType.Outsiders }, { filesOnly: true, order: CustomSortOrder.byModifiedTime, type: CustomSortGroupType.Outsiders }, { exactPrefix: "Ref", filesOnly: true, order: CustomSortOrder.alphabetical, type: CustomSortGroupType.ExactPrefix }, { exactPrefix: "Att", exactSuffix: "ch", filesOnly: true, order: CustomSortOrder.alphabetical, type: CustomSortGroupType.ExactHeadAndTail }, { exactPrefix: "sort", exactSuffix: "spec", order: CustomSortOrder.alphabetical, type: CustomSortGroupType.ExactHeadAndTail }, { exactText: "sortspec.md", filesOnly: true, matchFilenameWithExt: true, order: CustomSortOrder.alphabetical, type: CustomSortGroupType.ExactName }], outsidersFilesGroupIdx: 2, outsidersFoldersGroupIdx: 1, targetFoldersPaths: ['/'] }, "Sandbox": { defaultOrder: CustomSortOrder.byModifiedTimeReverse, groups: [{ exactText: "adfsasda", filesOnly: true, order: CustomSortOrder.byModifiedTimeReverse, type: CustomSortGroupType.ExactName }, { exactText: "sdsadasdsa", foldersOnly: true, order: CustomSortOrder.alphabeticalReverse, type: CustomSortGroupType.ExactName }, { exactText: "fdsfdsfdsfs", foldersOnly: true, order: CustomSortOrder.byCreatedTimeReverse, type: CustomSortGroupType.ExactName }, { order: CustomSortOrder.byModifiedTimeReverse, type: CustomSortGroupType.Outsiders }], outsidersGroupIdx: 3, targetFoldersPaths: ['Sandbox'] }, "Abcd efgh ijk": { defaultOrder: CustomSortOrder.alphabeticalReverse, groups: [{ exactPrefix: "Plain text spec bla bla bla (matches files and folders)", order: CustomSortOrder.alphabeticalReverse, type: CustomSortGroupType.ExactPrefix }, { exactText: "files only matching", filesOnly: true, order: CustomSortOrder.alphabeticalReverse, type: CustomSortGroupType.ExactName }, { exactText: "folders only matching", foldersOnly: true, order: CustomSortOrder.alphabetical, type: CustomSortGroupType.ExactName }, { exactText: "some-file (or folder)", order: CustomSortOrder.alphabeticalReverse, type: CustomSortGroupType.ExactName }, { exactPrefix: "sort", exactSuffix: ".md", filesOnly: true, matchFilenameWithExt: true, order: CustomSortOrder.alphabeticalReverse, type: CustomSortGroupType.ExactHeadAndTail }, { exactText: "Trailer item", order: CustomSortOrder.alphabeticalReverse, type: CustomSortGroupType.ExactName }, { order: CustomSortOrder.alphabeticalReverse, type: CustomSortGroupType.Outsiders }], outsidersGroupIdx: 6, targetFoldersPaths: ['Abcd efgh ijk'] }, "References": { groups: [{ exactPrefix: "Recently", order: CustomSortOrder.alphabetical, type: CustomSortGroupType.ExactPrefix }, { order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders }], outsidersGroupIdx: 1, targetFoldersPaths: ['References', 'Same rules as for References'] }, "Same rules as for References": { groups: [{ exactPrefix: "Recently", order: CustomSortOrder.alphabetical, type: CustomSortGroupType.ExactPrefix }, { order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders }], outsidersGroupIdx: 1, targetFoldersPaths: ['References', 'Same rules as for References'] } } const expectedSortSpecsExampleNumericSortingSymbols: { [key: string]: CustomSortSpec } = { "mock-folder": { groups: [{ foldersOnly: true, order: CustomSortOrder.alphabetical, type: CustomSortGroupType.ExactPrefix, regexSpec: { regex: /^Chapter *(\d+(?:\.\d+)*) /i, normalizerFn: CompoundDotNumberNormalizerFn } }, { filesOnly: true, order: CustomSortOrder.alphabetical, type: CustomSortGroupType.ExactSuffix, regexSpec: { regex: /section *([MDCLXVI]+(?:-[MDCLXVI]+)*)\.$/i, normalizerFn: CompoundDashRomanNumberNormalizerFn } }, { order: CustomSortOrder.alphabetical, type: CustomSortGroupType.ExactName, regexSpec: { regex: /^Appendix *(\d+(?:-\d+)*) \(attachments\)$/i, normalizerFn: CompoundDashNumberNormalizerFn } }, { order: CustomSortOrder.alphabetical, type: CustomSortGroupType.ExactHeadAndTail, exactSuffix: ' works?', regexSpec: { regex: /^Plain syntax *([MDCLXVI]+) /i, normalizerFn: RomanNumberNormalizerFn } }, { order: CustomSortOrder.alphabetical, type: CustomSortGroupType.ExactHeadAndTail, exactPrefix: 'And this kind of', regexSpec: { regex: / *(\d+)plain syntax\?\?\?$/i, normalizerFn: NumberNormalizerFn } }, { type: CustomSortGroupType.Outsiders, order: CustomSortOrder.alphabetical, }], targetFoldersPaths: ['mock-folder'], outsidersGroupIdx: 5 } } const txtInputExampleNumericSortingSymbols: string = ` /folders Chapter \\.d+ ... /:files ...section \\-r+. % Appendix \\-d+ (attachments) Plain syntax\\R+ ... works? And this kind of... \\D+plain syntax??? ` describe('SortingSpecProcessor', () => { let processor: SortingSpecProcessor; beforeEach(() => { processor = new SortingSpecProcessor(); }); it('should generate correct SortSpecs (complex example A)', () => { const inputTxtArr: Array = txtInputExampleA.split('\n') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') expect(result?.sortSpecByPath).toEqual(expectedSortSpecsExampleA) }) it('should generate correct SortSpecs (complex example A verbose)', () => { const inputTxtArr: Array = txtInputExampleAVerbose.split('\n') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') expect(result?.sortSpecByPath).toEqual(expectedSortSpecsExampleA) }) it('should generate correct SortSpecs (example with numerical sorting symbols)', () => { const inputTxtArr: Array = txtInputExampleNumericSortingSymbols.split('\n') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') expect(result?.sortSpecByPath).toEqual(expectedSortSpecsExampleNumericSortingSymbols) }) }) const txtInputNotDuplicatedSortSpec: string = ` target-folder: AAA > A-Z target-folder: BBB % Whatever ... ` const expectedSortSpecsNotDuplicatedSortSpec: { [key: string]: CustomSortSpec } = { "AAA": { defaultOrder: CustomSortOrder.alphabeticalReverse, groups: [{ order: CustomSortOrder.alphabeticalReverse, type: CustomSortGroupType.Outsiders }], outsidersGroupIdx: 0, targetFoldersPaths: ['AAA'] }, "BBB": { groups: [{ exactPrefix: "Whatever ", order: CustomSortOrder.alphabetical, type: CustomSortGroupType.ExactPrefix }, { order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders }], outsidersGroupIdx: 1, targetFoldersPaths: ['BBB'] } } describe('SortingSpecProcessor', () => { let processor: SortingSpecProcessor; beforeEach(() => { processor = new SortingSpecProcessor(); }); it('should not duplicate spec if former target-folder had some attribute specified', () => { const inputTxtArr: Array = txtInputNotDuplicatedSortSpec.split('\n') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') expect(result?.sortSpecByPath).toEqual(expectedSortSpecsNotDuplicatedSortSpec) }) }) const txtInputStandardObsidianSortAttr: string = ` target-folder: AAA sorting: standard ` const expectedSortSpecForObsidianStandardSorting: { [key: string]: CustomSortSpec } = { "AAA": { defaultOrder: CustomSortOrder.standardObsidian, groups: [{ order: CustomSortOrder.standardObsidian, type: CustomSortGroupType.Outsiders }], outsidersGroupIdx: 0, targetFoldersPaths: ['AAA'] } } describe('SortingSpecProcessor', () => { let processor: SortingSpecProcessor; beforeEach(() => { processor = new SortingSpecProcessor(); }); it('should recognize the standard Obsidian sorting attribute for a folder', () => { const inputTxtArr: Array = txtInputStandardObsidianSortAttr.split('\n') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') expect(result?.sortSpecByPath).toEqual(expectedSortSpecForObsidianStandardSorting) }) }) const txtInputItemsToHideWithDupsSortSpec: string = ` target-folder: AAA /--hide: SomeFileToHide.md --% SomeFileToHide.md --% SomeFolderToHide /--hide: SomeFolderToHide --% HideItRegardlessFileOrFolder ` const expectedHiddenItemsSortSpec: { [key: string]: CustomSortSpec } = { "AAA": { groups: [{ order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders }], itemsToHide: new Set(['SomeFileToHide.md', 'SomeFolderToHide', 'HideItRegardlessFileOrFolder']), outsidersGroupIdx: 0, targetFoldersPaths: ['AAA'] } } describe('SortingSpecProcessor bonus experimental feature', () => { let processor: SortingSpecProcessor; beforeEach(() => { processor = new SortingSpecProcessor(); }); it('should correctly parse list of items to hide', () => { const inputTxtArr: Array = txtInputItemsToHideWithDupsSortSpec.split('\n') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') // REMARK: be careful with examining Set object expect(result?.sortSpecByPath).toEqual(expectedHiddenItemsSortSpec) }) }) const txtInputItemsReadmeExample1Spec: string = ` // Less verbose versions of the spec, // I know that at root level there will only folders matching // the below names, so I can skip the /folders prefix // I know there are no other root level folders and files // so no need to specify order for them target-folder: / Projects Areas Responsibilities Archive /--hide: sortspec.md ` const expectedReadmeExample1SortSpec: { [key: string]: CustomSortSpec } = { "/": { groups: [{ exactText: 'Projects', order: CustomSortOrder.alphabetical, type: CustomSortGroupType.ExactName }, { exactText: 'Areas', order: CustomSortOrder.alphabetical, type: CustomSortGroupType.ExactName }, { exactText: 'Responsibilities', order: CustomSortOrder.alphabetical, type: CustomSortGroupType.ExactName }, { exactText: 'Archive', order: CustomSortOrder.alphabetical, type: CustomSortGroupType.ExactName }, { order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders }], itemsToHide: new Set(['sortspec.md']), outsidersGroupIdx: 4, targetFoldersPaths: ['/'] } } describe('SortingSpecProcessor - README.md examples', () => { let processor: SortingSpecProcessor; beforeEach(() => { processor = new SortingSpecProcessor(); }); it('should correctly parse example 1', () => { const inputTxtArr: Array = txtInputItemsReadmeExample1Spec.split('\n') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') // REMARK: be careful with examining Set object expect(result?.sortSpecByPath).toEqual(expectedReadmeExample1SortSpec) }) }) const txtInputEmptySpecOnlyTargetFolder: string = ` target-folder: BBB ` const expectedSortSpecsOnlyTargetFolder: { [key: string]: CustomSortSpec } = { "BBB": { groups: [{ order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders }], outsidersGroupIdx: 0, targetFoldersPaths: ['BBB'] } } const txtInputTargetFolderAsDot: string = ` // Let me introduce a comment here ;-) to ensure it is ignored target-folder: . target-folder: CCC target-folder: ./sub target-folder: ./* target-folder: ./... //target-folder: ./.../ // This comment should be ignored as well ` const expectedSortSpecToBeMultiplied = { groups: [{ order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders }], outsidersGroupIdx: 0, targetFoldersPaths: ['mock-folder', 'CCC', 'mock-folder/sub', "mock-folder/*", "mock-folder/..."] } const expectedSortSpecsTargetFolderAsDot: { [key: string]: CustomSortSpec } = { 'mock-folder': expectedSortSpecToBeMultiplied, 'CCC': expectedSortSpecToBeMultiplied, 'mock-folder/sub': expectedSortSpecToBeMultiplied } describe('SortingSpecProcessor edge case', () => { let processor: SortingSpecProcessor; beforeEach(() => { processor = new SortingSpecProcessor(); }); it('should recognize empty spec containing only target folder', () => { const inputTxtArr: Array = txtInputEmptySpecOnlyTargetFolder.split('\n') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') expect(result?.sortSpecByPath).toEqual(expectedSortSpecsOnlyTargetFolder) }) it('should recognize and correctly replace dot as the target folder', () => { const inputTxtArr: Array = txtInputTargetFolderAsDot.split('\n') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') expect(result?.sortSpecByPath).toEqual(expectedSortSpecsTargetFolderAsDot) expect(result?.sortSpecByWildcard).not.toBeNull() }) }) const txtInputTargetFolderMultiSpecA: string = ` target-folder: . < a-z target-folder: ./* > a-z target-folder: ./.../ < modified ` const txtInputTargetFolderMultiSpecB: string = ` target-folder: ./* > a-z target-folder: ./.../ < modified target-folder: . < a-z ` const expectedSortSpecForMultiSpecAandB: { [key: string]: CustomSortSpec } = { 'mock-folder': { defaultOrder: CustomSortOrder.alphabetical, groups: [{ order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders }], outsidersGroupIdx: 0, targetFoldersPaths: ['mock-folder'] } } const expectedWildcardMatchingTreeForMultiSpecAandB: FolderMatchingTreeNode = { subtree: { "mock-folder": { matchAll: { "defaultOrder": CustomSortOrder.alphabeticalReverse, "groups": [{ "order": CustomSortOrder.alphabeticalReverse, "type": CustomSortGroupType.Outsiders }], "outsidersGroupIdx": 0, "targetFoldersPaths": ["mock-folder/*"] }, matchChildren: { "defaultOrder": CustomSortOrder.byModifiedTime, "groups": [{ "order": CustomSortOrder.byModifiedTime, "type": CustomSortGroupType.Outsiders }], "outsidersGroupIdx": 0, "targetFoldersPaths": ["mock-folder/.../"] }, name: "mock-folder", subtree: {} } } } const txtInputTargetFolderMultiSpecC: string = ` target-folder: ./* > a-z target-folder: ./.../ ` const expectedSortSpecForMultiSpecC: { [key: string]: CustomSortSpec } = { 'mock-folder': { groups: [{ order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders }], outsidersGroupIdx: 0, targetFoldersPaths: ['mock-folder/.../'] } } const expectedWildcardMatchingTreeForMultiSpecC: FolderMatchingTreeNode = { subtree: { "mock-folder": { matchAll: { "defaultOrder": CustomSortOrder.alphabeticalReverse, "groups": [{ "order": CustomSortOrder.alphabeticalReverse, "type": CustomSortGroupType.Outsiders }], "outsidersGroupIdx": 0, "targetFoldersPaths": ["mock-folder/*"] }, matchChildren: { "groups": [{ "order": CustomSortOrder.alphabetical, "type": CustomSortGroupType.Outsiders }], "outsidersGroupIdx": 0, "targetFoldersPaths": ["mock-folder/.../"] }, name: "mock-folder", subtree: {} } } } const txtInputTargetFolderMultiSpecD: string = ` target-folder: ./* ` const expectedSortSpecForMultiSpecD: { [key: string]: CustomSortSpec } = { 'mock-folder': { groups: [{ order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders }], outsidersGroupIdx: 0, targetFoldersPaths: ['mock-folder/*'] } } const expectedWildcardMatchingTreeForMultiSpecD: FolderMatchingTreeNode = { subtree: { "mock-folder": { matchAll: { "groups": [{ "order": CustomSortOrder.alphabetical, "type": CustomSortGroupType.Outsiders }], "outsidersGroupIdx": 0, "targetFoldersPaths": ["mock-folder/*"] }, name: "mock-folder", subtree: {} } } } const txtInputTargetFolderMultiSpecE: string = ` target-folder: mock-folder/... ` const expectedSortSpecForMultiSpecE: { [key: string]: CustomSortSpec } = { 'mock-folder': { groups: [{ order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders }], outsidersGroupIdx: 0, targetFoldersPaths: ['mock-folder/...'] } } const expectedWildcardMatchingTreeForMultiSpecE: FolderMatchingTreeNode = { subtree: { "mock-folder": { matchChildren: { "groups": [{ "order": CustomSortOrder.alphabetical, "type": CustomSortGroupType.Outsiders }], "outsidersGroupIdx": 0, "targetFoldersPaths": ["mock-folder/..."] }, name: "mock-folder", subtree: {} } } } describe('SortingSpecProcessor path wildcard priorities', () => { let processor: SortingSpecProcessor; beforeEach(() => { processor = new SortingSpecProcessor(); }); it('should not raise error for multiple spec for the same path and choose correct spec, case A', () => { const inputTxtArr: Array = txtInputTargetFolderMultiSpecA.split('\n') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') expect(result?.sortSpecByPath).toEqual(expectedSortSpecForMultiSpecAandB) expect(result?.sortSpecByWildcard.tree).toEqual(expectedWildcardMatchingTreeForMultiSpecAandB) }) it('should not raise error for multiple spec for the same path and choose correct spec, case B', () => { const inputTxtArr: Array = txtInputTargetFolderMultiSpecB.split('\n') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') expect(result?.sortSpecByPath).toEqual(expectedSortSpecForMultiSpecAandB) expect(result?.sortSpecByWildcard.tree).toEqual(expectedWildcardMatchingTreeForMultiSpecAandB) }) it('should not raise error for multiple spec for the same path and choose correct spec, case C', () => { const inputTxtArr: Array = txtInputTargetFolderMultiSpecC.split('\n') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') expect(result?.sortSpecByPath).toEqual(expectedSortSpecForMultiSpecC) expect(result?.sortSpecByWildcard.tree).toEqual(expectedWildcardMatchingTreeForMultiSpecC) }) it('should not raise error for multiple spec for the same path and choose correct spec, case D', () => { const inputTxtArr: Array = txtInputTargetFolderMultiSpecD.split('\n') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') expect(result?.sortSpecByPath).toEqual(expectedSortSpecForMultiSpecD) expect(result?.sortSpecByWildcard.tree).toEqual(expectedWildcardMatchingTreeForMultiSpecD) }) it('should not raise error for multiple spec for the same path and choose correct spec, case E', () => { const inputTxtArr: Array = txtInputTargetFolderMultiSpecE.split('\n') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') expect(result?.sortSpecByPath).toEqual(expectedSortSpecForMultiSpecE) expect(result?.sortSpecByWildcard.tree).toEqual(expectedWildcardMatchingTreeForMultiSpecE) }) }) const errorsLogger = jest.fn(); const ERR_PREFIX = 'Sorting specification problem:' const ERR_SUFFIX = '---encountered in sorting spec in file mock-folder/custom-name-note.md' const ERR_SUFFIX_IN_LINE = (n: number) => `---encountered in line ${n} of sorting spec in file mock-folder/custom-name-note.md` const ERR_LINE_TXT = (txt: string) => `Content of problematic line: "${txt}"` const txtInputErrorDupTargetFolder: string = ` target-folder: AAA :::: AAA > Modified ` const txtInputErrorMissingSpaceTargetFolderAttr: string = ` target-folder:AAA :::: AAA > Modified ` const txtInputErrorEmptyValueOfTargetFolderAttr: string = ` target-folder: :::: AAA > Modified ` // There is a trailing space character in the first line const txtInputErrorSpaceAsValueOfTargetFolderAttr: string = ` TARGET-FOLDER: :::: AAA > Modified ` const txtInputErrorSpaceAsValueOfAscendingAttr: string = ` ORDER-ASC: ` const txtInputErrorInvalidValueOfDescendingAttr: string = ` /Folders: > definitely not correct ` const txtInputErrorNoSpaceDescendingAttr: string = ` /files: Chapter ... Order-DESC:MODIFIED ` const txtInputErrorItemToHideWithNoValue: string = ` target-folder: AAA --% ` const txtInputErrorTooManyNumericSortSymbols: string = ` % Chapter\\R+ ... page\\d+ ` const txtInputErrorNestedStandardObsidianSortAttr: string = ` target-folder: AAA / Some folder sorting: standard ` const txtInputEmptySpec: string = `` describe('SortingSpecProcessor error detection and reporting', () => { let processor: SortingSpecProcessor; beforeEach(() => { processor = new SortingSpecProcessor(errorsLogger); errorsLogger.mockReset() }); it('should recognize error: target folder name duplicated (edge case)', () => { const inputTxtArr: Array = txtInputErrorDupTargetFolder.split('\n') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') expect(result).toBeNull() expect(errorsLogger).toHaveBeenCalledTimes(1) expect(errorsLogger).toHaveBeenCalledWith(`${ERR_PREFIX} 2:DuplicateSortSpecForSameFolder Duplicate sorting spec for folder AAA ${ERR_SUFFIX}`) }) it('should recognize error: no space before target folder name ', () => { const inputTxtArr: Array = txtInputErrorMissingSpaceTargetFolderAttr.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} 6:NoSpaceBetweenAttributeAndValue Space required after attribute name "target-folder:" ${ERR_SUFFIX_IN_LINE(2)}`) expect(errorsLogger ).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('target-folder:AAA')) }) it('should recognize error: no value for target folder attr (immediate endline)', () => { const inputTxtArr: Array = txtInputErrorEmptyValueOfTargetFolderAttr.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} 5:MissingAttributeValue Attribute "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 target folder attr (space only)', () => { const inputTxtArr: Array = txtInputErrorSpaceAsValueOfTargetFolderAttr.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} 5:MissingAttributeValue Attribute "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)', () => { const inputTxtArr: Array = txtInputErrorSpaceAsValueOfAscendingAttr.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} 5:MissingAttributeValue Attribute "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)', () => { const inputTxtArr: Array = txtInputErrorInvalidValueOfDescendingAttr.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 Invalid value of the attribute ">" ${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)', () => { const inputTxtArr: Array = txtInputErrorNoSpaceDescendingAttr.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} 6:NoSpaceBetweenAttributeAndValue Space required after attribute name "Order-DESC:" ${ERR_SUFFIX_IN_LINE(3)}`) expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('Order-DESC:MODIFIED')) }) it('should recognize error: item to hide requires exact name with ext', () => { const inputTxtArr: Array = txtInputErrorItemToHideWithNoValue.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} 11:ItemToHideExactNameWithExtRequired Exact name with ext of file or folders to hide is required ${ERR_SUFFIX_IN_LINE(3)}`) expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('--%')) }) it('should recognize error: too many numeric sorting indicators in a line', () => { const inputTxtArr: Array = txtInputErrorTooManyNumericSortSymbols.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} 9:TooManyNumericSortingSymbols Maximum one numeric sorting indicator allowed per line ${ERR_SUFFIX_IN_LINE(2)}`) expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('% Chapter\\R+ ... page\\d+ ')) }) it('should recognize error: nested standard obsidian sorting attribute', () => { const inputTxtArr: Array = txtInputErrorNestedStandardObsidianSortAttr.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} 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 empty spec', () => { const inputTxtArr: Array = txtInputEmptySpec.split('\n') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') expect(result).toBeNull() expect(errorsLogger).toHaveBeenCalledTimes(0) }) it.each([ '% \\.d+...', '% ...\\d+', '% Chapter\\R+... page', '% Section ...\\-r+page' ])('should recognize error: numeric sorting symbol adjacent to wildcard in >%s<', (s: string) => { const inputTxtArr: Array = s.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} 10:NumericalSymbolAdjacentToWildcard Numerical sorting symbol must not be directly adjacent to a wildcard because of potential performance problem. An additional explicit separator helps in such case. ${ERR_SUFFIX_IN_LINE(1)}`) expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT(s)) }) }) const txtInputTargetFolderCCC: string = ` target-folder: CCC ` describe('SortingSpecProcessor advanced error detection', () => { it('should retain state of duplicates detection in the instance', () => { let processor: SortingSpecProcessor = new SortingSpecProcessor(errorsLogger); errorsLogger.mockReset() const inputTxtArr: Array = txtInputTargetFolderCCC.split('\n') const result1 = processor.parseSortSpecFromText(inputTxtArr, 'another-mock-folder', 'sortspec.md') const result2 = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') expect(result1).not.toBeNull() expect(result2).toBeNull() expect(errorsLogger).toHaveBeenCalledTimes(1) expect(errorsLogger).toHaveBeenCalledWith(`${ERR_PREFIX} 2:DuplicateSortSpecForSameFolder Duplicate sorting spec for folder CCC ${ERR_SUFFIX}`) }) }) describe('convertPlainStringSortingGroupSpecToArraySpec', () => { let processor: SortingSpecProcessor; beforeEach(() => { processor = new SortingSpecProcessor(); }); it('should recognize infix', () => { const s = 'Advanced adv...ed, etc. and so on' expect(processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s)).toEqual([ 'Advanced adv', '...', 'ed, etc. and so on' ]) }) it('should recognize suffix', () => { const s = 'Advanced... ' expect(processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s)).toEqual([ 'Advanced', '...' ]) }) it('should recognize prefix', () => { const s = ' ...tion. !!!' expect(processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s)).toEqual([ '...', 'tion. !!!' ]) }) it('should recognize some edge case', () => { const s = 'Edge...... ... ..... ... eee?' expect(processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s)).toEqual([ 'Edge', '...', '... ... ..... ... eee?' ]) }) it('should recognize some other edge case', () => { const s = 'Edge... ... ... ..... ... eee?' expect(processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s)).toEqual([ 'Edge', '...', ' ... ... ..... ... eee?' ]) }) it('should recognize another edge case', () => { const s = '...Edge ... ... ... ..... ... eee? ...' expect(processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s)).toEqual([ '...', 'Edge ... ... ... ..... ... eee? ...' ]) }) it('should recognize yet another edge case', () => { const s = '. .. ... ...' const result = processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s) expect(result).toEqual([ '. .. ... ', '...' // Edge case -> splitting here is neutral, syntax error should be raised later on ]) }) it('should recognize tricky edge case', () => { const s = '... ...' const result = processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s) expect(result).toEqual([ '...', ' ...' // Edge case -> splitting here is neutral, syntax error should be raised later on ]) }) it('should recognize a variant of tricky edge case', () => { const s = '......' const result = processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s) expect(result).toEqual([ '...', '...' // Edge case -> splitting here is neutral, syntax error should be raised later on ]) }) it('edge case behavior', () => { const s = ' ...... .......... ' const result = processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s) expect(result).toEqual([ '...', '... ..........' // Edge case -> splitting here is neutral, syntax error should be raised later on ]) }) it('intentional edge case parsing', () => { const s = ' Abc......def..........ghi ' const result = processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s) expect(result).toEqual([ 'Abc', '...', '...def..........ghi' // Edge case -> splitting here is neutral, syntax error should be raised later on ]) }) it.each([ ' ... ', '... ', ' ...' ])('should not split >%s< and only trim', (s: string) => { const result = processor._l2s2_convertPlainStringSortingGroupSpecToArraySpec(s) expect(result).toEqual([s.trim()]) }) }) describe('escapeRegexUnsafeCharacters', () => { it.each([ ['^', '\\^'], ['...', '\\.\\.\\.'], [' \\ ', ' \\\\ '], ['^$.-+[]{}()|\\*?=!', '\\^\\$\\.\\-\\+\\[\\]\\{\\}\\(\\)\\|\\\\\\*\\?\\=\\!'], ['^Chapter \\.d+ -', '\\^Chapter \\\\\\.d\\+ \\-'] ])('should correctly escape >%s< to >%s<', (s: string, ss: string) => { // const UnsafeRegexChars: string = '^$.-+[]{}()|\\*?=!'; const result = escapeRegexUnsafeCharacters(s) expect(result).toBe(ss); }) }) describe('detectNumericSortingSymbols', () => { it.each([ ['', false], ['d+', false], ['\\d +', false], ['\\ d +', false], ['\\D+', true], [' \\D+ ', true], ['\\.D+', true], [' \\.D+ ', true], ['\\-D+', true], [' \\-D+ ', true], ['\\r+', true], [' \\r+ ', true], ['\\.r+', true], [' \\.r+ ', true], ['\\-r+', true], [' \\-r+ ', true], ['\\d+abcd\\d+efgh', true], ['\\d+\\.D+\\-d+\\R+\\.r+\\-R+ \\d+', true] ])('should correctly detect in >%s< (%s) sorting regex symbols', (s: string, b: boolean) => { const result = detectNumericSortingSymbols(s) expect(result).toBe(b) }) }) describe('hasMoreThanOneNumericSortingSymbol', () => { it.each([ ['', false], [' d+', false], ['\\d +', false], ['\\ d +', false], [' \\D+', false], [' \\D+ \\R+ ', true], [' \\.D+', false], ['\\.D+ \\.R+', true], [' \\-D+ ', false], [' \\-D+\\-R+', true], ['\\r+', false], [' \\r+ \\D+ ', true], ['\\.r+', false], ['ab\\.r+de\\.D+fg', true], ['\\-r+', false], ['--\\-r+--\\-D+++', true], ['\\d+abcd\\d+efgh', true], ['\\R+abcd\\.R+efgh', true], ['\\d+\\.D+\\-d+\\R+\\.r+\\-R+ \\d+', true] ])('should correctly detect in >%s< (%s) sorting regex symbols', (s: string, b: boolean) => { const result = hasMoreThanOneNumericSortingSymbol(s) expect(result).toBe(b) }) }) describe('extractNumericSortingSymbol', () => { it.each([ ['', null], ['d+', null], [' \\d +', null], ['\\ d +', null], [' \\d+', '\\d+'], ['--\\.D+\\d+', '\\.D+'], ['wdwqwqe\\d+\\.D+\\-d+\\R+\\.r+\\-R+ \\d+', '\\d+'] ])('should correctly extract from >%s< the numeric sorting symbol (%s)', (s: string, ss: string) => { const result = extractNumericSortingSymbol(s) expect(result).toBe(ss) }) }) describe('convertPlainStringWithNumericSortingSymbolToRegex', () => { it.each([ [' \\d+ ', / *(\d+) /i], ['--\\.D+\\d+', /\-\- *(\d+(?:\.\d+)*)\\d\+/i], ['Chapter \\D+:', /Chapter *(\d+):/i], ['Section \\.D+ of', /Section *(\d+(?:\.\d+)*) of/i], ['Part\\-D+:', /Part *(\d+(?:-\d+)*):/i], ['Lorem ipsum\\r+:', /Lorem ipsum *([MDCLXVI]+):/i], ['\\.r+', / *([MDCLXVI]+(?:\.[MDCLXVI]+)*)/i], ['\\-r+:Lorem', / *([MDCLXVI]+(?:-[MDCLXVI]+)*):Lorem/i], ['abc\\d+efg\\d+hij', /abc *(\d+)efg/i], // Double numerical sorting symbol, error case, covered for clarity of implementation detail ])('should correctly extract from >%s< the numeric sorting symbol (%s)', (s: string, regex: RegExp) => { const result = convertPlainStringWithNumericSortingSymbolToRegex(s, RegexpUsedAs.InUnitTest) expect(result.regexpSpec.regex).toEqual(regex) // No need to examine prefix and suffix fields of result, they are secondary and derived from the returned regexp }) it('should not process string not containing numeric sorting symbol', () => { const input = 'abc' const result = convertPlainStringWithNumericSortingSymbolToRegex(input, RegexpUsedAs.InUnitTest) expect(result).toBeNull() }) it('should correctly include regex token for string begin', () => { const input = 'Part\\-D+:' const result = convertPlainStringWithNumericSortingSymbolToRegex(input, RegexpUsedAs.Prefix) expect(result.regexpSpec.regex).toEqual(/^Part *(\d+(?:-\d+)*):/i) }) it('should correctly include regex token for string end', () => { const input = 'Part\\-D+:' const result = convertPlainStringWithNumericSortingSymbolToRegex(input, RegexpUsedAs.Suffix) expect(result.regexpSpec.regex).toEqual(/Part *(\d+(?:-\d+)*):$/i) }) it('should correctly include regex token for string begin and end', () => { const input = 'Part\\.D+:' const result = convertPlainStringWithNumericSortingSymbolToRegex(input, RegexpUsedAs.FullMatch) expect(result.regexpSpec.regex).toEqual(/^Part *(\d+(?:\.\d+)*):$/i) }) })