#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
This commit is contained in:
SebastianMC 2022-11-12 16:18:18 +01:00 committed by GitHub
parent 9541202b40
commit fabd586348
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 1024 additions and 45 deletions

View File

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

View File

@ -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<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,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<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,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<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) => {
@ -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
};

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

@ -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']
}
}

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

View File

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