obsidian-sample-plugin/src/custom-sort/sorting-spec-processor.spec.ts

1171 lines
39 KiB
TypeScript

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<string> = 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<string> = 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<string> = 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<string> = 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<string> = 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<string> = 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<string> = 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<string> = 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<string> = 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<CustomSortSpec> = {
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<CustomSortSpec> = {
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<CustomSortSpec> = {
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<CustomSortSpec> = {
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<string> = 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<string> = 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<string> = 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<string> = 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<string> = 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<string> = 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<string> = 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<string> = 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<string> = 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<string> = 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<string> = 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<string> = 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<string> = 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<string> = 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<string> = 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<string> = 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<string> = 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<string> = 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)
})
})