Major improvement: added support for determining and applying sort order currently selected in Obsidian UI

- the meaning of CustomSortOrder.standardObsidian changes from a fixed one to what is actually selected in Obsidian UI
- the CustomSortOrder.standardObsidian can be applied at a folder level (as the default for folder) and at a group level (this is a major addition)
- added a mapping of Obsidian UI sorting methods onto internal plugin sorting methods, plus addition of the Obsidian UI logic to push folders to the top unconditionally
- !!! NO NEW UNIT TESTS FOR THIS FEATURE - must add later
- not tested manually, as the commits extraction and pushing is done as part of #88 github issue
This commit is contained in:
SebastianMC 2023-08-24 01:11:22 +02:00
parent 16f5d61818
commit 45f5918598
8 changed files with 156 additions and 42 deletions

View File

@ -8,6 +8,7 @@ import {
matchGroupRegex, matchGroupRegex,
sorterByMetadataField, sorterByMetadataField,
SorterFn, SorterFn,
getSorterFnFor,
Sorters Sorters
} from './custom-sort'; } from './custom-sort';
import {CustomSortGroupType, CustomSortOrder, CustomSortSpec, RegExpSpec} from './custom-sort-types'; import {CustomSortGroupType, CustomSortOrder, CustomSortSpec, RegExpSpec} from './custom-sort-types';

View File

@ -64,6 +64,8 @@ export interface FolderItemForSorting {
} }
export type SorterFn = (a: FolderItemForSorting, b: FolderItemForSorting) => number export type SorterFn = (a: FolderItemForSorting, b: FolderItemForSorting) => number
export type PlainSorterFn = (a: TAbstractFile, b: TAbstractFile) => number
export type PlainFileOnlySorterFn = (a: TFile, b: TFile) => number
export type CollatorCompareFn = (a: string, b: string) => number export type CollatorCompareFn = (a: string, b: string) => number
// Syntax sugar // Syntax sugar
@ -112,19 +114,83 @@ export let Sorters: { [key in CustomSortOrder]: SorterFn } = {
[CustomSortOrder.byMetadataFieldAlphabeticalReverse]: sorterByMetadataField(ReverseOrder), [CustomSortOrder.byMetadataFieldAlphabeticalReverse]: sorterByMetadataField(ReverseOrder),
[CustomSortOrder.byMetadataFieldTrueAlphabeticalReverse]: sorterByMetadataField(ReverseOrder, TrueAlphabetical), [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 // This is a fallback entry which should not be used - the getSorterFor() function below should protect against it
[CustomSortOrder.standardObsidian]: (a: FolderItemForSorting, b: FolderItemForSorting) => CollatorCompare(a.sortString, b.sortString), [CustomSortOrder.standardObsidian]: (a: FolderItemForSorting, b: FolderItemForSorting) => CollatorCompare(a.sortString, b.sortString),
}; };
function compareTwoItems(itA: FolderItemForSorting, itB: FolderItemForSorting, sortSpec: CustomSortSpec) { // OS - Obsidian Sort
const OS_alphabetical = 'alphabetical'
const OS_alphabeticalReverse = 'alphabeticalReverse'
const OS_byModifiedTime = 'byModifiedTime'
const OS_byModifiedTimeReverse = 'byModifiedTimeReverse'
const OS_byCreatedTime = 'byCreatedTime'
const OS_byCreatedTimeReverse = 'byCreatedTimeReverse'
export const ObsidianStandardDefaultSortingName = OS_alphabetical
const StandardObsidianToCustomSort: {[key: string]: CustomSortOrder} = {
[OS_alphabetical]: CustomSortOrder.alphabetical,
[OS_alphabeticalReverse]: CustomSortOrder.alphabeticalReverse,
[OS_byModifiedTime]: CustomSortOrder.byModifiedTimeReverse, // In Obsidian labeled as 'Modified time (new to old)'
[OS_byModifiedTimeReverse]: CustomSortOrder.byModifiedTime, // In Obsidian labeled as 'Modified time (old to new)'
[OS_byCreatedTime]: CustomSortOrder.byCreatedTimeReverse, // In Obsidian labeled as 'Created time (new to old)'
[OS_byCreatedTimeReverse]: CustomSortOrder.byCreatedTime // In Obsidian labeled as 'Created time (old to new)'
}
const StandardObsidianToPlainSortFn: {[key: string]: PlainFileOnlySorterFn} = {
[OS_alphabetical]: (a: TFile, b: TFile) => CollatorCompare(a.basename, b.basename),
[OS_alphabeticalReverse]: (a: TFile, b: TFile) => -StandardObsidianToPlainSortFn[OS_alphabetical](a,b),
[OS_byModifiedTime]: (a: TFile, b: TFile) => b.stat.mtime - a.stat.mtime,
[OS_byModifiedTimeReverse]: (a: TFile, b: TFile) => -StandardObsidianToPlainSortFn[OS_byModifiedTime](a,b),
[OS_byCreatedTime]: (a: TFile, b: TFile) => b.stat.ctime - a.stat.ctime,
[OS_byCreatedTimeReverse]: (a: TFile, b: TFile) => -StandardObsidianToPlainSortFn[OS_byCreatedTime](a,b)
}
// Standard Obsidian comparator keeps folders in the top sorted alphabetically
const StandardObsidianComparator = (order: CustomSortOrder): SorterFn => {
const customSorterFn = Sorters[order]
return (a: FolderItemForSorting, b: FolderItemForSorting): number => {
return a.isFolder || b.isFolder
?
(a.isFolder && !b.isFolder ? -1 : (b.isFolder && !a.isFolder ? 1 : Sorters[CustomSortOrder.alphabetical](a,b)))
:
customSorterFn(a, b);
}
}
// Equivalent of StandardObsidianComparator working directly on TAbstractFile items
export const StandardPlainObsidianComparator = (order: string): PlainSorterFn => {
const fileSorterFn = StandardObsidianToPlainSortFn[order] || StandardObsidianToCustomSort[OS_alphabetical]
return (a: TAbstractFile, b: TAbstractFile): number => {
const aIsFolder: boolean = a instanceof TFolder
const bIsFolder: boolean = b instanceof TFolder
return aIsFolder || bIsFolder
?
(aIsFolder && !bIsFolder ? -1 : (bIsFolder && !aIsFolder ? 1 : CollatorCompare(a.name,b.name)))
:
fileSorterFn(a as TFile, b as TFile);
}
}
export const getSorterFnFor = (sorting: CustomSortOrder, currentUIselectedSorting?: string): SorterFn => {
if (sorting === CustomSortOrder.standardObsidian) {
sorting = StandardObsidianToCustomSort[currentUIselectedSorting ?? 'alphabetical'] ?? CustomSortOrder.alphabetical
return StandardObsidianComparator(sorting)
} else {
return Sorters[sorting]
}
}
function getComparator(sortSpec: CustomSortSpec, currentUIselectedSorting?: string): SorterFn {
const compareTwoItems = (itA: FolderItemForSorting, itB: FolderItemForSorting) => {
if (itA.groupIdx != undefined && itB.groupIdx != undefined) { if (itA.groupIdx != undefined && itB.groupIdx != undefined) {
if (itA.groupIdx === itB.groupIdx) { if (itA.groupIdx === itB.groupIdx) {
const group: CustomSortGroup | undefined = sortSpec.groups[itA.groupIdx] const group: CustomSortGroup | undefined = sortSpec.groups[itA.groupIdx]
const matchingGroupPresentOnBothSidesAndEqual: boolean = itA.matchGroup !== undefined && itA.matchGroup === itB.matchGroup const matchingGroupPresentOnBothSidesAndEqual: boolean = itA.matchGroup !== undefined && itA.matchGroup === itB.matchGroup
if (matchingGroupPresentOnBothSidesAndEqual && group.secondaryOrder) { if (matchingGroupPresentOnBothSidesAndEqual && group.secondaryOrder) {
return Sorters[group.secondaryOrder ?? CustomSortOrder.default](itA, itB) return getSorterFnFor(group.secondaryOrder ?? CustomSortOrder.default, currentUIselectedSorting)(itA, itB)
} else { } else {
return Sorters[group?.order ?? CustomSortOrder.default](itA, itB) return getSorterFnFor(group?.order ?? CustomSortOrder.default, currentUIselectedSorting)(itA, itB)
} }
} else { } else {
return itA.groupIdx - itB.groupIdx; return itA.groupIdx - itB.groupIdx;
@ -133,9 +199,11 @@ function compareTwoItems(itA: FolderItemForSorting, itB: FolderItemForSorting, s
// should never happen - groupIdx is not known for at least one of items to compare. // should never happen - groupIdx is not known for at least one of items to compare.
// The logic of determining the index always sets some idx // The logic of determining the index always sets some idx
// Yet for sanity and to satisfy TS code analyzer a fallback to default behavior below // Yet for sanity and to satisfy TS code analyzer a fallback to default behavior below
return Sorters[CustomSortOrder.default](itA, itB) return getSorterFnFor(CustomSortOrder.default, currentUIselectedSorting)(itA, itB)
} }
} }
return compareTwoItems
}
const isFolder = (entry: TAbstractFile) => { const isFolder = (entry: TAbstractFile) => {
// The plain obvious 'entry instanceof TFolder' doesn't work inside Jest unit tests, hence a workaround below // The plain obvious 'entry instanceof TFolder' doesn't work inside Jest unit tests, hence a workaround below
@ -270,7 +338,7 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus
break break
case CustomSortGroupType.StarredOnly: case CustomSortGroupType.StarredOnly:
if (ctx?.starredPluginInstance) { if (ctx?.starredPluginInstance) {
let starred: boolean = determineStarredStatusOf(entry, aFile, ctx.starredPluginInstance) const starred: boolean = determineStarredStatusOf(entry, aFile, ctx.starredPluginInstance)
if (starred) { if (starred) {
determined = true determined = true
} }
@ -458,9 +526,9 @@ export const folderSort = function (sortingSpec: CustomSortSpec, order: string[]
// Finally, for advanced sorting by modified date, for some 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) determineFolderDatesIfNeeded(folderItems, sortingSpec)
folderItems.sort(function (itA: FolderItemForSorting, itB: FolderItemForSorting) { const comparator: SorterFn = getComparator(sortingSpec, fileExplorer.sortOrder)
return compareTwoItems(itA, itB, sortingSpec);
}); folderItems.sort(comparator)
const items = folderItems const items = folderItems
.map((item: FolderItemForSorting) => fileExplorer.fileItems[item.path]) .map((item: FolderItemForSorting) => fileExplorer.fileItems[item.path])

View File

@ -489,16 +489,23 @@ describe('SortingSpecProcessor', () => {
const txtInputStandardObsidianSortAttr: string = ` const txtInputStandardObsidianSortAttr: string = `
target-folder: AAA target-folder: AAA
sorting: standard sorting: standard
/ Some folder
sorting: standard
` `
const expectedSortSpecForObsidianStandardSorting: { [key: string]: CustomSortSpec } = { const expectedSortSpecForObsidianStandardSorting: { [key: string]: CustomSortSpec } = {
"AAA": { "AAA": {
defaultOrder: CustomSortOrder.standardObsidian, defaultOrder: CustomSortOrder.standardObsidian,
groups: [{ groups: [{
exactText: 'Some folder',
foldersOnly: true,
order: CustomSortOrder.standardObsidian,
type: CustomSortGroupType.ExactName
}, {
order: CustomSortOrder.standardObsidian, order: CustomSortOrder.standardObsidian,
type: CustomSortGroupType.Outsiders type: CustomSortGroupType.Outsiders
}], }],
outsidersGroupIdx: 0, outsidersGroupIdx: 1,
targetFoldersPaths: ['AAA'] targetFoldersPaths: ['AAA']
} }
} }
@ -1649,11 +1656,13 @@ const txtInputErrorTooManyNumericSortSymbols: string = `
% Chapter\\R+ ... page\\d+ % Chapter\\R+ ... page\\d+
` `
/* No longer applicable
const txtInputErrorNestedStandardObsidianSortAttr: string = ` const txtInputErrorNestedStandardObsidianSortAttr: string = `
target-folder: AAA target-folder: AAA
/ Some folder / Some folder
sorting: standard sorting: standard
` `
*/
const txtInputErrorPriorityEmptyFilePattern: string = ` const txtInputErrorPriorityEmptyFilePattern: string = `
/!! /: /!! /:
@ -1754,6 +1763,7 @@ describe('SortingSpecProcessor error detection and reporting', () => {
`${ERR_PREFIX} 9:TooManySortingSymbols Maximum one sorting symbol allowed per line ${ERR_SUFFIX_IN_LINE(2)}`) `${ERR_PREFIX} 9:TooManySortingSymbols Maximum one sorting symbol allowed per line ${ERR_SUFFIX_IN_LINE(2)}`)
expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('% Chapter\\R+ ... page\\d+ ')) expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('% Chapter\\R+ ... page\\d+ '))
}) })
/* Problem no longer applicable
it('should recognize error: nested standard obsidian sorting attribute', () => { it('should recognize error: nested standard obsidian sorting attribute', () => {
const inputTxtArr: Array<string> = txtInputErrorNestedStandardObsidianSortAttr.split('\n') const inputTxtArr: Array<string> = txtInputErrorNestedStandardObsidianSortAttr.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
@ -1763,6 +1773,7 @@ describe('SortingSpecProcessor error detection and reporting', () => {
`${ERR_PREFIX} 14:StandardObsidianSortAllowedOnlyAtFolderLevel The standard Obsidian sort order is only allowed at a folder level (not nested syntax) ${ERR_SUFFIX_IN_LINE(4)}`) `${ERR_PREFIX} 14:StandardObsidianSortAllowedOnlyAtFolderLevel The standard Obsidian sort order is only allowed at a folder level (not nested syntax) ${ERR_SUFFIX_IN_LINE(4)}`)
expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT(' sorting: standard')) expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT(' sorting: standard'))
}) })
*/
it('should recognize error: priority indicator alone', () => { it('should recognize error: priority indicator alone', () => {
const inputTxtArr: Array<string> = ` const inputTxtArr: Array<string> = `
/! /!

View File

@ -69,7 +69,7 @@ export enum ProblemCode {
ItemToHideExactNameWithExtRequired, ItemToHideExactNameWithExtRequired,
ItemToHideNoSupportForThreeDots, ItemToHideNoSupportForThreeDots,
DuplicateWildcardSortSpecForSameFolder, DuplicateWildcardSortSpecForSameFolder,
StandardObsidianSortAllowedOnlyAtFolderLevel, ProblemNoLongerApplicable_StandardObsidianSortAllowedOnlyAtFolderLevel, // Placeholder kept to avoid refactoring of many unit tests (hardcoded error codes)
PriorityNotAllowedOnOutsidersGroup, PriorityNotAllowedOnOutsidersGroup,
TooManyPriorityPrefixes, TooManyPriorityPrefixes,
CombiningNotAllowedOnOutsidersGroup, CombiningNotAllowedOnOutsidersGroup,
@ -971,10 +971,6 @@ export class SortingSpecProcessor {
this.problem(ProblemCode.DuplicateOrderAttr, `Duplicate order specification for a sorting rule of folder ${folderPathsForProblemMsg}`) this.problem(ProblemCode.DuplicateOrderAttr, `Duplicate order specification for a sorting rule of folder ${folderPathsForProblemMsg}`)
return false; return false;
} }
if ((attr.value as RecognizedOrderValue).order === CustomSortOrder.standardObsidian) {
this.problem(ProblemCode.StandardObsidianSortAllowedOnlyAtFolderLevel, `The standard Obsidian sort order is only allowed at a folder level (not nested syntax)`)
return false;
}
this.ctx.currentSpecGroup.order = (attr.value as RecognizedOrderValue).order this.ctx.currentSpecGroup.order = (attr.value as RecognizedOrderValue).order
this.ctx.currentSpecGroup.byMetadataField = (attr.value as RecognizedOrderValue).applyToMetadataField this.ctx.currentSpecGroup.byMetadataField = (attr.value as RecognizedOrderValue).applyToMetadataField
this.ctx.currentSpecGroup.secondaryOrder = (attr.value as RecognizedOrderValue).secondaryOrder this.ctx.currentSpecGroup.secondaryOrder = (attr.value as RecognizedOrderValue).secondaryOrder

View File

@ -16,7 +16,9 @@ import {
Vault Vault
} from 'obsidian'; } from 'obsidian';
import {around} from 'monkey-around'; import {around} from 'monkey-around';
import {folderSort} from './custom-sort/custom-sort'; import {
folderSort
} from './custom-sort/custom-sort';
import {SortingSpecProcessor, SortSpecsCollection} from './custom-sort/sorting-spec-processor'; import {SortingSpecProcessor, SortSpecsCollection} from './custom-sort/sorting-spec-processor';
import {CustomSortOrder, CustomSortSpec} from './custom-sort/custom-sort-types'; import {CustomSortOrder, CustomSortSpec} from './custom-sort/custom-sort-types';
@ -30,6 +32,8 @@ import {
ICON_SORT_SUSPENDED_SYNTAX_ERROR ICON_SORT_SUSPENDED_SYNTAX_ERROR
} from "./custom-sort/icons"; } from "./custom-sort/icons";
import {lastPathComponent} from "./utils/utils";
interface CustomSortPluginSettings { interface CustomSortPluginSettings {
additionalSortspecFile: string additionalSortspecFile: string
suspended: boolean suspended: boolean
@ -309,6 +313,18 @@ export default class CustomSortPlugin extends Plugin {
}) })
} }
determineSortSpecForFolder(folderPath: string, folderName?: string): CustomSortSpec|null|undefined {
folderName = folderName ?? lastPathComponent(folderPath)
let sortSpec: CustomSortSpec | null | undefined = this.sortSpecCache?.sortSpecByPath?.[folderPath]
sortSpec = sortSpec ?? this.sortSpecCache?.sortSpecByName?.[folderName]
if (!sortSpec && this.sortSpecCache?.sortSpecByWildcard) {
// when no sorting spec found directly by folder path, check for wildcard-based match
sortSpec = this.sortSpecCache?.sortSpecByWildcard.folderMatch(folderPath, folderName)
}
return sortSpec
}
// For the idea of monkey-patching credits go to https://github.com/nothingislost/obsidian-bartender // For the idea of monkey-patching credits go to https://github.com/nothingislost/obsidian-bartender
patchFileExplorerFolder(patchableFileExplorer?: FileExplorerView): boolean { patchFileExplorerFolder(patchableFileExplorer?: FileExplorerView): boolean {
let plugin = this; let plugin = this;
@ -332,23 +348,10 @@ export default class CustomSortPlugin extends Plugin {
setIcon(plugin.ribbonIconEl, ICON_SORT_ENABLED_ACTIVE) setIcon(plugin.ribbonIconEl, ICON_SORT_ENABLED_ACTIVE)
} }
// if custom sort is not specified, use the UI-selected
const folder: TFolder = this.file const folder: TFolder = this.file
let sortSpec: CustomSortSpec | null | undefined = plugin.sortSpecCache?.sortSpecByPath?.[folder.path] let sortSpec: CustomSortSpec | null | undefined = plugin.determineSortSpecForFolder(folder.path, folder.name)
sortSpec = sortSpec ?? plugin.sortSpecCache?.sortSpecByName?.[folder.name]
if (sortSpec) { if (sortSpec) {
if (sortSpec.defaultOrder === CustomSortOrder.standardObsidian) {
sortSpec = null // A folder is explicitly excluded from custom sorting plugin
}
} else if (plugin.sortSpecCache?.sortSpecByWildcard) {
// when no sorting spec found directly by folder path, check for wildcard-based match
sortSpec = plugin.sortSpecCache?.sortSpecByWildcard.folderMatch(folder.path, folder.name)
if (sortSpec?.defaultOrder === CustomSortOrder.standardObsidian) {
sortSpec = null // A folder is explicitly excluded from custom sorting plugin
}
}
if (sortSpec) {
sortSpec.plugin = plugin
return folderSort.call(this, sortSpec, ...args); return folderSort.call(this, sortSpec, ...args);
} else { } else {
return old.call(this, ...args); return old.call(this, ...args);
@ -371,7 +374,6 @@ export default class CustomSortPlugin extends Plugin {
} }
onunload() { onunload() {
} }
updateStatusBar() { updateStatusBar() {

View File

@ -53,5 +53,7 @@ declare module 'obsidian' {
createFolderDom(folder: TFolder): FileExplorerFolder; createFolderDom(folder: TFolder): FileExplorerFolder;
requestSort(): void; requestSort(): void;
sortOrder: string
} }
} }

24
src/utils/utils.spec.ts Normal file
View File

@ -0,0 +1,24 @@
import {lastPathComponent, extractParentFolderPath} from "./utils";
describe('lastPathComponent and extractParentFolderPath', () => {
it.each([
['a folder', '', 'a folder'],
['a/subfolder', 'a', 'subfolder'],
['parent/child', 'parent', 'child'],
['','',''],
[' ','',''],
['/strange', '', 'strange'],
['a/b/c/', 'a/b/c', ''],
['d d d/e e e/f f f/ggg ggg', 'd d d/e e e/f f f', 'ggg ggg'],
['/','',''],
[' / ','',''],
[' /','',''],
['/ ','','']
])('should from %s extract %s and %s', (path: string, parentPath: string, lastComponent: string) => {
const extractedParentPath: string = extractParentFolderPath(path)
const extractedLastComponent: string = lastPathComponent(path)
expect(extractedParentPath).toBe(parentPath)
expect(extractedLastComponent).toBe(lastComponent)
}
)
})

View File

@ -6,3 +6,13 @@ export function isDefined(o: any): boolean {
export function last<T>(o: Array<T>): T | undefined { export function last<T>(o: Array<T>): T | undefined {
return o?.length > 0 ? o[o.length - 1] : undefined return o?.length > 0 ? o[o.length - 1] : undefined
} }
export function lastPathComponent(path: string): string {
const lastPathSeparatorIdx = (path ?? '').lastIndexOf('/')
return lastPathSeparatorIdx >= 0 ? path.substring(lastPathSeparatorIdx + 1).trim() : path.trim()
}
export function extractParentFolderPath(path: string): string {
const lastPathSeparatorIdx = (path ?? '').lastIndexOf('/')
return lastPathSeparatorIdx > 0 ? path.substring(0, lastPathSeparatorIdx).trim() : ''
}