From e972bce0073729c3b347a3a41cd81bfa63c608f9 Mon Sep 17 00:00:00 2001 From: SebastianMC Date: Tue, 30 Aug 2022 18:53:58 +0200 Subject: [PATCH] Ticket #1: added support for imposed sorting rules inheritance by subfolders - new syntax: target-folder: Reviews/* <-- applies to folder, subfolders and all descendant folders - new syntax: target-folder: Reviews/... <-- applies to folder and subfolders - special case: target-folder: /* tells the plugin to apply the specified sorting to entire vault - enhanced syntax for '.' - can be used to specify relative path now (e.g. target-folder: ./daily) - added new sorting attribute (sorting: standard) to actually give back the sorting into the hands of standard Obsidian mechanisms - fixed a minor bug: some of error messages were displayed only in console, not in the ballons - unit tests for all new and changed functionality - pending README.md update --- manifest.json | 2 +- package.json | 2 +- src/custom-sort/custom-sort-types.ts | 11 +- src/custom-sort/custom-sort.ts | 8 +- src/custom-sort/folder-matching-rules.spec.ts | 120 +++++++ src/custom-sort/folder-matching-rules.ts | 116 +++++++ .../sorting-spec-processor.spec.ts | 304 ++++++++++++++++-- src/custom-sort/sorting-spec-processor.ts | 143 +++++++- src/main.ts | 34 +- versions.json | 3 +- 10 files changed, 679 insertions(+), 64 deletions(-) create mode 100644 src/custom-sort/folder-matching-rules.spec.ts create mode 100644 src/custom-sort/folder-matching-rules.ts diff --git a/manifest.json b/manifest.json index fe0b526..a6ae89d 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "custom-sort", "name": "Custom File Explorer sorting", - "version": "0.5.189", + "version": "0.6.0", "minAppVersion": "0.12.0", "description": "Allows for manual and automatic, config-driven reordering and sorting of files and folders in File Explorer", "author": "SebastianMC ", diff --git a/package.json b/package.json index 0823f72..3d7d0b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-custom-sort", - "version": "0.5.189", + "version": "0.6.0", "description": "Custom Sort plugin for Obsidian (https://obsidian.md)", "main": "main.js", "scripts": { diff --git a/src/custom-sort/custom-sort-types.ts b/src/custom-sort/custom-sort-types.ts index 8b293ef..5866ca7 100644 --- a/src/custom-sort/custom-sort-types.ts +++ b/src/custom-sort/custom-sort-types.ts @@ -1,5 +1,3 @@ -export const SortSpecFileName: string = 'sortspec.md'; - export enum CustomSortGroupType { 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 @@ -15,7 +13,8 @@ export enum CustomSortOrder { byModifiedTime, byModifiedTimeReverse, byCreatedTime, - byCreatedTimeReverse + byCreatedTimeReverse, + standardObsidian// Let the folder sorting be in hands of Obsidian, whatever user selected in the UI } export interface RecognizedOrderValue { @@ -52,9 +51,3 @@ export interface CustomSortSpec { outsidersFoldersGroupIdx?: number itemsToHide?: Set } - -export interface FolderPathToSortSpecMap { - [key: string]: CustomSortSpec -} - -export type SortSpecsCollection = FolderPathToSortSpecMap diff --git a/src/custom-sort/custom-sort.ts b/src/custom-sort/custom-sort.ts index 564b086..0791e68 100644 --- a/src/custom-sort/custom-sort.ts +++ b/src/custom-sort/custom-sort.ts @@ -31,7 +31,10 @@ let Sorters: { [key in CustomSortOrder]: SorterFn } = { [CustomSortOrder.byModifiedTime]: (a: FolderItemForSorting, b: FolderItemForSorting) => a.mtime - b.mtime, [CustomSortOrder.byModifiedTimeReverse]: (a: FolderItemForSorting, b: FolderItemForSorting) => b.mtime - a.mtime, [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) { @@ -53,7 +56,7 @@ const isFolder = (entry: TFile | TFolder) => { } export const determineSortingGroup = function (entry: TFile | TFolder, spec: CustomSortSpec): FolderItemForSorting { - let groupIdx: number = 0 + let groupIdx: number let determined: boolean = false let matchedGroup: string 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[]) { let fileExplorer = this.fileExplorer - const thisFolderPath: string = this.file.path; const folderItems: Array = (sortingSpec.itemsToHide ? this.file.children.filter((entry: TFile | TFolder) => { diff --git a/src/custom-sort/folder-matching-rules.spec.ts b/src/custom-sort/folder-matching-rules.spec.ts new file mode 100644 index 0000000..836a56b --- /dev/null +++ b/src/custom-sort/folder-matching-rules.spec.ts @@ -0,0 +1,120 @@ +import {FolderWildcardMatching} from './folder-matching-rules' + +type SortingSpec = string + +const createMockMatcherRichVersion = (): FolderWildcardMatching => { + const matcher: FolderWildcardMatching = 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 => { + const matcher: FolderWildcardMatching = new FolderWildcardMatching() + matcher.addWildcardDefinition('/Reviews/daily/*', '/Reviews/daily/*') + return matcher +} + +const createMockMatcherRootOnlyVersion = (): FolderWildcardMatching => { + const matcher: FolderWildcardMatching = new FolderWildcardMatching() + matcher.addWildcardDefinition('/...', '/...') + return matcher +} + +const createMockMatcherRootOnlyDeepVersion = (): FolderWildcardMatching => { + const matcher: FolderWildcardMatching = new FolderWildcardMatching() + matcher.addWildcardDefinition('/*', '/*') + return matcher +} + +const createMockMatcherSimpleVersion = (): FolderWildcardMatching => { + const matcher: FolderWildcardMatching = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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/*"}) + }) +}) diff --git a/src/custom-sort/folder-matching-rules.ts b/src/custom-sort/folder-matching-rules.ts new file mode 100644 index 0000000..722edce --- /dev/null +++ b/src/custom-sort/folder-matching-rules.ts @@ -0,0 +1,116 @@ +export interface FolderPattern { + path: string + deep: boolean + nestingLevel: number +} + +export type DeterminedSortingSpec = { + spec?: SortingSpec +} + +export interface FolderMatchingTreeNode { + path?: string + name?: string + matchChildren?: SortingSpec + matchAll?: SortingSpec + subtree: { [key: string]: FolderMatchingTreeNode } +} + +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 => { + return path.split(SLASH).filter((name) => !!name) +} + +export interface AddingWildcardFailure { + errorMsg: string +} + +export class FolderWildcardMatching { + + tree: FolderMatchingTreeNode = { + subtree: {} + } + + // cache + determinedWildcardRules: { [key: string]: DeterminedSortingSpec } = {} + + addWildcardDefinition = (wilcardDefinition: string, rule: SortingSpec): AddingWildcardFailure | null => { + const pathComponents: Array = splitPath(wilcardDefinition) + const lastComponent: string = pathComponents.pop() + if (lastComponent !== MATCH_ALL_PATH_TOKEN && lastComponent !== MATCH_CHILDREN_PATH_TOKEN) { + return null + } + let leafNode: FolderMatchingTreeNode = this.tree + pathComponents.forEach((pathComponent) => { + let subtree: FolderMatchingTreeNode = leafNode.subtree[pathComponent] + if (subtree) { + leafNode = subtree + } else { + const newSubtree: FolderMatchingTreeNode = { + 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 = 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 = splitPath(folderPath) + let parentNode: FolderMatchingTreeNode = this.tree + let lastIdx: number = pathComponents.length - 1 + for(let i=0; i<=lastIdx; i++) { + const name: string = pathComponents[i] + let matchedPath: FolderMatchingTreeNode = parentNode.subtree[name] + if (matchedPath) { + parentNode = matchedPath + rule = matchedPath?.matchChildren ?? null + inheritedRule = matchedPath.matchAll ?? inheritedRule + } else { + if (i < lastIdx) { + rule = inheritedRule + } + break + } + } + + rule = rule ?? inheritedRule + + if (rule) { + this.determinedWildcardRules[folderPath] = {spec: rule} + return rule + } else { + this.determinedWildcardRules[folderPath] = {} + return null + } + } + } +} diff --git a/src/custom-sort/sorting-spec-processor.spec.ts b/src/custom-sort/sorting-spec-processor.spec.ts index c760ba1..03cc210 100644 --- a/src/custom-sort/sorting-spec-processor.spec.ts +++ b/src/custom-sort/sorting-spec-processor.spec.ts @@ -1,5 +1,6 @@ import { - CompoundDashNumberNormalizerFn, CompoundDashRomanNumberNormalizerFn, + CompoundDashNumberNormalizerFn, + CompoundDashRomanNumberNormalizerFn, CompoundDotNumberNormalizerFn, convertPlainStringWithNumericSortingSymbolToRegex, detectNumericSortingSymbols, @@ -12,6 +13,7 @@ import { SortingSpecProcessor } from "./sorting-spec-processor" import {CustomSortGroupType, CustomSortOrder, CustomSortSpec} from "./custom-sort-types"; +import {FolderMatchingTreeNode} from "./folder-matching-rules"; const txtInputExampleA: string = ` order-asc: a-z @@ -348,17 +350,17 @@ describe('SortingSpecProcessor', () => { it('should generate correct SortSpecs (complex example A)', () => { const inputTxtArr: Array = txtInputExampleA.split('\n') 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)', () => { const inputTxtArr: Array = txtInputExampleAVerbose.split('\n') 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)', () => { const inputTxtArr: Array = txtInputExampleNumericSortingSymbols.split('\n') 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', () => { const inputTxtArr: Array = txtInputNotDuplicatedSortSpec.split('\n') 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 = 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 = txtInputItemsToHideWithDupsSortSpec.split('\n') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') // 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 = txtInputItemsReadmeExample1Spec.split('\n') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') // 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 target-folder: . target-folder: CCC +target-folder: ./sub +target-folder: ./* +target-folder: ./... +//target-folder: ./.../ // This comment should be ignored as well ` -const expectedSortSpecsTargetFolderAsDot: { [key: string]: CustomSortSpec } = { - 'mock-folder': { - groups: [{ - order: CustomSortOrder.alphabetical, - type: CustomSortGroupType.Outsiders - }], - outsidersGroupIdx: 0, - targetFoldersPaths: ['mock-folder', 'CCC'] - }, - 'CCC': { - groups: [{ - order: CustomSortOrder.alphabetical, - type: CustomSortGroupType.Outsiders - }], - outsidersGroupIdx: 0, - targetFoldersPaths: ['mock-folder', 'CCC'] - } +const expectedSortSpecToBeMultiplied = { + groups: [{ + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 0, + targetFoldersPaths: ['mock-folder', 'CCC', 'mock-folder/sub', "mock-folder/*", "mock-folder/..."] } +const expectedSortSpecsTargetFolderAsDot: { [key: string]: CustomSortSpec } = { + 'mock-folder': expectedSortSpecToBeMultiplied, + 'CCC': expectedSortSpecToBeMultiplied, + 'mock-folder/sub': expectedSortSpecToBeMultiplied +} describe('SortingSpecProcessor edge case', () => { let processor: SortingSpecProcessor; beforeEach(() => { 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 = txtInputEmptySpecOnlyTargetFolder.split('\n') 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 = txtInputTargetFolderAsDot.split('\n') 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 = { + 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 = { + 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 = { + 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 = { + 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 = 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 = 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 = 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 = 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 = 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 = ` % Chapter\\R+ ... page\\d+ ` + +const txtInputErrorNestedStandardObsidianSortAttr: string = ` +target-folder: AAA +/ Some folder + sorting: standard +` + const txtInputEmptySpec: string = `` 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)}`) expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('% Chapter\\R+ ... page\\d+ ')) }) + it('should recognize error: nested standard obsidian sorting attribute', () => { + const inputTxtArr: Array = 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', () => { const inputTxtArr: Array = txtInputEmptySpec.split('\n') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') diff --git a/src/custom-sort/sorting-spec-processor.ts b/src/custom-sort/sorting-spec-processor.ts index 82d41f1..e435c68 100644 --- a/src/custom-sort/sorting-spec-processor.ts +++ b/src/custom-sort/sorting-spec-processor.ts @@ -3,9 +3,9 @@ import { CustomSortGroupType, CustomSortOrder, CustomSortSpec, - NormalizerFn, RecognizedOrderValue, - RegExpSpec, - SortSpecsCollection + NormalizerFn, + RecognizedOrderValue, + RegExpSpec } from "./custom-sort-types"; import {isDefined, last} from "../utils/utils"; import { @@ -20,6 +20,12 @@ import { NumberRegexStr, RomanNumberRegexStr } from "./matchers"; +import { + FolderWildcardMatching, + MATCH_ALL_SUFFIX, + MATCH_CHILDREN_1_SUFFIX, + MATCH_CHILDREN_2_SUFFIX +} from "./folder-matching-rules" interface ProcessingContext { folderPath: string @@ -54,11 +60,14 @@ export enum ProblemCode { TooManyNumericSortingSymbols, NumericalSymbolAdjacentToWildcard, ItemToHideExactNameWithExtRequired, - ItemToHideNoSupportForThreeDots + ItemToHideNoSupportForThreeDots, + DuplicateWildcardSortSpecForSameFolder, + StandardObsidianSortAllowedOnlyAtFolderLevel } const ContextFreeProblems = new Set([ - ProblemCode.DuplicateSortSpecForSameFolder + ProblemCode.DuplicateSortSpecForSameFolder, + ProblemCode.DuplicateWildcardSortSpecForSameFolder ]) const ThreeDots = '...'; @@ -104,7 +113,8 @@ const OrderLiterals: { [key: string]: CustomSortOrderAscDescPair } = { enum Attribute { TargetFolder = 1, // Starting from 1 to allow: if (attribute) { ... OrderAsc, - OrderDesc + OrderDesc, + OrderStandardObsidian } const AttrLexems: { [key: string]: Attribute } = { @@ -112,6 +122,7 @@ const AttrLexems: { [key: string]: Attribute } = { 'target-folder:': Attribute.TargetFolder, 'order-asc:': Attribute.OrderAsc, 'order-desc:': Attribute.OrderDesc, + 'sorting:': Attribute.OrderStandardObsidian, // Concise abbreviated equivalents '::::': Attribute.TargetFolder, '<': 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 +} + interface AdjacencyInfo { noPrefix: 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." export class SortingSpecProcessor { @@ -359,13 +416,56 @@ export class SortingSpecProcessor { if (this.ctx.specs.length > 0) { for (let spec of this.ctx.specs) { this._l1s6_postprocessSortSpec(spec) - for (let folderPath of spec.targetFoldersPaths) { - collection = collection ?? {} - if (collection[folderPath]) { - this.problem(ProblemCode.DuplicateSortSpecForSameFolder, `Duplicate sorting spec for folder ${folderPath}`) - return null // Failure - not allow duplicate specs for the same folder + } + + let sortspecByWildcard: FolderWildcardMatching + for (let spec of this.ctx.specs) { + // Consume the folder paths ending with wildcard specs + for (let idx = 0; idx() + 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 + } + + // Helper transient 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 + const pathMatchPriorityForPath: {[key: string]: WildcardPriority} = {} + + 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 = 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 + pathMatchPriorityForPath[path] = detectedWildcardPriority } - collection[folderPath] = spec } } } @@ -452,7 +552,7 @@ export class SortingSpecProcessor { this.problem(ProblemCode.TargetFolderNestedSpec, `Nested (indented) specification of target folder is not allowed`) 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 (!this.ctx.currentSpec) { this._l2s2_putNewSpecForNewTargetFolder() @@ -474,6 +574,10 @@ export class SortingSpecProcessor { this.problem(ProblemCode.DuplicateOrderAttr, `Duplicate order specification for a sorting rule of folder ${folderPathsForProblemMsg}`) 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.secondaryOrder = (attr.value as RecognizedOrderValue).secondaryOrder return true; @@ -635,10 +739,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 spec.targetFoldersPaths.forEach((path, idx) => { if (path === CURRENT_FOLDER_SYMBOL) { 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 +783,19 @@ export class SortingSpecProcessor { } : 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 } = { [Attribute.TargetFolder]: this._l2s1_validateTargetFolderAttrValue.bind(this), [Attribute.OrderAsc]: this._l2s1_validateOrderAscAttrValue.bind(this), [Attribute.OrderDesc]: this._l2s1_validateOrderDescAttrValue.bind(this), + [Attribute.OrderStandardObsidian]: this._l2s1_validateSortingAttrValue.bind(this) } _l2s2_convertPlainStringSortingGroupSpecToArraySpec = (spec: string): Array => { diff --git a/src/main.ts b/src/main.ts index 54ac35b..4cf6878 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,14 +14,15 @@ import { } from 'obsidian'; import {around} from 'monkey-around'; import {folderSort} from './custom-sort/custom-sort'; -import {SortingSpecProcessor} from './custom-sort/sorting-spec-processor'; -import {CustomSortSpec, SortSpecsCollection} from './custom-sort/custom-sort-types'; +import {SortingSpecProcessor, SortSpecsCollection} from './custom-sort/sorting-spec-processor'; +import {CustomSortOrder, CustomSortSpec} from './custom-sort/custom-sort-types'; + import { addIcons, ICON_SORT_ENABLED_ACTIVE, + ICON_SORT_ENABLED_NOT_APPLIED, ICON_SORT_SUSPENDED, - ICON_SORT_SUSPENDED_SYNTAX_ERROR, - ICON_SORT_ENABLED_NOT_APPLIED + ICON_SORT_SUSPENDED_SYNTAX_ERROR } from "./custom-sort/icons"; interface CustomSortPluginSettings { @@ -87,9 +88,9 @@ export default class CustomSortPlugin extends Plugin { new Notice(`Parsing custom sorting specification SUCCEEDED!`) } else { 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 { - 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) this.settings.suspended = true @@ -196,13 +197,30 @@ export default class CustomSortPlugin extends Plugin { let tmpFolder = new TFolder(Vault, ""); let Folder = fileExplorer.createFolderDom(tmpFolder).constructor; this.register( + // TODO: Unit tests please!!! The logic below becomes more and more complex, bugs are captured at run-time... around(Folder.prototype, { sort(old: 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 const folder: TFolder = this.file - const sortSpec: CustomSortSpec = plugin.sortSpecCache?.[folder.path] - if (!plugin.settings.suspended && sortSpec) { + let sortSpec: CustomSortSpec = plugin.sortSpecCache?.sortSpecByPath[folder.path] + 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); } else { return old.call(this, ...args); diff --git a/versions.json b/versions.json index f2f0b28..f172bd3 100644 --- a/versions.json +++ b/versions.json @@ -1,4 +1,5 @@ { "0.5.188": "0.12.0", - "0.5.189": "0.12.0" + "0.5.189": "0.12.0", + "0.6.0": "0.12.0" }