From 8512f1b4cbe60e8e55142850d74493bd6581bb8f Mon Sep 17 00:00:00 2001 From: SebastianMC <23032356+SebastianMC@users.noreply.github.com> Date: Mon, 6 Feb 2023 23:38:27 +0100 Subject: [PATCH 1/6] #50 - regexp and by-name matching support for target-folder - complete implementation - full unit tests coverage - NO update to documentation (yet to be done) --- src/custom-sort/custom-sort-types.ts | 1 + src/custom-sort/folder-matching-rules.spec.ts | 170 ++++++++++++++ src/custom-sort/folder-matching-rules.ts | 103 +++++++-- .../sorting-spec-processor.spec.ts | 206 +++++++++++++++++ src/custom-sort/sorting-spec-processor.ts | 211 ++++++++++++++---- src/main.ts | 9 +- 6 files changed, 629 insertions(+), 71 deletions(-) diff --git a/src/custom-sort/custom-sort-types.ts b/src/custom-sort/custom-sort-types.ts index 7eadf36..f50f916 100644 --- a/src/custom-sort/custom-sort-types.ts +++ b/src/custom-sort/custom-sort-types.ts @@ -64,6 +64,7 @@ export interface CustomSortGroup { } export interface CustomSortSpec { + // plays only informative role about the original parsed 'target-folder:' values targetFoldersPaths: Array // For root use '/' defaultOrder?: CustomSortOrder byMetadataField?: string // for 'by-metadata:' if the defaultOrder is by metadata alphabetical or reverse diff --git a/src/custom-sort/folder-matching-rules.spec.ts b/src/custom-sort/folder-matching-rules.spec.ts index 909c969..ff9f849 100644 --- a/src/custom-sort/folder-matching-rules.spec.ts +++ b/src/custom-sort/folder-matching-rules.spec.ts @@ -14,6 +14,43 @@ const createMockMatcherRichVersion = (): FolderWildcardMatching => return matcher } +const PRIO1 = 1 +const PRIO2 = 2 +const PRIO3 = 3 + +const createMockMatcherRichWithRegexpVersion = (usePriorities?: boolean): FolderWildcardMatching => { + const matcher: FolderWildcardMatching = 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 => { const matcher: FolderWildcardMatching = new FolderWildcardMatching() matcher.addWildcardDefinition('/Reviews/daily/*', '/Reviews/daily/*') @@ -117,4 +154,137 @@ describe('folderMatch', () => { 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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() + }) }) diff --git a/src/custom-sort/folder-matching-rules.ts b/src/custom-sort/folder-matching-rules.ts index 62d3d75..750028e 100644 --- a/src/custom-sort/folder-matching-rules.ts +++ b/src/custom-sort/folder-matching-rules.ts @@ -1,8 +1,4 @@ -export interface FolderPattern { - path: string - deep: boolean - nestingLevel: number -} +import * as regexpp from "regexpp"; export type DeterminedSortingSpec = { spec?: SortingSpec @@ -16,6 +12,14 @@ export interface FolderMatchingTreeNode { subtree: { [key: string]: FolderMatchingTreeNode } } +export interface FolderMatchingRegexp { + regexp: RegExp + againstName: boolean + priority: number + logMatches: boolean + sortingSpec: SortingSpec +} + const SLASH: string = '/' export const MATCH_CHILDREN_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_ALL_SUFFIX: string = `/${MATCH_ALL_PATH_TOKEN}` +export const NO_PRIORITY = 0 export const splitPath = (path: string): Array => { return path.split(SLASH).filter((name) => !!name) @@ -34,10 +39,13 @@ export interface AddingWildcardFailure { export class FolderWildcardMatching { + // mimics the structure of folders, so for example tree.matchAll contains the matchAll flag for the root '/' tree: FolderMatchingTreeNode = { subtree: {} } + regexps: Array> + // cache determinedWildcardRules: { [key: string]: DeterminedSortingSpec } = {} @@ -76,33 +84,82 @@ export class FolderWildcardMatching { } } - folderMatch = (folderPath: string): SortingSpec | null => { + addRegexpDefinition = (regexp: RegExp, + againstName: boolean, + priority: number | undefined, + log: boolean | undefined, + rule: SortingSpec + ) => { + const newItem: FolderMatchingRegexp = { + 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 = this.determinedWildcardRules[folderPath] if (spec) { return spec.spec ?? null } else { - let rule: SortingSpec | null | undefined = this.tree.matchChildren - let inheritedRule: SortingSpec | undefined = this.tree.matchAll - const pathComponents: Array = splitPath(folderPath) - let parentNode: FolderMatchingTreeNode = this.tree - let lastIdx: number = pathComponents.length - 1 - for(let i=0; i<=lastIdx; i++) { - const name: string = pathComponents[i] - let matchedPath: FolderMatchingTreeNode = parentNode.subtree[name] - if (matchedPath) { - parentNode = matchedPath - rule = matchedPath?.matchChildren ?? null - inheritedRule = matchedPath.matchAll ?? inheritedRule - } else { - if (i < lastIdx) { - rule = inheritedRule + let rule: SortingSpec | null | undefined + // regexp matching + if (this.regexps) { + for (let r of this.regexps) { + if (r.againstName && !folderName) { + // exclude the edge case: + // - root folder which has empty name (and path /) + // AND name-matching regexp allows zero-length matches + continue + } + if (r.regexp.test(r.againstName ? (folderName || '') : folderPath)) { + rule = r.sortingSpec + if (r.logMatches) { + const msgDetails: string = (r.againstName) ? `name: ${folderName}` : `path: ${folderPath}` + 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 = splitPath(folderPath) + let parentNode: FolderMatchingTreeNode = this.tree + let lastIdx: number = pathComponents.length - 1 + for (let i = 0; i <= lastIdx; i++) { + const name: string = pathComponents[i] + let matchedPath: FolderMatchingTreeNode = 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) { this.determinedWildcardRules[folderPath] = {spec: rule} diff --git a/src/custom-sort/sorting-spec-processor.spec.ts b/src/custom-sort/sorting-spec-processor.spec.ts index 482acaa..be656bc 100644 --- a/src/custom-sort/sorting-spec-processor.spec.ts +++ b/src/custom-sort/sorting-spec-processor.spec.ts @@ -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 = 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 = 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 = ` target-folder: / /:files @@ -1694,6 +1858,48 @@ describe('SortingSpecProcessor error detection and reporting', () => { expect(result).not.toBeNull() expect(errorsLogger).not.toHaveBeenCalled() }) + it('should recognize empty regexp of target-folder:', () => { + const inputTxtArr: Array = ` + 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 = ` + 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 ${ERR_SUFFIX}`) + }) + it('should recognize empty name in target-folder: name:', () => { + const inputTxtArr: Array = ` + 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 = ` + 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 = ` diff --git a/src/custom-sort/sorting-spec-processor.ts b/src/custom-sort/sorting-spec-processor.ts index c695d72..591a397 100644 --- a/src/custom-sort/sorting-spec-processor.ts +++ b/src/custom-sort/sorting-spec-processor.ts @@ -25,7 +25,8 @@ import { FolderWildcardMatching, MATCH_ALL_SUFFIX, MATCH_CHILDREN_1_SUFFIX, - MATCH_CHILDREN_2_SUFFIX + MATCH_CHILDREN_2_SUFFIX, + NO_PRIORITY } from "./folder-matching-rules" interface ProcessingContext { @@ -75,13 +76,19 @@ export enum ProblemCode { TooManyGroupTypePrefixes, PriorityPrefixAfterGroupTypePrefix, CombinePrefixAfterGroupTypePrefix, - InlineRegexInPrefixAndSuffix + InlineRegexInPrefixAndSuffix, + DuplicateByNameSortSpecForFolder, + EmptyFolderNameToMatch, + InvalidOrEmptyFolderMatchingRegexp } const ContextFreeProblems = new Set([ ProblemCode.DuplicateSortSpecForSameFolder, ProblemCode.DuplicateWildcardSortSpecForSameFolder, - ProblemCode.OnlyLastCombinedGroupCanSpecifyOrder + ProblemCode.OnlyLastCombinedGroupCanSpecifyOrder, + ProblemCode.DuplicateByNameSortSpecForFolder, + ProblemCode.EmptyFolderNameToMatch, + ProblemCode.InvalidOrEmptyFolderMatchingRegexp ]) const ThreeDots = '...'; @@ -157,9 +164,11 @@ enum Attribute { OrderStandardObsidian } +const TargetFolderLexeme: string = 'target-folder:' + const AttrLexems: { [key: string]: Attribute } = { // Verbose attr names - 'target-folder:': Attribute.TargetFolder, + [TargetFolderLexeme]: Attribute.TargetFolder, 'order-asc:': Attribute.OrderAsc, 'order-desc:': Attribute.OrderDesc, 'sorting:': Attribute.OrderStandardObsidian, @@ -203,6 +212,10 @@ const PriorityModifierPrio1Lexeme: string = '/!' const PriorityModifierPrio2Lexeme: string = '/!!' const PriorityModifierPrio3Lexeme: string = '/!!!' +const PriorityModifierPrio1TargetFolderLexeme: string = '/!:' +const PriorityModifierPrio2TargetFolderLexeme: string = '/!!:' +const PriorityModifierPrio3TargetFolderLexeme: string = '/!!!:' + const PRIO_1: number = 1 const PRIO_2: number = 2 const PRIO_3: number = 3 @@ -213,6 +226,12 @@ const SortingGroupPriorityPrefixes: { [key: string]: number } = { [PriorityModifierPrio3Lexeme]: PRIO_3 } +const TargetFolderRegexpPriorityPrefixes: { [key: string]: number } = { + [PriorityModifierPrio1TargetFolderLexeme]: PRIO_1, + [PriorityModifierPrio2TargetFolderLexeme]: PRIO_2, + [PriorityModifierPrio3TargetFolderLexeme]: PRIO_3 +} + const CombineGroupLexeme: string = '/+' const CombiningGroupPrefixes: Array = [ @@ -491,15 +510,35 @@ export const convertInlineRegexSymbolsAndEscapeTheRest = (s: string): RegexAsStr 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 { - [key: string]: CustomSortSpec + [key: FolderPath]: CustomSortSpec +} + +export interface FolderNameToSortSpecMap { + [key: FolderName]: CustomSortSpec } export interface SortSpecsCollection { sortSpecByPath: FolderPathToSortSpecMap + sortSpecByName: FolderNameToSortSpecMap sortSpecByWildcard?: FolderWildcardMatching } +export const newSortSpecsCollection = (): SortSpecsCollection => { + return { + sortSpecByPath: {}, + sortSpecByName: {} + } +} + interface AdjacencyInfo { noPrefix: boolean, noSuffix: boolean @@ -524,32 +563,78 @@ enum WildcardPriority { MATCH_ALL } -const stripWildcardPatternSuffix = (path: string): [path: string, priority: number] => { +const stripWildcardPatternSuffix = (path: string): {path: string, detectedWildcardPriority: number} => { if (path.endsWith(MATCH_ALL_SUFFIX)) { path = path.slice(0, -MATCH_ALL_SUFFIX.length) - return [ - path.length > 0 ? path : '/', - WildcardPriority.MATCH_ALL - ] + return { + path: path.length > 0 ? path : '/', + detectedWildcardPriority: WildcardPriority.MATCH_ALL + } } if (path.endsWith(MATCH_CHILDREN_1_SUFFIX)) { path = path.slice(0, -MATCH_CHILDREN_1_SUFFIX.length) - return [ - path.length > 0 ? path : '/', - WildcardPriority.MATCH_CHILDREN, - ] + return { + path: path.length > 0 ? path : '/', + detectedWildcardPriority: WildcardPriority.MATCH_CHILDREN, + } } if (path.endsWith(MATCH_CHILDREN_2_SUFFIX)) { path = path.slice(0, -MATCH_CHILDREN_2_SUFFIX.length) - return [ - path.length > 0 ? path : '/', - WildcardPriority.MATCH_CHILDREN - ] + return { + path: path.length > 0 ? path : '/', + 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 @@ -641,12 +726,52 @@ export class SortingSpecProcessor { } } - let sortspecByWildcard: FolderWildcardMatching | undefined + let sortspecByName: FolderNameToSortSpecMap | undefined 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` ) + 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 | undefined + for (let spec of this.ctx.specs) { + // Consume the folder paths ending with wildcard specs or regexp-based + for (let idx = 0; idx() + 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() const ruleAdded = sortspecByWildcard.addWildcardDefinition(path, spec) if (ruleAdded?.errorMsg) { @@ -658,31 +783,31 @@ export class SortingSpecProcessor { } if (sortspecByWildcard) { - collection = collection ?? { sortSpecByPath:{} } + collection = collection ?? newSortSpecsCollection() collection.sortSpecByWildcard = sortspecByWildcard } for (let spec of this.ctx.specs) { for (let idx = 0; idx < spec.targetFoldersPaths.length; idx++) { const originalPath = spec.targetFoldersPaths[idx] - collection = collection ?? { sortSpecByPath: {} } - let detectedWildcardPriority: WildcardPriority - let path: string - [path, detectedWildcardPriority] = stripWildcardPatternSuffix(originalPath) - let storeTheSpec: boolean = true - const preexistingSortSpecPriority: WildcardPriority = this.pathMatchPriorityForPath[path] - if (preexistingSortSpecPriority) { - if (preexistingSortSpecPriority === WildcardPriority.NO_WILDCARD && detectedWildcardPriority === WildcardPriority.NO_WILDCARD) { - this.problem(ProblemCode.DuplicateSortSpecForSameFolder, `Duplicate sorting spec for folder ${path}`) - return null // Failure - not allow duplicate specs for the same no-wildcard folder path - } else if (detectedWildcardPriority >= preexistingSortSpecPriority) { - // Ignore lower priority rule - storeTheSpec = false + if (!originalPath.startsWith(MatchFolderNameLexeme) && !originalPath.startsWith(MatchFolderByRegexpLexeme)) { + collection = collection ?? newSortSpecsCollection() + const {path, detectedWildcardPriority} = stripWildcardPatternSuffix(originalPath) + let storeTheSpec: boolean = true + const preexistingSortSpecPriority: WildcardPriority = this.pathMatchPriorityForPath[path] + if (preexistingSortSpecPriority) { + if (preexistingSortSpecPriority === WildcardPriority.NO_WILDCARD && detectedWildcardPriority === WildcardPriority.NO_WILDCARD) { + this.problem(ProblemCode.DuplicateSortSpecForSameFolder, `Duplicate sorting spec for folder ${path}`) + return null // Failure - not allow duplicate specs for the same no-wildcard folder path + } else if (detectedWildcardPriority >= preexistingSortSpecPriority) { + // Ignore lower priority rule + storeTheSpec = false + } + } + if (storeTheSpec) { + collection.sortSpecByPath[path] = spec + this.pathMatchPriorityForPath[path] = detectedWildcardPriority } - } - if (storeTheSpec) { - collection.sortSpecByPath[path] = spec - this.pathMatchPriorityForPath[path] = detectedWildcardPriority } } } diff --git a/src/main.ts b/src/main.ts index 56def40..5600446 100644 --- a/src/main.ts +++ b/src/main.ts @@ -32,15 +32,13 @@ interface CustomSortPluginSettings { suspended: boolean statusBarEntryEnabled: boolean notificationsEnabled: boolean - allowRegexpInTargetFolder: boolean } const DEFAULT_SETTINGS: CustomSortPluginSettings = { additionalSortspecFile: '', suspended: true, // if false by default, it would be hard to handle the auto-parse after plugin install statusBarEntryEnabled: true, - notificationsEnabled: true, - allowRegexpInTargetFolder: false + notificationsEnabled: true } 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 const folder: TFolder = this.file let sortSpec: CustomSortSpec | null | undefined = plugin.sortSpecCache?.sortSpecByPath[folder.path] + sortSpec = sortSpec ?? plugin.sortSpecCache?.sortSpecByName[folder.name] if (sortSpec) { if (sortSpec.defaultOrder === CustomSortOrder.standardObsidian) { sortSpec = null // A folder is explicitly excluded from custom sorting plugin } } else if (plugin.sortSpecCache?.sortSpecByWildcard) { // 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) { - 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) { From ea018db5748e0036bbda79ebc95861830a3e48d8 Mon Sep 17 00:00:00 2001 From: SebastianMC <23032356+SebastianMC@users.noreply.github.com> Date: Mon, 6 Feb 2023 23:42:56 +0100 Subject: [PATCH 2/6] #50 - regexp and by-name matching support for target-folder - removed unused code --- src/custom-sort/folder-matching-rules.spec.ts | 33 ------------------- src/custom-sort/folder-matching-rules.ts | 2 -- 2 files changed, 35 deletions(-) diff --git a/src/custom-sort/folder-matching-rules.spec.ts b/src/custom-sort/folder-matching-rules.spec.ts index ff9f849..8cb8af9 100644 --- a/src/custom-sort/folder-matching-rules.spec.ts +++ b/src/custom-sort/folder-matching-rules.spec.ts @@ -18,39 +18,6 @@ const PRIO1 = 1 const PRIO2 = 2 const PRIO3 = 3 -const createMockMatcherRichWithRegexpVersion = (usePriorities?: boolean): FolderWildcardMatching => { - const matcher: FolderWildcardMatching = 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 => { const matcher: FolderWildcardMatching = new FolderWildcardMatching() matcher.addWildcardDefinition('/Reviews/daily/*', '/Reviews/daily/*') diff --git a/src/custom-sort/folder-matching-rules.ts b/src/custom-sort/folder-matching-rules.ts index 750028e..bd18721 100644 --- a/src/custom-sort/folder-matching-rules.ts +++ b/src/custom-sort/folder-matching-rules.ts @@ -1,5 +1,3 @@ -import * as regexpp from "regexpp"; - export type DeterminedSortingSpec = { spec?: SortingSpec } From afcb5056338a5a27f22318596d1a50e87f6b57b2 Mon Sep 17 00:00:00 2001 From: SebastianMC <23032356+SebastianMC@users.noreply.github.com> Date: Mon, 6 Feb 2023 23:59:14 +0100 Subject: [PATCH 3/6] #50 - regexp and by-name matching support for target-folder - code readability (implicit structure turned into explicit interface) --- src/custom-sort/sorting-spec-processor.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/custom-sort/sorting-spec-processor.ts b/src/custom-sort/sorting-spec-processor.ts index 591a397..00f1aa6 100644 --- a/src/custom-sort/sorting-spec-processor.ts +++ b/src/custom-sort/sorting-spec-processor.ts @@ -22,6 +22,7 @@ import { RomanNumberRegexStr } from "./matchers"; import { + FolderMatchingRegexp, FolderWildcardMatching, MATCH_ALL_SUFFIX, MATCH_CHILDREN_1_SUFFIX, @@ -601,7 +602,14 @@ const eatPrefixIfPresent = (expression: string, prefix: string, onDetected: () = } } -const consumeFolderByRegexpExpression = (expression: string): {regexp: RegExp, againstName: boolean, priority: number | undefined, log: boolean | undefined} => { +export interface ConsumedFolderMatchingRegexp { + regexp: RegExp + againstName: boolean + priority: number | undefined + log: boolean | undefined +} + +export const consumeFolderByRegexpExpression = (expression: string): ConsumedFolderMatchingRegexp => { let againstName: boolean = false let priority: number | undefined let logMatches: boolean | undefined @@ -764,8 +772,8 @@ export class SortingSpecProcessor { sortspecByWildcard = sortspecByWildcard ?? new FolderWildcardMatching() const folderByRegexpExpression: string = path.substring(MatchFolderByRegexpLexeme.length).trim() try { - const {regexp, againstName, priority, log} = consumeFolderByRegexpExpression(folderByRegexpExpression) - sortspecByWildcard.addRegexpDefinition(regexp, againstName, priority, log, spec) + const r: ConsumedFolderMatchingRegexp = consumeFolderByRegexpExpression(folderByRegexpExpression) + sortspecByWildcard.addRegexpDefinition(r.regexp, r.againstName, r.priority, r.log, spec) } catch (e) { this.problem(ProblemCode.InvalidOrEmptyFolderMatchingRegexp, `Invalid or empty folder regexp expression <${folderByRegexpExpression}>`) From 51733476e7800d459b8dedcdbe0606e772d1087c Mon Sep 17 00:00:00 2001 From: SebastianMC <23032356+SebastianMC@users.noreply.github.com> Date: Tue, 7 Feb 2023 14:11:50 +0100 Subject: [PATCH 4/6] #50 - regexp and by-name matching support for target-folder - more flexible syntax for target-folder modifiers: allow them in any order and also allow duplicates --- .../sorting-spec-processor.spec.ts | 64 ++++++++++++++++++- src/custom-sort/sorting-spec-processor.ts | 38 +++++++---- 2 files changed, 85 insertions(+), 17 deletions(-) diff --git a/src/custom-sort/sorting-spec-processor.spec.ts b/src/custom-sort/sorting-spec-processor.spec.ts index be656bc..de04efa 100644 --- a/src/custom-sort/sorting-spec-processor.spec.ts +++ b/src/custom-sort/sorting-spec-processor.spec.ts @@ -1,7 +1,7 @@ import { CompoundDashNumberNormalizerFn, CompoundDashRomanNumberNormalizerFn, - CompoundDotNumberNormalizerFn, + CompoundDotNumberNormalizerFn, ConsumedFolderMatchingRegexp, consumeFolderByRegexpExpression, convertPlainStringToRegex, detectNumericSortingSymbols, escapeRegexUnsafeCharacters, @@ -13,7 +13,7 @@ import { SortingSpecProcessor } from "./sorting-spec-processor" import {CustomSortGroupType, CustomSortOrder, CustomSortSpec} from "./custom-sort-types"; -import {FolderMatchingTreeNode} from "./folder-matching-rules"; +import {FolderMatchingRegexp, FolderMatchingTreeNode} from "./folder-matching-rules"; const txtInputExampleA: string = ` order-asc: a-z @@ -849,7 +849,7 @@ const expectedSortSpecForRegexpTextCase = { ] } -const expectedTargetFolderRegexpArr = [ +const expectedTargetFolderRegexpArr: Array> = [ { regexp: /r9 [abc]+/, againstName: true, @@ -944,6 +944,64 @@ describe('SortingSpecProcessor target-folder by name and regex', () => { }) }) +const NOPRIO = 0 +const PRIO1 = 1 +const PRIO2 = 2 +const PRIO3 = 3 + +const consumedTargetFolderRegexp: Array = [ + { + regexp: /r4\d/, + againstName: true, + priority: undefined, + log: true + }, { + regexp: /r4\d/, + againstName: true, + priority: PRIO1, + log: true + }, { + regexp: /r4\d/, + againstName: true, + priority: PRIO2, + log: true + }, { + regexp: /r4\d/, + againstName: true, + priority: PRIO3, + log: true + }, +] + +describe( 'consumeFolderByRegexpExpression', () => { + // and accept priority in any order + // the last one is in effect + // and accept multiple + it.each([ + // Plain cases + ['for-name: /!: debug: r4\\d', PRIO1], + ['for-name: /!: debug: r4\\d', PRIO1], + ['/!!: for-name: debug: r4\\d', PRIO2], + ['/!: debug: for-name: r4\\d', PRIO1], + ['debug: for-name: /!!!: r4\\d', PRIO3], + ['debug: /!: for-name: r4\\d', PRIO1], + // Cases with duplication of same + ['for-name: for-name: /!: debug: r4\\d', PRIO1], + ['for-name: /!: /!: debug: debug: r4\\d', PRIO1], + ['/!!: for-name: /!!: debug: r4\\d', PRIO2], + ['/!: debug: debug: for-name: r4\\d', PRIO1], + ['debug: for-name: /!!!:/!!!: r4\\d', PRIO3], + ['debug: /!: for-name: /!: r4\\d', PRIO1], + // Cases with duplication of different priority + ['debug: /!!!: for-name: /!: r4\\d', PRIO1], + ['debug: /!: for-name: /!!: r4\\d', PRIO2], + ['debug: /!: for-name: /!!: /!!!: /!: /!!!: r4\\d', PRIO3], + ])('should recognize all modifiers in >%s< of priority %s', (regexpExpr: string, prio: number) => { + const result: ConsumedFolderMatchingRegexp = consumeFolderByRegexpExpression(regexpExpr) + expect(result).toEqual(consumedTargetFolderRegexp[prio]) + }) +}) + const txtInputPriorityGroups1: string = ` target-folder: / /:files diff --git a/src/custom-sort/sorting-spec-processor.ts b/src/custom-sort/sorting-spec-processor.ts index 00f1aa6..3aeb28c 100644 --- a/src/custom-sort/sorting-spec-processor.ts +++ b/src/custom-sort/sorting-spec-processor.ts @@ -614,23 +614,33 @@ export const consumeFolderByRegexpExpression = (expression: string): ConsumedFol let priority: number | undefined let logMatches: boolean | undefined - // For simplicity, strict imposed order of regexp-specific attributes - expression = eatPrefixIfPresent(expression, RegexpAgainstFolderName, () => { - againstName = true - }) + let nextRoundNeeded: boolean - for (const priorityPrefix of Object.keys(TargetFolderRegexpPriorityPrefixes)) { - expression = eatPrefixIfPresent(expression, priorityPrefix, () => { - priority = TargetFolderRegexpPriorityPrefixes[priorityPrefix] + do { + nextRoundNeeded = false + + expression = eatPrefixIfPresent(expression, RegexpAgainstFolderName, () => { + againstName = true + nextRoundNeeded = true }) - if (priority) { - break - } - } - expression = eatPrefixIfPresent(expression, DebugFolderRegexMatchesLexeme, () => { - logMatches = true - }) + for (const priorityPrefix of Object.keys(TargetFolderRegexpPriorityPrefixes)) { + let doBreak: boolean = false + expression = eatPrefixIfPresent(expression, priorityPrefix, () => { + priority = TargetFolderRegexpPriorityPrefixes[priorityPrefix] + nextRoundNeeded = true + doBreak = true + }) + if (doBreak) { + break + } + } + + expression = eatPrefixIfPresent(expression, DebugFolderRegexMatchesLexeme, () => { + logMatches = true + nextRoundNeeded = true + }) + } while (nextRoundNeeded) // do not allow empty regexp if (!expression || expression.trim() === '') { From 4a27ef03d26075d22f7403ff6e86378c4f622f62 Mon Sep 17 00:00:00 2001 From: SebastianMC <23032356+SebastianMC@users.noreply.github.com> Date: Tue, 7 Feb 2023 17:13:32 +0100 Subject: [PATCH 5/6] #50 - regexp and by-name matching support for target-folder - documentation update --- docs/manual.md | 185 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 184 insertions(+), 1 deletion(-) diff --git a/docs/manual.md b/docs/manual.md index b6f1cb1..79e091b 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -70,7 +70,7 @@ For clarity: the three available prefixes `/!` and `/!!` and `/!!!` allow for fu ## Simple wildcards -Currently, the below simple wildcard syntax is supported: +Currently, the below simple wildcard syntax is supported for sorting group: ### A single digit (exactly one) @@ -248,3 +248,186 @@ sorting-spec: | /! starred: --- ``` + +## Options for target-folder: matching + +The `target-folder:` has the following variants, listed in the order of precedence: + +1. match by the **exact folder path** (the default) +2. match by the **exact folder name** +3. match by **regexp** (for experts, be careful!) +4. match by **wildcard suffix** (aka match folders subtree) + +If a folder in the vault matches more than one `target-folder:` definitions, +the above list shows the precedence, e.g. 1. has precedence over 2., 3. and 4. for example. +In other words, match by exact folder path always wins, then goes the match by folder exact name, +and so on. + +If a folder in the vault matches more than one `target-folder:` definitions of the same type, +see the detailed description below for the behavior + +### By folder path (the default) + +If no additional modifiers follow the `target-folder:`, the remaining part of the line +is treated as an exact folder path (leading and trailing spaces are ignored, +infix spaces are treated literally as part of the folder path) + +Within the same vault duplicate definitions of same path in `target-folder:` are detected +and error is raised in that case, indicating the duplicated path + +Examples of `target-folder:` with match by the exact folder path: + +- `target-folder: My Folder` + - this refers to the folder in the root of the vault and only to it +- `target-folder: Archive/My Folder` + - matches the `My Folder` sub-folder in the `Archive` folder (a sub-folder of the root) +- `target-folder: .` + - this refers to path of the folder where the sorting specification resides (the specification containing the line, + keep in mind that the sorting specification can reside in multiple locations in multiple notes) +- `target-folder: ./Some Subfolder` + - this refers to path of a sub-folder of the folder where the sorting specification resides (the specification containing the line, + keep in mind that the sorting specification can reside in multiple locations in multiple notes) + +### By folder name + +The modifier `name:` tells the `target-folder:` to match the folder name and not the full folder path + +This is an exact match of the full folder name, no partial matching + +Within the same vault duplicate definitions of same name in `target-folder: name:` are detected +and error is raised in that case, indicating the duplicated folder name in sorting specification + +Examples of `target-folder:` with match by the exact folder name: + +- `target-folder: name: My Folder` + - matches all the folders with the name `My Folder` regardless of their location within the vault + +### By regexp (expert feature) + +> WARNING!!! This is an EXPERT FEATURE. +> +> Involving and constructing the regexp-s requires at least basic knowledge about the potential pitfalls.\ +> If you introduce a heavy _regexp-backtracking_ it can **kill performance of Obsidian and even make it unresponsive**\ +> If you don't know what the _regexp-backtracking_ is, be careful when using regexp for `target-folder:` + +The modifier `regexp:` tells the `target-folder:` to involve the specified regular expressions in matching + +Additional dependent modifiers are supported for `regexp:`: +- `for-name:` + - tells the matching to be done against the folder name, not the full path +- `debug:` + - tells the regexp to report its match in the developer console, so that you can easily investigate + why the regexp matches (or why it doesn't match) as expected +- `/!:` `/!!:` `/!!!:` + - sets the priority of the regexp + +By default, the regexp is matched against the full path of the folder, unless the `for-name:` modifiers tells otherwise. + +By default, the regexp-es have no priority and are evaluated in the order of their definition.\ +If you store `sorting-spec:` configurations in notes spread all over the vault, +consider the order of `target-folder: regexp:` to be undefined and - if needed - use +explicit priority modifiers (`/!:` `/!!:` `/!!!:`) to impose the desired order of matching. + - a regexp with modifier `/!!!:` if evaluated before all other regexps, regardless of where they are configured + - if two or more regexps are stamped with `/!!!:`, they are matched in the order in which they were defined.\ + Within a single YAML section of a note the order is obvious.\ + For sorting specifications spread over many notes in the vault consider the order to be undefined. + - a regexp with modifier `/!!:` if evaluated after any `/!!!:` and before all other regexps + - the same logic as described above applies when multiple regexps have the `/!!:` stamp + - a regexp with modifier `/!:` indicates the lowest of explicitly defined priorities.\ + Such a regexp is matched after all priority-stamped regexps, before the regexps not having + any explicit priority stamp + +The escape character is \ - the standard one in regexp world. + +Examples of `target-folder:` with match by regexp: + +- `target-folder: regexp: reading` + - matches any folder which contains the word `reading` in its path or name +- `target-folder: regexp: \d?\d-\d?\d-\d\d\d\d$` + - matches any folder which ends with date-alike numerical expression, e.g.: + - `1-1-2023` + - `Archive/Monthly/12/05-12-2022` + - `Inbox/Not digested notes from 20-7-2019` +- `target-folder: regexp: for-name: I am everywhere` + - matches all folders which contain the phrase `I am everywhere` in their name, e.g.: + - `Reports/Not processed/What the I am everywhere report from Paul means?` + - `Chapters/I am everywhere` +- `target-folder: regexp: for-name: ^I am (everyw)?here$` + - matches all folders with name exactly `I am everywhere` or `I am here` +- `target-folder: regexp: for-name: debug: ^...$` + - matches all folders with name comprising exactly 3 character + - when a folder is matched, a diagnostic line is written to the console - `debug:` modifiers enables the logging +- `target-folder: regexp: debug: ^.{13,15}$` + - matches all folders with path length between 13 and 15 characters + - diagnostic line is written to the console due to `debug:` +- `target-folder: regexp: for-name: /!: ^[aA]` + - matches all folders with name starting with `a` or `A` + - the priority `/!:` modifier causes the matching to be done before all other regexps + which don't have any priority +- `target-folder: regexp: /!!!: debug: for-name: abc|def|ghi` + - matches all folders with name containing the sequence `abc` or `def` or `ghi` + - the modifier `/!!!:` imposes the highest priority of regexp matching + - `debug:` tells to report each matching folder in the console +- `target-folder: regexp: ^[^/]+/[^/]+$` + - matches all folders which are at the 2nd level of vault tree, e.g.: + - `Inbox/Priority input` + - `Archive/2021` +- `target-folder: regexp: ^[^\/]+(\/[^\/]+){2}$` + - matches all folders which are at the 3rd level of vault tree, e.g.: + - `Archive/2019/05` + - `Aaaa/Bbbb/Test test` + +### By wildcard + +In the default usage of `target-folder:` with the exact full folder path, if the path contains +the `/...` or `/*` suffix its meaning is extended to: +- match the folder and all its immediate (child) subfolders - `/...` suffix +- match the folder and all its subfolders at any level (all descendants, the entire subtree) - `/*` suffix + +For example: + +- `target-folder: /*` + - matches all folders in the vault (the root folder and all its descendants) +- `target-folder: /...` + - matches the root folder and its immediate children (aka immediate subfolders of the root) + +If the sorting specification contains duplicate wildcard-ed path in `target-folder:` +an error is raised, indicating the duplicate path + +If a folder is matched by two (or more) wildcarded paths, the one with more path segments +(the deeper one) wins. For example: +- a folder `Book/Chapters/12/a` is matched by: + - (a) `target-folder: Book/*`, and + - (b) `target-folder: Book/Chapters/*` + - In this case the (b) wins, because it contains a deeper path + +If the depth of matches specification is the same, the `/...` takes precedence over `/*` +- a folder `Book/Appendix/III` is matched by: + - (a) `target-folder: Book/Appendix/...`, and + - (b) `target-folder: Book/Appendix/*` + - In this case the (a) wins + +## Excluding folders from custom sorting + +Having the ability to wildard- and regexp-based match of `target-folder:` in some cases +you might want to exclude folder(s) from custom sorting. + +This can be done by combination of the `target-folder:` (in any of its variants) +and specification of the sort order as `sorting: standard` + +An example piece of YAML frontmatter could look like: + +```yaml +--- +sorting-spec: | + + // ... some sorting specification above + + target-folder: Reviews/Attachments + target-folder: TODOs + sorting: standard + + // ... some sorting specification below + +--- +``` From 103821c71276eb1de0f7f98a8383a0081bd801c6 Mon Sep 17 00:00:00 2001 From: SebastianMC <23032356+SebastianMC@users.noreply.github.com> Date: Tue, 7 Feb 2023 17:18:07 +0100 Subject: [PATCH 6/6] #50 - regexp and by-name matching support for target-folder - documentation updates --- docs/manual.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 79e091b..cd3cccd 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -1,5 +1,5 @@ > Document is partial, creation in progress -> Please refer to [README.md](../README.md) for more usage examples +> Please refer to [README.md](../README.md) and [advanced-README.md](../advanced-README.md) for more usage examples > Check also [syntax-reference.md](./syntax-reference.md) --- @@ -421,13 +421,13 @@ An example piece of YAML frontmatter could look like: --- sorting-spec: | - // ... some sorting specification above + // ... some sorting specification above - target-folder: Reviews/Attachments - target-folder: TODOs - sorting: standard + target-folder: Reviews/Attachments + target-folder: TODOs + sorting: standard - // ... some sorting specification below + // ... some sorting specification below --- ```