UNPKG

insta-toc

Version:

Simultaneously generate, update, and maintain a table of contents for your notes in real time.

375 lines (310 loc) 14.6 kB
import { HeadingCache, CachedMetadata, SectionCache, Editor, EditorPosition, parseYaml, EditorRange } from "obsidian"; import { getDefaultLocalSettings, instaTocCodeBlockId, localTocSettingsRegex } from "./constants"; import { HeadingLevel, LocalTocSettings, ValidatedInstaToc, ValidCacheType } from "./types"; import { deepMerge, escapeRegExp, isHeadingLevel, isRegexPattern } from "./Utils"; import InstaTocPlugin from "./main"; export class Validator { private plugin: InstaTocPlugin; private previousHeadings: HeadingCache[] = []; public editor: Editor; public cursorPos: EditorPosition; public tocInsertPos: EditorRange; public fileHeadings: HeadingCache[]; public localTocSettings: LocalTocSettings; public updatedLocalSettings: LocalTocSettings | undefined; public metadata: CachedMetadata; public instaTocSection: SectionCache; constructor( plugin: InstaTocPlugin, metadata: CachedMetadata, editor: Editor, cursorPos: EditorPosition ) { this.plugin = plugin; this.metadata = metadata; this.editor = editor; this.cursorPos = cursorPos; this.localTocSettings = getDefaultLocalSettings(); } // Method to update the validator properties while maintaining the previous state public update( plugin: InstaTocPlugin, metadata: CachedMetadata, editor: Editor, cursorPos: EditorPosition ): void { this.plugin = plugin; this.metadata = metadata; this.editor = editor; this.cursorPos = cursorPos; } // Method to compare current headings with previous headings private haveHeadingsChanged(): boolean { const currentHeadings: HeadingCache[] = this.metadata.headings || []; const noPrevHeadings: boolean = this.previousHeadings.length === 0; const diffHeadingsLength: boolean = currentHeadings.length !== this.previousHeadings.length; const noHeadingsChange: boolean = noPrevHeadings || diffHeadingsLength ? false : currentHeadings.every( (headingCache: HeadingCache, index: number) => { return ( headingCache.heading === this.previousHeadings[index].heading && headingCache.level === this.previousHeadings[index].level ); } ); if (noHeadingsChange) return false; // Headings have changed, update previousHeadings this.previousHeadings = currentHeadings; return true; } // Type predicate to assert that metadata has headings and sections private hasHeadingsAndSections(): this is Validator & { metadata: ValidCacheType } { return ( !!this.metadata && !!this.metadata.headings && !!this.metadata.sections ) } // Finds and stores the instaTocSection private hasInstaTocSection(): this is Validator & { metadata: ValidCacheType; instaTocSection: SectionCache } { if (!this.hasHeadingsAndSections()) return false; const instaTocSection: SectionCache | undefined = this.metadata.sections.find( (section: SectionCache) => section.type === 'code' && this.editor.getLine(section.position.start.line) === `\`\`\`${instaTocCodeBlockId}` ); if (instaTocSection) { this.instaTocSection = instaTocSection; return true; } return false; } // Provides the insert location range for the new insta-toc codeblock private setTocInsertPos(): void { // Extract the star/end line/character index const startLine: number = this.instaTocSection.position.start.line; const startCh = 0; const endLine: number = this.instaTocSection.position.end.line; const endCh: number = this.instaTocSection.position.end.col; const tocStartPos: EditorPosition = { line: startLine, ch: startCh }; const tocEndPos: EditorPosition = { line: endLine, ch: endCh }; this.tocInsertPos = { from: tocStartPos, to: tocEndPos } } private configureLocalSettings(): void { const tocRange = this.editor.getRange( this.tocInsertPos.from, this.tocInsertPos.to ); const tocData = tocRange.match(localTocSettingsRegex); if (!tocData) return; const [, settingString] = tocData; this.validateLocalSettings(settingString); } private validateLocalSettings(yml: string): void { let parsedYml: Partial<LocalTocSettings>; try { parsedYml = parseYaml(yml); } catch (err) { this.localTocSettings = this.updatedLocalSettings || this.localTocSettings; const errMsg = 'Invalid YAML in insta-toc settings:\n' + err; console.error(errMsg); new Notice(errMsg); return; } const validationErrors: string[] = []; // Validate and assign 'title' if (parsedYml.title !== undefined) { const title = parsedYml.title; if (typeof title !== 'object' || title === null) { validationErrors.push("'title' must be an object."); } else { const { name, level, center } = title; if (name !== undefined && typeof name !== 'string') { validationErrors.push("'title.name' must be a string indicating the title to be displayed on the ToC."); } if (level !== undefined && !isHeadingLevel(level)) { validationErrors.push("'title.level' must be an integer between 1 and 6 indicating the heading level of the ToC title."); } if (center !== undefined && !(typeof center === 'boolean')) { validationErrors.push("'title.center' must be a boolean indicating whether the title position should be centered."); } } } // Validate and assign 'exclude' if (parsedYml.exclude !== undefined) { if (typeof parsedYml.exclude !== 'string') { validationErrors.push("'exclude' must be a string (\"...\") containing each character to exclude, or a regex pattern (/.../)."); } } // Validate and assign 'style' if (parsedYml.style !== undefined) { const style = parsedYml.style; if (typeof style !== 'object' || style === null) { validationErrors.push("'style' must be an object."); } else { const { listType } = style; if (listType !== undefined && !['dash', 'number'].includes(listType)) { validationErrors.push("'style.listType' must be 'dash' or 'number'."); } } } // Validate and assign 'omit' if (parsedYml.omit !== undefined) { if (!Array.isArray(parsedYml.omit)) { validationErrors.push("'omit' must be an array of strings indicating the text of each heading you'd like to omit."); } else { for (const item of parsedYml.omit) { if (typeof item !== 'string') { validationErrors.push("'omit' array must contain only strings indicating the text of headings you'd like to omit."); break; } } } } // Validate and assign 'levels' if (parsedYml.levels !== undefined) { const levels = parsedYml.levels; if (typeof levels !== 'object' || levels === null) { validationErrors.push("'levels' must be an object."); } else { const { min, max } = levels; if (min !== undefined && !isHeadingLevel(min)) { validationErrors.push("'levels.min' must be an integer between 1 and 6 indicating the minimum heading level to include."); } if (max !== undefined && !isHeadingLevel(max)) { validationErrors.push("'levels.max' must be an integer between 1 and 6 indicating the maximum heading level to include."); } if (min !== undefined && max !== undefined && min > max) { validationErrors.push("'levels.min' cannot be greater than 'levels.max'."); } } } if (validationErrors.length > 0) { const validationErrorMsg: string = 'Invalid properties in insta-toc settings:\n' + validationErrors.join('\n'); console.error(validationErrorMsg); new Notice(validationErrorMsg); this.updatedLocalSettings = this.localTocSettings; } else { // All validations passed; merge if (!this.updatedLocalSettings) { this.updatedLocalSettings = deepMerge<LocalTocSettings>( this.localTocSettings, parsedYml, true ); } else { this.updatedLocalSettings = deepMerge<LocalTocSettings>( this.updatedLocalSettings, parsedYml, false ); } } this.localTocSettings = this.updatedLocalSettings; } private cursorInToc(): boolean { return this.cursorPos.line >= this.instaTocSection.position.start.line && this.cursorPos.line <= this.instaTocSection.position.end.line; } private setFileHeadings(): void { if (this.metadata.headings) { // Store the file headings to reference in later code this.fileHeadings = this.metadata.headings .filter((heading: HeadingCache) => { const headingText: string = heading.heading.trim(); const headingLevel = heading.level as HeadingLevel; return ( // Omit headings with "<!-- omit -->" !headingText.match(/<!--\s*omit\s*-->/) && // Omit headings included within local "omit" setting !this.localTocSettings.omit.includes(headingText) && // Omit headings with levels outside of the specified local min/max setting headingLevel >= this.localTocSettings.levels.min && headingLevel <= this.localTocSettings.levels.max && // Omit empty headings headingText.trim().length > 0 && // Omit heading text specified in the global exclude setting !this.plugin.settings.excludedHeadingText.includes(headingText) && // Omit heading levels specified in the global exclude setting !this.plugin.settings.excludedHeadingLevels.includes(headingLevel) ); }) .map((heading: HeadingCache) => { let modifiedHeading = heading.heading; const patterns: string[] = []; // Process global excluded characters if ( this.plugin.settings.excludedChars && this.plugin.settings.excludedChars.length > 0 ) { // Escape and join global excluded characters const escapedGlobalChars = this.plugin.settings.excludedChars.map( char => escapeRegExp(char) ).join(''); if (escapedGlobalChars.length > 0) { patterns.push(`[${escapedGlobalChars}]`); } } // Process local 'exclude' setting if ( this.localTocSettings.exclude && this.localTocSettings.exclude.length > 0 ) { const excludeStr = this.localTocSettings.exclude; if (isRegexPattern(excludeStr)) { // It's a regex pattern (e.g., '/\d+/'), remove the slashes const regexBody = excludeStr.slice(1, -1); patterns.push(`(${regexBody})`); } else { // It's a string of characters to exclude const escapedLocalChars = escapeRegExp(excludeStr); if (escapedLocalChars.length > 0) { patterns.push(`[${escapedLocalChars}]`); } } } // Build and apply the combined regex pattern if (patterns.length > 0) { const combinedPattern = new RegExp(patterns.join('|'), 'g'); modifiedHeading = modifiedHeading.replace(combinedPattern, ''); } return { ...heading, heading: modifiedHeading }; } ); } } // Validates all conditions and asserts the type when true public isValid(): this is Validator & ValidatedInstaToc { const hasInstaTocSectionResult: boolean = this.hasInstaTocSection(); // If file has no insta-toc section, skip processing if (!hasInstaTocSectionResult) { // Set the plugin.hasTocBlock variable, considering the // code block processor in main.ts can't this.plugin.hasTocBlock = false; return false; } const headingsChanged: boolean = this.haveHeadingsChanged(); // If the headings have not changed, skip processing if (!headingsChanged) return false; // Process and store data for later use this.setTocInsertPos(); this.configureLocalSettings(); this.setFileHeadings(); // Lastly, ensure the cursor is not within the ToC return !this.cursorInToc(); } }