1005 lines
36 KiB
TypeScript
1005 lines
36 KiB
TypeScript
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<CustomSortSpec>
|
|
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<string>
|
|
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>([
|
|
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<string> = [
|
|
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<CustomSortSpec>
|
|
}
|
|
|
|
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<string>,
|
|
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<CustomSortSpec>
|
|
for (let spec of this.ctx.specs) {
|
|
// Consume the folder paths ending with wildcard specs
|
|
for (let idx = 0; idx<spec.targetFoldersPaths.length; idx++) {
|
|
const path = spec.targetFoldersPaths[idx]
|
|
if (endsWithWildcardPatternSuffix(path)) {
|
|
sortspecByWildcard = sortspecByWildcard ?? new FolderWildcardMatching<CustomSortSpec>()
|
|
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<string> => {
|
|
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<string> = this.ctx.currentSpec.itemsToHide ?? new Set<string>()
|
|
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
|
|
}
|
|
}
|