From 10bf6e42c73b2744fe45ae583b11ed5123952ca1 Mon Sep 17 00:00:00 2001 From: SebastianMC <23032356+SebastianMC@users.noreply.github.com> Date: Wed, 9 Nov 2022 18:52:41 +0100 Subject: [PATCH] #23 - support for sorting by metadata - more advanced version of implementation: with-metadata and by-metadata support - readme update --- README.md | 29 +- src/custom-sort/custom-sort-types.ts | 15 +- src/custom-sort/custom-sort.spec.ts | 540 +++++++++++++++++- src/custom-sort/custom-sort.ts | 116 +++- src/custom-sort/matchers.ts | 2 +- .../sorting-spec-processor.spec.ts | 21 +- src/custom-sort/sorting-spec-processor.ts | 57 +- 7 files changed, 734 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 653af8f..b86a8bc 100644 --- a/README.md +++ b/README.md @@ -416,7 +416,8 @@ sorting-spec: | ### Example 14: Grouping and sorting by metadata value Notes can contain metadata, let me use the example inspired by the [Feature Request #23](https://github.com/SebastianMC/obsidian-custom-sort/issues/23). -Namely, someone can create notes when reading a book and use the `Pages` metadata field. In that field s/he enters page(s) number(s) of the book, for reference +Namely, someone can create notes when reading a book and use the `Pages` metadata field. In that field s/he enters page(s) number(s) of the book, for reference. + For example: ```yaml @@ -444,20 +445,38 @@ Pages: 12-15 --- ``` -Using this plugin you can group and sort notes by the value of the specific metadata, for example: +Using this plugin you can sort notes by the value of the specific metadata, for example: + +```yaml +--- +sorting-spec: | + target-folder: Remarks from 'The Little Prince' book + < a-z by-metadata: Pages +--- +``` + +In that approach, the notes containing the metadata `Pages` will go first, sorted alphabetically by the value of that metadata. +The remaining notes (not having the metadata) will go below, sorted alphabetically by default. + +In the above example the syntax `by-metadata: Pages` was used to tell the plugin about the metadata field name for sorting. +The specified sorting `< a-z` is obviously alphabetical, and in this specific context it tells to sort by the value of the specified metadata (and not by the note or folder name). + +In a more advanced fine-tuned approach you can explicitly group notes having some metadata and sort by that (or other) metadata: ```yaml --- sorting-spec: | target-folder: Remarks from 'The Little Prince' book with-metadata: Pages - < a-z + < a-z by-metadata: Pages + ... + > modified --- ``` -In the above example the syntax `with-metadata: Pages` was used to tell the plugin about the metadata field name for grouping and sorting. +In the above example the syntax `with-metadata: Pages` was used to tell the plugin about the metadata field name for grouping. The specified sorting `< a-z` is obviously alphabetical, and in this specific context it tells to sort by the value of the specified metadata (and not by the note or folder name). -Any other sorting from the supported set of rules can be applied, also these not related to the metadata value +Then the remaining notes (not having the `Pages` metadata) are sorted by modification date descending. > NOTE > diff --git a/src/custom-sort/custom-sort-types.ts b/src/custom-sort/custom-sort-types.ts index 1a7df75..0f4416f 100644 --- a/src/custom-sort/custom-sort-types.ts +++ b/src/custom-sort/custom-sort-types.ts @@ -1,4 +1,4 @@ -import {Plugin} from "obsidian"; +import {MetadataCache, Plugin} from "obsidian"; export enum CustomSortGroupType { Outsiders, // Not belonging to any of other groups @@ -21,7 +21,8 @@ export enum CustomSortOrder { byCreatedTimeAdvanced, byCreatedTimeReverse, byCreatedTimeReverseAdvanced, - byMetadataField, + byMetadataFieldAlphabetical, + byMetadataFieldAlphabeticalReverse, standardObsidian, // Let the folder sorting be in hands of Obsidian, whatever user selected in the UI default = alphabetical } @@ -29,6 +30,7 @@ export enum CustomSortOrder { export interface RecognizedOrderValue { order: CustomSortOrder secondaryOrder?: CustomSortOrder + applyToMetadataField?: string } export type NormalizerFn = (s: string) => string | null @@ -45,20 +47,27 @@ export interface CustomSortGroup { exactPrefix?: string exactSuffix?: string order?: CustomSortOrder + byMetadataField?: string // for 'by-metadata:' if the order is by metadata alphabetical or reverse secondaryOrder?: CustomSortOrder filesOnly?: boolean matchFilenameWithExt?: boolean foldersOnly?: boolean - metadataFieldName?: string + withMetadataFieldName?: string // for 'with-metadata:' } export interface CustomSortSpec { targetFoldersPaths: Array // For root use '/' defaultOrder?: CustomSortOrder + byMetadataField?: string // for 'by-metadata:' if the defaultOrder is by metadata alphabetical or reverse groups: Array outsidersGroupIdx?: number outsidersFilesGroupIdx?: number outsidersFoldersGroupIdx?: number itemsToHide?: Set plugin?: Plugin // to hand over the access to App instance to the sorting engine + + // For internal transient use + _mCache?: MetadataCache } + +export const DEFAULT_METADATA_FIELD_FOR_SORTING: string = 'sort-index-value' diff --git a/src/custom-sort/custom-sort.spec.ts b/src/custom-sort/custom-sort.spec.ts index e0f1d88..c353457 100644 --- a/src/custom-sort/custom-sort.spec.ts +++ b/src/custom-sort/custom-sort.spec.ts @@ -1,9 +1,12 @@ -import {TFile, TFolder, Vault} from 'obsidian'; +import {CachedMetadata, MetadataCache, Pos, TFile, TFolder, Vault} from 'obsidian'; import { DEFAULT_FOLDER_CTIME, + DEFAULT_FOLDER_MTIME, determineFolderDatesIfNeeded, determineSortingGroup, - FolderItemForSorting + FolderItemForSorting, + SorterFn, + Sorters } from './custom-sort'; import {CustomSortGroupType, CustomSortOrder, CustomSortSpec} from './custom-sort-types'; import {CompoundDashNumberNormalizerFn, CompoundDotRomanNumberNormalizerFn} from "./sorting-spec-processor"; @@ -28,7 +31,7 @@ const mockTFolder = (name: string, children?: Array, parent?: TFo return { isRoot(): boolean { return name === '/' }, vault: {} as Vault, // To satisfy TS typechecking - path: `/${name}`, + path: `${name}`, name: name, parent: parent ?? ({} as TFolder), // To satisfy TS typechecking children: children ?? [] @@ -50,6 +53,11 @@ const mockTFolderWithChildren = (name: string): TFolder => { return mockTFolder('Mock parent folder', [child1, child2, child3, child4, child5]) } +const MockedLoc: Pos = { + start: {col:0,offset:0,line:0}, + end: {col:0,offset:0,line:0} +} + describe('determineSortingGroup', () => { describe('CustomSortGroupType.ExactHeadAndTail', () => { it('should correctly recognize head and tail', () => { @@ -288,7 +296,6 @@ describe('determineSortingGroup', () => { exactPrefix: 'Pref' }] } - // when const result = determineSortingGroup(file, sortSpec) @@ -304,6 +311,363 @@ describe('determineSortingGroup', () => { }); }) }) + describe('CustomSortGroupType.byMetadataFieldAlphabetical', () => { + it('should ignore the file item if it has no direct metadata', () => { + // given + const file: TFile = mockTFile('References', 'md', 111, MOCK_TIMESTAMP + 222, MOCK_TIMESTAMP + 333); + const sortSpec: CustomSortSpec = { + targetFoldersPaths: ['/'], + groups: [{ + type: CustomSortGroupType.HasMetadataField, + withMetadataFieldName: "metadataField1", + exactPrefix: 'Ref' + }], + _mCache: { + getCache: function (path: string): CachedMetadata | undefined { + return { + "References": { + frontmatter: { + metadataField1InvalidField: "directMetadataOnFile", + position: MockedLoc + } + } + }[path] + } + } as MetadataCache + } + + // when + const result = determineSortingGroup(file, sortSpec) + + // then + expect(result).toEqual({ + groupIdx: 1, // The lastIdx+1, group not determined + isFolder: false, + sortString: "References.md", + ctimeNewest: MOCK_TIMESTAMP + 222, + ctimeOldest: MOCK_TIMESTAMP + 222, + mtime: MOCK_TIMESTAMP + 333, + path: 'Some parent folder/References.md' + }); + }) + it('should ignore the folder item if it has no metadata on folder note', () => { + // given + const file: TFile = mockTFile('References', 'md', 111, MOCK_TIMESTAMP + 222, MOCK_TIMESTAMP + 333); + const sortSpec: CustomSortSpec = { + targetFoldersPaths: ['/'], + groups: [{ + type: CustomSortGroupType.HasMetadataField, + withMetadataFieldName: "metadataField1", + exactPrefix: 'Ref' + }], + _mCache: { + getCache: function (path: string): CachedMetadata | undefined { + return { + "References": { + frontmatter: { + metadataField1: undefined, + position: MockedLoc + } + } + }[path] + } + } as MetadataCache + } + + // when + const result = determineSortingGroup(file, sortSpec) + + // then + expect(result).toEqual({ + groupIdx: 1, // lastIdx + 1, group not determined + isFolder: false, + sortString: "References.md", + ctimeNewest: MOCK_TIMESTAMP + 222, + ctimeOldest: MOCK_TIMESTAMP + 222, + mtime: MOCK_TIMESTAMP + 333, + path: 'Some parent folder/References.md' + }); + }) + it('should correctly include the File item if has direct metadata (group not sorted by metadata', () => { + // given + const file: TFile = mockTFile('References', 'md', 111, MOCK_TIMESTAMP + 222, MOCK_TIMESTAMP + 333); + const sortSpec: CustomSortSpec = { + targetFoldersPaths: ['/'], + groups: [{ + type: CustomSortGroupType.HasMetadataField, + withMetadataFieldName: "metadataField1", + exactPrefix: 'Ref' + }], + _mCache: { + getCache: function (path: string): CachedMetadata | undefined { + return { + 'Some parent folder/References.md': { + frontmatter: { + "metadataField1": "directMetadataOnFile", + position: MockedLoc + } + } + }[path] + } + } as MetadataCache + } + + // when + const result = determineSortingGroup(file, sortSpec) + + // then + expect(result).toEqual({ + groupIdx: 0, + isFolder: false, + sortString: "References.md", + ctimeNewest: MOCK_TIMESTAMP + 222, + ctimeOldest: MOCK_TIMESTAMP + 222, + mtime: MOCK_TIMESTAMP + 333, + path: 'Some parent folder/References.md' + } as FolderItemForSorting); + }) + it('should correctly include the Folder item if it has folder note metadata (group not sorted by metadata', () => { + // given + const folder: TFolder = mockTFolder('References'); + const sortSpec: CustomSortSpec = { + targetFoldersPaths: ['/'], + groups: [{ + type: CustomSortGroupType.HasMetadataField, + withMetadataFieldName: "metadataField1", + exactPrefix: 'Ref' + }], + _mCache: { + getCache: function (path: string): CachedMetadata | undefined { + return { + 'References/References.md': { + frontmatter: { + "metadataField1": "directMetadataOnFile", + position: MockedLoc + } + } + }[path] + } + } as MetadataCache + } + + // when + const result = determineSortingGroup(folder, sortSpec) + + // then + expect(result).toEqual({ + groupIdx: 0, + isFolder: true, + sortString: "References", + ctimeNewest: DEFAULT_FOLDER_CTIME, + ctimeOldest: DEFAULT_FOLDER_CTIME, + mtime: DEFAULT_FOLDER_MTIME, + path: 'References', + folder: folder + } as FolderItemForSorting); + }) + }) + describe('when sort by metadata is involved', () => { + it('should correctly read direct metadata from File item (order by metadata set on group)', () => { + // given + const file: TFile = mockTFile('References', 'md', 111, MOCK_TIMESTAMP + 222, MOCK_TIMESTAMP + 333); + const sortSpec: CustomSortSpec = { + targetFoldersPaths: ['/'], + groups: [{ + type: CustomSortGroupType.ExactPrefix, + byMetadataField: 'metadata-field-for-sorting', + exactPrefix: 'Ref', + order: CustomSortOrder.byMetadataFieldAlphabetical + }], + _mCache: { + getCache: function (path: string): CachedMetadata | undefined { + return { + 'Some parent folder/References.md': { + frontmatter: { + "metadata-field-for-sorting": "direct metadata on file", + position: MockedLoc + } + } + }[path] + } + } as MetadataCache + } + + // when + const result = determineSortingGroup(file, sortSpec) + + // then + expect(result).toEqual({ + groupIdx: 0, + isFolder: false, + sortString: "References.md", + ctimeNewest: MOCK_TIMESTAMP + 222, + ctimeOldest: MOCK_TIMESTAMP + 222, + mtime: MOCK_TIMESTAMP + 333, + path: 'Some parent folder/References.md', + metadataFieldValue: 'direct metadata on file' + } as FolderItemForSorting); + }) + it('should correctly read direct metadata from folder note item (order by metadata set on group)', () => { + // given + const folder: TFolder = mockTFolder('References'); + const sortSpec: CustomSortSpec = { + targetFoldersPaths: ['/'], + groups: [{ + type: CustomSortGroupType.ExactPrefix, + exactPrefix: 'Ref', + byMetadataField: 'metadata-field-for-sorting', + order: CustomSortOrder.byMetadataFieldAlphabeticalReverse + }], + _mCache: { + getCache: function (path: string): CachedMetadata | undefined { + return { + 'References/References.md': { + frontmatter: { + 'metadata-field-for-sorting': "metadata on folder note", + position: MockedLoc + } + } + }[path] + } + } as MetadataCache + } + + // when + const result = determineSortingGroup(folder, sortSpec) + + // then + expect(result).toEqual({ + groupIdx: 0, + isFolder: true, + sortString: "References", + ctimeNewest: DEFAULT_FOLDER_CTIME, + ctimeOldest: DEFAULT_FOLDER_CTIME, + mtime: DEFAULT_FOLDER_MTIME, + path: 'References', + metadataFieldValue: 'metadata on folder note', + folder: folder + } as FolderItemForSorting); + }) + it('should correctly read direct metadata from File item (order by metadata set on target folder)', () => { + // given + const file: TFile = mockTFile('References', 'md', 111, MOCK_TIMESTAMP + 222, MOCK_TIMESTAMP + 333); + const sortSpec: CustomSortSpec = { + targetFoldersPaths: ['/'], + groups: [{ + type: CustomSortGroupType.ExactPrefix, + exactPrefix: 'Ref', + order: CustomSortOrder.byMetadataFieldAlphabetical + }], + _mCache: { + getCache: function (path: string): CachedMetadata | undefined { + return { + 'Some parent folder/References.md': { + frontmatter: { + "metadata-field-for-sorting-specified-on-target-folder": "direct metadata on file, not obvious", + position: MockedLoc + } + } + }[path] + } + } as MetadataCache, + defaultOrder: CustomSortOrder.byMetadataFieldAlphabeticalReverse, + byMetadataField: 'metadata-field-for-sorting-specified-on-target-folder', + } + + // when + const result = determineSortingGroup(file, sortSpec) + + // then + expect(result).toEqual({ + groupIdx: 0, + isFolder: false, + sortString: "References.md", + ctimeNewest: MOCK_TIMESTAMP + 222, + ctimeOldest: MOCK_TIMESTAMP + 222, + mtime: MOCK_TIMESTAMP + 333, + path: 'Some parent folder/References.md', + metadataFieldValue: 'direct metadata on file, not obvious' + } as FolderItemForSorting); + }) + it('should correctly read direct metadata from File item (order by metadata set on group, no metadata name specified on group)', () => { + // given + const file: TFile = mockTFile('References', 'md', 111, MOCK_TIMESTAMP + 222, MOCK_TIMESTAMP + 333); + const sortSpec: CustomSortSpec = { + targetFoldersPaths: ['/'], + groups: [{ + type: CustomSortGroupType.HasMetadataField, + order: CustomSortOrder.byMetadataFieldAlphabetical, + withMetadataFieldName: 'field-used-with-with-metadata-syntax' + }], + _mCache: { + getCache: function (path: string): CachedMetadata | undefined { + return { + 'Some parent folder/References.md': { + frontmatter: { + 'field-used-with-with-metadata-syntax': "direct metadata on file, tricky", + position: MockedLoc + } + } + }[path] + } + } as MetadataCache + } + + // when + const result = determineSortingGroup(file, sortSpec) + + // then + expect(result).toEqual({ + groupIdx: 0, + isFolder: false, + sortString: "References.md", + ctimeNewest: MOCK_TIMESTAMP + 222, + ctimeOldest: MOCK_TIMESTAMP + 222, + mtime: MOCK_TIMESTAMP + 333, + path: 'Some parent folder/References.md', + metadataFieldValue: 'direct metadata on file, tricky' + } as FolderItemForSorting); + }) + it('should correctly read direct metadata from File item (order by metadata set on group, no metadata name specified anywhere)', () => { + // given + const file: TFile = mockTFile('References', 'md', 111, MOCK_TIMESTAMP + 222, MOCK_TIMESTAMP + 333); + const sortSpec: CustomSortSpec = { + targetFoldersPaths: ['/'], + groups: [{ + type: CustomSortGroupType.ExactPrefix, + exactPrefix: 'Ref', + order: CustomSortOrder.byMetadataFieldAlphabetical + }], + _mCache: { + getCache: function (path: string): CachedMetadata | undefined { + return { + 'Some parent folder/References.md': { + frontmatter: { + 'sort-index-value': "direct metadata on file, under default name", + position: MockedLoc + } + } + }[path] + } + } as MetadataCache + } + + // when + const result = determineSortingGroup(file, sortSpec) + + // then + expect(result).toEqual({ + groupIdx: 0, + isFolder: false, + sortString: "References.md", + ctimeNewest: MOCK_TIMESTAMP + 222, + ctimeOldest: MOCK_TIMESTAMP + 222, + mtime: MOCK_TIMESTAMP + 333, + path: 'Some parent folder/References.md', + metadataFieldValue: 'direct metadata on file, under default name' + } as FolderItemForSorting); + }) + }) }) describe('determineFolderDatesIfNeeded', () => { @@ -377,3 +741,171 @@ describe('determineFolderDatesIfNeeded', () => { expect(result.mtime).toEqual(TIMESTAMP_NEWEST) }) }) + +const SORT_FIRST_GOES_EARLIER: number = -1 +const SORT_FIRST_GOES_LATER: number = 1 +const SORT_ITEMS_ARE_EQUAL: number = 0 + +describe('CustomSortOrder.byMetadataFieldAlphabetical', () => { + it('should correctly order alphabetically when metadata on both items is present', () => { + // given + const itemA: Partial = { + metadataFieldValue: 'A' + } + const itemB: Partial = { + metadataFieldValue: 'B' + } + const sorter: SorterFn = Sorters[CustomSortOrder.byMetadataFieldAlphabetical] + + // when + const result1: number = sorter(itemA as FolderItemForSorting, itemB as FolderItemForSorting) + const result2: number = sorter(itemB as FolderItemForSorting, itemA as FolderItemForSorting) + + // then + expect(result1).toBe(SORT_FIRST_GOES_EARLIER) + expect(result2).toBe(SORT_FIRST_GOES_LATER) + }) + it('should correctly fallback to alphabetical by name when metadata on both items is present and equal', () => { + // given + const itemA: Partial = { + metadataFieldValue: 'Aaa', + sortString: 'n123' + } + const itemB: Partial = { + metadataFieldValue: 'Aaa', + sortString: 'a123' + } + const sorter: SorterFn = Sorters[CustomSortOrder.byMetadataFieldAlphabetical] + + // when + const result1: number = sorter(itemA as FolderItemForSorting, itemB as FolderItemForSorting) + const result2: number = sorter(itemB as FolderItemForSorting, itemA as FolderItemForSorting) + const result3: number = sorter(itemB as FolderItemForSorting, itemB as FolderItemForSorting) + + // then + expect(result1).toBe(SORT_FIRST_GOES_LATER) + expect(result2).toBe(SORT_FIRST_GOES_EARLIER) + expect(result3).toBe(SORT_ITEMS_ARE_EQUAL) + }) + it('should put the item with metadata earlier if the second one has no metadata ', () => { + // given + const itemA: Partial = { + metadataFieldValue: 'n159', + sortString: 'n123' + } + const itemB: Partial = { + sortString: 'n123' + } + const sorter: SorterFn = Sorters[CustomSortOrder.byMetadataFieldAlphabetical] + + // when + const result1: number = sorter(itemA as FolderItemForSorting, itemB as FolderItemForSorting) + const result2: number = sorter(itemB as FolderItemForSorting, itemA as FolderItemForSorting) + + // then + expect(result1).toBe(SORT_FIRST_GOES_EARLIER) + expect(result2).toBe(SORT_FIRST_GOES_LATER) + }) + it('should correctly fallback to alphabetical if no metadata on both items', () => { + // given + const itemA: Partial = { + sortString: 'ccc' + } + const itemB: Partial = { + sortString: 'ccc ' + } + const sorter: SorterFn = Sorters[CustomSortOrder.byMetadataFieldAlphabetical] + + // when + const result1: number = sorter(itemA as FolderItemForSorting, itemB as FolderItemForSorting) + const result2: number = sorter(itemB as FolderItemForSorting, itemA as FolderItemForSorting) + const result3: number = sorter(itemB as FolderItemForSorting, itemB as FolderItemForSorting) + + // then + expect(result1).toBe(SORT_FIRST_GOES_EARLIER) + expect(result2).toBe(SORT_FIRST_GOES_LATER) + expect(result3).toBe(SORT_ITEMS_ARE_EQUAL) + }) +}) + +describe('CustomSortOrder.byMetadataFieldAlphabeticalReverse', () => { + it('should correctly order alphabetically reverse when metadata on both items is present', () => { + // given + const itemA: Partial = { + metadataFieldValue: 'A' + } + const itemB: Partial = { + metadataFieldValue: 'B' + } + const sorter: SorterFn = Sorters[CustomSortOrder.byMetadataFieldAlphabeticalReverse] + + // when + const result1: number = sorter(itemA as FolderItemForSorting, itemB as FolderItemForSorting) + const result2: number = sorter(itemB as FolderItemForSorting, itemA as FolderItemForSorting) + + // then + expect(result1).toBe(SORT_FIRST_GOES_LATER) + expect(result2).toBe(SORT_FIRST_GOES_EARLIER) + }) + it('should correctly fallback to alphabetical reverse by name when metadata on both items is present and equal', () => { + // given + const itemA: Partial = { + metadataFieldValue: 'Aaa', + sortString: 'n123' + } + const itemB: Partial = { + metadataFieldValue: 'Aaa', + sortString: 'a123' + } + const sorter: SorterFn = Sorters[CustomSortOrder.byMetadataFieldAlphabeticalReverse] + + // when + const result1: number = sorter(itemA as FolderItemForSorting, itemB as FolderItemForSorting) + const result2: number = sorter(itemB as FolderItemForSorting, itemA as FolderItemForSorting) + const result3: number = sorter(itemB as FolderItemForSorting, itemB as FolderItemForSorting) + + // then + expect(result1).toBe(SORT_FIRST_GOES_EARLIER) + expect(result2).toBe(SORT_FIRST_GOES_LATER) + expect(result3).toBe(SORT_ITEMS_ARE_EQUAL) + }) + it('should put the item with metadata earlier if the second one has no metadata ', () => { + // given + const itemA: Partial = { + metadataFieldValue: '15', + sortString: 'n123' + } + const itemB: Partial = { + sortString: 'n123' + } + const sorter: SorterFn = Sorters[CustomSortOrder.byMetadataFieldAlphabeticalReverse] + + // when + const result1: number = sorter(itemA as FolderItemForSorting, itemB as FolderItemForSorting) + const result2: number = sorter(itemB as FolderItemForSorting, itemA as FolderItemForSorting) + + // then + expect(result1).toBe(SORT_FIRST_GOES_EARLIER) + expect(result2).toBe(SORT_FIRST_GOES_LATER) + }) + it('should correctly fallback to alphabetical reverse if no metadata on both items', () => { + // given + const itemA: Partial = { + sortString: 'ccc' + } + const itemB: Partial = { + sortString: 'ccc ' + } + const sorter: SorterFn = Sorters[CustomSortOrder.byMetadataFieldAlphabeticalReverse] + + // when + const result1: number = sorter(itemA as FolderItemForSorting, itemB as FolderItemForSorting) + const result2: number = sorter(itemB as FolderItemForSorting, itemA as FolderItemForSorting) + const result3: number = sorter(itemB as FolderItemForSorting, itemB as FolderItemForSorting) + + // then + expect(result1).toBe(SORT_FIRST_GOES_LATER) + expect(result2).toBe(SORT_FIRST_GOES_EARLIER) + expect(result3).toBe(SORT_ITEMS_ARE_EQUAL) + }) +}) diff --git a/src/custom-sort/custom-sort.ts b/src/custom-sort/custom-sort.ts index 5223626..19120f8 100644 --- a/src/custom-sort/custom-sort.ts +++ b/src/custom-sort/custom-sort.ts @@ -1,5 +1,11 @@ -import {MetadataCache, requireApiVersion, TAbstractFile, TFile, TFolder} from 'obsidian'; -import {CustomSortGroup, CustomSortGroupType, CustomSortOrder, CustomSortSpec} from "./custom-sort-types"; +import {FrontMatterCache, MetadataCache, requireApiVersion, TAbstractFile, TFile, TFolder} from 'obsidian'; +import { + CustomSortGroup, + CustomSortGroupType, + CustomSortOrder, + CustomSortSpec, + DEFAULT_METADATA_FIELD_FOR_SORTING +} from "./custom-sort-types"; import {isDefined} from "../utils/utils"; let Collator = new Intl.Collator(undefined, { @@ -12,6 +18,7 @@ export interface FolderItemForSorting { path: string groupIdx?: number // the index itself represents order for groups sortString: string // fragment (or full name) to be used for sorting + metadataFieldValue?: string // relevant to metadata-based sorting only matchGroup?: string // advanced - used for secondary sorting rule, to recognize 'same regex match' ctimeOldest: number // for a file, both ctime values are the same. For folder they can be different: ctimeNewest: number // ctimeOldest = ctime of oldest child file, ctimeNewest = ctime of newest child file @@ -20,9 +27,9 @@ export interface FolderItemForSorting { folder?: TFolder } -type SorterFn = (a: FolderItemForSorting, b: FolderItemForSorting) => number +export type SorterFn = (a: FolderItemForSorting, b: FolderItemForSorting) => number -let Sorters: { [key in CustomSortOrder]: SorterFn } = { +export let Sorters: { [key in CustomSortOrder]: SorterFn } = { [CustomSortOrder.alphabetical]: (a: FolderItemForSorting, b: FolderItemForSorting) => Collator(a.sortString, b.sortString), [CustomSortOrder.alphabeticalReverse]: (a: FolderItemForSorting, b: FolderItemForSorting) => Collator(b.sortString, a.sortString), [CustomSortOrder.byModifiedTime]: (a: FolderItemForSorting, b: FolderItemForSorting) => (a.isFolder && b.isFolder) ? Collator(a.sortString, b.sortString) : (a.mtime - b.mtime), @@ -33,7 +40,38 @@ let Sorters: { [key in CustomSortOrder]: SorterFn } = { [CustomSortOrder.byCreatedTimeAdvanced]: (a: FolderItemForSorting, b: FolderItemForSorting) => a.ctimeNewest - b.ctimeNewest, [CustomSortOrder.byCreatedTimeReverse]: (a: FolderItemForSorting, b: FolderItemForSorting) => (a.isFolder && b.isFolder) ? Collator(a.sortString, b.sortString) : (b.ctimeOldest - a.ctimeOldest), [CustomSortOrder.byCreatedTimeReverseAdvanced]: (a: FolderItemForSorting, b: FolderItemForSorting) => b.ctimeOldest - a.ctimeOldest, - [CustomSortOrder.byMetadataField]: (a: FolderItemForSorting, b: FolderItemForSorting) => Collator(a.sortString, b.sortString), + [CustomSortOrder.byMetadataFieldAlphabetical]: (a: FolderItemForSorting, b: FolderItemForSorting) => { + if (a.metadataFieldValue && b.metadataFieldValue) { + const sortResult: number = Collator(a.metadataFieldValue, b.metadataFieldValue) + if (sortResult === 0) { + // Fallback -> requested sort by metadata and both items have the same metadata value + return Collator(a.sortString, b.sortString) // switch to alphabetical sort by note/folder titles + } else { + return sortResult + } + } + // Item with metadata goes before the w/o metadata + if (a.metadataFieldValue) return -1 + if (b.metadataFieldValue) return 1 + // Fallback -> requested sort by metadata, yet none of two items contain it, use alphabetical by name + return Collator(a.sortString, b.sortString) + }, + [CustomSortOrder.byMetadataFieldAlphabeticalReverse]: (a: FolderItemForSorting, b: FolderItemForSorting) => { + if (a.metadataFieldValue && b.metadataFieldValue) { + const sortResult: number = Collator(b.metadataFieldValue, a.metadataFieldValue) + if (sortResult === 0) { + // Fallback -> requested sort by metadata and both items have the same metadata value + return Collator(b.sortString, a.sortString) // switch to alphabetical sort by note/folder titles + } else { + return sortResult + } + } + // Item with metadata goes before the w/o metadata + if (a.metadataFieldValue) return -1 + if (b.metadataFieldValue) return 1 + // Fallback -> requested sort by metadata, yet none of two items contain it, use alphabetical reverse by name + return Collator(b.sortString, a.sortString) + }, // 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), @@ -64,6 +102,10 @@ const isFolder = (entry: TAbstractFile) => { return !!((entry as any).isRoot); } +const isByMetadata = (order: CustomSortOrder | undefined) => { + return order === CustomSortOrder.byMetadataFieldAlphabetical || order === CustomSortOrder.byMetadataFieldAlphabeticalReverse +} + export const DEFAULT_FOLDER_MTIME: number = 0 export const DEFAULT_FOLDER_CTIME: number = 0 @@ -71,7 +113,7 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus let groupIdx: number let determined: boolean = false let matchedGroup: string | null | undefined - let sortString: string | undefined + let metadataValueToSortBy: string | undefined const aFolder: boolean = isFolder(entry) const aFile: boolean = !aFolder const entryAsTFile: TFile = entry as TFile @@ -148,15 +190,15 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus } break case CustomSortGroupType.HasMetadataField: - if (group.metadataFieldName) { - const mCache: MetadataCache | undefined = spec.plugin?.app.metadataCache - if (mCache) { + if (group.withMetadataFieldName) { + if (spec._mCache) { // For folders - scan metadata of 'folder note' const notePathToScan: string = aFile ? entry.path : `${entry.path}/${entry.name}.md` - const metadataValue: string | undefined = mCache.getCache(notePathToScan)?.frontmatter?.[group.metadataFieldName] - if (metadataValue) { + const frontMatterCache: FrontMatterCache | undefined = spec._mCache.getCache(notePathToScan)?.frontmatter + const hasMetadata: boolean | undefined = frontMatterCache?.hasOwnProperty(group.withMetadataFieldName) + + if (hasMetadata) { determined = true - sortString = metadataValue } } } @@ -177,17 +219,64 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus // Automatically assign the index to outsiders group, if relevant was configured if (isDefined(spec.outsidersFilesGroupIdx) && aFile) { determinedGroupIdx = spec.outsidersFilesGroupIdx; + determined = true } else if (isDefined(spec.outsidersFoldersGroupIdx) && aFolder) { determinedGroupIdx = spec.outsidersFoldersGroupIdx; + determined = true } else if (isDefined(spec.outsidersGroupIdx)) { determinedGroupIdx = spec.outsidersGroupIdx; + determined = true + } + } + + // The not obvious logic of determining the value of metadata field to use its value for sorting + // - the sorting spec processor automatically populates the order field of CustomSortingGroup for each group + // - yet defensive code should assume some default + // - if the order in group is by metadata (and only in that case): + // - if byMetadata field name is defined for the group -> use it. Done even if value empty or not present. + // - else, if byMetadata field name is defined for the Sorting spec (folder level, for all groups) -> use it. Done even if value empty or not present. + // - else, if withMetadata field name is defined for the group -> use it. Done even if value empty or not present. + // - otherwise, fallback to the default metadata field name (hardcoded in the plugin as 'sort-index-value') + + + // TODO: in manual of plugin, in details, explain these nuances. Let readme.md contain only the basic simple example and reference to manual.md section + + // One of nuances: if metadata value is not present or empty -> is this treated same or not? + // Other nuance: the fallback to compare titles -> exactly when (I suspect when there are no metadata or empty values. Because if one item has some metadata value and the other doesn't have any, the one with the metadata value goes first (for alphabetical) or goes last (for alphabetical reverse) - maybe in both cases should go last? + // + // I should probably write a test for this specific comparator + + if (determined && determinedGroupIdx !== undefined) { // <-- defensive code, maybe too defensive + const group: CustomSortGroup = spec.groups[determinedGroupIdx]; + if (isByMetadata(group?.order)) { + let metadataFieldName: string | undefined = group.byMetadataField + if (!metadataFieldName) { + if (isByMetadata(spec.defaultOrder)) { + metadataFieldName = spec.byMetadataField + } + } + if (!metadataFieldName) { + metadataFieldName = group.withMetadataFieldName + } + if (!metadataFieldName) { + metadataFieldName = DEFAULT_METADATA_FIELD_FOR_SORTING + } + if (metadataFieldName) { + if (spec._mCache) { + // For folders - scan metadata of 'folder note' + const notePathToScan: string = aFile ? entry.path : `${entry.path}/${entry.name}.md` + const frontMatterCache: FrontMatterCache | undefined = spec._mCache.getCache(notePathToScan)?.frontmatter + metadataValueToSortBy = frontMatterCache?.[metadataFieldName] + } + } } } return { // idx of the matched group or idx of Outsiders group or the largest index (= groups count+1) groupIdx: determinedGroupIdx, - sortString: sortString ?? (matchedGroup ? (matchedGroup + '//' + entry.name) : entry.name), + sortString: matchedGroup ? (matchedGroup + '//' + entry.name) : entry.name, + metadataFieldValue: metadataValueToSortBy, matchGroup: matchedGroup ?? undefined, isFolder: aFolder, folder: aFolder ? (entry as TFolder) : undefined, @@ -255,6 +344,7 @@ export const determineFolderDatesIfNeeded = (folderItems: Array = (sortingSpec.itemsToHide ? this.file.children.filter((entry: TFile | TFolder) => { diff --git a/src/custom-sort/matchers.ts b/src/custom-sort/matchers.ts index b296722..b1fde50 100644 --- a/src/custom-sort/matchers.ts +++ b/src/custom-sort/matchers.ts @@ -23,7 +23,7 @@ export const DEFAULT_NORMALIZATION_PLACES = 8; // Fixed width of a normalized n export function prependWithZeros(s: string, minLength: number) { if (s.length < minLength) { const delta: number = minLength - s.length; - return '000000000000000000000000000'.substr(0, delta) + s; + return '000000000000000000000000000'.substring(0, delta) + s; } else { return s; } diff --git a/src/custom-sort/sorting-spec-processor.spec.ts b/src/custom-sort/sorting-spec-processor.spec.ts index 80525b4..9ec7322 100644 --- a/src/custom-sort/sorting-spec-processor.spec.ts +++ b/src/custom-sort/sorting-spec-processor.spec.ts @@ -26,9 +26,9 @@ target-folder: tricky folder :::: tricky folder 2 /: with-metadata: - > modified + < a-z by-metadata: Some-dedicated-field with-metadata: Pages - > a-z + > a-z by-metadata: :::: Conceptual model /: Entities @@ -79,9 +79,9 @@ target-folder: tricky folder target-folder: tricky folder 2 /:files with-metadata: - > modified + < a-z by-metadata: Some-dedicated-field % with-metadata: Pages - > a-z + > a-z by-metadata: :::: Conceptual model /:files Entities @@ -157,13 +157,14 @@ const expectedSortSpecsExampleA: { [key: string]: CustomSortSpec } = { "tricky folder 2": { groups: [{ filesOnly: true, - metadataFieldName: 'sort-index-value', - order: CustomSortOrder.byModifiedTimeReverse, - type: CustomSortGroupType.HasMetadataField + type: CustomSortGroupType.HasMetadataField, + withMetadataFieldName: 'sort-index-value', + order: CustomSortOrder.byMetadataFieldAlphabetical, + byMetadataField: 'Some-dedicated-field', }, { - metadataFieldName: 'Pages', - order: CustomSortOrder.alphabeticalReverse, - type: CustomSortGroupType.HasMetadataField + type: CustomSortGroupType.HasMetadataField, + withMetadataFieldName: 'Pages', + order: CustomSortOrder.byMetadataFieldAlphabeticalReverse }, { order: CustomSortOrder.alphabetical, type: CustomSortGroupType.Outsiders diff --git a/src/custom-sort/sorting-spec-processor.ts b/src/custom-sort/sorting-spec-processor.ts index 6682d7d..13d5466 100644 --- a/src/custom-sort/sorting-spec-processor.ts +++ b/src/custom-sort/sorting-spec-processor.ts @@ -3,6 +3,7 @@ import { CustomSortGroupType, CustomSortOrder, CustomSortSpec, + DEFAULT_METADATA_FIELD_FOR_SORTING, NormalizerFn, RecognizedOrderValue, RegExpSpec @@ -79,6 +80,7 @@ interface CustomSortOrderAscDescPair { asc: CustomSortOrder, desc: CustomSortOrder, secondary?: CustomSortOrder + applyToMetadataField?: string } // remember about .toLowerCase() before comparison! @@ -132,6 +134,8 @@ const OrderLiterals: { [key: string]: CustomSortOrderAscDescPair } = { } } +const OrderByMetadataLexeme: string = 'by-metadata:' + enum Attribute { TargetFolder = 1, // Starting from 1 to allow: if (attribute) { ... OrderAsc, @@ -175,8 +179,6 @@ const HideItemVerboseLexeme: string = '/--hide:' const MetadataFieldIndicatorLexeme: string = 'with-metadata:' -const DEFAULT_METADATA_FIELD_FOR_SORTING: string = 'sort-index-value' - const CommentPrefix: string = '//' interface SortingGroupType { @@ -616,6 +618,7 @@ export class SortingSpecProcessor { return false; } this.ctx.currentSpec.defaultOrder = (attr.value as RecognizedOrderValue).order + this.ctx.currentSpec.byMetadataField = (attr.value as RecognizedOrderValue).applyToMetadataField return true; } else if (attr.nesting > 0) { // For now only distinguishing nested (indented) and not-nested (not-indented), the depth doesn't matter if (!this.ctx.currentSpec || !this.ctx.currentSpecGroup) { @@ -632,6 +635,7 @@ export class SortingSpecProcessor { return false; } this.ctx.currentSpecGroup.order = (attr.value as RecognizedOrderValue).order + this.ctx.currentSpecGroup.byMetadataField = (attr.value as RecognizedOrderValue).applyToMetadataField this.ctx.currentSpecGroup.secondaryOrder = (attr.value as RecognizedOrderValue).secondaryOrder return true; } @@ -645,7 +649,7 @@ export class SortingSpecProcessor { // no space present, check for potential syntax errors for (let attrLexeme of Object.keys(AttrLexems)) { if (lineTrimmedStartLowerCase.startsWith(attrLexeme)) { - const originalAttrLexeme: string = lineTrimmedStart.substr(0, attrLexeme.length) + const originalAttrLexeme: string = lineTrimmedStart.substring(0, attrLexeme.length) if (lineTrimmedStartLowerCase.length === attrLexeme.length) { this.problem(ProblemCode.MissingAttributeValue, `Attribute "${originalAttrLexeme}" requires a value to follow`) return true @@ -803,7 +807,7 @@ export class SortingSpecProcessor { 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)}` + spec.targetFoldersPaths[idx] = `${this.ctx.folderPath}/${path.substring(CURRENT_FOLDER_PREFIX.length)}` } }); } @@ -821,14 +825,46 @@ export class SortingSpecProcessor { private internalValidateOrderAttrValue = (v: string): CustomSortOrderAscDescPair | null => { v = v.trim(); - return v ? OrderLiterals[v.toLowerCase()] : null + let orderLiteral: string = v + let metadataSpec: Partial = {} + let applyToMetadata: boolean = false + + if (v.indexOf(OrderByMetadataLexeme) > 0) { // Intentionally > 0 -> not allow the metadata lexeme alone + const pieces: Array = v.split(OrderByMetadataLexeme) + // there are at least two pieces by definition, prefix and suffix of the metadata lexeme + orderLiteral = pieces[0]?.trim() + let metadataFieldName: string = pieces[1]?.trim() + if (metadataFieldName) { + metadataSpec.applyToMetadataField = metadataFieldName + } + applyToMetadata = true + } + + let attr: CustomSortOrderAscDescPair | null = orderLiteral ? OrderLiterals[orderLiteral.toLowerCase()] : null + if (attr) { + if (applyToMetadata && + (attr.asc === CustomSortOrder.alphabetical || attr.desc === CustomSortOrder.alphabeticalReverse)) { + + // Create adjusted copy + attr = { + ...attr, + asc: CustomSortOrder.byMetadataFieldAlphabetical, + desc: CustomSortOrder.byMetadataFieldAlphabeticalReverse + } + } else { // For orders different from alphabetical (and reverse) a reference to metadata is not supported + metadataSpec.applyToMetadataField = undefined + } + } + + return attr ? {...attr, ...metadataSpec} : null } private validateOrderAscAttrValue = (v: string): RecognizedOrderValue | null => { const recognized: CustomSortOrderAscDescPair | null = this.internalValidateOrderAttrValue(v) return recognized ? { order: recognized.asc, - secondaryOrder: recognized.secondary + secondaryOrder: recognized.secondary, + applyToMetadataField: recognized.applyToMetadataField } : null; } @@ -836,7 +872,8 @@ export class SortingSpecProcessor { const recognized: CustomSortOrderAscDescPair | null = this.internalValidateOrderAttrValue(v) return recognized ? { order: recognized.desc, - secondaryOrder: recognized.secondary + secondaryOrder: recognized.secondary, + applyToMetadataField: recognized.applyToMetadataField } : null; } @@ -861,7 +898,7 @@ export class SortingSpecProcessor { return [ThreeDots] } if (spec.startsWith(ThreeDots)) { - return [ThreeDots, spec.substr(ThreeDotsLength)]; + return [ThreeDots, spec.substring(ThreeDotsLength)]; } if (spec.endsWith(ThreeDots)) { return [spec.substring(0, spec.length - ThreeDotsLength), ThreeDots]; @@ -872,7 +909,7 @@ export class SortingSpecProcessor { return [ spec.substring(0, idx), ThreeDots, - spec.substr(idx + ThreeDotsLength) + spec.substring(idx + ThreeDotsLength) ]; } @@ -942,7 +979,7 @@ export class SortingSpecProcessor { ) return { type: CustomSortGroupType.HasMetadataField, - metadataFieldName: metadataFieldName, + withMetadataFieldName: metadataFieldName, filesOnly: spec.filesOnly, foldersOnly: spec.foldersOnly, matchFilenameWithExt: spec.matchFilenameWithExt