#29 - Feature: priorities of sorting rules

This commit is contained in:
SebastianMC 2022-11-15 16:56:25 +01:00
parent 1300caf291
commit c397782a99
5 changed files with 331 additions and 21 deletions

View File

@ -57,6 +57,7 @@ export interface CustomSortGroup {
matchFilenameWithExt?: boolean
foldersOnly?: boolean
withMetadataFieldName?: string // for 'with-metadata:'
priority?: number
}
export interface CustomSortSpec {
@ -68,9 +69,10 @@ export interface CustomSortSpec {
outsidersFilesGroupIdx?: number
outsidersFoldersGroupIdx?: number
itemsToHide?: Set<string>
plugin?: Plugin // to hand over the access to App instance to the sorting engine
priorityOrder?: Array<number> // Indexes of groups in evaluation order
// For internal transient use
plugin?: Plugin // to hand over the access to App instance to the sorting engine
_mCache?: MetadataCache
}

View File

@ -788,6 +788,52 @@ describe('determineSortingGroup', () => {
} as FolderItemForSorting);
})
})
it('should correctly apply priority group', () => {
// given
const file: TFile = mockTFile('Abcdef!', 'md', 111, MOCK_TIMESTAMP + 222, MOCK_TIMESTAMP + 333);
const sortSpec: CustomSortSpec = {
groups: [{
filesOnly: true,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.MatchAll
}, {
foldersOnly: true,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.MatchAll
}, {
exactSuffix: "def!",
priority: 2,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactSuffix
}, {
exactText: "Abcdef!",
order: CustomSortOrder.alphabetical,
priority: 3,
type: CustomSortGroupType.ExactName
}, {
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}],
outsidersGroupIdx: 4,
targetFoldersPaths: ['/'],
priorityOrder: [3,2,0,1]
}
// when
const result = determineSortingGroup(file, sortSpec)
// then
expect(result).toEqual({
groupIdx: 3,
isFolder: false,
sortString: "Abcdef!.md",
ctimeNewest: MOCK_TIMESTAMP + 222,
ctimeOldest: MOCK_TIMESTAMP + 222,
mtime: MOCK_TIMESTAMP + 333,
path: 'Some parent folder/Abcdef!.md'
});
})
})
describe('determineFolderDatesIfNeeded', () => {

View File

@ -129,8 +129,10 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus
const entryAsTFile: TFile = entry as TFile
const basename: string = aFolder ? entry.name : entryAsTFile.basename
for (groupIdx = 0; groupIdx < spec.groups.length; groupIdx++) {
const numOfGroupsToCheck: number = spec.priorityOrder ? spec.priorityOrder.length : spec.groups.length
for (let idx = 0; idx < numOfGroupsToCheck; idx++) {
matchedGroup = null
groupIdx = spec.priorityOrder ? spec.priorityOrder[idx] : idx
const group: CustomSortGroup = spec.groups[groupIdx];
if (group.foldersOnly && aFile) continue;
if (group.filesOnly && aFolder) continue;
@ -218,12 +220,12 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus
break
}
if (determined) {
break;
break; // No need to check other sorting groups
}
}
// the final groupIdx for undetermined folder entry is either the last+1 groupIdx or idx of explicitly defined outsiders group
let determinedGroupIdx: number | undefined = groupIdx;
const idxAfterLastGroupIdx: number = spec.groups.length
let determinedGroupIdx: number | undefined = determined ? groupIdx! : idxAfterLastGroupIdx
if (!determined) {
// Automatically assign the index to outsiders group, if relevant was configured

View File

@ -7,7 +7,7 @@ import {
escapeRegexUnsafeCharacters,
extractNumericSortingSymbol,
hasMoreThanOneNumericSortingSymbol,
NumberNormalizerFn,
NumberNormalizerFn, ProblemCode,
RegexpUsedAs,
RomanNumberNormalizerFn,
SortingSpecProcessor
@ -535,7 +535,7 @@ describe('SortingSpecProcessor', () => {
const txtInputSimplistic1: string = `
target-folder: /*
/:files
//folders
/folders
`
const expectedSortSpecForSimplistic1: { [key: string]: CustomSortSpec } = {
@ -545,11 +545,12 @@ const expectedSortSpecForSimplistic1: { [key: string]: CustomSortSpec } = {
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}, {
foldersOnly: true,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}],
outsidersFilesGroupIdx: 0,
outsidersGroupIdx: 1,
outsidersFoldersGroupIdx: 1,
targetFoldersPaths: ['/*']
}
}
@ -561,11 +562,12 @@ const expectedWildcardMatchingTreeForSimplistic1 = {
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}, {
foldersOnly: true,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}],
outsidersFilesGroupIdx: 0,
outsidersGroupIdx: 1,
outsidersFoldersGroupIdx: 1,
targetFoldersPaths: ['/*']
},
"subtree": {}
@ -574,7 +576,7 @@ const expectedWildcardMatchingTreeForSimplistic1 = {
const txtInputSimplistic2: string = `
target-folder: /
/:files
//folders
/folders
`
const expectedSortSpecForSimplistic2: { [key: string]: CustomSortSpec } = {
@ -584,11 +586,12 @@ const expectedSortSpecForSimplistic2: { [key: string]: CustomSortSpec } = {
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}, {
foldersOnly: true,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}],
outsidersFilesGroupIdx: 0,
outsidersGroupIdx: 1,
outsidersFoldersGroupIdx: 1,
targetFoldersPaths: ['/']
}
}
@ -760,6 +763,123 @@ describe('SortingSpecProcessor edge case', () => {
})
})
const txtInputPriorityGroups1: string = `
target-folder: /
/:files
/folders
/! /:files Fi...
/!! /folders Fo...
/!!! ...def!
Plain text
/! % Anything
`
const txtInputPriorityGroups2: string = `
target-folder: /
/! /:files Fi...
/!! /folders Fo...
/!!! ...def!
/! Anything
`
const expectedSortSpecForPriorityGroups1: { [key: string]: CustomSortSpec } = {
"/": {
groups: [{
filesOnly: true,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}, {
foldersOnly: true,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}, {
exactPrefix: "Fi",
filesOnly: true,
order: CustomSortOrder.alphabetical,
priority: 1,
type: CustomSortGroupType.ExactPrefix
}, {
exactPrefix: "Fo",
foldersOnly: true,
order: CustomSortOrder.alphabetical,
priority: 2,
type: CustomSortGroupType.ExactPrefix
}, {
exactSuffix: "def!",
priority: 3,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactSuffix
}, {
exactText: "Plain text",
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactName
},{
exactText: "Anything",
order: CustomSortOrder.alphabetical,
priority: 1,
type: CustomSortGroupType.ExactName
}],
outsidersFilesGroupIdx: 0,
outsidersFoldersGroupIdx: 1,
targetFoldersPaths: ['/'],
priorityOrder: [4,3,2,6,5]
}
}
const expectedSortSpecForPriorityGroups2: { [key: string]: CustomSortSpec } = {
"/": {
groups: [{
exactPrefix: "Fi",
filesOnly: true,
order: CustomSortOrder.alphabetical,
priority: 1,
type: CustomSortGroupType.ExactPrefix
}, {
exactPrefix: "Fo",
foldersOnly: true,
order: CustomSortOrder.alphabetical,
priority: 2,
type: CustomSortGroupType.ExactPrefix
}, {
exactSuffix: "def!",
priority: 3,
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.ExactSuffix
}, {
exactText: "Anything",
order: CustomSortOrder.alphabetical,
priority: 1,
type: CustomSortGroupType.ExactName
}, {
order: CustomSortOrder.alphabetical,
type: CustomSortGroupType.Outsiders
}],
outsidersGroupIdx: 4,
targetFoldersPaths: ['/'],
priorityOrder: [2,1,0,3]
}
}
describe('SortingSpecProcessor', () => {
let processor: SortingSpecProcessor;
beforeEach(() => {
processor = new SortingSpecProcessor();
});
it('should recognize the sorting groups with priority example 1', () => {
const inputTxtArr: Array<string> = txtInputPriorityGroups1.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result?.sortSpecByPath).toEqual(expectedSortSpecForPriorityGroups1)
expect(result?.sortSpecByWildcard).toBeUndefined()
})
it('should recognize the sorting groups with priority example 2', () => {
const inputTxtArr: Array<string> = txtInputPriorityGroups2.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result?.sortSpecByPath).toEqual(expectedSortSpecForPriorityGroups2)
expect(result?.sortSpecByWildcard).toBeUndefined()
})
})
const txtInputTargetFolderMultiSpecA: string = `
target-folder: .
< a-z
@ -1111,6 +1231,22 @@ target-folder: AAA
sorting: standard
`
const txtInputErrorPriorityAlone: string = `
/!
`
const txtInputErrorPriorityEmptyFilePattern: string = `
/!! /:
`
const txtInputErrorPriorityEmptyFolderPattern: string = `
/!!! /
`
const txtInputErrorPriorityEmptyPattern: string = `
/! %
`
const txtInputEmptySpec: string = ``
describe('SortingSpecProcessor error detection and reporting', () => {
@ -1207,6 +1343,42 @@ 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)}`)
expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT(' sorting: standard'))
})
it('should recognize error: priority indicator alone', () => {
const inputTxtArr: Array<string> = txtInputErrorPriorityAlone.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toBeNull()
expect(errorsLogger).toHaveBeenCalledTimes(2)
expect(errorsLogger).toHaveBeenNthCalledWith(1,
`${ERR_PREFIX} 15:PriorityNotAllowedOnOutsidersGroup Priority is not allowed for sorting group with empty match-pattern ${ERR_SUFFIX_IN_LINE(2)}`)
expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('/!'))
})
it('should recognize error: priority indicator with empty file pattern', () => {
const inputTxtArr: Array<string> = txtInputErrorPriorityEmptyFilePattern.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toBeNull()
expect(errorsLogger).toHaveBeenCalledTimes(2)
expect(errorsLogger).toHaveBeenNthCalledWith(1,
`${ERR_PREFIX} 15:PriorityNotAllowedOnOutsidersGroup Priority is not allowed for sorting group with empty match-pattern ${ERR_SUFFIX_IN_LINE(2)}`)
expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('/!! /:'))
})
it('should recognize error: priority indicator with empty folder pattern', () => {
const inputTxtArr: Array<string> = txtInputErrorPriorityEmptyFolderPattern.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toBeNull()
expect(errorsLogger).toHaveBeenCalledTimes(2)
expect(errorsLogger).toHaveBeenNthCalledWith(1,
`${ERR_PREFIX} 15:PriorityNotAllowedOnOutsidersGroup Priority is not allowed for sorting group with empty match-pattern ${ERR_SUFFIX_IN_LINE(2)}`)
expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('/!!! /'))
})
it('should recognize error: priority indicator with empty pattern', () => {
const inputTxtArr: Array<string> = txtInputErrorPriorityEmptyPattern.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')
expect(result).toBeNull()
expect(errorsLogger).toHaveBeenCalledTimes(2)
expect(errorsLogger).toHaveBeenNthCalledWith(1,
`${ERR_PREFIX} 15:PriorityNotAllowedOnOutsidersGroup Priority is not allowed for sorting group with empty match-pattern ${ERR_SUFFIX_IN_LINE(2)}`)
expect(errorsLogger).toHaveBeenNthCalledWith(2, ERR_LINE_TXT('/! %'))
})
it('should recognize empty spec', () => {
const inputTxtArr: Array<string> = txtInputEmptySpec.split('\n')
const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md')

View File

@ -46,6 +46,7 @@ interface ParsedSortingGroup {
arraySpec?: Array<string>
outsidersGroup?: boolean // Mutually exclusive with plainSpec and arraySpec
itemToHide?: boolean
priority?: number
}
export enum ProblemCode {
@ -63,7 +64,8 @@ export enum ProblemCode {
ItemToHideExactNameWithExtRequired,
ItemToHideNoSupportForThreeDots,
DuplicateWildcardSortSpecForSameFolder,
StandardObsidianSortAllowedOnlyAtFolderLevel
StandardObsidianSortAllowedOnlyAtFolderLevel,
PriorityNotAllowedOnOutsidersGroup
}
const ContextFreeProblems = new Set<ProblemCode>([
@ -174,7 +176,8 @@ const FilesWithExtGroupVerboseLexeme: string = '/:files.'
const FilesWithExtGroupShortLexeme: string = '/:.'
const FoldersGroupVerboseLexeme: string = '/folders'
const FoldersGroupShortLexeme: string = '/'
const AnyTypeGroupLexeme: string = '%' // See % as a combination of / and :
const AnyTypeGroupLexemeShort: string = '%' // See % as a combination of / and :
const AnyTypeGroupLexeme: string = '/%' // See % as a combination of / and :
const HideItemShortLexeme: string = '--%' // See % as a combination of / and :
const HideItemVerboseLexeme: string = '/--hide:'
@ -182,11 +185,26 @@ const MetadataFieldIndicatorLexeme: string = 'with-metadata:'
const CommentPrefix: string = '//'
const FileGroupModifierPrio1Lexeme: string = '/!'
const FileGroupModifierPrio2Lexeme: string = '/!!'
const FileGroupModifierPrio3Lexeme: string = '/!!!'
const PRIO_1: number = 1
const PRIO_2: number = 2
const PRIO_3: number = 3
const SortingGroupPriorityPrefixes: { [key: string]: number } = {
[FileGroupModifierPrio1Lexeme]: PRIO_1,
[FileGroupModifierPrio2Lexeme]: PRIO_2,
[FileGroupModifierPrio3Lexeme]: PRIO_3
}
interface SortingGroupType {
filesOnly?: boolean
filenameWithExt?: boolean // The text matching criteria should apply to filename + extension
foldersOnly?: boolean
itemToHide?: boolean
priority?: number
}
const SortingGroupPrefixes: { [key: string]: SortingGroupType } = {
@ -196,6 +214,7 @@ const SortingGroupPrefixes: { [key: string]: SortingGroupType } = {
[FilesWithExtGroupVerboseLexeme]: {filesOnly: true, filenameWithExt: true},
[FoldersGroupShortLexeme]: {foldersOnly: true},
[FoldersGroupVerboseLexeme]: {foldersOnly: true},
[AnyTypeGroupLexemeShort]: {},
[AnyTypeGroupLexeme]: {},
[HideItemShortLexeme]: {itemToHide: true},
[HideItemVerboseLexeme]: {itemToHide: true}
@ -664,23 +683,43 @@ export class SortingSpecProcessor {
}
private parseSortingGroupSpec = (line: string): ParsedSortingGroup | null => {
const s: string = line.trim()
let s: string = line.trim()
if (hasMoreThanOneNumericSortingSymbol(s)) {
this.problem(ProblemCode.TooManyNumericSortingSymbols, 'Maximum one numeric sorting indicator allowed per line')
return null
}
const priorityPrefixAlone: number = SortingGroupPriorityPrefixes[s]
if (priorityPrefixAlone) {
this.problem(ProblemCode.PriorityNotAllowedOnOutsidersGroup, 'Priority is not allowed for sorting group with empty match-pattern')
return null
}
let groupPriority: number | undefined = undefined
for (const priorityPrefix of Object.keys(SortingGroupPriorityPrefixes)) {
if (s.startsWith(priorityPrefix + ' ')) {
groupPriority = SortingGroupPriorityPrefixes[priorityPrefix]
s = s.substring(priorityPrefix.length).trim()
break
}
}
const prefixAlone: SortingGroupType = SortingGroupPrefixes[s]
if (prefixAlone) {
if (prefixAlone.itemToHide) {
this.problem(ProblemCode.ItemToHideExactNameWithExtRequired, 'Exact name with ext of file or folders to hide is required')
return null
} else { // !prefixAlone.itemToHide
return {
outsidersGroup: true,
filesOnly: prefixAlone.filesOnly,
foldersOnly: prefixAlone.foldersOnly
if (groupPriority) {
this.problem(ProblemCode.PriorityNotAllowedOnOutsidersGroup, 'Priority is not allowed for sorting group with empty match-pattern')
return null
} else {
return {
outsidersGroup: true,
filesOnly: prefixAlone.filesOnly,
foldersOnly: prefixAlone.foldersOnly
}
}
}
}
@ -700,12 +739,26 @@ export class SortingSpecProcessor {
plainSpec: s.substring(prefix.length + 1),
filesOnly: sortingGroupType.filesOnly,
foldersOnly: sortingGroupType.foldersOnly,
matchFilenameWithExt: sortingGroupType.filenameWithExt
matchFilenameWithExt: sortingGroupType.filenameWithExt,
priority: groupPriority ?? undefined
}
}
}
}
if (groupPriority) {
if (s === '') {
// Edge case: line with only priority prefix and no other content
this.problem(ProblemCode.PriorityNotAllowedOnOutsidersGroup, 'Priority is not allowed for sorting group with empty match-pattern')
return null
} else {
// Edge case: line with only priority prefix and no other known syntax, yet some content
return {
plainSpec: s,
priority: groupPriority
}
}
}
return null;
}
@ -731,8 +784,14 @@ export class SortingSpecProcessor {
if (newGroup) {
if (this.adjustSortingGroupForNumericSortingSymbol(newGroup)) {
if (this.ctx.currentSpec) {
this.ctx.currentSpec.groups.push(newGroup)
const groupIdx = this.ctx.currentSpec.groups.push(newGroup) - 1
this.ctx.currentSpecGroup = newGroup
// Consume group with priority
if (group.priority && group.priority > 0) {
newGroup.priority = group.priority
this.addExpediteGroupInfo(this.ctx.currentSpec, group.priority, groupIdx)
}
return true;
} else {
return false
@ -794,7 +853,7 @@ export class SortingSpecProcessor {
})
}
// Populate sorting order for a bit more efficient sorting later on
// Populate sorting order down the hierarchy for more clean sorting logic later on
for (let group of spec.groups) {
if (!group.order) {
group.order = spec.defaultOrder ?? DEFAULT_SORT_ORDER
@ -802,6 +861,18 @@ export class SortingSpecProcessor {
}
}
// If any priority sorting group was present in the spec, determine the groups evaluation order
if (spec.priorityOrder) {
// priorityOrder array already contains at least one priority group, so append all non-priority groups for the final order
// (Outsiders groups are ignored intentionally)
for (let idx=0; idx < spec.groups.length; idx++) {
const group: CustomSortGroup = spec.groups[idx]
if (group.priority === undefined && group.type !== CustomSortGroupType.Outsiders) {
spec.priorityOrder.push(idx)
}
}
}
const CURRENT_FOLDER_PREFIX: string = `${CURRENT_FOLDER_SYMBOL}/`
// Replace the dot-folder names (coming from: 'target-folder: .') with actual folder names
@ -1108,4 +1179,21 @@ export class SortingSpecProcessor {
}
return true
}
private addExpediteGroupInfo = (spec: CustomSortSpec, groupPriority: number, groupIdx: number) => {
if (!spec.priorityOrder) {
spec.priorityOrder = []
}
let inserted: boolean = false
for (let idx=0; idx<spec.priorityOrder.length; idx++) {
if (groupPriority > spec.groups[spec.priorityOrder[idx]].priority!) {
spec.priorityOrder.splice(idx, 0, groupIdx)
inserted = true
break
}
}
if (!inserted) {
spec.priorityOrder.push(groupIdx)
}
}
}