Skip to content

Commit 7a15746

Browse files
authored
Merge pull request #52 from SebastianMC/50-regexp-and-by-name-support-for-target-folder
#50 regexp and by name support for target folder
2 parents 8e39779 + 103821c commit 7a15746

File tree

7 files changed

+857
-75
lines changed

7 files changed

+857
-75
lines changed

docs/manual.md

Lines changed: 185 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
> Document is partial, creation in progress
2-
> Please refer to [README.md](../README.md) for more usage examples
2+
> Please refer to [README.md](../README.md) and [advanced-README.md](../advanced-README.md) for more usage examples
33
> Check also [syntax-reference.md](./syntax-reference.md)
44
55
---
@@ -70,7 +70,7 @@ For clarity: the three available prefixes `/!` and `/!!` and `/!!!` allow for fu
7070
7171
## Simple wildcards
7272
73-
Currently, the below simple wildcard syntax is supported:
73+
Currently, the below simple wildcard syntax is supported for sorting group:
7474
7575
### A single digit (exactly one)
7676
@@ -248,3 +248,186 @@ sorting-spec: |
248248
/! starred:
249249
---
250250
```
251+
252+
## Options for target-folder: matching
253+
254+
The `target-folder:` has the following variants, listed in the order of precedence:
255+
256+
1. match by the **exact folder path** (the default)
257+
2. match by the **exact folder name**
258+
3. match by **regexp** (for experts, be careful!)
259+
4. match by **wildcard suffix** (aka match folders subtree)
260+
261+
If a folder in the vault matches more than one `target-folder:` definitions,
262+
the above list shows the precedence, e.g. 1. has precedence over 2., 3. and 4. for example.
263+
In other words, match by exact folder path always wins, then goes the match by folder exact name,
264+
and so on.
265+
266+
If a folder in the vault matches more than one `target-folder:` definitions of the same type,
267+
see the detailed description below for the behavior
268+
269+
### By folder path (the default)
270+
271+
If no additional modifiers follow the `target-folder:`, the remaining part of the line
272+
is treated as an exact folder path (leading and trailing spaces are ignored,
273+
infix spaces are treated literally as part of the folder path)
274+
275+
Within the same vault duplicate definitions of same path in `target-folder:` are detected
276+
and error is raised in that case, indicating the duplicated path
277+
278+
Examples of `target-folder:` with match by the exact folder path:
279+
280+
- `target-folder: My Folder`
281+
- this refers to the folder in the root of the vault and only to it
282+
- `target-folder: Archive/My Folder`
283+
- matches the `My Folder` sub-folder in the `Archive` folder (a sub-folder of the root)
284+
- `target-folder: .`
285+
- this refers to path of the folder where the sorting specification resides (the specification containing the line,
286+
keep in mind that the sorting specification can reside in multiple locations in multiple notes)
287+
- `target-folder: ./Some Subfolder`
288+
- this refers to path of a sub-folder of the folder where the sorting specification resides (the specification containing the line,
289+
keep in mind that the sorting specification can reside in multiple locations in multiple notes)
290+
291+
### By folder name
292+
293+
The modifier `name:` tells the `target-folder:` to match the folder name and not the full folder path
294+
295+
This is an exact match of the full folder name, no partial matching
296+
297+
Within the same vault duplicate definitions of same name in `target-folder: name:` are detected
298+
and error is raised in that case, indicating the duplicated folder name in sorting specification
299+
300+
Examples of `target-folder:` with match by the exact folder name:
301+
302+
- `target-folder: name: My Folder`
303+
- matches all the folders with the name `My Folder` regardless of their location within the vault
304+
305+
### By regexp (expert feature)
306+
307+
> WARNING!!! This is an EXPERT FEATURE.
308+
>
309+
> Involving and constructing the regexp-s requires at least basic knowledge about the potential pitfalls.\
310+
> If you introduce a heavy _regexp-backtracking_ it can **kill performance of Obsidian and even make it unresponsive**\
311+
> If you don't know what the _regexp-backtracking_ is, be careful when using regexp for `target-folder:`
312+
313+
The modifier `regexp:` tells the `target-folder:` to involve the specified regular expressions in matching
314+
315+
Additional dependent modifiers are supported for `regexp:`:
316+
- `for-name:`
317+
- tells the matching to be done against the folder name, not the full path
318+
- `debug:`
319+
- tells the regexp to report its match in the developer console, so that you can easily investigate
320+
why the regexp matches (or why it doesn't match) as expected
321+
- `/!:` `/!!:` `/!!!:`
322+
- sets the priority of the regexp
323+
324+
By default, the regexp is matched against the full path of the folder, unless the `for-name:` modifiers tells otherwise.
325+
326+
By default, the regexp-es have no priority and are evaluated in the order of their definition.\
327+
If you store `sorting-spec:` configurations in notes spread all over the vault,
328+
consider the order of `target-folder: regexp:` to be undefined and - if needed - use
329+
explicit priority modifiers (`/!:` `/!!:` `/!!!:`) to impose the desired order of matching.
330+
- a regexp with modifier `/!!!:` if evaluated before all other regexps, regardless of where they are configured
331+
- if two or more regexps are stamped with `/!!!:`, they are matched in the order in which they were defined.\
332+
Within a single YAML section of a note the order is obvious.\
333+
For sorting specifications spread over many notes in the vault consider the order to be undefined.
334+
- a regexp with modifier `/!!:` if evaluated after any `/!!!:` and before all other regexps
335+
- the same logic as described above applies when multiple regexps have the `/!!:` stamp
336+
- a regexp with modifier `/!:` indicates the lowest of explicitly defined priorities.\
337+
Such a regexp is matched after all priority-stamped regexps, before the regexps not having
338+
any explicit priority stamp
339+
340+
The escape character is \ - the standard one in regexp world.
341+
342+
Examples of `target-folder:` with match by regexp:
343+
344+
- `target-folder: regexp: reading`
345+
- matches any folder which contains the word `reading` in its path or name
346+
- `target-folder: regexp: \d?\d-\d?\d-\d\d\d\d$`
347+
- matches any folder which ends with date-alike numerical expression, e.g.:
348+
- `1-1-2023`
349+
- `Archive/Monthly/12/05-12-2022`
350+
- `Inbox/Not digested notes from 20-7-2019`
351+
- `target-folder: regexp: for-name: I am everywhere`
352+
- matches all folders which contain the phrase `I am everywhere` in their name, e.g.:
353+
- `Reports/Not processed/What the I am everywhere report from Paul means?`
354+
- `Chapters/I am everywhere`
355+
- `target-folder: regexp: for-name: ^I am (everyw)?here$`
356+
- matches all folders with name exactly `I am everywhere` or `I am here`
357+
- `target-folder: regexp: for-name: debug: ^...$`
358+
- matches all folders with name comprising exactly 3 character
359+
- when a folder is matched, a diagnostic line is written to the console - `debug:` modifiers enables the logging
360+
- `target-folder: regexp: debug: ^.{13,15}$`
361+
- matches all folders with path length between 13 and 15 characters
362+
- diagnostic line is written to the console due to `debug:`
363+
- `target-folder: regexp: for-name: /!: ^[aA]`
364+
- matches all folders with name starting with `a` or `A`
365+
- the priority `/!:` modifier causes the matching to be done before all other regexps
366+
which don't have any priority
367+
- `target-folder: regexp: /!!!: debug: for-name: abc|def|ghi`
368+
- matches all folders with name containing the sequence `abc` or `def` or `ghi`
369+
- the modifier `/!!!:` imposes the highest priority of regexp matching
370+
- `debug:` tells to report each matching folder in the console
371+
- `target-folder: regexp: ^[^/]+/[^/]+$`
372+
- matches all folders which are at the 2nd level of vault tree, e.g.:
373+
- `Inbox/Priority input`
374+
- `Archive/2021`
375+
- `target-folder: regexp: ^[^\/]+(\/[^\/]+){2}$`
376+
- matches all folders which are at the 3rd level of vault tree, e.g.:
377+
- `Archive/2019/05`
378+
- `Aaaa/Bbbb/Test test`
379+
380+
### By wildcard
381+
382+
In the default usage of `target-folder:` with the exact full folder path, if the path contains
383+
the `/...` or `/*` suffix its meaning is extended to:
384+
- match the folder and all its immediate (child) subfolders - `/...` suffix
385+
- match the folder and all its subfolders at any level (all descendants, the entire subtree) - `/*` suffix
386+
387+
For example:
388+
389+
- `target-folder: /*`
390+
- matches all folders in the vault (the root folder and all its descendants)
391+
- `target-folder: /...`
392+
- matches the root folder and its immediate children (aka immediate subfolders of the root)
393+
394+
If the sorting specification contains duplicate wildcard-ed path in `target-folder:`
395+
an error is raised, indicating the duplicate path
396+
397+
If a folder is matched by two (or more) wildcarded paths, the one with more path segments
398+
(the deeper one) wins. For example:
399+
- a folder `Book/Chapters/12/a` is matched by:
400+
- (a) `target-folder: Book/*`, and
401+
- (b) `target-folder: Book/Chapters/*`
402+
- In this case the (b) wins, because it contains a deeper path
403+
404+
If the depth of matches specification is the same, the `/...` takes precedence over `/*`
405+
- a folder `Book/Appendix/III` is matched by:
406+
- (a) `target-folder: Book/Appendix/...`, and
407+
- (b) `target-folder: Book/Appendix/*`
408+
- In this case the (a) wins
409+
410+
## Excluding folders from custom sorting
411+
412+
Having the ability to wildard- and regexp-based match of `target-folder:` in some cases
413+
you might want to exclude folder(s) from custom sorting.
414+
415+
This can be done by combination of the `target-folder:` (in any of its variants)
416+
and specification of the sort order as `sorting: standard`
417+
418+
An example piece of YAML frontmatter could look like:
419+
420+
```yaml
421+
---
422+
sorting-spec: |
423+
424+
// ... some sorting specification above
425+
426+
target-folder: Reviews/Attachments
427+
target-folder: TODOs
428+
sorting: standard
429+
430+
// ... some sorting specification below
431+
432+
---
433+
```

src/custom-sort/custom-sort-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export interface CustomSortGroup {
6464
}
6565

6666
export interface CustomSortSpec {
67+
// plays only informative role about the original parsed 'target-folder:' values
6768
targetFoldersPaths: Array<string> // For root use '/'
6869
defaultOrder?: CustomSortOrder
6970
byMetadataField?: string // for 'by-metadata:' if the defaultOrder is by metadata alphabetical or reverse

src/custom-sort/folder-matching-rules.spec.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ const createMockMatcherRichVersion = (): FolderWildcardMatching<SortingSpec> =>
1414
return matcher
1515
}
1616

17+
const PRIO1 = 1
18+
const PRIO2 = 2
19+
const PRIO3 = 3
20+
1721
const createMockMatcherSimplestVersion = (): FolderWildcardMatching<SortingSpec> => {
1822
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
1923
matcher.addWildcardDefinition('/Reviews/daily/*', '/Reviews/daily/*')
@@ -117,4 +121,137 @@ describe('folderMatch', () => {
117121

118122
expect(result).toEqual({errorMsg: "Duplicate wildcard '*' specification for Archive/2019/*"})
119123
})
124+
it('regexp-match by name works (order of regexp doesn\'t matter) case A', () => {
125+
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
126+
matcher.addRegexpDefinition(/^daily$/, false, undefined, false, `r1`)
127+
matcher.addRegexpDefinition(/^daily$/, true, undefined, false, `r2`)
128+
matcher.addWildcardDefinition('/Reviews/*', `w1`)
129+
// Path with leading /
130+
const match1: SortingSpec | null = matcher.folderMatch('/Reviews/daily', 'daily')
131+
// Path w/o leading / - this is how Obsidian supplies the path
132+
const match2: SortingSpec | null = matcher.folderMatch('Reviews/daily', 'daily')
133+
expect(match1).toBe('r2')
134+
expect(match2).toBe('r2')
135+
})
136+
it('regexp-match by name works (order of regexp doesn\'t matter) reversed case A', () => {
137+
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
138+
matcher.addRegexpDefinition(/^daily$/, true, undefined, false, `r2`)
139+
matcher.addRegexpDefinition(/^daily$/, false, undefined, false, `r1`)
140+
matcher.addWildcardDefinition('/Reviews/*', `w1`)
141+
// Path with leading /
142+
const match1: SortingSpec | null = matcher.folderMatch('/Reviews/daily', 'daily')
143+
// Path w/o leading / - this is how Obsidian supplies the path
144+
const match2: SortingSpec | null = matcher.folderMatch('Reviews/daily', 'daily')
145+
expect(match1).toBe('r2')
146+
expect(match2).toBe('r2')
147+
})
148+
it('regexp-match by path works (order of regexp doesn\'t matter) case A', () => {
149+
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
150+
matcher.addRegexpDefinition(/^Reviews\/daily$/, false, undefined, false, `r1`)
151+
matcher.addRegexpDefinition(/^Reviews\/daily$/, true, undefined, false, `r2`)
152+
matcher.addWildcardDefinition('/Reviews/*', `w1`)
153+
// Path with leading /
154+
const match1: SortingSpec | null = matcher.folderMatch('/Reviews/daily', 'daily')
155+
// Path w/o leading / - this is how Obsidian supplies the path
156+
const match2: SortingSpec | null = matcher.folderMatch('Reviews/daily', 'daily')
157+
expect(match1).toBe('w1') // The path-based regexp doesn't match the leading /
158+
expect(match2).toBe('r1')
159+
})
160+
it('regexp-match by path works (order of regexp doesn\'t matter) reversed case A', () => {
161+
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
162+
matcher.addRegexpDefinition(/^Reviews\/daily$/, true, undefined, false, `r2`)
163+
matcher.addRegexpDefinition(/^Reviews\/daily$/, false, undefined, false, `r1`)
164+
matcher.addWildcardDefinition('/Reviews/*', `w1`)
165+
// Path with leading /
166+
const match1: SortingSpec | null = matcher.folderMatch('/Reviews/daily', 'daily')
167+
// Path w/o leading / - this is how Obsidian supplies the path
168+
const match2: SortingSpec | null = matcher.folderMatch('Reviews/daily', 'daily')
169+
expect(match1).toBe('w1') // The path-based regexp doesn't match the leading /
170+
expect(match2).toBe('r1')
171+
})
172+
it('regexp-match by path and name for root level - order of regexp decides - case A', () => {
173+
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
174+
matcher.addRegexpDefinition(/^daily$/, false, undefined, false, `r1`)
175+
matcher.addRegexpDefinition(/^daily$/, true, undefined, false, `r2`)
176+
matcher.addWildcardDefinition('/Reviews/*', `w1`)
177+
// Path w/o leading / - this is how Obsidian supplies the path
178+
const match: SortingSpec | null = matcher.folderMatch('daily', 'daily')
179+
expect(match).toBe('r2')
180+
})
181+
it('regexp-match by path and name for root level - order of regexp decides - reversed case A', () => {
182+
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
183+
matcher.addRegexpDefinition(/^daily$/, true, undefined, false, `r2`)
184+
matcher.addRegexpDefinition(/^daily$/, false, undefined, false, `r1`)
185+
matcher.addWildcardDefinition('/Reviews/*', `w1`)
186+
// Path w/o leading / - this is how Obsidian supplies the path
187+
const match: SortingSpec | null = matcher.folderMatch('daily', 'daily')
188+
expect(match).toBe('r1')
189+
})
190+
it('regexp-match priorities - order of definitions irrelevant - unique priorities - case A', () => {
191+
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
192+
matcher.addRegexpDefinition(/^freq\/daily$/, false, 3, false, `r1p3`)
193+
matcher.addRegexpDefinition(/^freq\/daily$/, false, 2, false, `r2p2`)
194+
matcher.addRegexpDefinition(/^freq\/daily$/, false, 1, false, `r3p1`)
195+
matcher.addRegexpDefinition(/^freq\/daily$/, false, undefined, false, `r4pNone`)
196+
// Path w/o leading / - this is how Obsidian supplies the path
197+
const match: SortingSpec | null = matcher.folderMatch('freq/daily', 'daily')
198+
expect(match).toBe('r1p3')
199+
})
200+
it('regexp-match priorities - order of definitions irrelevant - unique priorities - reversed case A', () => {
201+
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
202+
matcher.addRegexpDefinition(/^freq\/daily$/, false, undefined, false, `r4pNone`)
203+
matcher.addRegexpDefinition(/^freq\/daily$/, false, 1, false, `r3p1`)
204+
matcher.addRegexpDefinition(/^freq\/daily$/, false, 2, false, `r2p2`)
205+
matcher.addRegexpDefinition(/^freq\/daily$/, false, 3, false, `r1p3`)
206+
// Path w/o leading / - this is how Obsidian supplies the path
207+
const match: SortingSpec | null = matcher.folderMatch('freq/daily', 'daily')
208+
expect(match).toBe('r1p3')
209+
})
210+
it('regexp-match priorities - order of definitions irrelevant - duplicate priorities - case A', () => {
211+
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
212+
matcher.addRegexpDefinition(/^daily$/, true, 3, false, `r1p3a`)
213+
matcher.addRegexpDefinition(/^daily$/, true, 3, false, `r1p3b`)
214+
matcher.addRegexpDefinition(/^daily$/, true, 2, false, `r2p2a`)
215+
matcher.addRegexpDefinition(/^daily$/, true, 2, false, `r2p2b`)
216+
matcher.addRegexpDefinition(/^daily$/, true, 1, false, `r3p1a`)
217+
matcher.addRegexpDefinition(/^daily$/, true, 1, false, `r3p1b`)
218+
matcher.addRegexpDefinition(/^daily$/, true, undefined, false, `r4pNone`)
219+
// Path w/o leading / - this is how Obsidian supplies the path
220+
const match: SortingSpec | null = matcher.folderMatch('daily', 'daily')
221+
expect(match).toBe('r1p3b')
222+
})
223+
it('regexp-match priorities - order of definitions irrelevant - unique priorities - reversed case A', () => {
224+
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
225+
matcher.addRegexpDefinition(/^freq\/daily$/, false, undefined, false, `r4pNone`)
226+
matcher.addRegexpDefinition(/^freq\/daily$/, false, 1, false, `r3p1`)
227+
matcher.addRegexpDefinition(/^freq\/daily$/, false, 2, false, `r2p2`)
228+
matcher.addRegexpDefinition(/^freq\/daily$/, false, 3, false, `r1p3`)
229+
// Path w/o leading / - this is how Obsidian supplies the path
230+
const match: SortingSpec | null = matcher.folderMatch('freq/daily', 'daily')
231+
expect(match).toBe('r1p3')
232+
})
233+
it('regexp-match - edge case of matching the root folder - match by path', () => {
234+
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
235+
matcher.addRegexpDefinition(/^\/$/, false, undefined, false, `r1`)
236+
// Path w/o leading / - this is how Obsidian supplies the path
237+
const match: SortingSpec | null = matcher.folderMatch('/', '')
238+
expect(match).toBe('r1')
239+
})
240+
it('regexp-match - edge case of matching the root folder - match by name not possible', () => {
241+
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
242+
// Tricky regexp which can return zero length matches
243+
matcher.addRegexpDefinition(/.*/, true, undefined, false, `r1`)
244+
matcher.addWildcardDefinition('/*', `w1`)
245+
// Path w/o leading / - this is how Obsidian supplies the path
246+
const match: SortingSpec | null = matcher.folderMatch('/', '')
247+
expect(match).toBe('w1')
248+
})
249+
it('regexp-match - edge case of no match when only regexp rules present', () => {
250+
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
251+
// Tricky regexp which can return zero length matches
252+
matcher.addRegexpDefinition(/abc/, true, undefined, false, `r1`)
253+
// Path w/o leading / - this is how Obsidian supplies the path
254+
const match: SortingSpec | null = matcher.folderMatch('/', '')
255+
expect(match).toBeNull()
256+
})
120257
})

0 commit comments

Comments
 (0)