#23 - support for sorting by metadata

- more advanced version of implementation: with-metadata and by-metadata support
- readme update
This commit is contained in:
SebastianMC 2022-11-09 18:52:41 +01:00
parent fcaedf9ea5
commit 10bf6e42c7
7 changed files with 734 additions and 46 deletions

View File

@ -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
>

View File

@ -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<string> // For root use '/'
defaultOrder?: CustomSortOrder
byMetadataField?: string // for 'by-metadata:' if the defaultOrder is by metadata alphabetical or reverse
groups: Array<CustomSortGroup>
outsidersGroupIdx?: number
outsidersFilesGroupIdx?: number
outsidersFoldersGroupIdx?: number
itemsToHide?: Set<string>
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'

View File

@ -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<TFolder|TFile>, 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<FolderItemForSorting> = {
metadataFieldValue: 'A'
}
const itemB: Partial<FolderItemForSorting> = {
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<FolderItemForSorting> = {
metadataFieldValue: 'Aaa',
sortString: 'n123'
}
const itemB: Partial<FolderItemForSorting> = {
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<FolderItemForSorting> = {
metadataFieldValue: 'n159',
sortString: 'n123'
}
const itemB: Partial<FolderItemForSorting> = {
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<FolderItemForSorting> = {
sortString: 'ccc'
}
const itemB: Partial<FolderItemForSorting> = {
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<FolderItemForSorting> = {
metadataFieldValue: 'A'
}
const itemB: Partial<FolderItemForSorting> = {
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<FolderItemForSorting> = {
metadataFieldValue: 'Aaa',
sortString: 'n123'
}
const itemB: Partial<FolderItemForSorting> = {
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<FolderItemForSorting> = {
metadataFieldValue: '15',
sortString: 'n123'
}
const itemB: Partial<FolderItemForSorting> = {
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<FolderItemForSorting> = {
sortString: 'ccc'
}
const itemB: Partial<FolderItemForSorting> = {
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)
})
})

View File

@ -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<FolderItemForSor
export const folderSort = function (sortingSpec: CustomSortSpec, order: string[]) {
let fileExplorer = this.fileExplorer
const sortingGroupsCardinality: {[key: number]: number} = {}
sortingSpec._mCache = sortingSpec.plugin?.app.metadataCache
const folderItems: Array<FolderItemForSorting> = (sortingSpec.itemsToHide ?
this.file.children.filter((entry: TFile | TFolder) => {

View File

@ -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;
}

View File

@ -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

View File

@ -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<CustomSortOrderAscDescPair> = {}
let applyToMetadata: boolean = false
if (v.indexOf(OrderByMetadataLexeme) > 0) { // Intentionally > 0 -> not allow the metadata lexeme alone
const pieces: Array<string> = 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