From e972bce0073729c3b347a3a41cd81bfa63c608f9 Mon Sep 17 00:00:00 2001 From: SebastianMC Date: Tue, 30 Aug 2022 18:53:58 +0200 Subject: [PATCH 1/7] 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" } From 8e298aac184af73cd76d9eee0fc024f03c07b79b Mon Sep 17 00:00:00 2001 From: SebastianMC Date: Tue, 30 Aug 2022 20:21:17 +0200 Subject: [PATCH 2/7] Ticket #1: added support for imposed sorting rules inheritance by subfolders - README.md updated with concise examples of the new functionality --- README.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fbbeaeb..20de1cd 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Take full control of the order of your notes and folders: ## Table of contents - [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 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 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 +32,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 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 12: Apply same sorting to all folders in the vault] + - [Example 13: Sorting rules inheritance by subfolders] - [Location of sorting specification YAML entry](#location-of-sorting-specification-yaml-entry) - [Ribbon icon](#ribbon-icon) - [Installing the plugin](#installing-the-plugin) @@ -58,9 +60,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) 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 @@ -322,6 +330,60 @@ the result is: ![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 You can keep the custom sorting specifications in any of the following locations (or in all of them): From 20c79b0ee1d7b93b6161cbc2da10c224e6984fa0 Mon Sep 17 00:00:00 2001 From: SebastianMC Date: Tue, 30 Aug 2022 20:27:38 +0200 Subject: [PATCH 3/7] Ticket #1: added support for imposed sorting rules inheritance by subfolders - README.md updated with internal links --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 20de1cd..1b17056 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,12 @@ Take full control of the order of your notes and folders: - order configuration stored directly in your note(s) front matter - use a dedicated key in YAML - 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 - [TL;DR Usage](#tldr-usage) - - [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-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) - [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) @@ -32,8 +33,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 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 12: Apply same sorting to all folders in the vault] - - [Example 13: Sorting rules inheritance by subfolders] + - [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) - [Ribbon icon](#ribbon-icon) - [Installing the plugin](#installing-the-plugin) From d798f4a96cc608ac2a40d64b9ca98f0a6ca4e133 Mon Sep 17 00:00:00 2001 From: SebastianMC Date: Tue, 30 Aug 2022 20:31:20 +0200 Subject: [PATCH 4/7] Ticket #1: added support for imposed sorting rules inheritance by subfolders - YAML snippet in README.md updated for consistency with other examples --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1b17056..8c87281 100644 --- a/README.md +++ b/README.md @@ -361,7 +361,7 @@ You can read the below YAML specification as: - 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 +- 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 @@ -372,14 +372,14 @@ sorting-spec: | target-folder: /* < a-z - target-folder: /Reviews/... + target-folder: Reviews/... < modified target-folder: Archive target-folder: Inbox < created - target-folder: /Reviews/Attachments + target-folder: Reviews/Attachments target-folder: TODOs sorting: standard --- From 5838a81b9c32bf3b8a546df0bef1e6155f75758f Mon Sep 17 00:00:00 2001 From: SebastianMC Date: Tue, 30 Aug 2022 23:25:39 +0200 Subject: [PATCH 5/7] Ticket #1: added support for imposed sorting rules inheritance by subfolders - bugfix: detection of duplicate specifications for the same folder across multiple (yaml) source files --- .../sorting-spec-processor.spec.ts | 18 ++++++++++++++++ src/custom-sort/sorting-spec-processor.ts | 21 ++++++++++++------- src/main.ts | 3 ++- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/custom-sort/sorting-spec-processor.spec.ts b/src/custom-sort/sorting-spec-processor.spec.ts index 03cc210..7632209 100644 --- a/src/custom-sort/sorting-spec-processor.spec.ts +++ b/src/custom-sort/sorting-spec-processor.spec.ts @@ -957,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 = 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', () => { let processor: SortingSpecProcessor; beforeEach(() => { diff --git a/src/custom-sort/sorting-spec-processor.ts b/src/custom-sort/sorting-spec-processor.ts index e435c68..be2aadf 100644 --- a/src/custom-sort/sorting-spec-processor.ts +++ b/src/custom-sort/sorting-spec-processor.ts @@ -359,6 +359,11 @@ export class SortingSpecProcessor { problemAlreadyReportedForCurrentLine: boolean 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 // for in-app presentation constructor(private errorLogger?: typeof console.log) { @@ -370,10 +375,17 @@ export class SortingSpecProcessor { sortingSpecFileName: string, collection?: SortSpecsCollection ): SortSpecsCollection { + // reset / init processing state after potential previous invocation this.ctx = { folderPath: folderPath, // location of the sorting spec file specs: [] }; + this.currentEntryLine = null + this.currentEntryLineIdx = null + this.currentSortingSpecContainerFilePath = null + this.problemAlreadyReportedForCurrentLine = null + this.recentErrorMessage = null + let success: boolean = false; let lineIdx: number = 0; for (let entryLine of text) { @@ -439,11 +451,6 @@ export class SortingSpecProcessor { 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] @@ -452,7 +459,7 @@ export class SortingSpecProcessor { let path: string [path, detectedWildcardPriority] = stripWildcardPatternSuffix(originalPath) let storeTheSpec: boolean = true - const preexistingSortSpecPriority: WildcardPriority = pathMatchPriorityForPath[path] + 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}`) @@ -464,7 +471,7 @@ export class SortingSpecProcessor { } if (storeTheSpec) { collection.sortSpecByPath[path] = spec - pathMatchPriorityForPath[path] = detectedWildcardPriority + this.pathMatchPriorityForPath[path] = detectedWildcardPriority } } } diff --git a/src/main.ts b/src/main.ts index 4cf6878..307f439 100644 --- a/src/main.ts +++ b/src/main.ts @@ -55,6 +55,8 @@ export default class CustomSortPlugin extends Plugin { let errorMessage: string // reset cache this.sortSpecCache = null + const processor: SortingSpecProcessor = new SortingSpecProcessor() + Vault.recurseChildren(this.app.vault.getRoot(), (file: TAbstractFile) => { if (failed) return if (file instanceof TFile) { @@ -68,7 +70,6 @@ export default class CustomSortPlugin extends Plugin { const sortingSpecTxt: string = mCache.getCache(aFile.path)?.frontmatter?.[SORTINGSPEC_YAML_KEY] if (sortingSpecTxt) { anySortingSpecFound = true - const processor: SortingSpecProcessor = new SortingSpecProcessor() this.sortSpecCache = processor.parseSortSpecFromText( sortingSpecTxt.split('\n'), parent.path, From 4cf19d1d8ef16c488e926e487c01be287d1238c1 Mon Sep 17 00:00:00 2001 From: SebastianMC Date: Wed, 31 Aug 2022 23:49:00 +0200 Subject: [PATCH 6/7] README.md update, added Quickstart section --- README.md | 25 +++++++++++++++++++++++++ docs/examples/quickstart/sortspec.md | 15 +++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 docs/examples/quickstart/sortspec.md diff --git a/README.md b/README.md index 8c87281..788ebf4 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Take full control of the order of your notes and folders: ## Table of contents - [TL;DR Usage](#tldr-usage) + - - [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) - [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) @@ -47,6 +48,30 @@ 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]() > 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. +> 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. For simplicity (if you are examining the plugin for the first time) copy and paste the below YAML snippets to the front diff --git a/docs/examples/quickstart/sortspec.md b/docs/examples/quickstart/sortspec.md new file mode 100644 index 0000000..37d70a9 --- /dev/null +++ b/docs/examples/quickstart/sortspec.md @@ -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 +--- From dc0c2d0909aa32318e3279b974301f5ed7e3740b Mon Sep 17 00:00:00 2001 From: SebastianMC Date: Wed, 31 Aug 2022 23:56:01 +0200 Subject: [PATCH 7/7] README.md update, finetuned Quickstart section --- README.md | 1 + docs/icons/parsing-succeeded.png | Bin 0 -> 7480 bytes 2 files changed, 1 insertion(+) create mode 100644 docs/icons/parsing-succeeded.png diff --git a/README.md b/README.md index 788ebf4..bdfb479 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ specification from `sortspec` note (the `sortspec.md` file which you downloaded > - 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 diff --git a/docs/icons/parsing-succeeded.png b/docs/icons/parsing-succeeded.png new file mode 100644 index 0000000000000000000000000000000000000000..9058de6e5fd99f89b7b945ed91314ca9b055ec66 GIT binary patch literal 7480 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 literal 0 HcmV?d00001