From 8fc25eb3c8ddc413564ec6d1f47397dead3f600b Mon Sep 17 00:00:00 2001 From: Ezra Marks Date: Fri, 12 Sep 2025 08:01:43 -0400 Subject: [PATCH] Add null-default option to by-metadata sorting, so that null metadata values can be explicitly defaulted, rather than always sorted last. Example usage: 'by-metadata: property_name null-default: 0` --- README.md | 1 + src/custom-sort/custom-sort-types.ts | 1 + src/custom-sort/custom-sort.ts | 8 ++-- src/custom-sort/sorting-spec-processor.ts | 15 ++++++- src/test/unit/custom-sort.spec.ts | 46 ++++++++++++++++++++ src/test/unit/sorting-spec-processor.spec.ts | 36 +++++++++++++++ 6 files changed, 101 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4645bdcb6..42506c142 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,7 @@ The list of automatic sorting orders includes: - `vsc-unicode` or `unicode-charcode` - tricky for geeks - `by-metadata:` modifier to use specific metadata for sorting - `using-extractor:` in connection with `by-metadata:` to use only part of metadata value, for example a date in specified format +- `null-default:` in connection with `by-metadata:` to specify fallback value for missing metadata, e.g. `a-z by-metadata: property_name null-default: 0` - `,` separator to specify two levels of sorting. When combining folder-level and group-level sorting this allows for up to 4 sorting levels - `advanced recursive modified` or `advanced recursive created` - advanced variants of `advanced modified` and `advanced created`. Use with care on larger vaults because the deep scanning of folder descendants can have impact on performance on mobile devices diff --git a/src/custom-sort/custom-sort-types.ts b/src/custom-sort/custom-sort-types.ts index aaaa29f1b..381f987fd 100644 --- a/src/custom-sort/custom-sort-types.ts +++ b/src/custom-sort/custom-sort-types.ts @@ -61,6 +61,7 @@ export interface CustomSort { order: CustomSortOrder // mandatory byMetadata?: string metadataValueExtractor?: MDataExtractor + nullDefault?: string // fallback value for missing metadata } export interface RecognizedSorting { diff --git a/src/custom-sort/custom-sort.ts b/src/custom-sort/custom-sort.ts index bbc323ee7..54eec3014 100644 --- a/src/custom-sort/custom-sort.ts +++ b/src/custom-sort/custom-sort.ts @@ -575,25 +575,25 @@ export const determineSortingGroup = function (entry: TFile | TFolder, spec: Cus group.sorting!.byMetadata || group.withMetadataFieldName || DEFAULT_METADATA_FIELD_FOR_SORTING, group.sorting!.metadataValueExtractor, frontMatterCache, - prioFrontMatterCache) + prioFrontMatterCache) ?? group.sorting!.nullDefault if (isSecondaryOrderByMetadata) metadataValueSecondaryToSortBy = mdataValueFromFMCaches ( group.secondarySorting!.byMetadata || group.withMetadataFieldName || DEFAULT_METADATA_FIELD_FOR_SORTING, group.secondarySorting!.metadataValueExtractor, frontMatterCache, - prioFrontMatterCache) + prioFrontMatterCache) ?? group.secondarySorting!.nullDefault if (isDerivedPrimaryByMetadata) metadataValueDerivedPrimaryToSortBy = mdataValueFromFMCaches ( spec.defaultSorting!.byMetadata || DEFAULT_METADATA_FIELD_FOR_SORTING, spec.defaultSorting!.metadataValueExtractor, frontMatterCache, - prioFrontMatterCache) + prioFrontMatterCache) ?? spec.defaultSorting!.nullDefault if (isDerivedSecondaryByMetadata) metadataValueDerivedSecondaryToSortBy = mdataValueFromFMCaches ( spec.defaultSecondarySorting!.byMetadata || DEFAULT_METADATA_FIELD_FOR_SORTING, spec.defaultSecondarySorting!.metadataValueExtractor, frontMatterCache, - prioFrontMatterCache) + prioFrontMatterCache) ?? spec.defaultSecondarySorting!.nullDefault } } } diff --git a/src/custom-sort/sorting-spec-processor.ts b/src/custom-sort/sorting-spec-processor.ts index 2081339fa..b4eb27905 100644 --- a/src/custom-sort/sorting-spec-processor.ts +++ b/src/custom-sort/sorting-spec-processor.ts @@ -154,6 +154,8 @@ const OrderByMetadataLexeme: string = 'by-metadata:' const ValueExtractorLexeme: string = 'using-extractor:' +const NullDefaultLexeme: string = 'null-default:' + const OrderLevelsSeparator: string = ',' enum Attribute { @@ -1573,9 +1575,17 @@ export class SortingSpecProcessor { let metadataName: string|undefined let metadataExtractor: MDataExtractor|undefined + let nullDefault: string|undefined if (orderSpec.startsWith(OrderByMetadataLexeme)) { applyToMetadata = true - const metadataNameAndOptionalExtractorSpec = orderSpec.substring(OrderByMetadataLexeme.length).trim() || undefined + let metadataNameAndOptionalExtractorSpec = orderSpec.substring(OrderByMetadataLexeme.length).trim() || undefined + + if (metadataNameAndOptionalExtractorSpec?.includes(NullDefaultLexeme)) { + const parts = metadataNameAndOptionalExtractorSpec.split(NullDefaultLexeme) + metadataNameAndOptionalExtractorSpec = parts[0]?.trim() + nullDefault = parts[1]?.trim() + } + if (metadataNameAndOptionalExtractorSpec) { if (metadataNameAndOptionalExtractorSpec.indexOf(ValueExtractorLexeme) > -1) { const metadataSpec = metadataNameAndOptionalExtractorSpec.split(ValueExtractorLexeme) @@ -1648,7 +1658,8 @@ export class SortingSpecProcessor { sortOrderSpec[level] = { order: order!, byMetadata: metadataName, - metadataValueExtractor: metadataExtractor + metadataValueExtractor: metadataExtractor, + nullDefault: nullDefault } } return sortOrderSpec diff --git a/src/test/unit/custom-sort.spec.ts b/src/test/unit/custom-sort.spec.ts index 04af391ec..fb38682cf 100644 --- a/src/test/unit/custom-sort.spec.ts +++ b/src/test/unit/custom-sort.spec.ts @@ -1800,6 +1800,52 @@ describe('determineSortingGroup', () => { metadataFieldValue: 'direct metadata on file, under default name' } as FolderItemForSorting); }) + it('should use nullDefault when metadata is missing for derived sorting', () => { + // given + const file: TFile = mockTFile('References', 'md', 111, MOCK_TIMESTAMP + 222, MOCK_TIMESTAMP + 333); + const sortSpec: CustomSortSpec = { + targetFoldersPaths: ['/'], + groups: [{ + type: CustomSortGroupType.ExactPrefix, + exactPrefix: 'Ref', + sorting: { order: CustomSortOrder.alphabetical }, + }], + defaultSorting: { + order: CustomSortOrder.byMetadataFieldAlphabetical, + byMetadata: 'missing-field', + nullDefault: 'default-value' + } + } + const ctx: Partial = { + _mCache: { + getCache: function (path: string): CachedMetadata | undefined { + return { + 'Some parent folder/References.md': { + frontmatter: { + // missing-field is not present + position: MockedLoc + } + } + }[path] + } + } as MetadataCache + } + + // when + const result = determineSortingGroup(file, sortSpec, ctx as ProcessingContext) + + // then + expect(result).toEqual({ + groupIdx: 0, + isFolder: false, + sortString: "References", + sortStringWithExt: "References.md", + ctime: MOCK_TIMESTAMP + 222, + mtime: MOCK_TIMESTAMP + 333, + path: 'Some parent folder/References.md', + metadataFieldValueForDerived: 'default-value' // Should use nullDefault + } as FolderItemForSorting); + }) }) describe('when sort by metadata is involved (specified in secondary sort, for group of for target folder)', () => { diff --git a/src/test/unit/sorting-spec-processor.spec.ts b/src/test/unit/sorting-spec-processor.spec.ts index 907597555..cac3575ed 100644 --- a/src/test/unit/sorting-spec-processor.spec.ts +++ b/src/test/unit/sorting-spec-processor.spec.ts @@ -576,6 +576,37 @@ const expectedSortSpecsExampleMDataExtractors2: { [key: string]: CustomSortSpec } } +const txtInputExampleMDataNullDefault: string = ` +< a-z by-metadata: priority null-default: zzz +/folders Chapter... + > a-z by-metadata: status using-extractor: date(dd/mm/yyyy) null-default: 1900-01-01 +` + +const expectedSortSpecsExampleMDataNullDefault: { [key: string]: CustomSortSpec } = { + "mock-folder": { + defaultSorting: { + order: CustomSortOrder.byMetadataFieldAlphabetical, + byMetadata: 'priority', + nullDefault: 'zzz' + }, + groups: [{ + foldersOnly: true, + type: CustomSortGroupType.ExactPrefix, + exactPrefix: 'Chapter', + sorting: { + order: CustomSortOrder.byMetadataFieldAlphabeticalReverse, + byMetadata: 'status', + metadataValueExtractor: _unitTests.extractorFnForDate_ddmmyyyy, + nullDefault: '1900-01-01' + } + }, { + type: CustomSortGroupType.Outsiders + }], + targetFoldersPaths: ['mock-folder'], + outsidersGroupIdx: 1 + } +} + describe('SortingSpecProcessor', () => { let processor: SortingSpecProcessor; beforeEach(() => { @@ -606,6 +637,11 @@ describe('SortingSpecProcessor', () => { const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') expect(result?.sortSpecByPath).toEqual(expectedSortSpecsExampleMDataExtractors2) }) + it('should generate correct SortSpecs (example with mdata null-default)', () => { + const inputTxtArr: Array = txtInputExampleMDataNullDefault.split('\n') + const result = processor.parseSortSpecFromText(inputTxtArr, 'mock-folder', 'custom-name-note.md') + expect(result?.sortSpecByPath).toEqual(expectedSortSpecsExampleMDataNullDefault) +}) }) const txtInputNotDuplicatedSortSpec: string = `