Merge pull request #2 from SebastianMC/1-inheritance-of-sorting-rules-for-subfolders

Ticket #1: added support for imposed sorting rules inheritance by subfolders
This commit is contained in:
SebastianMC 2022-09-01 01:15:38 +02:00 committed by GitHub
commit 1e30312e33
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 813 additions and 68 deletions

View File

@ -17,11 +17,13 @@ Take full control of the order of your notes and folders:
- order configuration stored directly in your note(s) front matter - order configuration stored directly in your note(s) front matter
- use a dedicated key in YAML - use a dedicated key in YAML
- folders not set up for the custom order remain on the standard Obsidian sorting - folders not set up for the custom order remain on the standard Obsidian sorting
- support for imposing inheritance of order specifications with flexible exclusion and overriding logic
## Table of contents ## Table of contents
- [TL;DR Usage](#tldr-usage) - [TL;DR Usage](#tldr-usage)
- [Simple case 1: in root folder sort items by a rule, intermixing folders and files](#simple-case-1-in-root-folder-sort-items-by-a-rule-intermixing-folders-and-files) -
- [Simple case 1: in root folder sort entries alphabetically treating folders and files equally](#simple-case-1-in-root-folder-sort-entries-alphabetically-treating-folders-and-files-equally)
- [Simple case 2: impose manual order of some items in root folder](#simple-case-2-impose-manual-order-of-some-items-in-root-folder) - [Simple case 2: impose manual order of some items in root folder](#simple-case-2-impose-manual-order-of-some-items-in-root-folder)
- [Example 3: In root folder, let files go first and folders get pushed to the bottom](#example-3-in-root-folder-let-files-go-first-and-folders-get-pushed-to-the-bottom) - [Example 3: In root folder, let files go first and folders get pushed to the bottom](#example-3-in-root-folder-let-files-go-first-and-folders-get-pushed-to-the-bottom)
- [Example 4: In root folder, pin a focus note, then Inbox folder, and push archive to the bottom](#example-4-in-root-folder-pin-a-focus-note-then-inbox-folder-and-push-archive-to-the-bottom) - [Example 4: In root folder, pin a focus note, then Inbox folder, and push archive to the bottom](#example-4-in-root-folder-pin-a-focus-note-then-inbox-folder-and-push-archive-to-the-bottom)
@ -32,6 +34,8 @@ Take full control of the order of your notes and folders:
- [Example 9: Sort by numerical suffix](#example-9-sort-by-numerical-suffix) - [Example 9: Sort by numerical suffix](#example-9-sort-by-numerical-suffix)
- [Example 10: Sample book structure with Roman numbered chapters](#example-10-sample-book-structure-with-roman-numbered-chapters) - [Example 10: Sample book structure with Roman numbered chapters](#example-10-sample-book-structure-with-roman-numbered-chapters)
- [Example 11: Sample book structure with compound Roman number suffixes](#example-11-sample-book-structure-with-compound-roman-number-suffixes) - [Example 11: Sample book structure with compound Roman number suffixes](#example-11-sample-book-structure-with-compound-roman-number-suffixes)
- [Example 12: Apply same sorting to all folders in the vault](#example-12-apply-same-sorting-to-all-folders-in-the-vault)
- [Example 13: Sorting rules inheritance by subfolders](#example-13-sorting-rules-inheritance-by-subfolders)
- [Location of sorting specification YAML entry](#location-of-sorting-specification-yaml-entry) - [Location of sorting specification YAML entry](#location-of-sorting-specification-yaml-entry)
- [Ribbon icon](#ribbon-icon) - [Ribbon icon](#ribbon-icon)
- [Installing the plugin](#installing-the-plugin) - [Installing the plugin](#installing-the-plugin)
@ -44,6 +48,31 @@ Take full control of the order of your notes and folders:
For full version of the manual go to [./docs/manual.md]() and [./docs/syntax-reference.md]() For full version of the manual go to [./docs/manual.md]() and [./docs/syntax-reference.md]()
> REMARK: as of this version of documentation, the manual and syntax reference are empty :-) > REMARK: as of this version of documentation, the manual and syntax reference are empty :-)
> **Quickstart**
>
> 1. Download the [sortspec.md](./docs/examples/quickstart/sortspec.md?plain=1) file and put it in any folder of your vault,
can be the root folder. That file contains a basic custom sorting specification.
>
> 2. Enable the plugin in obsidian.
>
> 3. Click the ribbon button (![Inactive](./docs/icons/icon-inactive.png)) to tell the plugin to read the sorting
specification from `sortspec` note (the `sortspec.md` file which you downloaded a second ago).
> - The observable effect should be the change of appearance of the ribbon button to
(![Active](./docs/icons/icon-active.png)) and reordering
of items in root vault folder to reverse alphabetical with folders and files treated equally.
> - The notification balloon should confirm success: ![Success](./docs/icons/parsing-succeeded.png)
> 4. Click the ribbon button again to suspend the plugin. The ribbon button should toggle its appearance again
and the order of files and folders in the root folder of your vault should get back to the order selected in
Obsidian UI
> 5. Happy custom sorting !!! Remember to click the ribbon button twice each time after sorting specification
change. This will suspend and re-enable the custom sorting, plus parse and apply the updated specification
>
> - If you don't have any
subfolder in the root folder, create one to observe the plugin at work.
>
> NOTE: the appearances of ribbon button also includes ![Not applied](./docs/icons/icon-not-applied.png)
and ![Error](./docs/icons/icon-error.png). For the meaning of them please refer to [ribbon icon](#ribbon_icon) section below
Below go examples of (some of) the key features, ready to copy & paste to your vault. Below go examples of (some of) the key features, ready to copy & paste to your vault.
For simplicity (if you are examining the plugin for the first time) copy and paste the below YAML snippets to the front For simplicity (if you are examining the plugin for the first time) copy and paste the below YAML snippets to the front
@ -58,9 +87,15 @@ Click the [ribbon icon](#ribbon_icon) again to disable custom sorting and switch
The [ribbon icon](#ribbon_icon) acts also as the visual indicator of the current state of the plugin - see The [ribbon icon](#ribbon_icon) acts also as the visual indicator of the current state of the plugin - see
the [ribbon icon](#ribbon_icon) section for details the [ribbon icon](#ribbon_icon) section for details
### Simple case 1: in root folder sort items by a rule, intermixing folders and files ### Simple case 1: in root folder sort entries alphabetically treating folders and files equally
The specified rule is to sort items alphabetically The specified rule is to sort items alphabetically in the root folder of the vault
The line `target-folder: /` specifies to which folder apply the sorting rules which follow.
The `/` indicates the root folder of the vault in File Explorer
And `< a-z` sets the order to alphabetical ascending
> IMPORTANT: indentation matters in all the examples > IMPORTANT: indentation matters in all the examples
@ -322,6 +357,60 @@ the result is:
![Book - Roman compond suffixes](./docs/svg/roman-suffix.svg) ![Book - Roman compond suffixes](./docs/svg/roman-suffix.svg)
### Example 12: Apply same sorting to all folders in the vault
Apply the alphabetical sorting to all folders in the Vault. The alphabetical sorting treats the folders and files equally
(which is different from the standard Obsidian sort, which groups folders in the top of File Explorer)
This involves the wildcard suffix syntax `*` which means _apply the sorting rule to the specified folder
and all of its subfolders, including descendants. In other words, this is imposing a deep inheritance
of sorting specification.
Applying the wildcard suffix to root folder path `/*` actually means _apply the sorting to all folders in the vault_
```yaml
---
sorting-spec: |
target-folder: /*
< a-z
---
```
### Example 13: Sorting rules inheritance by subfolders
A more advanced example showing finetuned options of manipulating of sorting rules inheritance:
You can read the below YAML specification as:
- all items in all folders in the vault (`target-folder: /*`) should be sorted alphabetically (files and folders treated equally)
- yet, items in the `Reviews` folder and its direct subfolders (like `Reviews/daily`) should be ordered by modification date
- the syntax `Reviews/...` means: the items in `Reviews` folder and its direct subfolders (and no deeper)
- the more nested folder like `Reviews/daily/morning` inherit the rule specified for root folder `/*`
- Note, that a more specific (or more nested or more focused) rule overrides the more generic inherited one
- at the same time, the folder `Archive` and `Inbox` sort their items by creation date
- this is because specifying direct name in `target-folder: Archive` has always the highest priority and overrides any inheritance
- and finally, the folders `Reviews/Attachments` and `TODOs` are explicitly excluded from the control of the custom sort
plugin and use the standard Obsidian UI sorting, as selected in the UI
- the special syntax `sorting: standard` tells the plugin to refrain from ordering items in specified folders
- again, specifying the folder by name in `target-folder: TODOs` overrides any inherited sorting rules
```yaml
---
sorting-spec: |
target-folder: /*
< a-z
target-folder: Reviews/...
< modified
target-folder: Archive
target-folder: Inbox
< created
target-folder: Reviews/Attachments
target-folder: TODOs
sorting: standard
---
```
## Location of sorting specification YAML entry ## Location of sorting specification YAML entry
You can keep the custom sorting specifications in any of the following locations (or in all of them): You can keep the custom sorting specifications in any of the following locations (or in all of them):

View File

@ -0,0 +1,15 @@
---
sorting-spec: |
//
// A simple configuration for obsidian-custom-sort plugin
// (https://github.com/SebastianMC/obsidian-custom-sort)
// It causes the plugin to take over the control of the order of items in the root folder ('/') of the vault
// It explicitly sets the sorting to descending ('>') alphabetical ('a-z')
// Folders and files are treated equally by the plugin (by default) so expect them intermixed
// in the root vault folder after enabling the custom sort plugin
//
// To play with more examples go to https://github.com/SebastianMC/obsidian-custom-sort#readme
target-folder: /
< a-z
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@ -1,7 +1,7 @@
{ {
"id": "custom-sort", "id": "custom-sort",
"name": "Custom File Explorer sorting", "name": "Custom File Explorer sorting",
"version": "0.5.189", "version": "0.6.0",
"minAppVersion": "0.12.0", "minAppVersion": "0.12.0",
"description": "Allows for manual and automatic, config-driven reordering and sorting of files and folders in File Explorer", "description": "Allows for manual and automatic, config-driven reordering and sorting of files and folders in File Explorer",
"author": "SebastianMC <SebastianMC.github@gmail.com>", "author": "SebastianMC <SebastianMC.github@gmail.com>",

View File

@ -1,6 +1,6 @@
{ {
"name": "obsidian-custom-sort", "name": "obsidian-custom-sort",
"version": "0.5.189", "version": "0.6.0",
"description": "Custom Sort plugin for Obsidian (https://obsidian.md)", "description": "Custom Sort plugin for Obsidian (https://obsidian.md)",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

View File

@ -1,5 +1,3 @@
export const SortSpecFileName: string = 'sortspec.md';
export enum CustomSortGroupType { export enum CustomSortGroupType {
Outsiders, // Not belonging to any of other groups Outsiders, // Not belonging to any of other groups
MatchAll, // like a wildard *, used in connection with foldersOnly or filesOnly. The difference between the MatchAll and Outsiders is MatchAll, // like a wildard *, used in connection with foldersOnly or filesOnly. The difference between the MatchAll and Outsiders is
@ -15,7 +13,8 @@ export enum CustomSortOrder {
byModifiedTime, byModifiedTime,
byModifiedTimeReverse, byModifiedTimeReverse,
byCreatedTime, byCreatedTime,
byCreatedTimeReverse byCreatedTimeReverse,
standardObsidian// Let the folder sorting be in hands of Obsidian, whatever user selected in the UI
} }
export interface RecognizedOrderValue { export interface RecognizedOrderValue {
@ -52,9 +51,3 @@ export interface CustomSortSpec {
outsidersFoldersGroupIdx?: number outsidersFoldersGroupIdx?: number
itemsToHide?: Set<string> itemsToHide?: Set<string>
} }
export interface FolderPathToSortSpecMap {
[key: string]: CustomSortSpec
}
export type SortSpecsCollection = FolderPathToSortSpecMap

View File

@ -31,7 +31,10 @@ let Sorters: { [key in CustomSortOrder]: SorterFn } = {
[CustomSortOrder.byModifiedTime]: (a: FolderItemForSorting, b: FolderItemForSorting) => a.mtime - b.mtime, [CustomSortOrder.byModifiedTime]: (a: FolderItemForSorting, b: FolderItemForSorting) => a.mtime - b.mtime,
[CustomSortOrder.byModifiedTimeReverse]: (a: FolderItemForSorting, b: FolderItemForSorting) => b.mtime - a.mtime, [CustomSortOrder.byModifiedTimeReverse]: (a: FolderItemForSorting, b: FolderItemForSorting) => b.mtime - a.mtime,
[CustomSortOrder.byCreatedTime]: (a: FolderItemForSorting, b: FolderItemForSorting) => a.ctime - b.ctime, [CustomSortOrder.byCreatedTime]: (a: FolderItemForSorting, b: FolderItemForSorting) => a.ctime - b.ctime,
[CustomSortOrder.byCreatedTimeReverse]: (a: FolderItemForSorting, b: FolderItemForSorting) => b.ctime - a.ctime [CustomSortOrder.byCreatedTimeReverse]: (a: FolderItemForSorting, b: FolderItemForSorting) => b.ctime - a.ctime,
// This is a fallback entry which should not be used - the plugin code should refrain from custom sorting at all
[CustomSortOrder.standardObsidian]: (a: FolderItemForSorting, b: FolderItemForSorting) => Collator(a.sortString, b.sortString),
}; };
function compareTwoItems(itA: FolderItemForSorting, itB: FolderItemForSorting, sortSpec: CustomSortSpec) { function compareTwoItems(itA: FolderItemForSorting, itB: FolderItemForSorting, sortSpec: CustomSortSpec) {
@ -53,7 +56,7 @@ const isFolder = (entry: TFile | TFolder) => {
} }
export const determineSortingGroup = function (entry: TFile | TFolder, spec: CustomSortSpec): FolderItemForSorting { export const determineSortingGroup = function (entry: TFile | TFolder, spec: CustomSortSpec): FolderItemForSorting {
let groupIdx: number = 0 let groupIdx: number
let determined: boolean = false let determined: boolean = false
let matchedGroup: string let matchedGroup: string
const aFolder: boolean = isFolder(entry) const aFolder: boolean = isFolder(entry)
@ -168,7 +171,6 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus
export const folderSort = function (sortingSpec: CustomSortSpec, order: string[]) { export const folderSort = function (sortingSpec: CustomSortSpec, order: string[]) {
let fileExplorer = this.fileExplorer let fileExplorer = this.fileExplorer
const thisFolderPath: string = this.file.path;
const folderItems: Array<FolderItemForSorting> = (sortingSpec.itemsToHide ? const folderItems: Array<FolderItemForSorting> = (sortingSpec.itemsToHide ?
this.file.children.filter((entry: TFile | TFolder) => { this.file.children.filter((entry: TFile | TFolder) => {

View File

@ -0,0 +1,120 @@
import {FolderWildcardMatching} from './folder-matching-rules'
type SortingSpec = string
const createMockMatcherRichVersion = (): FolderWildcardMatching<SortingSpec> => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
let p: string
p = '/...'; matcher.addWildcardDefinition(p, `00 ${p}`)
p = '/*'; matcher.addWildcardDefinition(p, `0 ${p}`)
p = 'Reviews/...'; matcher.addWildcardDefinition(p, `1 ${p}`)
p = '/Reviews/*'; matcher.addWildcardDefinition(p, `2 ${p}`)
p = '/Reviews/daily/a/.../'; matcher.addWildcardDefinition(p, `3 ${p}`)
p = 'Reviews/daily/a/*'; matcher.addWildcardDefinition(p, `4 ${p}`)
return matcher
}
const createMockMatcherSimplestVersion = (): FolderWildcardMatching<SortingSpec> => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
matcher.addWildcardDefinition('/Reviews/daily/*', '/Reviews/daily/*')
return matcher
}
const createMockMatcherRootOnlyVersion = (): FolderWildcardMatching<SortingSpec> => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
matcher.addWildcardDefinition('/...', '/...')
return matcher
}
const createMockMatcherRootOnlyDeepVersion = (): FolderWildcardMatching<SortingSpec> => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
matcher.addWildcardDefinition('/*', '/*')
return matcher
}
const createMockMatcherSimpleVersion = (): FolderWildcardMatching<SortingSpec> => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
matcher.addWildcardDefinition('/Reviews/daily/*', '/Reviews/daily/*')
matcher.addWildcardDefinition('/Reviews/daily/...', '/Reviews/daily/...')
return matcher
}
describe('folderMatch', () => {
it.each([
['/', '00 /...'],
['Archive/', '00 /...'],
['Archive', '00 /...'],
['/Archive/2019', '0 /*'],
['Archive/2019/', '0 /*'],
['Archive/2019/Jan', '0 /*'],
['/Reviews', '1 Reviews/...'],
['Reviews/weekly', '1 Reviews/...'],
['Reviews/weekly/w50/', '2 /Reviews/*'],
['/Reviews/daily', '2 /Reviews/*'],
['Reviews/daily/Mon', '2 /Reviews/*'],
['/Reviews/daily/a/', '3 /Reviews/daily/a/.../'],
['Reviews/daily/a/Mon', '3 /Reviews/daily/a/.../'],
['/Reviews/daily/a/Mon/Late', '4 Reviews/daily/a/*'],
['Reviews/daily/a/Tue/Early/9am', '4 Reviews/daily/a/*']
])('%s should match %s', (path: string, rule: string) => {
const matcher: FolderWildcardMatching<SortingSpec> = createMockMatcherRichVersion()
const match: SortingSpec = matcher.folderMatch(path)
const matchFromCache: SortingSpec = matcher.folderMatch(path)
expect(match).toBe(rule)
expect(matchFromCache).toBe(rule)
})
it('should correctly handle no-root definitions', () => {
const matcher: FolderWildcardMatching<SortingSpec> = createMockMatcherSimplestVersion()
const match1: SortingSpec = matcher.folderMatch('/')
const match2: SortingSpec = matcher.folderMatch('/Reviews')
const match3: SortingSpec = matcher.folderMatch('/Reviews/daily/')
const match4: SortingSpec = matcher.folderMatch('/Reviews/daily/Mon')
const match5: SortingSpec = matcher.folderMatch('/Reviews/daily/Mon')
expect(match1).toBeNull()
expect(match2).toBeNull()
expect(match3).toBe('/Reviews/daily/*')
expect(match4).toBe('/Reviews/daily/*')
expect(match5).toBe('/Reviews/daily/*')
})
it('should correctly handle root-only definition', () => {
const matcher: FolderWildcardMatching<SortingSpec> = createMockMatcherRootOnlyVersion()
const match1: SortingSpec = matcher.folderMatch('/')
const match2: SortingSpec = matcher.folderMatch('/Reviews')
const match3: SortingSpec = matcher.folderMatch('/Reviews/daily/')
expect(match1).toBe('/...')
expect(match2).toBe('/...')
expect(match3).toBeNull()
})
it('should correctly handle root-only deep definition', () => {
const matcher: FolderWildcardMatching<SortingSpec> = createMockMatcherRootOnlyDeepVersion()
const match1: SortingSpec = matcher.folderMatch('/')
const match2: SortingSpec = matcher.folderMatch('/Reviews')
const match3: SortingSpec = matcher.folderMatch('/Reviews/daily/')
expect(match1).toBe('/*')
expect(match2).toBe('/*')
expect(match3).toBe('/*')
})
it('should correctly handle match all and match children definitions for same path', () => {
const matcher: FolderWildcardMatching<SortingSpec> = createMockMatcherSimpleVersion()
const match1: SortingSpec = matcher.folderMatch('/')
const match2: SortingSpec = matcher.folderMatch('/Reviews/daily/')
const match3: SortingSpec = matcher.folderMatch('/Reviews/daily/1')
expect(match1).toBeNull()
expect(match2).toBe('/Reviews/daily/...')
expect(match3).toBe('/Reviews/daily/...')
})
it('should detect duplicate match children definitions for same path', () => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
matcher.addWildcardDefinition('Archive/2020/...', 'First occurrence')
const result = matcher.addWildcardDefinition('/Archive/2020/.../', 'Duplicate')
expect(result).toEqual({errorMsg: "Duplicate wildcard '...' specification for /Archive/2020/.../"})
})
it('should detect duplicate match all definitions for same path', () => {
const matcher: FolderWildcardMatching<SortingSpec> = new FolderWildcardMatching()
matcher.addWildcardDefinition('/Archive/2019/*', 'First occurrence')
const result = matcher.addWildcardDefinition('Archive/2019/*', 'Duplicate')
expect(result).toEqual({errorMsg: "Duplicate wildcard '*' specification for Archive/2019/*"})
})
})

View File

@ -0,0 +1,116 @@
export interface FolderPattern {
path: string
deep: boolean
nestingLevel: number
}
export type DeterminedSortingSpec<SortingSpec> = {
spec?: SortingSpec
}
export interface FolderMatchingTreeNode<SortingSpec> {
path?: string
name?: string
matchChildren?: SortingSpec
matchAll?: SortingSpec
subtree: { [key: string]: FolderMatchingTreeNode<SortingSpec> }
}
const SLASH: string = '/'
export const MATCH_CHILDREN_PATH_TOKEN: string = '...'
export const MATCH_ALL_PATH_TOKEN: string = '*'
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 splitPath = (path: string): Array<string> => {
return path.split(SLASH).filter((name) => !!name)
}
export interface AddingWildcardFailure {
errorMsg: string
}
export class FolderWildcardMatching<SortingSpec> {
tree: FolderMatchingTreeNode<SortingSpec> = {
subtree: {}
}
// cache
determinedWildcardRules: { [key: string]: DeterminedSortingSpec<SortingSpec> } = {}
addWildcardDefinition = (wilcardDefinition: string, rule: SortingSpec): AddingWildcardFailure | null => {
const pathComponents: Array<string> = splitPath(wilcardDefinition)
const lastComponent: string = pathComponents.pop()
if (lastComponent !== MATCH_ALL_PATH_TOKEN && lastComponent !== MATCH_CHILDREN_PATH_TOKEN) {
return null
}
let leafNode: FolderMatchingTreeNode<SortingSpec> = this.tree
pathComponents.forEach((pathComponent) => {
let subtree: FolderMatchingTreeNode<SortingSpec> = leafNode.subtree[pathComponent]
if (subtree) {
leafNode = subtree
} else {
const newSubtree: FolderMatchingTreeNode<SortingSpec> = {
name: pathComponent,
subtree: {}
}
leafNode.subtree[pathComponent] = newSubtree
leafNode = newSubtree
}
})
if (lastComponent === MATCH_CHILDREN_PATH_TOKEN) {
if (leafNode.matchChildren) {
return {errorMsg: `Duplicate wildcard '${lastComponent}' specification for ${wilcardDefinition}`}
} else {
leafNode.matchChildren = rule
}
} else { // Implicitly: MATCH_ALL_PATH_TOKEN
if (leafNode.matchAll) {
return {errorMsg: `Duplicate wildcard '${lastComponent}' specification for ${wilcardDefinition}`}
} else {
leafNode.matchAll = rule
}
}
}
folderMatch = (folderPath: string): SortingSpec | null => {
const spec: DeterminedSortingSpec<SortingSpec> = this.determinedWildcardRules[folderPath]
if (spec) {
return spec.spec ?? null
} else {
let rule: SortingSpec = this.tree.matchChildren
let inheritedRule: SortingSpec = 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}
return rule
} else {
this.determinedWildcardRules[folderPath] = {}
return null
}
}
}
}

View File

@ -1,5 +1,6 @@
import { import {
CompoundDashNumberNormalizerFn, CompoundDashRomanNumberNormalizerFn, CompoundDashNumberNormalizerFn,
CompoundDashRomanNumberNormalizerFn,
CompoundDotNumberNormalizerFn, CompoundDotNumberNormalizerFn,
convertPlainStringWithNumericSortingSymbolToRegex, convertPlainStringWithNumericSortingSymbolToRegex,
detectNumericSortingSymbols, detectNumericSortingSymbols,
@ -12,6 +13,7 @@ import {
SortingSpecProcessor SortingSpecProcessor
} from "./sorting-spec-processor" } from "./sorting-spec-processor"
import {CustomSortGroupType, CustomSortOrder, CustomSortSpec} from "./custom-sort-types"; import {CustomSortGroupType, CustomSortOrder, CustomSortSpec} from "./custom-sort-types";
import {FolderMatchingTreeNode} from "./folder-matching-rules";
const txtInputExampleA: string = ` const txtInputExampleA: string = `
order-asc: a-z order-asc: a-z
@ -348,17 +350,17 @@ describe('SortingSpecProcessor', () => {
it('should generate correct SortSpecs (complex example A)', () => { it('should generate correct SortSpecs (complex example A)', () => {
const inputTxtArr: Array<string> = txtInputExampleA.split('\n') const inputTxtArr: Array<string> = txtInputExampleA.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toEqual(expectedSortSpecsExampleA) expect(result?.sortSpecByPath).toEqual(expectedSortSpecsExampleA)
}) })
it('should generate correct SortSpecs (complex example A verbose)', () => { it('should generate correct SortSpecs (complex example A verbose)', () => {
const inputTxtArr: Array<string> = txtInputExampleAVerbose.split('\n') const inputTxtArr: Array<string> = txtInputExampleAVerbose.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toEqual(expectedSortSpecsExampleA) expect(result?.sortSpecByPath).toEqual(expectedSortSpecsExampleA)
}) })
it('should generate correct SortSpecs (example with numerical sorting symbols)', () => { it('should generate correct SortSpecs (example with numerical sorting symbols)', () => {
const inputTxtArr: Array<string> = txtInputExampleNumericSortingSymbols.split('\n') const inputTxtArr: Array<string> = txtInputExampleNumericSortingSymbols.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toEqual(expectedSortSpecsExampleNumericSortingSymbols) expect(result?.sortSpecByPath).toEqual(expectedSortSpecsExampleNumericSortingSymbols)
}) })
}) })
@ -401,7 +403,36 @@ describe('SortingSpecProcessor', () => {
it('should not duplicate spec if former target-folder had some attribute specified', () => { it('should not duplicate spec if former target-folder had some attribute specified', () => {
const inputTxtArr: Array<string> = txtInputNotDuplicatedSortSpec.split('\n') const inputTxtArr: Array<string> = txtInputNotDuplicatedSortSpec.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toEqual(expectedSortSpecsNotDuplicatedSortSpec) expect(result?.sortSpecByPath).toEqual(expectedSortSpecsNotDuplicatedSortSpec)
})
})
const txtInputStandardObsidianSortAttr: string = `
target-folder: AAA
sorting: standard
`
const expectedSortSpecForObsidianStandardSorting: { [key: string]: CustomSortSpec } = {
"AAA": {
defaultOrder: CustomSortOrder.standardObsidian,
groups: [{
order: CustomSortOrder.standardObsidian,
type: CustomSortGroupType.Outsiders
}],
outsidersGroupIdx: 0,
targetFoldersPaths: ['AAA']
}
}
describe('SortingSpecProcessor', () => {
let processor: SortingSpecProcessor;
beforeEach(() => {
processor = new SortingSpecProcessor();
});
it('should recognize the standard Obsidian sorting attribute for a folder', () => {
const inputTxtArr: Array<string> = txtInputStandardObsidianSortAttr.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result?.sortSpecByPath).toEqual(expectedSortSpecForObsidianStandardSorting)
}) })
}) })
@ -435,7 +466,7 @@ describe('SortingSpecProcessor bonus experimental feature', () => {
const inputTxtArr: Array<string> = txtInputItemsToHideWithDupsSortSpec.split('\n') const inputTxtArr: Array<string> = txtInputItemsToHideWithDupsSortSpec.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
// REMARK: be careful with examining Set object // REMARK: be careful with examining Set object
expect(result).toEqual(expectedHiddenItemsSortSpec) expect(result?.sortSpecByPath).toEqual(expectedHiddenItemsSortSpec)
}) })
}) })
@ -490,7 +521,7 @@ describe('SortingSpecProcessor - README.md examples', () => {
const inputTxtArr: Array<string> = txtInputItemsReadmeExample1Spec.split('\n') const inputTxtArr: Array<string> = txtInputItemsReadmeExample1Spec.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
// REMARK: be careful with examining Set object // REMARK: be careful with examining Set object
expect(result).toEqual(expectedReadmeExample1SortSpec) expect(result?.sortSpecByPath).toEqual(expectedReadmeExample1SortSpec)
}) })
}) })
@ -513,43 +544,244 @@ const txtInputTargetFolderAsDot: string = `
// Let me introduce a comment here ;-) to ensure it is ignored // Let me introduce a comment here ;-) to ensure it is ignored
target-folder: . target-folder: .
target-folder: CCC target-folder: CCC
target-folder: ./sub
target-folder: ./*
target-folder: ./...
//target-folder: ./.../
// This comment should be ignored as well // This comment should be ignored as well
` `
const expectedSortSpecsTargetFolderAsDot: { [key: string]: CustomSortSpec } = { const expectedSortSpecToBeMultiplied = {
'mock-folder': { groups: [{
groups: [{ order: CustomSortOrder.alphabetical,
order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders
type: CustomSortGroupType.Outsiders }],
}], outsidersGroupIdx: 0,
outsidersGroupIdx: 0, targetFoldersPaths: ['mock-folder', 'CCC', 'mock-folder/sub', "mock-folder/*", "mock-folder/..."]
targetFoldersPaths: ['mock-folder', 'CCC']
},
'CCC': {
groups: [{
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}],
outsidersGroupIdx: 0,
targetFoldersPaths: ['mock-folder', 'CCC']
}
} }
const expectedSortSpecsTargetFolderAsDot: { [key: string]: CustomSortSpec } = {
'mock-folder': expectedSortSpecToBeMultiplied,
'CCC': expectedSortSpecToBeMultiplied,
'mock-folder/sub': expectedSortSpecToBeMultiplied
}
describe('SortingSpecProcessor edge case', () => { describe('SortingSpecProcessor edge case', () => {
let processor: SortingSpecProcessor; let processor: SortingSpecProcessor;
beforeEach(() => { beforeEach(() => {
processor = new SortingSpecProcessor(); processor = new SortingSpecProcessor();
}); });
it('should not recognize empty spec containing only target folder', () => { it('should recognize empty spec containing only target folder', () => {
const inputTxtArr: Array<string> = txtInputEmptySpecOnlyTargetFolder.split('\n') const inputTxtArr: Array<string> = txtInputEmptySpecOnlyTargetFolder.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toEqual(expectedSortSpecsOnlyTargetFolder) expect(result?.sortSpecByPath).toEqual(expectedSortSpecsOnlyTargetFolder)
}) })
it('should not recognize and correctly replace dot as the target folder', () => { it('should recognize and correctly replace dot as the target folder', () => {
const inputTxtArr: Array<string> = txtInputTargetFolderAsDot.split('\n') const inputTxtArr: Array<string> = txtInputTargetFolderAsDot.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toEqual(expectedSortSpecsTargetFolderAsDot) expect(result?.sortSpecByPath).toEqual(expectedSortSpecsTargetFolderAsDot)
expect(result?.sortSpecByWildcard).not.toBeNull()
})
})
const txtInputTargetFolderMultiSpecA: string = `
target-folder: .
< a-z
target-folder: ./*
> a-z
target-folder: ./.../
< modified
`
const txtInputTargetFolderMultiSpecB: string = `
target-folder: ./*
> a-z
target-folder: ./.../
< modified
target-folder: .
< a-z
`
const expectedSortSpecForMultiSpecAandB: { [key: string]: CustomSortSpec } = {
'mock-folder': {
defaultOrder: CustomSortOrder.alphabetical,
groups: [{
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}],
outsidersGroupIdx: 0,
targetFoldersPaths: ['mock-folder']
}
}
const expectedWildcardMatchingTreeForMultiSpecAandB: FolderMatchingTreeNode<CustomSortSpec> = {
subtree: {
"mock-folder": {
matchAll: {
"defaultOrder": CustomSortOrder.alphabeticalReverse,
"groups": [{
"order": CustomSortOrder.alphabeticalReverse,
"type": CustomSortGroupType.Outsiders
}],
"outsidersGroupIdx": 0,
"targetFoldersPaths": ["mock-folder/*"]
},
matchChildren: {
"defaultOrder": CustomSortOrder.byModifiedTime,
"groups": [{
"order": CustomSortOrder.byModifiedTime,
"type": CustomSortGroupType.Outsiders
}],
"outsidersGroupIdx": 0,
"targetFoldersPaths": ["mock-folder/.../"]
},
name: "mock-folder",
subtree: {}
}
}
}
const txtInputTargetFolderMultiSpecC: string = `
target-folder: ./*
> a-z
target-folder: ./.../
`
const expectedSortSpecForMultiSpecC: { [key: string]: CustomSortSpec } = {
'mock-folder': {
groups: [{
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}],
outsidersGroupIdx: 0,
targetFoldersPaths: ['mock-folder/.../']
}
}
const expectedWildcardMatchingTreeForMultiSpecC: FolderMatchingTreeNode<CustomSortSpec> = {
subtree: {
"mock-folder": {
matchAll: {
"defaultOrder": CustomSortOrder.alphabeticalReverse,
"groups": [{
"order": CustomSortOrder.alphabeticalReverse,
"type": CustomSortGroupType.Outsiders
}],
"outsidersGroupIdx": 0,
"targetFoldersPaths": ["mock-folder/*"]
},
matchChildren: {
"groups": [{
"order": CustomSortOrder.alphabetical,
"type": CustomSortGroupType.Outsiders
}],
"outsidersGroupIdx": 0,
"targetFoldersPaths": ["mock-folder/.../"]
},
name: "mock-folder",
subtree: {}
}
}
}
const txtInputTargetFolderMultiSpecD: string = `
target-folder: ./*
`
const expectedSortSpecForMultiSpecD: { [key: string]: CustomSortSpec } = {
'mock-folder': {
groups: [{
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}],
outsidersGroupIdx: 0,
targetFoldersPaths: ['mock-folder/*']
}
}
const expectedWildcardMatchingTreeForMultiSpecD: FolderMatchingTreeNode<CustomSortSpec> = {
subtree: {
"mock-folder": {
matchAll: {
"groups": [{
"order": CustomSortOrder.alphabetical,
"type": CustomSortGroupType.Outsiders
}],
"outsidersGroupIdx": 0,
"targetFoldersPaths": ["mock-folder/*"]
},
name: "mock-folder",
subtree: {}
}
}
}
const txtInputTargetFolderMultiSpecE: string = `
target-folder: mock-folder/...
`
const expectedSortSpecForMultiSpecE: { [key: string]: CustomSortSpec } = {
'mock-folder': {
groups: [{
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}],
outsidersGroupIdx: 0,
targetFoldersPaths: ['mock-folder/...']
}
}
const expectedWildcardMatchingTreeForMultiSpecE: FolderMatchingTreeNode<CustomSortSpec> = {
subtree: {
"mock-folder": {
matchChildren: {
"groups": [{
"order": CustomSortOrder.alphabetical,
"type": CustomSortGroupType.Outsiders
}],
"outsidersGroupIdx": 0,
"targetFoldersPaths": ["mock-folder/..."]
},
name: "mock-folder",
subtree: {}
}
}
}
describe('SortingSpecProcessor path wildcard priorities', () => {
let processor: SortingSpecProcessor;
beforeEach(() => {
processor = new SortingSpecProcessor();
});
it('should not raise error for multiple spec for the same path and choose correct spec, case A', () => {
const inputTxtArr: Array<string> = txtInputTargetFolderMultiSpecA.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result?.sortSpecByPath).toEqual(expectedSortSpecForMultiSpecAandB)
expect(result?.sortSpecByWildcard.tree).toEqual(expectedWildcardMatchingTreeForMultiSpecAandB)
})
it('should not raise error for multiple spec for the same path and choose correct spec, case B', () => {
const inputTxtArr: Array<string> = txtInputTargetFolderMultiSpecB.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result?.sortSpecByPath).toEqual(expectedSortSpecForMultiSpecAandB)
expect(result?.sortSpecByWildcard.tree).toEqual(expectedWildcardMatchingTreeForMultiSpecAandB)
})
it('should not raise error for multiple spec for the same path and choose correct spec, case C', () => {
const inputTxtArr: Array<string> = txtInputTargetFolderMultiSpecC.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result?.sortSpecByPath).toEqual(expectedSortSpecForMultiSpecC)
expect(result?.sortSpecByWildcard.tree).toEqual(expectedWildcardMatchingTreeForMultiSpecC)
})
it('should not raise error for multiple spec for the same path and choose correct spec, case D', () => {
const inputTxtArr: Array<string> = txtInputTargetFolderMultiSpecD.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result?.sortSpecByPath).toEqual(expectedSortSpecForMultiSpecD)
expect(result?.sortSpecByWildcard.tree).toEqual(expectedWildcardMatchingTreeForMultiSpecD)
})
it('should not raise error for multiple spec for the same path and choose correct spec, case E', () => {
const inputTxtArr: Array<string> = txtInputTargetFolderMultiSpecE.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result?.sortSpecByPath).toEqual(expectedSortSpecForMultiSpecE)
expect(result?.sortSpecByWildcard.tree).toEqual(expectedWildcardMatchingTreeForMultiSpecE)
}) })
}) })
@ -600,6 +832,13 @@ target-folder: AAA
const txtInputErrorTooManyNumericSortSymbols: string = ` const txtInputErrorTooManyNumericSortSymbols: string = `
% Chapter\\R+ ... page\\d+ % Chapter\\R+ ... page\\d+
` `
const txtInputErrorNestedStandardObsidianSortAttr: string = `
target-folder: AAA
/ Some folder
sorting: standard
`
const txtInputEmptySpec: string = `` const txtInputEmptySpec: string = ``
describe('SortingSpecProcessor error detection and reporting', () => { describe('SortingSpecProcessor error detection and reporting', () => {
@ -687,6 +926,15 @@ describe('SortingSpecProcessor error detection and reporting', () => {
`${ERR_PREFIX} 9:TooManyNumericSortingSymbols Maximum one numeric sorting indicator allowed per line ${ERR_SUFFIX_IN_LINE(2)}`) `${ERR_PREFIX} 9:TooManyNumericSortingSymbols Maximum one numeric sorting indicator allowed per line ${ERR_SUFFIX_IN_LINE(2)}`)
expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('% Chapter\\R+ ... page\\d+ ')) expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('% Chapter\\R+ ... page\\d+ '))
}) })
it('should recognize error: nested standard obsidian sorting attribute', () => {
const inputTxtArr: Array<string> = txtInputErrorNestedStandardObsidianSortAttr.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toBeNull()
expect(errorsLogger).toHaveBeenCalledTimes(2)
expect(errorsLogger).toHaveBeenNthCalledWith(1,
`${ERR_PREFIX} 14:StandardObsidianSortAllowedOnlyAtFolderLevel The standard Obsidian sort order is only allowed at a folder level (not nested syntax) ${ERR_SUFFIX_IN_LINE(4)}`)
expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT(' sorting: standard'))
})
it('should recognize empty spec', () => { it('should recognize empty spec', () => {
const inputTxtArr: Array<string> = txtInputEmptySpec.split('\n') const inputTxtArr: Array<string> = txtInputEmptySpec.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
@ -709,6 +957,24 @@ describe('SortingSpecProcessor error detection and reporting', () => {
}) })
}) })
const txtInputTargetFolderCCC: string = `
target-folder: CCC
`
describe('SortingSpecProcessor advanced error detection', () => {
it('should retain state of duplicates detection in the instance', () => {
let processor: SortingSpecProcessor = new SortingSpecProcessor(errorsLogger);
errorsLogger.mockReset()
const inputTxtArr: Array<string> = txtInputTargetFolderCCC.split('\n')
const result1 = processor.parseSortSpecFromText(inputTxtArr, 'another-mock-folder', 'sortspec.md')
const result2 = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result1).not.toBeNull()
expect(result2).toBeNull()
expect(errorsLogger).toHaveBeenCalledTimes(1)
expect(errorsLogger).toHaveBeenCalledWith(`${ERR_PREFIX} 2:DuplicateSortSpecForSameFolder Duplicate sorting spec for folder CCC ${ERR_SUFFIX}`)
})
})
describe('convertPlainStringSortingGroupSpecToArraySpec', () => { describe('convertPlainStringSortingGroupSpecToArraySpec', () => {
let processor: SortingSpecProcessor; let processor: SortingSpecProcessor;
beforeEach(() => { beforeEach(() => {

View File

@ -3,9 +3,9 @@ import {
CustomSortGroupType, CustomSortGroupType,
CustomSortOrder, CustomSortOrder,
CustomSortSpec, CustomSortSpec,
NormalizerFn, RecognizedOrderValue, NormalizerFn,
RegExpSpec, RecognizedOrderValue,
SortSpecsCollection RegExpSpec
} from "./custom-sort-types"; } from "./custom-sort-types";
import {isDefined, last} from "../utils/utils"; import {isDefined, last} from "../utils/utils";
import { import {
@ -20,6 +20,12 @@ import {
NumberRegexStr, NumberRegexStr,
RomanNumberRegexStr RomanNumberRegexStr
} from "./matchers"; } from "./matchers";
import {
FolderWildcardMatching,
MATCH_ALL_SUFFIX,
MATCH_CHILDREN_1_SUFFIX,
MATCH_CHILDREN_2_SUFFIX
} from "./folder-matching-rules"
interface ProcessingContext { interface ProcessingContext {
folderPath: string folderPath: string
@ -54,11 +60,14 @@ export enum ProblemCode {
TooManyNumericSortingSymbols, TooManyNumericSortingSymbols,
NumericalSymbolAdjacentToWildcard, NumericalSymbolAdjacentToWildcard,
ItemToHideExactNameWithExtRequired, ItemToHideExactNameWithExtRequired,
ItemToHideNoSupportForThreeDots ItemToHideNoSupportForThreeDots,
DuplicateWildcardSortSpecForSameFolder,
StandardObsidianSortAllowedOnlyAtFolderLevel
} }
const ContextFreeProblems = new Set<ProblemCode>([ const ContextFreeProblems = new Set<ProblemCode>([
ProblemCode.DuplicateSortSpecForSameFolder ProblemCode.DuplicateSortSpecForSameFolder,
ProblemCode.DuplicateWildcardSortSpecForSameFolder
]) ])
const ThreeDots = '...'; const ThreeDots = '...';
@ -104,7 +113,8 @@ const OrderLiterals: { [key: string]: CustomSortOrderAscDescPair } = {
enum Attribute { enum Attribute {
TargetFolder = 1, // Starting from 1 to allow: if (attribute) { ... TargetFolder = 1, // Starting from 1 to allow: if (attribute) { ...
OrderAsc, OrderAsc,
OrderDesc OrderDesc,
OrderStandardObsidian
} }
const AttrLexems: { [key: string]: Attribute } = { const AttrLexems: { [key: string]: Attribute } = {
@ -112,6 +122,7 @@ const AttrLexems: { [key: string]: Attribute } = {
'target-folder:': Attribute.TargetFolder, 'target-folder:': Attribute.TargetFolder,
'order-asc:': Attribute.OrderAsc, 'order-asc:': Attribute.OrderAsc,
'order-desc:': Attribute.OrderDesc, 'order-desc:': Attribute.OrderDesc,
'sorting:': Attribute.OrderStandardObsidian,
// Concise abbreviated equivalents // Concise abbreviated equivalents
'::::': Attribute.TargetFolder, '::::': Attribute.TargetFolder,
'<': Attribute.OrderAsc, '<': Attribute.OrderAsc,
@ -280,6 +291,15 @@ export const convertPlainStringWithNumericSortingSymbolToRegex = (s: string, act
} }
} }
export interface FolderPathToSortSpecMap {
[key: string]: CustomSortSpec
}
export interface SortSpecsCollection {
sortSpecByPath: FolderPathToSortSpecMap
sortSpecByWildcard?: FolderWildcardMatching<CustomSortSpec>
}
interface AdjacencyInfo { interface AdjacencyInfo {
noPrefix: boolean, noPrefix: boolean,
noSuffix: boolean noSuffix: boolean
@ -292,6 +312,43 @@ const checkAdjacency = (sortingSymbolInfo: ExtractedNumericSortingSymbolInfo): A
} }
} }
const endsWithWildcardPatternSuffix = (path: string): boolean => {
return path.endsWith(MATCH_CHILDREN_1_SUFFIX) ||
path.endsWith(MATCH_CHILDREN_2_SUFFIX) ||
path.endsWith(MATCH_ALL_SUFFIX)
}
enum WildcardPriority {
NO_WILDCARD = 1,
MATCH_CHILDREN,
MATCH_ALL
}
const stripWildcardPatternSuffix = (path: string): [path: string, priority: number] => {
if (path.endsWith(MATCH_ALL_SUFFIX)) {
return [
path.slice(0, -MATCH_ALL_SUFFIX.length),
WildcardPriority.MATCH_ALL
]
}
if (path.endsWith(MATCH_CHILDREN_1_SUFFIX)) {
return [
path.slice(0, -MATCH_CHILDREN_1_SUFFIX.length),
WildcardPriority.MATCH_CHILDREN,
]
}
if (path.endsWith(MATCH_CHILDREN_2_SUFFIX)) {
return [
path.slice(0, -MATCH_CHILDREN_2_SUFFIX.length),
WildcardPriority.MATCH_CHILDREN
]
}
return [
path,
WildcardPriority.NO_WILDCARD
]
}
const ADJACENCY_ERROR: string = "Numerical sorting symbol must not be directly adjacent to a wildcard because of potential performance problem. An additional explicit separator helps in such case." const ADJACENCY_ERROR: string = "Numerical sorting symbol must not be directly adjacent to a wildcard because of potential performance problem. An additional explicit separator helps in such case."
export class SortingSpecProcessor { export class SortingSpecProcessor {
@ -302,6 +359,11 @@ export class SortingSpecProcessor {
problemAlreadyReportedForCurrentLine: boolean problemAlreadyReportedForCurrentLine: boolean
recentErrorMessage: string recentErrorMessage: string
// Helper map to deal with rule priorities for the same path
// and also detect non-wildcard duplicates.
// The wildcard duplicates were detected prior to this point, no need to bother about them
pathMatchPriorityForPath: {[key: string]: WildcardPriority} = {}
// Logger parameter exposed to support unit testing of error cases as well as capturing error messages // Logger parameter exposed to support unit testing of error cases as well as capturing error messages
// for in-app presentation // for in-app presentation
constructor(private errorLogger?: typeof console.log) { constructor(private errorLogger?: typeof console.log) {
@ -313,10 +375,17 @@ export class SortingSpecProcessor {
sortingSpecFileName: string, sortingSpecFileName: string,
collection?: SortSpecsCollection collection?: SortSpecsCollection
): SortSpecsCollection { ): SortSpecsCollection {
// reset / init processing state after potential previous invocation
this.ctx = { this.ctx = {
folderPath: folderPath, // location of the sorting spec file folderPath: folderPath, // location of the sorting spec file
specs: [] specs: []
}; };
this.currentEntryLine = null
this.currentEntryLineIdx = null
this.currentSortingSpecContainerFilePath = null
this.problemAlreadyReportedForCurrentLine = null
this.recentErrorMessage = null
let success: boolean = false; let success: boolean = false;
let lineIdx: number = 0; let lineIdx: number = 0;
for (let entryLine of text) { for (let entryLine of text) {
@ -359,13 +428,51 @@ export class SortingSpecProcessor {
if (this.ctx.specs.length > 0) { if (this.ctx.specs.length > 0) {
for (let spec of this.ctx.specs) { for (let spec of this.ctx.specs) {
this._l1s6_postprocessSortSpec(spec) this._l1s6_postprocessSortSpec(spec)
for (let folderPath of spec.targetFoldersPaths) { }
collection = collection ?? {}
if (collection[folderPath]) { let sortspecByWildcard: FolderWildcardMatching<CustomSortSpec>
this.problem(ProblemCode.DuplicateSortSpecForSameFolder, `Duplicate sorting spec for folder ${folderPath}`) for (let spec of this.ctx.specs) {
return null // Failure - not allow duplicate specs for the same folder // Consume the folder paths ending with wildcard specs
for (let idx = 0; idx<spec.targetFoldersPaths.length; idx++) {
const path = spec.targetFoldersPaths[idx]
if (endsWithWildcardPatternSuffix(path)) {
sortspecByWildcard = sortspecByWildcard ?? new FolderWildcardMatching<CustomSortSpec>()
const ruleAdded = sortspecByWildcard.addWildcardDefinition(path, spec)
if (ruleAdded?.errorMsg) {
this.problem(ProblemCode.DuplicateWildcardSortSpecForSameFolder, ruleAdded?.errorMsg)
return null // Failure - not allow duplicate wildcard specs for the same folder
}
}
}
}
if (sortspecByWildcard) {
collection = collection ?? { sortSpecByPath:{} }
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 (storeTheSpec) {
collection.sortSpecByPath[path] = spec
this.pathMatchPriorityForPath[path] = detectedWildcardPriority
} }
collection[folderPath] = spec
} }
} }
} }
@ -452,7 +559,7 @@ export class SortingSpecProcessor {
this.problem(ProblemCode.TargetFolderNestedSpec, `Nested (indented) specification of target folder is not allowed`) this.problem(ProblemCode.TargetFolderNestedSpec, `Nested (indented) specification of target folder is not allowed`)
return false return false
} }
} else if (attr.attribute === Attribute.OrderAsc || attr.attribute === Attribute.OrderDesc) { } else if (attr.attribute === Attribute.OrderAsc || attr.attribute === Attribute.OrderDesc || attr.attribute === Attribute.OrderStandardObsidian) {
if (attr.nesting === 0) { if (attr.nesting === 0) {
if (!this.ctx.currentSpec) { if (!this.ctx.currentSpec) {
this._l2s2_putNewSpecForNewTargetFolder() this._l2s2_putNewSpecForNewTargetFolder()
@ -474,6 +581,10 @@ export class SortingSpecProcessor {
this.problem(ProblemCode.DuplicateOrderAttr, `Duplicate order specification for a sorting rule of folder ${folderPathsForProblemMsg}`) this.problem(ProblemCode.DuplicateOrderAttr, `Duplicate order specification for a sorting rule of folder ${folderPathsForProblemMsg}`)
return false; return false;
} }
if ((attr.value as RecognizedOrderValue).order === CustomSortOrder.standardObsidian) {
this.problem(ProblemCode.StandardObsidianSortAllowedOnlyAtFolderLevel, `The standard Obsidian sort order is only allowed at a folder level (not nested syntax)`)
return false;
}
this.ctx.currentSpecGroup.order = (attr.value as RecognizedOrderValue).order this.ctx.currentSpecGroup.order = (attr.value as RecognizedOrderValue).order
this.ctx.currentSpecGroup.secondaryOrder = (attr.value as RecognizedOrderValue).secondaryOrder this.ctx.currentSpecGroup.secondaryOrder = (attr.value as RecognizedOrderValue).secondaryOrder
return true; return true;
@ -635,10 +746,14 @@ export class SortingSpecProcessor {
} }
} }
const CURRENT_FOLDER_PREFIX: string = `${CURRENT_FOLDER_SYMBOL}/`
// Replace the dot-folder names (coming from: 'target-folder: .') with actual folder names // Replace the dot-folder names (coming from: 'target-folder: .') with actual folder names
spec.targetFoldersPaths.forEach((path, idx) => { spec.targetFoldersPaths.forEach((path, idx) => {
if (path === CURRENT_FOLDER_SYMBOL) { if (path === CURRENT_FOLDER_SYMBOL) {
spec.targetFoldersPaths[idx] = this.ctx.folderPath spec.targetFoldersPaths[idx] = this.ctx.folderPath
} else if (path.startsWith(CURRENT_FOLDER_PREFIX)) {
spec.targetFoldersPaths[idx] = `${this.ctx.folderPath}/${path.substr(CURRENT_FOLDER_PREFIX.length)}`
} }
}); });
} }
@ -675,10 +790,19 @@ export class SortingSpecProcessor {
} : null; } : null;
} }
private _l2s1_validateSortingAttrValue = (v: string): RecognizedOrderValue | null => {
// for now only a single fixed lexem
const recognized: boolean = v.trim().toLowerCase() === 'standard'
return recognized ? {
order: CustomSortOrder.standardObsidian
} : null;
}
attrValueValidators: { [key in Attribute]: AttrValueValidatorFn } = { attrValueValidators: { [key in Attribute]: AttrValueValidatorFn } = {
[Attribute.TargetFolder]: this._l2s1_validateTargetFolderAttrValue.bind(this), [Attribute.TargetFolder]: this._l2s1_validateTargetFolderAttrValue.bind(this),
[Attribute.OrderAsc]: this._l2s1_validateOrderAscAttrValue.bind(this), [Attribute.OrderAsc]: this._l2s1_validateOrderAscAttrValue.bind(this),
[Attribute.OrderDesc]: this._l2s1_validateOrderDescAttrValue.bind(this), [Attribute.OrderDesc]: this._l2s1_validateOrderDescAttrValue.bind(this),
[Attribute.OrderStandardObsidian]: this._l2s1_validateSortingAttrValue.bind(this)
} }
_l2s2_convertPlainStringSortingGroupSpecToArraySpec = (spec: string): Array<string> => { _l2s2_convertPlainStringSortingGroupSpecToArraySpec = (spec: string): Array<string> => {

View File

@ -14,14 +14,15 @@ import {
} from 'obsidian'; } from 'obsidian';
import {around} from 'monkey-around'; import {around} from 'monkey-around';
import {folderSort} from './custom-sort/custom-sort'; import {folderSort} from './custom-sort/custom-sort';
import {SortingSpecProcessor} from './custom-sort/sorting-spec-processor'; import {SortingSpecProcessor, SortSpecsCollection} from './custom-sort/sorting-spec-processor';
import {CustomSortSpec, SortSpecsCollection} from './custom-sort/custom-sort-types'; import {CustomSortOrder, CustomSortSpec} from './custom-sort/custom-sort-types';
import { import {
addIcons, addIcons,
ICON_SORT_ENABLED_ACTIVE, ICON_SORT_ENABLED_ACTIVE,
ICON_SORT_ENABLED_NOT_APPLIED,
ICON_SORT_SUSPENDED, ICON_SORT_SUSPENDED,
ICON_SORT_SUSPENDED_SYNTAX_ERROR, ICON_SORT_SUSPENDED_SYNTAX_ERROR
ICON_SORT_ENABLED_NOT_APPLIED
} from "./custom-sort/icons"; } from "./custom-sort/icons";
interface CustomSortPluginSettings { interface CustomSortPluginSettings {
@ -54,6 +55,8 @@ export default class CustomSortPlugin extends Plugin {
let errorMessage: string let errorMessage: string
// reset cache // reset cache
this.sortSpecCache = null this.sortSpecCache = null
const processor: SortingSpecProcessor = new SortingSpecProcessor()
Vault.recurseChildren(this.app.vault.getRoot(), (file: TAbstractFile) => { Vault.recurseChildren(this.app.vault.getRoot(), (file: TAbstractFile) => {
if (failed) return if (failed) return
if (file instanceof TFile) { if (file instanceof TFile) {
@ -67,7 +70,6 @@ export default class CustomSortPlugin extends Plugin {
const sortingSpecTxt: string = mCache.getCache(aFile.path)?.frontmatter?.[SORTINGSPEC_YAML_KEY] const sortingSpecTxt: string = mCache.getCache(aFile.path)?.frontmatter?.[SORTINGSPEC_YAML_KEY]
if (sortingSpecTxt) { if (sortingSpecTxt) {
anySortingSpecFound = true anySortingSpecFound = true
const processor: SortingSpecProcessor = new SortingSpecProcessor()
this.sortSpecCache = processor.parseSortSpecFromText( this.sortSpecCache = processor.parseSortSpecFromText(
sortingSpecTxt.split('\n'), sortingSpecTxt.split('\n'),
parent.path, parent.path,
@ -87,9 +89,9 @@ export default class CustomSortPlugin extends Plugin {
new Notice(`Parsing custom sorting specification SUCCEEDED!`) new Notice(`Parsing custom sorting specification SUCCEEDED!`)
} else { } else {
if (anySortingSpecFound) { if (anySortingSpecFound) {
errorMessage = `No valid '${SORTINGSPEC_YAML_KEY}:' key(s) in YAML front matter or multiline YAML indentation error or general YAML syntax error` errorMessage = errorMessage ? errorMessage : `No valid '${SORTINGSPEC_YAML_KEY}:' key(s) in YAML front matter or multiline YAML indentation error or general YAML syntax error`
} else { } else {
errorMessage = errorMessage ? errorMessage : `No custom sorting specification found or only empty specification(s)` errorMessage = `No custom sorting specification found or only empty specification(s)`
} }
new Notice(`Parsing custom sorting specification FAILED. Suspending the plugin.\n${errorMessage}`, ERROR_NOTICE_TIMEOUT) new Notice(`Parsing custom sorting specification FAILED. Suspending the plugin.\n${errorMessage}`, ERROR_NOTICE_TIMEOUT)
this.settings.suspended = true this.settings.suspended = true
@ -196,13 +198,30 @@ export default class CustomSortPlugin extends Plugin {
let tmpFolder = new TFolder(Vault, ""); let tmpFolder = new TFolder(Vault, "");
let Folder = fileExplorer.createFolderDom(tmpFolder).constructor; let Folder = fileExplorer.createFolderDom(tmpFolder).constructor;
this.register( this.register(
// TODO: Unit tests please!!! The logic below becomes more and more complex, bugs are captured at run-time...
around(Folder.prototype, { around(Folder.prototype, {
sort(old: any) { sort(old: any) {
return function (...args: any[]) { return function (...args: any[]) {
// quick check for plugin status
if (plugin.settings.suspended) {
return old.call(this, ...args);
}
// if custom sort is not specified, use the UI-selected // if custom sort is not specified, use the UI-selected
const folder: TFolder = this.file const folder: TFolder = this.file
const sortSpec: CustomSortSpec = plugin.sortSpecCache?.[folder.path] let sortSpec: CustomSortSpec = plugin.sortSpecCache?.sortSpecByPath[folder.path]
if (!plugin.settings.suspended && sortSpec) { 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)
if (sortSpec?.defaultOrder === CustomSortOrder.standardObsidian) {
sortSpec = null // A folder subtree can be also explicitly excluded from custom sorting plugin
}
}
if (sortSpec) {
return folderSort.call(this, sortSpec, ...args); return folderSort.call(this, sortSpec, ...args);
} else { } else {
return old.call(this, ...args); return old.call(this, ...args);

View File

@ -1,4 +1,5 @@
{ {
"0.5.188": "0.12.0", "0.5.188": "0.12.0",
"0.5.189": "0.12.0" "0.5.189": "0.12.0",
"0.6.0": "0.12.0"
} }