import { CustomSortGroup, CustomSortGroupType, CustomSortOrder, CustomSortSpec, NormalizerFn, RecognizedOrderValue, RegExpSpec } from "./custom-sort-types"; import {isDefined, last} from "../utils/utils"; import { CompoundNumberDashRegexStr, CompoundNumberDotRegexStr, CompoundRomanNumberDashRegexStr, CompoundRomanNumberDotRegexStr, DASH_SEPARATOR, DOT_SEPARATOR, getNormalizedNumber, getNormalizedRomanNumber, NumberRegexStr, RomanNumberRegexStr } from "./matchers"; import { FolderWildcardMatching, MATCH_ALL_SUFFIX, MATCH_CHILDREN_1_SUFFIX, MATCH_CHILDREN_2_SUFFIX } from "./folder-matching-rules" interface ProcessingContext { folderPath: string specs: Array currentSpec?: CustomSortSpec currentSpecGroup?: CustomSortGroup // Support for specific conditions (intentionally not generic approach) previousValidEntryWasTargetFolderAttr?: boolean // Entry in previous non-empty valid line } interface ParsedSortingGroup { filesOnly?: boolean matchFilenameWithExt?: boolean foldersOnly?: boolean plainSpec?: string arraySpec?: Array outsidersGroup?: boolean // Mutually exclusive with plainSpec and arraySpec itemToHide?: boolean } export enum ProblemCode { SyntaxError, SyntaxErrorInGroupSpec, DuplicateSortSpecForSameFolder, DuplicateOrderAttr, DanglingOrderAttr, MissingAttributeValue, NoSpaceBetweenAttributeAndValue, InvalidAttributeValue, TargetFolderNestedSpec, TooManyNumericSortingSymbols, NumericalSymbolAdjacentToWildcard, ItemToHideExactNameWithExtRequired, ItemToHideNoSupportForThreeDots, DuplicateWildcardSortSpecForSameFolder, StandardObsidianSortAllowedOnlyAtFolderLevel } const ContextFreeProblems = new Set([ ProblemCode.DuplicateSortSpecForSameFolder, ProblemCode.DuplicateWildcardSortSpecForSameFolder ]) const ThreeDots = '...'; const ThreeDotsLength = ThreeDots.length; const DEFAULT_SORT_ORDER = CustomSortOrder.alphabetical interface CustomSortOrderAscDescPair { asc: CustomSortOrder, desc: CustomSortOrder, secondary?: CustomSortOrder } // remember about .toLowerCase() before comparison! const OrderLiterals: { [key: string]: CustomSortOrderAscDescPair } = { 'a-z': {asc: CustomSortOrder.alphabetical, desc: CustomSortOrder.alphabeticalReverse}, 'created': {asc: CustomSortOrder.byCreatedTime, desc: CustomSortOrder.byCreatedTimeReverse}, 'modified': {asc: CustomSortOrder.byModifiedTime, desc: CustomSortOrder.byModifiedTimeReverse}, // Advanced, for edge cases of secondary sorting, when if regexp match is the same, override the alphabetical sorting by full name 'a-z, created': { asc: CustomSortOrder.alphabetical, desc: CustomSortOrder.alphabeticalReverse, secondary: CustomSortOrder.byCreatedTime }, 'a-z, created desc': { asc: CustomSortOrder.alphabetical, desc: CustomSortOrder.alphabeticalReverse, secondary: CustomSortOrder.byCreatedTimeReverse }, 'a-z, modified': { asc: CustomSortOrder.alphabetical, desc: CustomSortOrder.alphabeticalReverse, secondary: CustomSortOrder.byModifiedTime }, 'a-z, modified desc': { asc: CustomSortOrder.alphabetical, desc: CustomSortOrder.alphabeticalReverse, secondary: CustomSortOrder.byModifiedTimeReverse } } enum Attribute { TargetFolder = 1, // Starting from 1 to allow: if (attribute) { ... OrderAsc, OrderDesc, OrderStandardObsidian } const AttrLexems: { [key: string]: Attribute } = { // Verbose attr names 'target-folder:': Attribute.TargetFolder, 'order-asc:': Attribute.OrderAsc, 'order-desc:': Attribute.OrderDesc, 'sorting:': Attribute.OrderStandardObsidian, // Concise abbreviated equivalents '::::': Attribute.TargetFolder, '<': Attribute.OrderAsc, '>': Attribute.OrderDesc } const CURRENT_FOLDER_SYMBOL: string = '.' interface ParsedSortingAttribute { nesting: number // nesting level, 0 (default), 1+ attribute: Attribute value?: any } type AttrValueValidatorFn = (v: string) => any | null; const FilesGroupVerboseLexeme: string = '/:files' const FilesGroupShortLexeme: string = '/:' const FilesWithExtGroupVerboseLexeme: string = '/:files.' const FilesWithExtGroupShortLexeme: string = '/:.' const FoldersGroupVerboseLexeme: string = '/folders' const FoldersGroupShortLexeme: string = '/' const AnyTypeGroupLexeme: string = '%' // See % as a combination of / and : const HideItemShortLexeme: string = '--%' // See % as a combination of / and : const HideItemVerboseLexeme: string = '/--hide:' const CommentPrefix: string = '//' interface SortingGroupType { filesOnly?: boolean filenameWithExt?: boolean // The text matching criteria should apply to filename + extension foldersOnly?: boolean itemToHide?: boolean } const SortingGroupPrefixes: { [key: string]: SortingGroupType } = { [FilesGroupShortLexeme]: {filesOnly: true}, [FilesGroupVerboseLexeme]: {filesOnly: true}, [FilesWithExtGroupShortLexeme]: {filesOnly: true, filenameWithExt: true}, [FilesWithExtGroupVerboseLexeme]: {filesOnly: true, filenameWithExt: true}, [FoldersGroupShortLexeme]: {foldersOnly: true}, [FoldersGroupVerboseLexeme]: {foldersOnly: true}, [AnyTypeGroupLexeme]: {}, [HideItemShortLexeme]: {itemToHide: true}, [HideItemVerboseLexeme]: {itemToHide: true} } const isThreeDots = (s: string): boolean => { return s === ThreeDots } const containsThreeDots = (s: string): boolean => { return s.indexOf(ThreeDots) !== -1 } const RomanNumberRegexSymbol: string = '\\R+' // Roman number const CompoundRomanNumberDotRegexSymbol: string = '\\.R+' // Compound Roman number with dot as separator const CompoundRomanNumberDashRegexSymbol: string = '\\-R+' // Compound Roman number with dash as separator const NumberRegexSymbol: string = '\\d+' // Plain number const CompoundNumberDotRegexSymbol: string = '\\.d+' // Compound number with dot as separator const CompoundNumberDashRegexSymbol: string = '\\-d+' // Compound number with dash as separator const UnsafeRegexCharsRegex: RegExp = /[\^$.\-+\[\]{}()|*?=!\\]/g export const escapeRegexUnsafeCharacters = (s: string): string => { return s.replace(UnsafeRegexCharsRegex, '\\$&') } const numericSortingSymbolsArr: Array = [ escapeRegexUnsafeCharacters(NumberRegexSymbol), escapeRegexUnsafeCharacters(RomanNumberRegexSymbol), escapeRegexUnsafeCharacters(CompoundNumberDotRegexSymbol), escapeRegexUnsafeCharacters(CompoundNumberDashRegexSymbol), escapeRegexUnsafeCharacters(CompoundRomanNumberDotRegexSymbol), escapeRegexUnsafeCharacters(CompoundRomanNumberDashRegexSymbol), ] const numericSortingSymbolsRegex = new RegExp(numericSortingSymbolsArr.join('|'), 'gi') export const hasMoreThanOneNumericSortingSymbol = (s: string): boolean => { numericSortingSymbolsRegex.lastIndex = 0 return numericSortingSymbolsRegex.test(s) && numericSortingSymbolsRegex.test(s) } export const detectNumericSortingSymbols = (s: string): boolean => { numericSortingSymbolsRegex.lastIndex = 0 return numericSortingSymbolsRegex.test(s) } export const extractNumericSortingSymbol = (s: string): string => { numericSortingSymbolsRegex.lastIndex = 0 const matches: RegExpMatchArray = numericSortingSymbolsRegex.exec(s) return matches ? matches[0] : null } export interface RegExpSpecStr { regexpStr: string normalizerFn: NormalizerFn } // Exposed as named exports to allow unit testing export const RomanNumberNormalizerFn: NormalizerFn = (s: string) => getNormalizedRomanNumber(s) export const CompoundDotRomanNumberNormalizerFn: NormalizerFn = (s: string) => getNormalizedRomanNumber(s, DOT_SEPARATOR) export const CompoundDashRomanNumberNormalizerFn: NormalizerFn = (s: string) => getNormalizedRomanNumber(s, DASH_SEPARATOR) export const NumberNormalizerFn: NormalizerFn = (s: string) => getNormalizedNumber(s) export const CompoundDotNumberNormalizerFn: NormalizerFn = (s: string) => getNormalizedNumber(s, DOT_SEPARATOR) export const CompoundDashNumberNormalizerFn: NormalizerFn = (s: string) => getNormalizedNumber(s, DASH_SEPARATOR) const numericSortingSymbolToRegexpStr: { [key: string]: RegExpSpecStr } = { [RomanNumberRegexSymbol.toLowerCase()]: { regexpStr: RomanNumberRegexStr, normalizerFn: RomanNumberNormalizerFn }, [CompoundRomanNumberDotRegexSymbol.toLowerCase()]: { regexpStr: CompoundRomanNumberDotRegexStr, normalizerFn: CompoundDotRomanNumberNormalizerFn }, [CompoundRomanNumberDashRegexSymbol.toLowerCase()]: { regexpStr: CompoundRomanNumberDashRegexStr, normalizerFn: CompoundDashRomanNumberNormalizerFn }, [NumberRegexSymbol.toLowerCase()]: { regexpStr: NumberRegexStr, normalizerFn: NumberNormalizerFn }, [CompoundNumberDotRegexSymbol.toLowerCase()]: { regexpStr: CompoundNumberDotRegexStr, normalizerFn: CompoundDotNumberNormalizerFn }, [CompoundNumberDashRegexSymbol.toLowerCase()]: { regexpStr: CompoundNumberDashRegexStr, normalizerFn: CompoundDashNumberNormalizerFn } } export interface ExtractedNumericSortingSymbolInfo { regexpSpec: RegExpSpec prefix: string suffix: string } export enum RegexpUsedAs { InUnitTest, Prefix, Suffix, FullMatch } export const convertPlainStringWithNumericSortingSymbolToRegex = (s: string, actAs: RegexpUsedAs): ExtractedNumericSortingSymbolInfo => { const detectedSymbol: string = extractNumericSortingSymbol(s) if (detectedSymbol) { const replacement: RegExpSpecStr = numericSortingSymbolToRegexpStr[detectedSymbol.toLowerCase()] const [extractedPrefix, extractedSuffix] = s.split(detectedSymbol) const regexPrefix: string = actAs === RegexpUsedAs.Prefix || actAs === RegexpUsedAs.FullMatch ? '^' : '' const regexSuffix: string = actAs === RegexpUsedAs.Suffix || actAs === RegexpUsedAs.FullMatch ? '$' : '' return { regexpSpec: { regex: new RegExp(`${regexPrefix}${escapeRegexUnsafeCharacters(extractedPrefix)}${replacement.regexpStr}${escapeRegexUnsafeCharacters(extractedSuffix)}${regexSuffix}`, 'i'), normalizerFn: replacement.normalizerFn }, prefix: extractedPrefix, suffix: extractedSuffix } } else { return null } } export interface FolderPathToSortSpecMap { [key: string]: CustomSortSpec } export interface SortSpecsCollection { sortSpecByPath: FolderPathToSortSpecMap sortSpecByWildcard?: FolderWildcardMatching } interface AdjacencyInfo { noPrefix: boolean, noSuffix: boolean } const checkAdjacency = (sortingSymbolInfo: ExtractedNumericSortingSymbolInfo): AdjacencyInfo => { return { noPrefix: sortingSymbolInfo.prefix.length === 0, noSuffix: sortingSymbolInfo.suffix.length === 0 } } const endsWithWildcardPatternSuffix = (path: string): boolean => { return path.endsWith(MATCH_CHILDREN_1_SUFFIX) || path.endsWith(MATCH_CHILDREN_2_SUFFIX) || path.endsWith(MATCH_ALL_SUFFIX) } enum WildcardPriority { NO_WILDCARD = 1, MATCH_CHILDREN, MATCH_ALL } const stripWildcardPatternSuffix = (path: string): [path: string, priority: number] => { if (path.endsWith(MATCH_ALL_SUFFIX)) { path = path.slice(0, -MATCH_ALL_SUFFIX.length) return [ path.length > 0 ? path : '/', WildcardPriority.MATCH_ALL ] } if (path.endsWith(MATCH_CHILDREN_1_SUFFIX)) { path = path.slice(0, -MATCH_CHILDREN_1_SUFFIX.length) return [ path.length > 0 ? path : '/', WildcardPriority.MATCH_CHILDREN, ] } if (path.endsWith(MATCH_CHILDREN_2_SUFFIX)) { path = path.slice(0, -MATCH_CHILDREN_2_SUFFIX.length) return [ path.length > 0 ? path : '/', WildcardPriority.MATCH_CHILDREN ] } return [ path, WildcardPriority.NO_WILDCARD ] } 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 { ctx: ProcessingContext currentEntryLine: string currentEntryLineIdx: number currentSortingSpecContainerFilePath: string problemAlreadyReportedForCurrentLine: boolean recentErrorMessage: string // Helper map to deal with rule priorities for the same path // and also detect non-wildcard duplicates. // The wildcard duplicates were detected prior to this point, no need to bother about them pathMatchPriorityForPath: {[key: string]: WildcardPriority} = {} // Logger parameter exposed to support unit testing of error cases as well as capturing error messages // for in-app presentation constructor(private errorLogger?: typeof console.log) { } // root level parser function parseSortSpecFromText(text: Array, folderPath: string, sortingSpecFileName: string, collection?: SortSpecsCollection ): SortSpecsCollection { // reset / init processing state after potential previous invocation this.ctx = { folderPath: folderPath, // location of the sorting spec file specs: [] }; this.currentEntryLine = null this.currentEntryLineIdx = null this.currentSortingSpecContainerFilePath = null this.problemAlreadyReportedForCurrentLine = null this.recentErrorMessage = null let success: boolean = false; let lineIdx: number = 0; for (let entryLine of text) { lineIdx++ this.currentEntryLine = entryLine this.currentEntryLineIdx = lineIdx this.currentSortingSpecContainerFilePath = `${folderPath}/${sortingSpecFileName}` this.problemAlreadyReportedForCurrentLine = false const trimmedEntryLine: string = entryLine.trim() if (trimmedEntryLine === '') continue if (trimmedEntryLine.startsWith(CommentPrefix)) continue success = false // Empty lines and comments are OK, that's why setting so late const attr: ParsedSortingAttribute = this._l1s1_parseAttribute(entryLine); if (attr) { success = this._l1s2_processParsedSortingAttribute(attr); this.ctx.previousValidEntryWasTargetFolderAttr = success && (attr.attribute === Attribute.TargetFolder) } else if (!this.problemAlreadyReportedForCurrentLine && !this._l1s3_checkForRiskyAttrSyntaxError(entryLine)) { let group: ParsedSortingGroup = this._l1s4_parseSortingGroupSpec(entryLine); if (!this.problemAlreadyReportedForCurrentLine && !group) { // Default for unrecognized syntax: treat the line as exact name (of file or folder) group = {plainSpec: trimmedEntryLine} } if (group) { success = this._l1s5_processParsedSortGroupSpec(group); } this.ctx.previousValidEntryWasTargetFolderAttr = undefined } if (!success) { if (!this.problemAlreadyReportedForCurrentLine) { this.problem(ProblemCode.SyntaxError, "Sorting specification line doesn't match any supported syntax") } break; } } if (success) { if (this.ctx.specs.length > 0) { for (let spec of this.ctx.specs) { this._l1s6_postprocessSortSpec(spec) } let sortspecByWildcard: FolderWildcardMatching for (let spec of this.ctx.specs) { // Consume the folder paths ending with wildcard specs for (let idx = 0; idx() const ruleAdded = sortspecByWildcard.addWildcardDefinition(path, spec) if (ruleAdded?.errorMsg) { this.problem(ProblemCode.DuplicateWildcardSortSpecForSameFolder, ruleAdded?.errorMsg) return null // Failure - not allow duplicate wildcard specs for the same folder } } } } if (sortspecByWildcard) { collection = collection ?? { sortSpecByPath:{} } collection.sortSpecByWildcard = sortspecByWildcard } for (let spec of this.ctx.specs) { for (let idx = 0; idx < spec.targetFoldersPaths.length; idx++) { const originalPath = spec.targetFoldersPaths[idx] collection = collection ?? { sortSpecByPath: {} } let detectedWildcardPriority: WildcardPriority let path: string [path, detectedWildcardPriority] = stripWildcardPatternSuffix(originalPath) let storeTheSpec: boolean = true const preexistingSortSpecPriority: WildcardPriority = this.pathMatchPriorityForPath[path] if (preexistingSortSpecPriority) { if (preexistingSortSpecPriority === WildcardPriority.NO_WILDCARD && detectedWildcardPriority === WildcardPriority.NO_WILDCARD) { this.problem(ProblemCode.DuplicateSortSpecForSameFolder, `Duplicate sorting spec for folder ${path}`) return null // Failure - not allow duplicate specs for the same no-wildcard folder path } else if (detectedWildcardPriority >= preexistingSortSpecPriority) { // Ignore lower priority rule storeTheSpec = false } } if (storeTheSpec) { collection.sortSpecByPath[path] = spec this.pathMatchPriorityForPath[path] = detectedWildcardPriority } } } } return collection } else { return null } } problem = (code: ProblemCode, details: string): void => { const problemLabel = ProblemCode[code] let logger: typeof console.log = this.errorLogger ?? console.error const hasLineContext: boolean = !ContextFreeProblems.has(code) const lineContext = (hasLineContext) ? ` line ${this.currentEntryLineIdx} of` : '' logger(`Sorting specification problem: ${code}:${problemLabel} ${details} ---` + `encountered in${lineContext} sorting spec in file ${this.currentSortingSpecContainerFilePath}`) if (lineContext) { logger(`Content of problematic line: "${this.currentEntryLine}"`) } this.recentErrorMessage = `File: ${this.currentSortingSpecContainerFilePath}\n` + (hasLineContext ? `Specification line #${this.currentEntryLineIdx}: "${this.currentEntryLine}"\n` : '') + `Problem: ${code}:${problemLabel}\n` + `Details: ${details}` this.problemAlreadyReportedForCurrentLine = true } // level 1 parser functions defined in order of occurrence and dependency private _l1s1_parseAttribute = (line: string): ParsedSortingAttribute | null => { const lineTrimmedStart: string = line.trimStart() const nestingLevel: number = line.length - lineTrimmedStart.length // Attribute lexeme (name or alias) requires trailing space separator const indexOfSpace: number = lineTrimmedStart.indexOf(' ') if (indexOfSpace === -1) { return null; // Seemingly not an attribute or a syntax error, to be checked separately } const firstLexeme: string = lineTrimmedStart.substring(0, indexOfSpace) const firstLexemeLowerCase: string = firstLexeme.toLowerCase() const recognizedAttr: Attribute = AttrLexems[firstLexemeLowerCase] if (recognizedAttr) { const attrValue: string = lineTrimmedStart.substring(indexOfSpace).trim() if (attrValue) { const validator: AttrValueValidatorFn = this.attrValueValidators[recognizedAttr] if (validator) { const validValue = validator(attrValue); if (validValue) { return { nesting: nestingLevel, attribute: recognizedAttr, value: validValue } } else { this.problem(ProblemCode.InvalidAttributeValue, `Invalid value of the attribute "${firstLexeme}"`) } } else { return { nesting: nestingLevel, attribute: recognizedAttr, value: attrValue } } } else { this.problem(ProblemCode.MissingAttributeValue, `Attribute "${firstLexeme}" requires a value to follow`) } } return null; // Seemingly not an attribute or not a valid attribute expression (respective syntax error could have been logged) } private _l1s2_processParsedSortingAttribute(attr: ParsedSortingAttribute): boolean { if (attr.attribute === Attribute.TargetFolder) { if (attr.nesting === 0) { // root-level attribute causing creation of new spec or decoration of a previous one if (this.ctx.previousValidEntryWasTargetFolderAttr) { this.ctx.currentSpec.targetFoldersPaths.push(attr.value) } else { this._l2s2_putNewSpecForNewTargetFolder(attr.value) } return true } else { this.problem(ProblemCode.TargetFolderNestedSpec, `Nested (indented) specification of target folder is not allowed`) return false } } else if (attr.attribute === Attribute.OrderAsc || attr.attribute === Attribute.OrderDesc || attr.attribute === Attribute.OrderStandardObsidian) { if (attr.nesting === 0) { if (!this.ctx.currentSpec) { this._l2s2_putNewSpecForNewTargetFolder() } if (this.ctx.currentSpec.defaultOrder) { const folderPathsForProblemMsg: string = this.ctx.currentSpec.targetFoldersPaths.join(' :: '); this.problem(ProblemCode.DuplicateOrderAttr, `Duplicate order specification for folder(s) ${folderPathsForProblemMsg}`) return false; } this.ctx.currentSpec.defaultOrder = (attr.value as RecognizedOrderValue).order 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) { this.problem(ProblemCode.DanglingOrderAttr, `Nested (indented) attribute requires prior sorting group definition`) return false; } if (this.ctx.currentSpecGroup.order) { const folderPathsForProblemMsg: string = this.ctx.currentSpec.targetFoldersPaths.join(' :: '); this.problem(ProblemCode.DuplicateOrderAttr, `Duplicate order specification for a sorting rule of folder ${folderPathsForProblemMsg}`) 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.secondaryOrder = (attr.value as RecognizedOrderValue).secondaryOrder return true; } } return false; } private _l1s3_checkForRiskyAttrSyntaxError = (line: string): boolean => { const lineTrimmedStart: string = line.trimStart() const lineTrimmedStartLowerCase: string = lineTrimmedStart.toLowerCase() // 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) if (lineTrimmedStartLowerCase.length === attrLexeme.length) { this.problem(ProblemCode.MissingAttributeValue, `Attribute "${originalAttrLexeme}" requires a value to follow`) return true } else { this.problem(ProblemCode.NoSpaceBetweenAttributeAndValue, `Space required after attribute name "${originalAttrLexeme}"`) return true } } } return false } private _l1s4_parseSortingGroupSpec = (line: string): ParsedSortingGroup | null => { const s: string = line.trim() if (hasMoreThanOneNumericSortingSymbol(s)) { this.problem(ProblemCode.TooManyNumericSortingSymbols, 'Maximum one numeric sorting indicator allowed per line') return null } 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 } } } for (const prefix of Object.keys(SortingGroupPrefixes)) { if (s.startsWith(prefix + ' ')) { const sortingGroupType: SortingGroupType = SortingGroupPrefixes[prefix] if (sortingGroupType.itemToHide) { return { itemToHide: true, plainSpec: s.substring(prefix.length + 1), filesOnly: sortingGroupType.filesOnly, foldersOnly: sortingGroupType.foldersOnly } } else { // !sortingGroupType.itemToHide return { plainSpec: s.substring(prefix.length + 1), filesOnly: sortingGroupType.filesOnly, foldersOnly: sortingGroupType.foldersOnly, matchFilenameWithExt: sortingGroupType.filenameWithExt } } } } return null; } private _l1s5_processParsedSortGroupSpec(group: ParsedSortingGroup): boolean { if (!this.ctx.currentSpec) { this._l2s2_putNewSpecForNewTargetFolder() } if (group.plainSpec) { group.arraySpec = this._l2s2_convertPlainStringSortingGroupSpecToArraySpec(group.plainSpec) delete group.plainSpec } if (group.itemToHide) { if (!this._l2s3_consumeParsedItemToHide(group)) { this.problem(ProblemCode.ItemToHideNoSupportForThreeDots, 'For hiding of file or folder, the exact name with ext is required and no numeric sorting indicator allowed') return false } else { return true } } else { // !group.itemToHide const newGroup: CustomSortGroup = this._l2s4_consumeParsedSortingGroupSpec(group) if (newGroup) { if (this._l2s5_adjustSortingGroupForNumericSortingSymbol(newGroup)) { this.ctx.currentSpec.groups.push(newGroup) this.ctx.currentSpecGroup = newGroup return true; } else { return false; } } else { return false; } } } private _l1s6_postprocessSortSpec(spec: CustomSortSpec): void { // clean up to prevent false warnings in console spec.outsidersGroupIdx = undefined spec.outsidersFilesGroupIdx = undefined spec.outsidersFoldersGroupIdx = undefined let outsidersGroupForFolders: boolean let outsidersGroupForFiles: boolean // process all defined sorting groups for (let groupIdx = 0; groupIdx < spec.groups.length; groupIdx++) { const group: CustomSortGroup = spec.groups[groupIdx]; if (group.type === CustomSortGroupType.Outsiders) { if (group.filesOnly) { if (isDefined(spec.outsidersFilesGroupIdx)) { console.warn(`Ignoring duplicate Outsiders-files sorting group definition in sort spec for folder '${last(spec.targetFoldersPaths)}'`) } else { spec.outsidersFilesGroupIdx = groupIdx outsidersGroupForFiles = true } } else if (group.foldersOnly) { if (isDefined(spec.outsidersFoldersGroupIdx)) { console.warn(`Ignoring duplicate Outsiders-folders sorting group definition in sort spec for folder '${last(spec.targetFoldersPaths)}'`) } else { spec.outsidersFoldersGroupIdx = groupIdx outsidersGroupForFolders = true } } else { if (isDefined(spec.outsidersGroupIdx)) { console.warn(`Ignoring duplicate Outsiders sorting group definition in sort spec for folder '${last(spec.targetFoldersPaths)}'`) } else { spec.outsidersGroupIdx = groupIdx outsidersGroupForFolders = true outsidersGroupForFiles = true } } } } if (isDefined(spec.outsidersGroupIdx) && (isDefined(spec.outsidersFilesGroupIdx) || isDefined(spec.outsidersFoldersGroupIdx))) { console.warn(`Inconsistent Outsiders sorting group definition in sort spec for folder '${last(spec.targetFoldersPaths)}'`) } // For consistency and to simplify sorting code later on, implicitly append a single catch-all Outsiders group if (!(outsidersGroupForFiles && outsidersGroupForFolders)) { spec.outsidersGroupIdx = spec.groups.length spec.groups.push({ type: CustomSortGroupType.Outsiders }) } // Populate sorting order for a bit more efficient sorting later on for (let group of spec.groups) { if (!group.order) { group.order = spec.defaultOrder ?? DEFAULT_SORT_ORDER } } const CURRENT_FOLDER_PREFIX: string = `${CURRENT_FOLDER_SYMBOL}/` // Replace the dot-folder names (coming from: 'target-folder: .') with actual folder names spec.targetFoldersPaths.forEach((path, idx) => { 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)}` } }); } // level 2 parser functions defined in order of occurrence and dependency private _l2s1_validateTargetFolderAttrValue = (v: string): string | null => { if (v) { const trimmed: string = v.trim(); return trimmed ? trimmed : null; // Can't use ?? - it treats '' as a valid value } else { return null; } } private _l2s1_internal_validateOrderAttrValue = (v: string): CustomSortOrderAscDescPair | null => { v = v.trim(); return v ? OrderLiterals[v.toLowerCase()] : null } private _l2s1_validateOrderAscAttrValue = (v: string): RecognizedOrderValue | null => { const recognized: CustomSortOrderAscDescPair = this._l2s1_internal_validateOrderAttrValue(v) return recognized ? { order: recognized.asc, secondaryOrder: recognized.secondary } : null; } private _l2s1_validateOrderDescAttrValue = (v: string): RecognizedOrderValue | null => { const recognized: CustomSortOrderAscDescPair = this._l2s1_internal_validateOrderAttrValue(v) return recognized ? { order: recognized.desc, secondaryOrder: recognized.secondary } : null; } private _l2s1_validateSortingAttrValue = (v: string): RecognizedOrderValue | null => { // for now only a single fixed lexem const recognized: boolean = v.trim().toLowerCase() === 'standard' return recognized ? { order: CustomSortOrder.standardObsidian } : null; } attrValueValidators: { [key in Attribute]: AttrValueValidatorFn } = { [Attribute.TargetFolder]: this._l2s1_validateTargetFolderAttrValue.bind(this), [Attribute.OrderAsc]: this._l2s1_validateOrderAscAttrValue.bind(this), [Attribute.OrderDesc]: this._l2s1_validateOrderDescAttrValue.bind(this), [Attribute.OrderStandardObsidian]: this._l2s1_validateSortingAttrValue.bind(this) } _l2s2_convertPlainStringSortingGroupSpecToArraySpec = (spec: string): Array => { spec = spec.trim() if (isThreeDots(spec)) { return [ThreeDots] } if (spec.startsWith(ThreeDots)) { return [ThreeDots, spec.substr(ThreeDotsLength)]; } if (spec.endsWith(ThreeDots)) { return [spec.substring(0, spec.length - ThreeDotsLength), ThreeDots]; } const idx = spec.indexOf(ThreeDots); if (idx > 0) { return [ spec.substring(0, idx), ThreeDots, spec.substr(idx + ThreeDotsLength) ]; } // Unrecognized, treat as exact match return [spec]; } private _l2s2_putNewSpecForNewTargetFolder(folderPath?: string) { const newSpec: CustomSortSpec = { targetFoldersPaths: [folderPath ?? this.ctx.folderPath], groups: [] } this.ctx.specs.push(newSpec); this.ctx.currentSpec = newSpec; this.ctx.currentSpecGroup = undefined; } // Detection of slippery syntax errors which can confuse user due to false positive parsing with an unexpected sorting result private _l2s3_consumeParsedItemToHide(spec: ParsedSortingGroup): boolean { if (spec.arraySpec.length === 1) { const theOnly: string = spec.arraySpec[0] if (!isThreeDots(theOnly)) { const nameWithExt: string = theOnly.trim() if (nameWithExt) { // Sanity check if (!detectNumericSortingSymbols(nameWithExt)) { const itemsToHide: Set = this.ctx.currentSpec.itemsToHide ?? new Set() itemsToHide.add(nameWithExt) this.ctx.currentSpec.itemsToHide = itemsToHide return true } } } } return false } private _l2s4_consumeParsedSortingGroupSpec = (spec: ParsedSortingGroup): CustomSortGroup => { if (spec.outsidersGroup) { return { type: CustomSortGroupType.Outsiders, filesOnly: spec.filesOnly, foldersOnly: spec.foldersOnly, matchFilenameWithExt: spec.matchFilenameWithExt // Doesn't make sense for matching, yet for multi-match } // theoretically could match the sorting of matched files } if (spec.arraySpec.length === 1) { const theOnly: string = spec.arraySpec[0] if (isThreeDots(theOnly)) { return { type: CustomSortGroupType.MatchAll, filesOnly: spec.filesOnly, foldersOnly: spec.foldersOnly, 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 (spec.arraySpec.length === 2) { const theFirst: string = spec.arraySpec[0] const theSecond: string = spec.arraySpec[1] if (isThreeDots(theFirst) && !isThreeDots(theSecond) && !containsThreeDots(theSecond)) { return { type: CustomSortGroupType.ExactSuffix, exactSuffix: theSecond, filesOnly: spec.filesOnly, foldersOnly: spec.foldersOnly, matchFilenameWithExt: spec.matchFilenameWithExt } } else if (!isThreeDots(theFirst) && isThreeDots(theSecond) && !containsThreeDots(theFirst)) { return { type: CustomSortGroupType.ExactPrefix, exactPrefix: theFirst, filesOnly: spec.filesOnly, foldersOnly: spec.foldersOnly, matchFilenameWithExt: spec.matchFilenameWithExt } } else { // both are three dots or contain three dots or this.problem(ProblemCode.SyntaxErrorInGroupSpec, "three dots occurring more than once and no more text specified") return null; } } if (spec.arraySpec.length === 3) { const theFirst: string = spec.arraySpec[0] const theMiddle: string = spec.arraySpec[1] const theLast: string = spec.arraySpec[2] if (isThreeDots(theMiddle) && !isThreeDots(theFirst) && !isThreeDots(theLast) && !containsThreeDots(theLast)) { return { type: CustomSortGroupType.ExactHeadAndTail, exactPrefix: theFirst, exactSuffix: theLast, filesOnly: spec.filesOnly, foldersOnly: spec.foldersOnly, matchFilenameWithExt: spec.matchFilenameWithExt } } else { // both are three dots or three dots occurring more times this.problem(ProblemCode.SyntaxErrorInGroupSpec, "three dots occurring more than once or unrecognized specification of sorting rule") return null; } } this.problem(ProblemCode.SyntaxErrorInGroupSpec, "Unrecognized specification of sorting rule") return null; } // Returns true if no numeric sorting symbol (hence no adjustment) or if correctly adjusted with regex private _l2s5_adjustSortingGroupForNumericSortingSymbol = (group: CustomSortGroup) => { switch (group.type) { case CustomSortGroupType.ExactPrefix: const numSymbolInPrefix = convertPlainStringWithNumericSortingSymbolToRegex(group.exactPrefix, RegexpUsedAs.Prefix) if (numSymbolInPrefix) { if (checkAdjacency(numSymbolInPrefix).noSuffix) { this.problem(ProblemCode.NumericalSymbolAdjacentToWildcard, ADJACENCY_ERROR) return false; } delete group.exactPrefix group.regexSpec = numSymbolInPrefix.regexpSpec } break; case CustomSortGroupType.ExactSuffix: const numSymbolInSuffix = convertPlainStringWithNumericSortingSymbolToRegex(group.exactSuffix, RegexpUsedAs.Suffix) if (numSymbolInSuffix) { if (checkAdjacency(numSymbolInSuffix).noPrefix) { this.problem(ProblemCode.NumericalSymbolAdjacentToWildcard, ADJACENCY_ERROR) return false; } delete group.exactSuffix group.regexSpec = numSymbolInSuffix.regexpSpec } break; case CustomSortGroupType.ExactHeadAndTail: const numSymbolInHead = convertPlainStringWithNumericSortingSymbolToRegex(group.exactPrefix, RegexpUsedAs.Prefix) if (numSymbolInHead) { if (checkAdjacency(numSymbolInHead).noSuffix) { this.problem(ProblemCode.NumericalSymbolAdjacentToWildcard, ADJACENCY_ERROR) return false; } delete group.exactPrefix group.regexSpec = numSymbolInHead.regexpSpec } else { const numSymbolInTail = convertPlainStringWithNumericSortingSymbolToRegex(group.exactSuffix, RegexpUsedAs.Suffix) if (numSymbolInTail) { if (checkAdjacency(numSymbolInTail).noPrefix) { this.problem(ProblemCode.NumericalSymbolAdjacentToWildcard, ADJACENCY_ERROR) return false; } delete group.exactSuffix group.regexSpec = numSymbolInTail.regexpSpec } } break; case CustomSortGroupType.ExactName: const numSymbolInExactMatch = convertPlainStringWithNumericSortingSymbolToRegex(group.exactText, RegexpUsedAs.FullMatch) if (numSymbolInExactMatch) { delete group.exactText group.regexSpec = numSymbolInExactMatch.regexpSpec } break; } return true } }