#50 - regexp and by-name matching support for target-folder

- complete implementation
- full unit tests coverage
- NO update to documentation (yet to be done)
This commit is contained in:
SebastianMC 2023-02-06 23:38:27 +01:00
parent 8e397797fc
commit 8512f1b4cb
6 changed files with 629 additions and 71 deletions

View File

@ -64,6 +64,7 @@ export interface CustomSortGroup {
} }
export interface CustomSortSpec { export interface CustomSortSpec {
// plays only informative role about the original parsed 'target-folder:' values
targetFoldersPaths: Array<string> // For root use '/' targetFoldersPaths: Array<string> // For root use '/'
defaultOrder?: CustomSortOrder defaultOrder?: CustomSortOrder
byMetadataField?: string // for 'by-metadata:' if the defaultOrder is by metadata alphabetical or reverse byMetadataField?: string // for 'by-metadata:' if the defaultOrder is by metadata alphabetical or reverse

View File

@ -14,6 +14,43 @@ const createMockMatcherRichVersion = (): FolderWildcardMatching<SortingSpec> =>
return matcher return matcher
} }
const PRIO1 = 1
const PRIO2 = 2
const PRIO3 = 3
const createMockMatcherRichWithRegexpVersion = (usePriorities?: boolean): FolderWildcardMatching<SortingSpec> => {
const matcher: FolderWildcardMatching<SortingSpec> = createMockMatcherRichVersion()
let p: RegExp
const {row3, row4, row5, row6} = usePriorities ?
{row3: PRIO1, row4: PRIO1, row5: PRIO2, row6: PRIO3}
:
{row3:undefined, row4:undefined, row5:undefined, row6:undefined}
p = /.../; matcher.addRegexpDefinition(p, true, undefined, false, `r1`)
p = /^\/$/; matcher.addRegexpDefinition(p, false, undefined, false, `r2`)
p = /^Arc..ve$/; matcher.addRegexpDefinition(p, true, row3, false, `r3`)
p = /^Arc..ve$/; matcher.addRegexpDefinition(p, false, row4, false, `r4`)
p = /Reviews\/daily\/a\/.../; matcher.addRegexpDefinition(p, true, row5, false, `r5`)
p = /Reviews\/daily\/a\/*/; matcher.addRegexpDefinition(p, true, row6, false, `r6`)
return matcher
}
/*
tests needed:
regexp-match by name works (ensure regexp input is the name)
regexp-match by path works (ensure regexp input is the path)
regexp-match by name works and has priority over wildcard
regexp-match by path works and has priority over wildcard
regexp-match by name vs by path is equal, order of definition matters (test two variants)
- priority /!!!: is higher over /!!:
- priority /!!: is higher over /!:
- priority /!: is higher over no-priority
- test adding priorities in all possible orders
- within the same priority the order of definition matters
- edge case -> root folder, has no name, has specific path '/', matching by name should not work, only by path
- what is the root folder name ???
*/
const createMockMatcherSimplestVersion = (): FolderWildcardMatching<SortingSpec> => { const createMockMatcherSimplestVersion = (): FolderWildcardMatching<SortingSpec> => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching() const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
matcher.addWildcardDefinition('/Reviews/daily/*', '/Reviews/daily/*') matcher.addWildcardDefinition('/Reviews/daily/*', '/Reviews/daily/*')
@ -117,4 +154,137 @@ describe('folderMatch', () => {
expect(result).toEqual({errorMsg: "Duplicate wildcard '*' specification for Archive/2019/*"}) expect(result).toEqual({errorMsg: "Duplicate wildcard '*' specification for Archive/2019/*"})
}) })
it('regexp-match by name works (order of regexp doesn\'t matter) case A', () => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
matcher.addRegexpDefinition(/^daily$/, false, undefined, false, `r1`)
matcher.addRegexpDefinition(/^daily$/, true, undefined, false, `r2`)
matcher.addWildcardDefinition('/Reviews/*', `w1`)
// Path with leading /
const match1: SortingSpec | null = matcher.folderMatch('/Reviews/daily', 'daily')
// Path w/o leading / - this is how Obsidian supplies the path
const match2: SortingSpec | null = matcher.folderMatch('Reviews/daily', 'daily')
expect(match1).toBe('r2')
expect(match2).toBe('r2')
})
it('regexp-match by name works (order of regexp doesn\'t matter) reversed case A', () => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
matcher.addRegexpDefinition(/^daily$/, true, undefined, false, `r2`)
matcher.addRegexpDefinition(/^daily$/, false, undefined, false, `r1`)
matcher.addWildcardDefinition('/Reviews/*', `w1`)
// Path with leading /
const match1: SortingSpec | null = matcher.folderMatch('/Reviews/daily', 'daily')
// Path w/o leading / - this is how Obsidian supplies the path
const match2: SortingSpec | null = matcher.folderMatch('Reviews/daily', 'daily')
expect(match1).toBe('r2')
expect(match2).toBe('r2')
})
it('regexp-match by path works (order of regexp doesn\'t matter) case A', () => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
matcher.addRegexpDefinition(/^Reviews\/daily$/, false, undefined, false, `r1`)
matcher.addRegexpDefinition(/^Reviews\/daily$/, true, undefined, false, `r2`)
matcher.addWildcardDefinition('/Reviews/*', `w1`)
// Path with leading /
const match1: SortingSpec | null = matcher.folderMatch('/Reviews/daily', 'daily')
// Path w/o leading / - this is how Obsidian supplies the path
const match2: SortingSpec | null = matcher.folderMatch('Reviews/daily', 'daily')
expect(match1).toBe('w1') // The path-based regexp doesn't match the leading /
expect(match2).toBe('r1')
})
it('regexp-match by path works (order of regexp doesn\'t matter) reversed case A', () => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
matcher.addRegexpDefinition(/^Reviews\/daily$/, true, undefined, false, `r2`)
matcher.addRegexpDefinition(/^Reviews\/daily$/, false, undefined, false, `r1`)
matcher.addWildcardDefinition('/Reviews/*', `w1`)
// Path with leading /
const match1: SortingSpec | null = matcher.folderMatch('/Reviews/daily', 'daily')
// Path w/o leading / - this is how Obsidian supplies the path
const match2: SortingSpec | null = matcher.folderMatch('Reviews/daily', 'daily')
expect(match1).toBe('w1') // The path-based regexp doesn't match the leading /
expect(match2).toBe('r1')
})
it('regexp-match by path and name for root level - order of regexp decides - case A', () => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
matcher.addRegexpDefinition(/^daily$/, false, undefined, false, `r1`)
matcher.addRegexpDefinition(/^daily$/, true, undefined, false, `r2`)
matcher.addWildcardDefinition('/Reviews/*', `w1`)
// Path w/o leading / - this is how Obsidian supplies the path
const match: SortingSpec | null = matcher.folderMatch('daily', 'daily')
expect(match).toBe('r2')
})
it('regexp-match by path and name for root level - order of regexp decides - reversed case A', () => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
matcher.addRegexpDefinition(/^daily$/, true, undefined, false, `r2`)
matcher.addRegexpDefinition(/^daily$/, false, undefined, false, `r1`)
matcher.addWildcardDefinition('/Reviews/*', `w1`)
// Path w/o leading / - this is how Obsidian supplies the path
const match: SortingSpec | null = matcher.folderMatch('daily', 'daily')
expect(match).toBe('r1')
})
it('regexp-match priorities - order of definitions irrelevant - unique priorities - case A', () => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
matcher.addRegexpDefinition(/^freq\/daily$/, false, 3, false, `r1p3`)
matcher.addRegexpDefinition(/^freq\/daily$/, false, 2, false, `r2p2`)
matcher.addRegexpDefinition(/^freq\/daily$/, false, 1, false, `r3p1`)
matcher.addRegexpDefinition(/^freq\/daily$/, false, undefined, false, `r4pNone`)
// Path w/o leading / - this is how Obsidian supplies the path
const match: SortingSpec | null = matcher.folderMatch('freq/daily', 'daily')
expect(match).toBe('r1p3')
})
it('regexp-match priorities - order of definitions irrelevant - unique priorities - reversed case A', () => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
matcher.addRegexpDefinition(/^freq\/daily$/, false, undefined, false, `r4pNone`)
matcher.addRegexpDefinition(/^freq\/daily$/, false, 1, false, `r3p1`)
matcher.addRegexpDefinition(/^freq\/daily$/, false, 2, false, `r2p2`)
matcher.addRegexpDefinition(/^freq\/daily$/, false, 3, false, `r1p3`)
// Path w/o leading / - this is how Obsidian supplies the path
const match: SortingSpec | null = matcher.folderMatch('freq/daily', 'daily')
expect(match).toBe('r1p3')
})
it('regexp-match priorities - order of definitions irrelevant - duplicate priorities - case A', () => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
matcher.addRegexpDefinition(/^daily$/, true, 3, false, `r1p3a`)
matcher.addRegexpDefinition(/^daily$/, true, 3, false, `r1p3b`)
matcher.addRegexpDefinition(/^daily$/, true, 2, false, `r2p2a`)
matcher.addRegexpDefinition(/^daily$/, true, 2, false, `r2p2b`)
matcher.addRegexpDefinition(/^daily$/, true, 1, false, `r3p1a`)
matcher.addRegexpDefinition(/^daily$/, true, 1, false, `r3p1b`)
matcher.addRegexpDefinition(/^daily$/, true, undefined, false, `r4pNone`)
// Path w/o leading / - this is how Obsidian supplies the path
const match: SortingSpec | null = matcher.folderMatch('daily', 'daily')
expect(match).toBe('r1p3b')
})
it('regexp-match priorities - order of definitions irrelevant - unique priorities - reversed case A', () => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
matcher.addRegexpDefinition(/^freq\/daily$/, false, undefined, false, `r4pNone`)
matcher.addRegexpDefinition(/^freq\/daily$/, false, 1, false, `r3p1`)
matcher.addRegexpDefinition(/^freq\/daily$/, false, 2, false, `r2p2`)
matcher.addRegexpDefinition(/^freq\/daily$/, false, 3, false, `r1p3`)
// Path w/o leading / - this is how Obsidian supplies the path
const match: SortingSpec | null = matcher.folderMatch('freq/daily', 'daily')
expect(match).toBe('r1p3')
})
it('regexp-match - edge case of matching the root folder - match by path', () => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
matcher.addRegexpDefinition(/^\/$/, false, undefined, false, `r1`)
// Path w/o leading / - this is how Obsidian supplies the path
const match: SortingSpec | null = matcher.folderMatch('/', '')
expect(match).toBe('r1')
})
it('regexp-match - edge case of matching the root folder - match by name not possible', () => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
// Tricky regexp which can return zero length matches
matcher.addRegexpDefinition(/.*/, true, undefined, false, `r1`)
matcher.addWildcardDefinition('/*', `w1`)
// Path w/o leading / - this is how Obsidian supplies the path
const match: SortingSpec | null = matcher.folderMatch('/', '')
expect(match).toBe('w1')
})
it('regexp-match - edge case of no match when only regexp rules present', () => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
// Tricky regexp which can return zero length matches
matcher.addRegexpDefinition(/abc/, true, undefined, false, `r1`)
// Path w/o leading / - this is how Obsidian supplies the path
const match: SortingSpec | null = matcher.folderMatch('/', '')
expect(match).toBeNull()
})
}) })

View File

@ -1,8 +1,4 @@
export interface FolderPattern { import * as regexpp from "regexpp";
path: string
deep: boolean
nestingLevel: number
}
export type DeterminedSortingSpec<SortingSpec> = { export type DeterminedSortingSpec<SortingSpec> = {
spec?: SortingSpec spec?: SortingSpec
@ -16,6 +12,14 @@ export interface FolderMatchingTreeNode<SortingSpec> {
subtree: { [key: string]: FolderMatchingTreeNode<SortingSpec> } subtree: { [key: string]: FolderMatchingTreeNode<SortingSpec> }
} }
export interface FolderMatchingRegexp<SortingSpec> {
regexp: RegExp
againstName: boolean
priority: number
logMatches: boolean
sortingSpec: SortingSpec
}
const SLASH: string = '/' const SLASH: string = '/'
export const MATCH_CHILDREN_PATH_TOKEN: string = '...' export const MATCH_CHILDREN_PATH_TOKEN: string = '...'
export const MATCH_ALL_PATH_TOKEN: string = '*' export const MATCH_ALL_PATH_TOKEN: string = '*'
@ -23,6 +27,7 @@ export const MATCH_CHILDREN_1_SUFFIX: string = `/${MATCH_CHILDREN_PATH_TOKEN}`
export const MATCH_CHILDREN_2_SUFFIX: string = `/${MATCH_CHILDREN_PATH_TOKEN}/` export const MATCH_CHILDREN_2_SUFFIX: string = `/${MATCH_CHILDREN_PATH_TOKEN}/`
export const MATCH_ALL_SUFFIX: string = `/${MATCH_ALL_PATH_TOKEN}` export const MATCH_ALL_SUFFIX: string = `/${MATCH_ALL_PATH_TOKEN}`
export const NO_PRIORITY = 0
export const splitPath = (path: string): Array<string> => { export const splitPath = (path: string): Array<string> => {
return path.split(SLASH).filter((name) => !!name) return path.split(SLASH).filter((name) => !!name)
@ -34,10 +39,13 @@ export interface AddingWildcardFailure {
export class FolderWildcardMatching<SortingSpec> { export class FolderWildcardMatching<SortingSpec> {
// mimics the structure of folders, so for example tree.matchAll contains the matchAll flag for the root '/'
tree: FolderMatchingTreeNode<SortingSpec> = { tree: FolderMatchingTreeNode<SortingSpec> = {
subtree: {} subtree: {}
} }
regexps: Array<FolderMatchingRegexp<SortingSpec>>
// cache // cache
determinedWildcardRules: { [key: string]: DeterminedSortingSpec<SortingSpec> } = {} determinedWildcardRules: { [key: string]: DeterminedSortingSpec<SortingSpec> } = {}
@ -76,33 +84,82 @@ export class FolderWildcardMatching<SortingSpec> {
} }
} }
folderMatch = (folderPath: string): SortingSpec | null => { addRegexpDefinition = (regexp: RegExp,
againstName: boolean,
priority: number | undefined,
log: boolean | undefined,
rule: SortingSpec
) => {
const newItem: FolderMatchingRegexp<SortingSpec> = {
regexp: regexp,
againstName: againstName,
priority: priority || NO_PRIORITY,
sortingSpec: rule,
logMatches: !!log
}
if (this.regexps === undefined || this.regexps.length === 0) {
this.regexps = [newItem]
} else {
// priority is present ==> consciously determine where to insert the regexp
let idx = 0
while (idx < this.regexps.length && this.regexps[idx].priority > newItem.priority) {
idx++
}
this.regexps.splice(idx, 0, newItem)
}
}
folderMatch = (folderPath: string, folderName?: string): SortingSpec | null => {
const spec: DeterminedSortingSpec<SortingSpec> = this.determinedWildcardRules[folderPath] const spec: DeterminedSortingSpec<SortingSpec> = this.determinedWildcardRules[folderPath]
if (spec) { if (spec) {
return spec.spec ?? null return spec.spec ?? null
} else { } else {
let rule: SortingSpec | null | undefined = this.tree.matchChildren let rule: SortingSpec | null | undefined
let inheritedRule: SortingSpec | undefined = this.tree.matchAll // regexp matching
const pathComponents: Array<string> = splitPath(folderPath) if (this.regexps) {
let parentNode: FolderMatchingTreeNode<SortingSpec> = this.tree for (let r of this.regexps) {
let lastIdx: number = pathComponents.length - 1 if (r.againstName && !folderName) {
for(let i=0; i<=lastIdx; i++) { // exclude the edge case:
const name: string = pathComponents[i] // - root folder which has empty name (and path /)
let matchedPath: FolderMatchingTreeNode<SortingSpec> = parentNode.subtree[name] // AND name-matching regexp allows zero-length matches
if (matchedPath) { continue
parentNode = matchedPath }
rule = matchedPath?.matchChildren ?? null if (r.regexp.test(r.againstName ? (folderName || '') : folderPath)) {
inheritedRule = matchedPath.matchAll ?? inheritedRule rule = r.sortingSpec
} else { if (r.logMatches) {
if (i < lastIdx) { const msgDetails: string = (r.againstName) ? `name: ${folderName}` : `path: ${folderPath}`
rule = inheritedRule console.log(`custom-sort plugin - regexp <${r.regexp.source}> matched folder ${msgDetails}`)
}
break
} }
break
} }
} }
rule = rule ?? inheritedRule // simple wildards matching
if (!rule) {
rule = this.tree.matchChildren
let inheritedRule: SortingSpec | undefined = this.tree.matchAll
const pathComponents: Array<string> = splitPath(folderPath)
let parentNode: FolderMatchingTreeNode<SortingSpec> = this.tree
let lastIdx: number = pathComponents.length - 1
for (let i = 0; i <= lastIdx; i++) {
const name: string = pathComponents[i]
let matchedPath: FolderMatchingTreeNode<SortingSpec> = parentNode.subtree[name]
if (matchedPath) {
parentNode = matchedPath
rule = matchedPath?.matchChildren ?? null
inheritedRule = matchedPath.matchAll ?? inheritedRule
} else {
if (i < lastIdx) {
rule = inheritedRule
}
break
}
}
rule = rule ?? inheritedRule
}
if (rule) { if (rule) {
this.determinedWildcardRules[folderPath] = {spec: rule} this.determinedWildcardRules[folderPath] = {spec: rule}

View File

@ -780,6 +780,170 @@ describe('SortingSpecProcessor edge case', () => {
}) })
}) })
const txtInputTargetFolderByName: string = `
target-folder: name: TheName
< a-z
`
const txtInputTargetFolderWithRegex: string = `
> advanced modified
target-folder: name: TheName
< a-z
target-folder: regexp: r1
target-folder: regexp: /!!: r2*
target-folder: regexp: for-name: r3.{2-3}$
target-folder: regexp: for-name: /!: r4\\d
target-folder: regexp: for-name: /!!: ^r5[^[]+
target-folder: regexp: for-name: /!!!: ^r6/+$
target-folder: regexp: debug: r7 +
target-folder: regexp: for-name: debug: r8 (aa|bb|cc)
target-folder: regexp: for-name: /!!!: debug: r9 [abc]+
target-folder: regexp: /!: debug: ^r10 /[^/]/.+$
`
const expectedSortSpecTargetFolderRegexAndName1 = {
defaultOrder: CustomSortOrder.byModifiedTimeReverseAdvanced,
groups: [{
order: CustomSortOrder.byModifiedTimeReverseAdvanced,
type: CustomSortGroupType.Outsiders
}],
outsidersGroupIdx: 0,
targetFoldersPaths: ['mock-folder']
}
const expectedSortSpecTargetFolderByName = {
defaultOrder: CustomSortOrder.alphabetical,
groups: [{
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}],
outsidersGroupIdx: 0,
targetFoldersPaths: ['name: TheName']
}
const expectedSortSpecsTargetFolderByPathInRegexTestCase: { [key: string]: CustomSortSpec } = {
'mock-folder': expectedSortSpecTargetFolderRegexAndName1
}
const expectedSortSpecsTargetFolderByName: { [key: string]: CustomSortSpec } = {
'TheName': expectedSortSpecTargetFolderByName
}
const expectedSortSpecForRegexpTextCase = {
groups: [{
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}],
outsidersGroupIdx: 0,
targetFoldersPaths: [
"regexp: r1",
"regexp: /!!: r2*",
"regexp: for-name: r3.{2-3}$",
"regexp: for-name: /!: r4\\d",
"regexp: for-name: /!!: ^r5[^[]+",
"regexp: for-name: /!!!: ^r6/+$",
"regexp: debug: r7 +",
"regexp: for-name: debug: r8 (aa|bb|cc)",
"regexp: for-name: /!!!: debug: r9 [abc]+",
"regexp: /!: debug: ^r10 /[^/]/.+$"
]
}
const expectedTargetFolderRegexpArr = [
{
regexp: /r9 [abc]+/,
againstName: true,
priority: 3,
logMatches: true,
sortingSpec: expectedSortSpecForRegexpTextCase
},
{
regexp: /^r6\/+$/,
againstName: true,
priority: 3,
logMatches: false,
sortingSpec: expectedSortSpecForRegexpTextCase
},
{
regexp: /^r5[^[]+/,
againstName: true,
priority: 2,
logMatches: false,
sortingSpec: expectedSortSpecForRegexpTextCase
},
{
regexp: /r2*/,
againstName: false,
priority: 2,
logMatches: false,
sortingSpec: expectedSortSpecForRegexpTextCase
},
{
regexp: /^r10 \/[^/]\/.+$/,
againstName: false,
priority: 1,
logMatches: true,
sortingSpec: expectedSortSpecForRegexpTextCase
},
{
regexp: /r4\d/,
againstName: true,
priority: 1,
logMatches: false,
sortingSpec: expectedSortSpecForRegexpTextCase
},
{
regexp: /r8 (aa|bb|cc)/,
againstName: true,
priority: 0,
logMatches: true,
sortingSpec: expectedSortSpecForRegexpTextCase
},
{
regexp: /r7 +/,
againstName: false,
priority: 0,
logMatches: true,
sortingSpec: expectedSortSpecForRegexpTextCase
},
{
regexp: /r3.{2-3}$/,
againstName: true,
priority: 0,
logMatches: false,
sortingSpec: expectedSortSpecForRegexpTextCase
},
{
regexp: /r1/,
againstName: false,
priority: 0,
logMatches: false,
sortingSpec: expectedSortSpecForRegexpTextCase
}
]
describe('SortingSpecProcessor target-folder by name and regex', () => {
let processor: SortingSpecProcessor;
beforeEach(() => {
processor = new SortingSpecProcessor();
});
it('should correctly handle the by-name only target-folder', () => {
const inputTxtArr: Array<string> = txtInputTargetFolderByName.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result?.sortSpecByPath).toEqual({})
expect(result?.sortSpecByName).toEqual(expectedSortSpecsTargetFolderByName)
expect(result?.sortSpecByWildcard).not.toBeNull()
})
it('should recognize and correctly parse target folder by name with and w/o regexp variants', () => {
const inputTxtArr: Array<string> = txtInputTargetFolderWithRegex.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result?.sortSpecByPath).toEqual(expectedSortSpecsTargetFolderByPathInRegexTestCase)
expect(result?.sortSpecByName).toEqual(expectedSortSpecsTargetFolderByName)
expect(result?.sortSpecByWildcard?.tree).toEqual({subtree: {}})
expect(result?.sortSpecByWildcard?.regexps).toEqual(expectedTargetFolderRegexpArr)
})
})
const txtInputPriorityGroups1: string = ` const txtInputPriorityGroups1: string = `
target-folder: / target-folder: /
/:files /:files
@ -1694,6 +1858,48 @@ describe('SortingSpecProcessor error detection and reporting', () => {
expect(result).not.toBeNull() expect(result).not.toBeNull()
expect(errorsLogger).not.toHaveBeenCalled() expect(errorsLogger).not.toHaveBeenCalled()
}) })
it('should recognize empty regexp of target-folder:', () => {
const inputTxtArr: Array<string> = `
target-folder: regexp:
`.replace(/\t/gi, '').split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toBeNull()
expect(errorsLogger).toHaveBeenCalledTimes(1)
expect(errorsLogger).toHaveBeenNthCalledWith(1,
`${ERR_PREFIX} 27:InvalidOrEmptyFolderMatchingRegexp Invalid or empty folder regexp expression <> ${ERR_SUFFIX}`)
})
it('should recognize error in regexp of target-folder:', () => {
const inputTxtArr: Array<string> = `
target-folder: regexp: bla (
`.replace(/\t/gi, '').split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toBeNull()
expect(errorsLogger).toHaveBeenCalledTimes(1)
expect(errorsLogger).toHaveBeenNthCalledWith(1,
`${ERR_PREFIX} 27:InvalidOrEmptyFolderMatchingRegexp Invalid or empty folder regexp expression <bla (> ${ERR_SUFFIX}`)
})
it('should recognize empty name in target-folder: name:', () => {
const inputTxtArr: Array<string> = `
target-folder: name:
`.replace(/\t/gi, '').split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toBeNull()
expect(errorsLogger).toHaveBeenCalledTimes(1)
expect(errorsLogger).toHaveBeenNthCalledWith(1,
`${ERR_PREFIX} 26:EmptyFolderNameToMatch Empty 'target-folder: name:' value ${ERR_SUFFIX}`)
})
it('should recognize duplicate name in target-folder: name:', () => {
const inputTxtArr: Array<string> = `
target-folder: name: 123
target-folder: name: xyz
target-folder: name: 123
`.replace(/\t/gi, '').split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toBeNull()
expect(errorsLogger).toHaveBeenCalledTimes(1)
expect(errorsLogger).toHaveBeenNthCalledWith(1,
`${ERR_PREFIX} 25:DuplicateByNameSortSpecForFolder Duplicate 'target-folder: name:' definition for the same name <123> ${ERR_SUFFIX}`)
})
}) })
const txtInputTargetFolderCCC: string = ` const txtInputTargetFolderCCC: string = `

View File

@ -25,7 +25,8 @@ import {
FolderWildcardMatching, FolderWildcardMatching,
MATCH_ALL_SUFFIX, MATCH_ALL_SUFFIX,
MATCH_CHILDREN_1_SUFFIX, MATCH_CHILDREN_1_SUFFIX,
MATCH_CHILDREN_2_SUFFIX MATCH_CHILDREN_2_SUFFIX,
NO_PRIORITY
} from "./folder-matching-rules" } from "./folder-matching-rules"
interface ProcessingContext { interface ProcessingContext {
@ -75,13 +76,19 @@ export enum ProblemCode {
TooManyGroupTypePrefixes, TooManyGroupTypePrefixes,
PriorityPrefixAfterGroupTypePrefix, PriorityPrefixAfterGroupTypePrefix,
CombinePrefixAfterGroupTypePrefix, CombinePrefixAfterGroupTypePrefix,
InlineRegexInPrefixAndSuffix InlineRegexInPrefixAndSuffix,
DuplicateByNameSortSpecForFolder,
EmptyFolderNameToMatch,
InvalidOrEmptyFolderMatchingRegexp
} }
const ContextFreeProblems = new Set<ProblemCode>([ const ContextFreeProblems = new Set<ProblemCode>([
ProblemCode.DuplicateSortSpecForSameFolder, ProblemCode.DuplicateSortSpecForSameFolder,
ProblemCode.DuplicateWildcardSortSpecForSameFolder, ProblemCode.DuplicateWildcardSortSpecForSameFolder,
ProblemCode.OnlyLastCombinedGroupCanSpecifyOrder ProblemCode.OnlyLastCombinedGroupCanSpecifyOrder,
ProblemCode.DuplicateByNameSortSpecForFolder,
ProblemCode.EmptyFolderNameToMatch,
ProblemCode.InvalidOrEmptyFolderMatchingRegexp
]) ])
const ThreeDots = '...'; const ThreeDots = '...';
@ -157,9 +164,11 @@ enum Attribute {
OrderStandardObsidian OrderStandardObsidian
} }
const TargetFolderLexeme: string = 'target-folder:'
const AttrLexems: { [key: string]: Attribute } = { const AttrLexems: { [key: string]: Attribute } = {
// Verbose attr names // Verbose attr names
'target-folder:': Attribute.TargetFolder, [TargetFolderLexeme]: Attribute.TargetFolder,
'order-asc:': Attribute.OrderAsc, 'order-asc:': Attribute.OrderAsc,
'order-desc:': Attribute.OrderDesc, 'order-desc:': Attribute.OrderDesc,
'sorting:': Attribute.OrderStandardObsidian, 'sorting:': Attribute.OrderStandardObsidian,
@ -203,6 +212,10 @@ const PriorityModifierPrio1Lexeme: string = '/!'
const PriorityModifierPrio2Lexeme: string = '/!!' const PriorityModifierPrio2Lexeme: string = '/!!'
const PriorityModifierPrio3Lexeme: string = '/!!!' const PriorityModifierPrio3Lexeme: string = '/!!!'
const PriorityModifierPrio1TargetFolderLexeme: string = '/!:'
const PriorityModifierPrio2TargetFolderLexeme: string = '/!!:'
const PriorityModifierPrio3TargetFolderLexeme: string = '/!!!:'
const PRIO_1: number = 1 const PRIO_1: number = 1
const PRIO_2: number = 2 const PRIO_2: number = 2
const PRIO_3: number = 3 const PRIO_3: number = 3
@ -213,6 +226,12 @@ const SortingGroupPriorityPrefixes: { [key: string]: number } = {
[PriorityModifierPrio3Lexeme]: PRIO_3 [PriorityModifierPrio3Lexeme]: PRIO_3
} }
const TargetFolderRegexpPriorityPrefixes: { [key: string]: number } = {
[PriorityModifierPrio1TargetFolderLexeme]: PRIO_1,
[PriorityModifierPrio2TargetFolderLexeme]: PRIO_2,
[PriorityModifierPrio3TargetFolderLexeme]: PRIO_3
}
const CombineGroupLexeme: string = '/+' const CombineGroupLexeme: string = '/+'
const CombiningGroupPrefixes: Array<string> = [ const CombiningGroupPrefixes: Array<string> = [
@ -491,15 +510,35 @@ export const convertInlineRegexSymbolsAndEscapeTheRest = (s: string): RegexAsStr
return regexAsString.join('') return regexAsString.join('')
} }
export const MatchFolderNameLexeme: string = 'name:'
export const MatchFolderByRegexpLexeme: string = 'regexp:'
export const RegexpAgainstFolderName: string = 'for-name:'
export const DebugFolderRegexMatchesLexeme: string = 'debug:'
type FolderPath = string
type FolderName = string
export interface FolderPathToSortSpecMap { export interface FolderPathToSortSpecMap {
[key: string]: CustomSortSpec [key: FolderPath]: CustomSortSpec
}
export interface FolderNameToSortSpecMap {
[key: FolderName]: CustomSortSpec
} }
export interface SortSpecsCollection { export interface SortSpecsCollection {
sortSpecByPath: FolderPathToSortSpecMap sortSpecByPath: FolderPathToSortSpecMap
sortSpecByName: FolderNameToSortSpecMap
sortSpecByWildcard?: FolderWildcardMatching<CustomSortSpec> sortSpecByWildcard?: FolderWildcardMatching<CustomSortSpec>
} }
export const newSortSpecsCollection = (): SortSpecsCollection => {
return {
sortSpecByPath: {},
sortSpecByName: {}
}
}
interface AdjacencyInfo { interface AdjacencyInfo {
noPrefix: boolean, noPrefix: boolean,
noSuffix: boolean noSuffix: boolean
@ -524,32 +563,78 @@ enum WildcardPriority {
MATCH_ALL MATCH_ALL
} }
const stripWildcardPatternSuffix = (path: string): [path: string, priority: number] => { const stripWildcardPatternSuffix = (path: string): {path: string, detectedWildcardPriority: number} => {
if (path.endsWith(MATCH_ALL_SUFFIX)) { if (path.endsWith(MATCH_ALL_SUFFIX)) {
path = path.slice(0, -MATCH_ALL_SUFFIX.length) path = path.slice(0, -MATCH_ALL_SUFFIX.length)
return [ return {
path.length > 0 ? path : '/', path: path.length > 0 ? path : '/',
WildcardPriority.MATCH_ALL detectedWildcardPriority: WildcardPriority.MATCH_ALL
] }
} }
if (path.endsWith(MATCH_CHILDREN_1_SUFFIX)) { if (path.endsWith(MATCH_CHILDREN_1_SUFFIX)) {
path = path.slice(0, -MATCH_CHILDREN_1_SUFFIX.length) path = path.slice(0, -MATCH_CHILDREN_1_SUFFIX.length)
return [ return {
path.length > 0 ? path : '/', path: path.length > 0 ? path : '/',
WildcardPriority.MATCH_CHILDREN, detectedWildcardPriority: WildcardPriority.MATCH_CHILDREN,
] }
} }
if (path.endsWith(MATCH_CHILDREN_2_SUFFIX)) { if (path.endsWith(MATCH_CHILDREN_2_SUFFIX)) {
path = path.slice(0, -MATCH_CHILDREN_2_SUFFIX.length) path = path.slice(0, -MATCH_CHILDREN_2_SUFFIX.length)
return [ return {
path.length > 0 ? path : '/', path: path.length > 0 ? path : '/',
WildcardPriority.MATCH_CHILDREN detectedWildcardPriority: WildcardPriority.MATCH_CHILDREN
] }
}
return {
path: path,
detectedWildcardPriority: WildcardPriority.NO_WILDCARD
}
}
const eatPrefixIfPresent = (expression: string, prefix: string, onDetected: () => void): string => {
const detected: boolean = expression.startsWith(prefix)
if (detected) {
onDetected()
return expression.substring(prefix.length).trim()
} else {
return expression
}
}
const consumeFolderByRegexpExpression = (expression: string): {regexp: RegExp, againstName: boolean, priority: number | undefined, log: boolean | undefined} => {
let againstName: boolean = false
let priority: number | undefined
let logMatches: boolean | undefined
// For simplicity, strict imposed order of regexp-specific attributes
expression = eatPrefixIfPresent(expression, RegexpAgainstFolderName, () => {
againstName = true
})
for (const priorityPrefix of Object.keys(TargetFolderRegexpPriorityPrefixes)) {
expression = eatPrefixIfPresent(expression, priorityPrefix, () => {
priority = TargetFolderRegexpPriorityPrefixes[priorityPrefix]
})
if (priority) {
break
}
}
expression = eatPrefixIfPresent(expression, DebugFolderRegexMatchesLexeme, () => {
logMatches = true
})
// do not allow empty regexp
if (!expression || expression.trim() === '') {
throw new Error('Empty regexp')
}
return {
regexp: new RegExp(expression),
againstName: againstName,
priority: priority === undefined ? NO_PRIORITY : priority,
log: !!logMatches
} }
return [
path,
WildcardPriority.NO_WILDCARD
]
} }
// Simplistic // Simplistic
@ -641,12 +726,52 @@ export class SortingSpecProcessor {
} }
} }
let sortspecByWildcard: FolderWildcardMatching<CustomSortSpec> | undefined let sortspecByName: FolderNameToSortSpecMap | undefined
for (let spec of this.ctx.specs) { for (let spec of this.ctx.specs) {
// Consume the folder paths ending with wildcard specs // Consume the folder names prefixed by the designated lexeme
for (let idx = 0; idx<spec.targetFoldersPaths.length; idx++) { for (let idx = 0; idx<spec.targetFoldersPaths.length; idx++) {
const path = spec.targetFoldersPaths[idx] const path = spec.targetFoldersPaths[idx]
if (endsWithWildcardPatternSuffix(path)) { if (path.startsWith(MatchFolderNameLexeme)) {
const folderNameToMatch: string = path.substring(MatchFolderNameLexeme.length).trim()
if (folderNameToMatch === '') {
this.problem(ProblemCode.EmptyFolderNameToMatch,
`Empty '${TargetFolderLexeme} ${MatchFolderNameLexeme}' value` )
return null // Failure - not allow duplicate by folderNameToMatch specs for the same folder folderNameToMatch
}
sortspecByName = sortspecByName ?? {}
if (sortspecByName[folderNameToMatch]) {
this.problem(ProblemCode.DuplicateByNameSortSpecForFolder,
`Duplicate '${TargetFolderLexeme} ${MatchFolderNameLexeme}' definition for the same name <${folderNameToMatch}>` )
return null // Failure - not allow duplicate by folderNameToMatch specs for the same folder folderNameToMatch
} else {
sortspecByName[folderNameToMatch] = spec
}
}
}
}
if (sortspecByName) {
collection = collection ?? newSortSpecsCollection()
collection.sortSpecByName = sortspecByName
}
let sortspecByWildcard: FolderWildcardMatching<CustomSortSpec> | undefined
for (let spec of this.ctx.specs) {
// Consume the folder paths ending with wildcard specs or regexp-based
for (let idx = 0; idx<spec.targetFoldersPaths.length; idx++) {
const path = spec.targetFoldersPaths[idx]
if (path.startsWith(MatchFolderByRegexpLexeme)) {
sortspecByWildcard = sortspecByWildcard ?? new FolderWildcardMatching<CustomSortSpec>()
const folderByRegexpExpression: string = path.substring(MatchFolderByRegexpLexeme.length).trim()
try {
const {regexp, againstName, priority, log} = consumeFolderByRegexpExpression(folderByRegexpExpression)
sortspecByWildcard.addRegexpDefinition(regexp, againstName, priority, log, spec)
} catch (e) {
this.problem(ProblemCode.InvalidOrEmptyFolderMatchingRegexp,
`Invalid or empty folder regexp expression <${folderByRegexpExpression}>`)
return null
}
} else if (endsWithWildcardPatternSuffix(path)) {
sortspecByWildcard = sortspecByWildcard ?? new FolderWildcardMatching<CustomSortSpec>() sortspecByWildcard = sortspecByWildcard ?? new FolderWildcardMatching<CustomSortSpec>()
const ruleAdded = sortspecByWildcard.addWildcardDefinition(path, spec) const ruleAdded = sortspecByWildcard.addWildcardDefinition(path, spec)
if (ruleAdded?.errorMsg) { if (ruleAdded?.errorMsg) {
@ -658,31 +783,31 @@ export class SortingSpecProcessor {
} }
if (sortspecByWildcard) { if (sortspecByWildcard) {
collection = collection ?? { sortSpecByPath:{} } collection = collection ?? newSortSpecsCollection()
collection.sortSpecByWildcard = sortspecByWildcard collection.sortSpecByWildcard = sortspecByWildcard
} }
for (let spec of this.ctx.specs) { for (let spec of this.ctx.specs) {
for (let idx = 0; idx < spec.targetFoldersPaths.length; idx++) { for (let idx = 0; idx < spec.targetFoldersPaths.length; idx++) {
const originalPath = spec.targetFoldersPaths[idx] const originalPath = spec.targetFoldersPaths[idx]
collection = collection ?? { sortSpecByPath: {} } if (!originalPath.startsWith(MatchFolderNameLexeme) && !originalPath.startsWith(MatchFolderByRegexpLexeme)) {
let detectedWildcardPriority: WildcardPriority collection = collection ?? newSortSpecsCollection()
let path: string const {path, detectedWildcardPriority} = stripWildcardPatternSuffix(originalPath)
[path, detectedWildcardPriority] = stripWildcardPatternSuffix(originalPath) let storeTheSpec: boolean = true
let storeTheSpec: boolean = true const preexistingSortSpecPriority: WildcardPriority = this.pathMatchPriorityForPath[path]
const preexistingSortSpecPriority: WildcardPriority = this.pathMatchPriorityForPath[path] if (preexistingSortSpecPriority) {
if (preexistingSortSpecPriority) { if (preexistingSortSpecPriority === WildcardPriority.NO_WILDCARD && detectedWildcardPriority === WildcardPriority.NO_WILDCARD) {
if (preexistingSortSpecPriority === WildcardPriority.NO_WILDCARD && detectedWildcardPriority === WildcardPriority.NO_WILDCARD) { this.problem(ProblemCode.DuplicateSortSpecForSameFolder, `Duplicate sorting spec for folder ${path}`)
this.problem(ProblemCode.DuplicateSortSpecForSameFolder, `Duplicate sorting spec for folder ${path}`) return null // Failure - not allow duplicate specs for the same no-wildcard folder path
return null // Failure - not allow duplicate specs for the same no-wildcard folder path } else if (detectedWildcardPriority >= preexistingSortSpecPriority) {
} else if (detectedWildcardPriority >= preexistingSortSpecPriority) { // Ignore lower priority rule
// Ignore lower priority rule storeTheSpec = false
storeTheSpec = false }
}
if (storeTheSpec) {
collection.sortSpecByPath[path] = spec
this.pathMatchPriorityForPath[path] = detectedWildcardPriority
} }
}
if (storeTheSpec) {
collection.sortSpecByPath[path] = spec
this.pathMatchPriorityForPath[path] = detectedWildcardPriority
} }
} }
} }

View File

@ -32,15 +32,13 @@ interface CustomSortPluginSettings {
suspended: boolean suspended: boolean
statusBarEntryEnabled: boolean statusBarEntryEnabled: boolean
notificationsEnabled: boolean notificationsEnabled: boolean
allowRegexpInTargetFolder: boolean
} }
const DEFAULT_SETTINGS: CustomSortPluginSettings = { const DEFAULT_SETTINGS: CustomSortPluginSettings = {
additionalSortspecFile: '', additionalSortspecFile: '',
suspended: true, // if false by default, it would be hard to handle the auto-parse after plugin install suspended: true, // if false by default, it would be hard to handle the auto-parse after plugin install
statusBarEntryEnabled: true, statusBarEntryEnabled: true,
notificationsEnabled: true, notificationsEnabled: true
allowRegexpInTargetFolder: false
} }
const SORTSPEC_FILE_NAME: string = 'sortspec.md' const SORTSPEC_FILE_NAME: string = 'sortspec.md'
@ -323,15 +321,16 @@ export default class CustomSortPlugin extends Plugin {
// if custom sort is not specified, use the UI-selected // if custom sort is not specified, use the UI-selected
const folder: TFolder = this.file const folder: TFolder = this.file
let sortSpec: CustomSortSpec | null | undefined = plugin.sortSpecCache?.sortSpecByPath[folder.path] let sortSpec: CustomSortSpec | null | undefined = plugin.sortSpecCache?.sortSpecByPath[folder.path]
sortSpec = sortSpec ?? plugin.sortSpecCache?.sortSpecByName[folder.name]
if (sortSpec) { if (sortSpec) {
if (sortSpec.defaultOrder === CustomSortOrder.standardObsidian) { if (sortSpec.defaultOrder === CustomSortOrder.standardObsidian) {
sortSpec = null // A folder is explicitly excluded from custom sorting plugin sortSpec = null // A folder is explicitly excluded from custom sorting plugin
} }
} else if (plugin.sortSpecCache?.sortSpecByWildcard) { } else if (plugin.sortSpecCache?.sortSpecByWildcard) {
// when no sorting spec found directly by folder path, check for wildcard-based match // when no sorting spec found directly by folder path, check for wildcard-based match
sortSpec = plugin.sortSpecCache?.sortSpecByWildcard.folderMatch(folder.path) sortSpec = plugin.sortSpecCache?.sortSpecByWildcard.folderMatch(folder.path, folder.name)
if (sortSpec?.defaultOrder === CustomSortOrder.standardObsidian) { if (sortSpec?.defaultOrder === CustomSortOrder.standardObsidian) {
sortSpec = null // A folder subtree can be also explicitly excluded from custom sorting plugin sortSpec = null // A folder is explicitly excluded from custom sorting plugin
} }
} }
if (sortSpec) { if (sortSpec) {