From fabd5863482fc43a983af0591295d488de46e4af Mon Sep 17 00:00:00 2001 From: SebastianMC <23032356+SebastianMC@users.noreply.github.com> Date: Sat, 12 Nov 2022 16:18:18 +0100 Subject: [PATCH] #23 feature request sorting notes with metadata (#26) * #23 - support for sorting by metadata - added support for grouping items by the presence of specified metadata - new keyword `with-metadata:` introduced for that purpose in lexer - if metadata field name is omitted, the default `sort-index-value` is used - added support for sorting items by notes and folders metadata - new keyword 'by-metadata:' introduced for that purpose - if metadata field name is omitted, the default `sort-index-value` is used (or metadata name inheritance is used) - unit tests of sorting spec processor extended accordingly - documentation and code example in README.md - extended to also support true alphabetical on metadata fields - release unnecessary references after sorting completed --- README.md | 84 ++- src/custom-sort/custom-sort-types.ts | 19 +- src/custom-sort/custom-sort.spec.ts | 660 +++++++++++++++++- src/custom-sort/custom-sort.ts | 142 +++- src/custom-sort/matchers.ts | 2 +- .../sorting-spec-processor.spec.ts | 68 +- src/custom-sort/sorting-spec-processor.ts | 93 ++- src/main.ts | 1 + 8 files changed, 1024 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 531572d..b07fe8b 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Take full control of the order of your notes and folders: - support for fully manual order - list notes and folders names explicitly, or use prefixes or suffixes only - wildcard names matching supported +- group and sort notes and folders by notes custom metadata - support for automatic sorting by standard and non-standard rules - mixing manual and automatic ordering also supported - order by compound numbers in prefix, in suffix (e.g date in suffix) or inbetween @@ -15,7 +16,7 @@ Take full control of the order of your notes and folders: - different sorting rules per group even inside the same folder - simple to use yet versatile configuration options - order configuration stored directly in your note(s) front matter - - use a dedicated key in YAML + - use a dedicated `sorting-spec:` 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 @@ -35,6 +36,7 @@ Take full control of the order of your notes and folders: - [Example 11: Sample book structure with compound Roman number suffixes](#example-11-sample-book-structure-with-compound-roman-number-suffixes) - [Example 12: Apply same sorting to all folders in the vault](#example-12-apply-same-sorting-to-all-folders-in-the-vault) - [Example 13: Sorting rules inheritance by subfolders](#example-13-sorting-rules-inheritance-by-subfolders) + - [Example 14: Grouping and sorting by metadata value](#example-14-grouping-and-sorting-by-metadata-value) - [Alphabetical, Natural and True Alphabetical sorting orders](#alphabetical-natural-and-true-alphabetical-sorting-orders) - [Location of sorting specification YAML entry](#location-of-sorting-specification-yaml-entry) - [Ribbon icon](#ribbon-icon) @@ -412,6 +414,86 @@ 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. + +For example: + +```yaml +--- +Pages: 6 +... +--- +``` + +or + +```yaml +--- +Pages: 7,8 +... +--- +``` + +or + +```yaml +--- +Pages: 12-15 +... +--- +``` + +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 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. +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). +Then the remaining notes (not having the `Pages` metadata) are sorted by modification date descending. + +> NOTE +> +> The grouping and sorting by metadata is not refreshed automatically after change of the metadata in note(s) to avoid impact on Obsidian performance. +> After editing of metadata of some note(s) you have to explicitly click the plugin ribbon button to refresh the sorting. Or issue the command `sort on`. Or close and reopen the vault. Or restart Obsidian. +> This behavior is intentionally different from other grouping and sorting rules, which stay active and up-to-date once enabled. + +> NOTE +> +> For folders, metadata of their 'folder note' is scanned (if present) + +> NOTE +> +> The `with-metadata:` keyword can be used with other specifiers like `/:files with-metadata: Pages` or `/folders with-metadata: Pages` +> If the metadata name is omitted, the default `sort-index-value` metadata name is assumed. + ## Alphabetical, Natural and True Alphabetical sorting orders The 'A-Z' sorting (visible in Obsidian UI of file explorer) at some point before the 1.0.0 release of Obsidian actually became the so-called 'natural' sort order. diff --git a/src/custom-sort/custom-sort-types.ts b/src/custom-sort/custom-sort-types.ts index 448a53b..1ce15b3 100644 --- a/src/custom-sort/custom-sort-types.ts +++ b/src/custom-sort/custom-sort-types.ts @@ -1,3 +1,5 @@ +import {MetadataCache, Plugin} from "obsidian"; + 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 @@ -5,6 +7,7 @@ export enum CustomSortGroupType { ExactPrefix, // ... while the Outsiders captures items which didn't match any of other defined groups ExactSuffix, ExactHeadAndTail, // Like W...n or Un...ed, which is shorter variant of typing the entire title + HasMetadataField // Notes (or folder's notes) containing a specific metadata field } export enum CustomSortOrder { @@ -20,6 +23,10 @@ export enum CustomSortOrder { byCreatedTimeAdvanced, byCreatedTimeReverse, byCreatedTimeReverseAdvanced, + byMetadataFieldAlphabetical, + byMetadataFieldTrueAlphabetical, + byMetadataFieldAlphabeticalReverse, + byMetadataFieldTrueAlphabeticalReverse, standardObsidian, // Let the folder sorting be in hands of Obsidian, whatever user selected in the UI default = alphabetical } @@ -27,6 +34,7 @@ export enum CustomSortOrder { export interface RecognizedOrderValue { order: CustomSortOrder secondaryOrder?: CustomSortOrder + applyToMetadataField?: string } export type NormalizerFn = (s: string) => string | null @@ -43,18 +51,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, + foldersOnly?: boolean + 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..3809eb3 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,483 @@ 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) alph', () => { + // 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 File item (order by metadata set on group) alph rev', () => { + // 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.byMetadataFieldAlphabeticalReverse + }], + _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 File item (order by metadata set on group) true alph', () => { + // 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.byMetadataFieldTrueAlphabetical + }], + _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 File item (order by metadata set on group) true alph rev', () => { + // 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.byMetadataFieldTrueAlphabeticalReverse + }], + _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 +861,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 c1685ba..4f3f6e8 100644 --- a/src/custom-sort/custom-sort.ts +++ b/src/custom-sort/custom-sort.ts @@ -1,14 +1,20 @@ -import {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, { +let CollatorCompare = new Intl.Collator(undefined, { usage: "sort", sensitivity: "base", numeric: true, }).compare; -let CollatorTrueAlphabetical = new Intl.Collator(undefined, { +let CollatorTrueAlphabeticalCompare = new Intl.Collator(undefined, { usage: "sort", sensitivity: "base", numeric: false, @@ -18,6 +24,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 @@ -26,24 +33,57 @@ export interface FolderItemForSorting { folder?: TFolder } -type SorterFn = (a: FolderItemForSorting, b: FolderItemForSorting) => number +export type SorterFn = (a: FolderItemForSorting, b: FolderItemForSorting) => number +export type CollatorCompareFn = (a: string, b: string) => number -let Sorters: { [key in CustomSortOrder]: SorterFn } = { - [CustomSortOrder.alphabetical]: (a: FolderItemForSorting, b: FolderItemForSorting) => Collator(a.sortString, b.sortString), - [CustomSortOrder.trueAlphabetical]: (a: FolderItemForSorting, b: FolderItemForSorting) => CollatorTrueAlphabetical(a.sortString, b.sortString), - [CustomSortOrder.alphabeticalReverse]: (a: FolderItemForSorting, b: FolderItemForSorting) => Collator(b.sortString, a.sortString), - [CustomSortOrder.trueAlphabeticalReverse]: (a: FolderItemForSorting, b: FolderItemForSorting) => CollatorTrueAlphabetical(b.sortString, a.sortString), - [CustomSortOrder.byModifiedTime]: (a: FolderItemForSorting, b: FolderItemForSorting) => (a.isFolder && b.isFolder) ? Collator(a.sortString, b.sortString) : (a.mtime - b.mtime), +// Syntax sugar +const TrueAlphabetical: boolean = true +const ReverseOrder: boolean = true +const StraightOrder: boolean = false + +const sorterByMetadataField:(reverseOrder?: boolean, trueAlphabetical?: boolean) => SorterFn = (reverseOrder: boolean, trueAlphabetical?: boolean) => { + const collatorCompareFn: CollatorCompareFn = trueAlphabetical ? CollatorTrueAlphabeticalCompare : CollatorCompare + return (a: FolderItemForSorting, b: FolderItemForSorting) => { + if (reverseOrder) { + [a, b] = [b, a] + } + if (a.metadataFieldValue && b.metadataFieldValue) { + const sortResult: number = collatorCompareFn(a.metadataFieldValue, b.metadataFieldValue) + if (sortResult === 0) { + // Fallback -> requested sort by metadata and both items have the same metadata value + return collatorCompareFn(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 reverseOrder ? 1 : -1 + if (b.metadataFieldValue) return reverseOrder ? -1 : 1 + // Fallback -> requested sort by metadata, yet none of two items contain it, use alphabetical by name + return collatorCompareFn(a.sortString, b.sortString) + } +} + +export let Sorters: { [key in CustomSortOrder]: SorterFn } = { + [CustomSortOrder.alphabetical]: (a: FolderItemForSorting, b: FolderItemForSorting) => CollatorCompare(a.sortString, b.sortString), + [CustomSortOrder.trueAlphabetical]: (a: FolderItemForSorting, b: FolderItemForSorting) => CollatorTrueAlphabeticalCompare(a.sortString, b.sortString), + [CustomSortOrder.alphabeticalReverse]: (a: FolderItemForSorting, b: FolderItemForSorting) => CollatorCompare(b.sortString, a.sortString), + [CustomSortOrder.trueAlphabeticalReverse]: (a: FolderItemForSorting, b: FolderItemForSorting) => CollatorTrueAlphabeticalCompare(b.sortString, a.sortString), + [CustomSortOrder.byModifiedTime]: (a: FolderItemForSorting, b: FolderItemForSorting) => (a.isFolder && b.isFolder) ? CollatorCompare(a.sortString, b.sortString) : (a.mtime - b.mtime), [CustomSortOrder.byModifiedTimeAdvanced]: (a: FolderItemForSorting, b: FolderItemForSorting) => a.mtime - b.mtime, - [CustomSortOrder.byModifiedTimeReverse]: (a: FolderItemForSorting, b: FolderItemForSorting) => (a.isFolder && b.isFolder) ? Collator(a.sortString, b.sortString) : (b.mtime - a.mtime), + [CustomSortOrder.byModifiedTimeReverse]: (a: FolderItemForSorting, b: FolderItemForSorting) => (a.isFolder && b.isFolder) ? CollatorCompare(a.sortString, b.sortString) : (b.mtime - a.mtime), [CustomSortOrder.byModifiedTimeReverseAdvanced]: (a: FolderItemForSorting, b: FolderItemForSorting) => b.mtime - a.mtime, - [CustomSortOrder.byCreatedTime]: (a: FolderItemForSorting, b: FolderItemForSorting) => (a.isFolder && b.isFolder) ? Collator(a.sortString, b.sortString) : (a.ctimeNewest - b.ctimeNewest), + [CustomSortOrder.byCreatedTime]: (a: FolderItemForSorting, b: FolderItemForSorting) => (a.isFolder && b.isFolder) ? CollatorCompare(a.sortString, b.sortString) : (a.ctimeNewest - b.ctimeNewest), [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.byCreatedTimeReverse]: (a: FolderItemForSorting, b: FolderItemForSorting) => (a.isFolder && b.isFolder) ? CollatorCompare(a.sortString, b.sortString) : (b.ctimeOldest - a.ctimeOldest), [CustomSortOrder.byCreatedTimeReverseAdvanced]: (a: FolderItemForSorting, b: FolderItemForSorting) => b.ctimeOldest - a.ctimeOldest, + [CustomSortOrder.byMetadataFieldAlphabetical]: sorterByMetadataField(StraightOrder), + [CustomSortOrder.byMetadataFieldTrueAlphabetical]: sorterByMetadataField(StraightOrder, TrueAlphabetical), + [CustomSortOrder.byMetadataFieldAlphabeticalReverse]: sorterByMetadataField(ReverseOrder), + [CustomSortOrder.byMetadataFieldTrueAlphabeticalReverse]: sorterByMetadataField(ReverseOrder, TrueAlphabetical), // 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), + [CustomSortOrder.standardObsidian]: (a: FolderItemForSorting, b: FolderItemForSorting) => CollatorCompare(a.sortString, b.sortString), }; function compareTwoItems(itA: FolderItemForSorting, itB: FolderItemForSorting, sortSpec: CustomSortSpec) { @@ -71,6 +111,11 @@ const isFolder = (entry: TAbstractFile) => { return !!((entry as any).isRoot); } +const isByMetadata = (order: CustomSortOrder | undefined) => { + return order === CustomSortOrder.byMetadataFieldAlphabetical || order === CustomSortOrder.byMetadataFieldAlphabeticalReverse || + order === CustomSortOrder.byMetadataFieldTrueAlphabetical || order === CustomSortOrder.byMetadataFieldTrueAlphabeticalReverse +} + export const DEFAULT_FOLDER_MTIME: number = 0 export const DEFAULT_FOLDER_CTIME: number = 0 @@ -78,6 +123,7 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus let groupIdx: number let determined: boolean = false let matchedGroup: string | null | undefined + let metadataValueToSortBy: string | undefined const aFolder: boolean = isFolder(entry) const aFile: boolean = !aFolder const entryAsTFile: TFile = entry as TFile @@ -152,10 +198,24 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus matchedGroup = group.regexSpec?.normalizerFn(match[1]); } } - break; + break + case CustomSortGroupType.HasMetadataField: + 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 frontMatterCache: FrontMatterCache | undefined = spec._mCache.getCache(notePathToScan)?.frontmatter + const hasMetadata: boolean | undefined = frontMatterCache?.hasOwnProperty(group.withMetadataFieldName) + + if (hasMetadata) { + determined = true + } + } + } + break case CustomSortGroupType.MatchAll: determined = true; - break; + break } if (determined) { break; @@ -169,10 +229,50 @@ 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 + + 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] + } + } } } @@ -180,6 +280,7 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus // idx of the matched group or idx of Outsiders group or the largest index (= groups count+1) groupIdx: determinedGroupIdx, sortString: matchedGroup ? (matchedGroup + '//' + entry.name) : entry.name, + metadataFieldValue: metadataValueToSortBy, matchGroup: matchedGroup ?? undefined, isFolder: aFolder, folder: aFolder ? (entry as TFolder) : undefined, @@ -247,6 +348,7 @@ export const determineFolderDatesIfNeeded = (folderItems: Array = (sortingSpec.itemsToHide ? this.file.children.filter((entry: TFile | TFolder) => { @@ -263,7 +365,7 @@ export const folderSort = function (sortingSpec: CustomSortSpec, order: string[] return itemForSorting }) - // Finally, for advanced sorting by modified date, for some of the folders the modified date has to be determined + // Finally, for advanced sorting by modified date, for some folders the modified date has to be determined determineFolderDatesIfNeeded(folderItems, sortingSpec, sortingGroupsCardinality) folderItems.sort(function (itA: FolderItemForSorting, itB: FolderItemForSorting) { @@ -278,4 +380,8 @@ export const folderSort = function (sortingSpec: CustomSortSpec, order: string[] } else { this.children = items; } + + // release risky references + sortingSpec._mCache = undefined + sortingSpec.plugin = undefined }; 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 54d4048..34c899d 100644 --- a/src/custom-sort/sorting-spec-processor.spec.ts +++ b/src/custom-sort/sorting-spec-processor.spec.ts @@ -24,6 +24,12 @@ target-folder: tricky folder / /: +:::: tricky folder 2 +/: with-metadata: + < a-z by-metadata: Some-dedicated-field +with-metadata: Pages + > a-z by-metadata: + :::: Conceptual model /: Entities % @@ -71,6 +77,12 @@ target-folder: tricky folder /folders /:files +target-folder: tricky folder 2 +/:files with-metadata: + < a-z by-metadata: Some-dedicated-field +% with-metadata: Pages + > a-z by-metadata: + :::: Conceptual model /:files Entities % @@ -142,6 +154,26 @@ const expectedSortSpecsExampleA: { [key: string]: CustomSortSpec } = { outsidersFoldersGroupIdx: 0, targetFoldersPaths: ['tricky folder'] }, + "tricky folder 2": { + groups: [{ + filesOnly: true, + type: CustomSortGroupType.HasMetadataField, + withMetadataFieldName: 'sort-index-value', + order: CustomSortOrder.byMetadataFieldAlphabetical, + byMetadataField: 'Some-dedicated-field', + }, { + type: CustomSortGroupType.HasMetadataField, + withMetadataFieldName: 'Pages', + order: CustomSortOrder.byMetadataFieldAlphabeticalReverse + }, { + order: CustomSortOrder.alphabetical, + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 2, + targetFoldersPaths: [ + 'tricky folder 2' + ] + }, "Conceptual model": { groups: [{ exactText: "Entities", @@ -437,30 +469,54 @@ describe('SortingSpecProcessor', () => { }) const txtInputTrueAlphabeticalSortAttr: string = ` -target-folder: AAA +target-folder: True Alpha < true a-z -target-folder: BBB +target-folder: True Alpha Rev > true a-z +target-folder: by-meta True Alpha +< true a-z by-metadata: +target-folder: by-meta True Alpha Rev +> true a-z by-metadata: Some-attr ` const expectedSortSpecForTrueAlphabeticalSorting: { [key: string]: CustomSortSpec } = { - "AAA": { + "True Alpha": { defaultOrder: CustomSortOrder.trueAlphabetical, groups: [{ order: CustomSortOrder.trueAlphabetical, type: CustomSortGroupType.Outsiders }], outsidersGroupIdx: 0, - targetFoldersPaths: ['AAA'] + targetFoldersPaths: ['True Alpha'] }, - "BBB": { + "True Alpha Rev": { defaultOrder: CustomSortOrder.trueAlphabeticalReverse, groups: [{ order: CustomSortOrder.trueAlphabeticalReverse, type: CustomSortGroupType.Outsiders }], outsidersGroupIdx: 0, - targetFoldersPaths: ['BBB'] + targetFoldersPaths: ['True Alpha Rev'] + }, + "by-meta True Alpha": { + defaultOrder: CustomSortOrder.byMetadataFieldTrueAlphabetical, + groups: [{ + order: CustomSortOrder.byMetadataFieldTrueAlphabetical, + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 0, + targetFoldersPaths: ['by-meta True Alpha'] + }, + "by-meta True Alpha Rev": { + defaultOrder: CustomSortOrder.byMetadataFieldTrueAlphabeticalReverse, + byMetadataField: 'Some-attr', + groups: [{ + order: CustomSortOrder.byMetadataFieldTrueAlphabeticalReverse, + byMetadataField: 'Some-attr', + type: CustomSortGroupType.Outsiders + }], + outsidersGroupIdx: 0, + targetFoldersPaths: ['by-meta True Alpha Rev'] } } diff --git a/src/custom-sort/sorting-spec-processor.ts b/src/custom-sort/sorting-spec-processor.ts index 6009f0a..f4aa3f1 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! @@ -133,6 +135,8 @@ const OrderLiterals: { [key: string]: CustomSortOrderAscDescPair } = { } } +const OrderByMetadataLexeme: string = 'by-metadata:' + enum Attribute { TargetFolder = 1, // Starting from 1 to allow: if (attribute) { ... OrderAsc, @@ -174,6 +178,8 @@ const AnyTypeGroupLexeme: string = '%' // See % as a combination of / and : const HideItemShortLexeme: string = '--%' // See % as a combination of / and : const HideItemVerboseLexeme: string = '/--hide:' +const MetadataFieldIndicatorLexeme: string = 'with-metadata:' + const CommentPrefix: string = '//' interface SortingGroupType { @@ -381,6 +387,12 @@ const stripWildcardPatternSuffix = (path: string): [path: string, priority: numb ] } +// Simplistic +const extractIdentifier = (text: string, defaultResult?: string): string | undefined => { + const identifier: string = text.trim().split(' ')?.[0]?.trim() + return identifier ? identifier : defaultResult +} + 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 { @@ -607,6 +619,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) { @@ -623,6 +636,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; } @@ -636,7 +650,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 @@ -784,6 +798,7 @@ export class SortingSpecProcessor { for (let group of spec.groups) { if (!group.order) { group.order = spec.defaultOrder ?? DEFAULT_SORT_ORDER + group.byMetadataField = spec.byMetadataField } } @@ -794,7 +809,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)}` } }); } @@ -812,14 +827,49 @@ 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 || + attr.asc === CustomSortOrder.trueAlphabetical || attr.desc === CustomSortOrder.trueAlphabeticalReverse )) { + + const trueAlphabetical: boolean = attr.asc === CustomSortOrder.trueAlphabetical || attr.desc === CustomSortOrder.trueAlphabeticalReverse + + // Create adjusted copy + attr = { + ...attr, + asc: trueAlphabetical ? CustomSortOrder.byMetadataFieldTrueAlphabetical : CustomSortOrder.byMetadataFieldAlphabetical, + desc: trueAlphabetical ? CustomSortOrder.byMetadataFieldTrueAlphabeticalReverse : 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; } @@ -827,7 +877,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; } @@ -852,7 +903,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]; @@ -863,7 +914,7 @@ export class SortingSpecProcessor { return [ spec.substring(0, idx), ThreeDots, - spec.substr(idx + ThreeDotsLength) + spec.substring(idx + ThreeDotsLength) ]; } @@ -926,13 +977,27 @@ export class SortingSpecProcessor { matchFilenameWithExt: spec.matchFilenameWithExt // Doesn't make sense for matching, yet for multi-match } // theoretically could match the sorting of matched files } else { - // For non-three dots single text line assume exact match group - return { - type: CustomSortGroupType.ExactName, - exactText: spec.arraySpec[0], - filesOnly: spec.filesOnly, - foldersOnly: spec.foldersOnly, - matchFilenameWithExt: spec.matchFilenameWithExt + if (theOnly.startsWith(MetadataFieldIndicatorLexeme)) { + const metadataFieldName: string | undefined = extractIdentifier( + theOnly.substring(MetadataFieldIndicatorLexeme.length), + DEFAULT_METADATA_FIELD_FOR_SORTING + ) + return { + type: CustomSortGroupType.HasMetadataField, + withMetadataFieldName: metadataFieldName, + filesOnly: spec.filesOnly, + foldersOnly: spec.foldersOnly, + matchFilenameWithExt: spec.matchFilenameWithExt + } + } else { + // For non-three dots single text line assume exact match group + return { + type: CustomSortGroupType.ExactName, + exactText: theOnly, + filesOnly: spec.filesOnly, + foldersOnly: spec.foldersOnly, + matchFilenameWithExt: spec.matchFilenameWithExt + } } } } diff --git a/src/main.ts b/src/main.ts index edf7ef2..ebd79dc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -292,6 +292,7 @@ export default class CustomSortPlugin extends Plugin { } } if (sortSpec) { + sortSpec.plugin = plugin return folderSort.call(this, sortSpec, ...args); } else { return old.call(this, ...args);