Merge pull request #52 from SebastianMC/50-regexp-and-by-name-support-for-target-folder

#50 regexp and by name support for target folder
This commit is contained in:
SebastianMC 2023-02-07 17:22:15 +01:00 committed by GitHub
commit 7a157464be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 858 additions and 76 deletions

View File

@ -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)
---
@ -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
---
```

View File

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

View File

@ -14,6 +14,10 @@ const createMockMatcherRichVersion = (): FolderWildcardMatching<SortingSpec> =>
return matcher
}
const PRIO1 = 1
const PRIO2 = 2
const PRIO3 = 3
const createMockMatcherSimplestVersion = (): FolderWildcardMatching<SortingSpec> => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
matcher.addWildcardDefinition('/Reviews/daily/*', '/Reviews/daily/*')
@ -117,4 +121,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<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,9 +1,3 @@
export interface FolderPattern {
path: string
deep: boolean
nestingLevel: number
}
export type DeterminedSortingSpec<SortingSpec> = {
spec?: SortingSpec
}
@ -16,6 +10,14 @@ export interface FolderMatchingTreeNode<SortingSpec> {
subtree: { [key: string]: FolderMatchingTreeNode<SortingSpec> }
}
export interface FolderMatchingRegexp<SortingSpec> {
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 +25,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<string> => {
return path.split(SLASH).filter((name) => !!name)
@ -34,10 +37,13 @@ export interface AddingWildcardFailure {
export class FolderWildcardMatching<SortingSpec> {
// mimics the structure of folders, so for example tree.matchAll contains the matchAll flag for the root '/'
tree: FolderMatchingTreeNode<SortingSpec> = {
subtree: {}
}
regexps: Array<FolderMatchingRegexp<SortingSpec>>
// cache
determinedWildcardRules: { [key: string]: DeterminedSortingSpec<SortingSpec> } = {}
@ -76,33 +82,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]
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<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
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<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) {
this.determinedWildcardRules[folderPath] = {spec: rule}

View File

@ -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
@ -780,6 +780,228 @@ 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: Array<FolderMatchingRegexp<CustomSortSpec>> = [
{
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 NOPRIO = 0
const PRIO1 = 1
const PRIO2 = 2
const PRIO3 = 3
const consumedTargetFolderRegexp: Array<ConsumedFolderMatchingRegexp> = [
{
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
@ -1694,6 +1916,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<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 = `

View File

@ -22,10 +22,12 @@ import {
RomanNumberRegexStr
} from "./matchers";
import {
FolderMatchingRegexp,
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 +77,19 @@ export enum ProblemCode {
TooManyGroupTypePrefixes,
PriorityPrefixAfterGroupTypePrefix,
CombinePrefixAfterGroupTypePrefix,
InlineRegexInPrefixAndSuffix
InlineRegexInPrefixAndSuffix,
DuplicateByNameSortSpecForFolder,
EmptyFolderNameToMatch,
InvalidOrEmptyFolderMatchingRegexp
}
const ContextFreeProblems = new Set<ProblemCode>([
ProblemCode.DuplicateSortSpecForSameFolder,
ProblemCode.DuplicateWildcardSortSpecForSameFolder,
ProblemCode.OnlyLastCombinedGroupCanSpecifyOrder
ProblemCode.OnlyLastCombinedGroupCanSpecifyOrder,
ProblemCode.DuplicateByNameSortSpecForFolder,
ProblemCode.EmptyFolderNameToMatch,
ProblemCode.InvalidOrEmptyFolderMatchingRegexp
])
const ThreeDots = '...';
@ -157,9 +165,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 +213,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 +227,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<string> = [
@ -491,15 +511,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<CustomSortSpec>
}
export const newSortSpecsCollection = (): SortSpecsCollection => {
return {
sortSpecByPath: {},
sortSpecByName: {}
}
}
interface AdjacencyInfo {
noPrefix: boolean,
noSuffix: boolean
@ -524,32 +564,95 @@ 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
}
}
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
let nextRoundNeeded: boolean
do {
nextRoundNeeded = false
expression = eatPrefixIfPresent(expression, RegexpAgainstFolderName, () => {
againstName = true
nextRoundNeeded = 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() === '') {
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 +744,52 @@ export class SortingSpecProcessor {
}
}
let sortspecByWildcard: FolderWildcardMatching<CustomSortSpec> | 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<spec.targetFoldersPaths.length; 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 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}>`)
return null
}
} else if (endsWithWildcardPatternSuffix(path)) {
sortspecByWildcard = sortspecByWildcard ?? new FolderWildcardMatching<CustomSortSpec>()
const ruleAdded = sortspecByWildcard.addWildcardDefinition(path, spec)
if (ruleAdded?.errorMsg) {
@ -658,31 +801,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
}
}
}

View File

@ -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) {