UNPKG

insta-toc

Version:

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

366 lines (294 loc) 13.8 kB
import type { CachedMetadata, Editor, EditorPosition, EditorRange, HeadingCache, SectionCache } from "obsidian"; import { Notice } from "obsidian"; import { deepMerge } from "./Utils"; import { instaTocCodeBlockId, localTocSettingsRegex } from "./constants"; import type EditorService from "./editorService"; import { getDefaultLocalSettings, type InstaTocSettings } from "./settings/Settings"; import { parseLocalTocSettingsYaml } from "./settings/localTocSettings"; import type { HeadingLevel, LocalTocSettings } from "./types"; /** * Type asserts that {@link SectionCache}[] is not undefined within the CachedMetadata type */ type ValidCacheType = CachedMetadata & { sections: SectionCache[]; }; /** * Type that represents a fully validated Validator instance */ type ValidatedInstaToc = { metadata: ValidCacheType; fileHeadings: HeadingCache[]; instaTocSection: SectionCache; editor: Editor; cursorPos: EditorPosition; tocInsertPos: EditorRange; localTocSettings: LocalTocSettings; }; function isInstaTocSection(section: SectionCache, editor: Editor): boolean { return (section.type === "code" && editor.getLine(section.position.start.line) === `\`\`\`${instaTocCodeBlockId}`); } // Finds and stores the instaTocSection export function hasInstaTocSection( editor: Editor, sections: SectionCache[], instance: Validator ): instance is Validator & { metadata: ValidCacheType; instaTocSection: SectionCache; }; export function hasInstaTocSection(editor: Editor, sections: SectionCache[]): boolean; export function hasInstaTocSection(editor: Editor, sections: SectionCache[], instance?: Validator): boolean { const instaTocSection: SectionCache | undefined = sections.find((section: SectionCache) => isInstaTocSection(section, editor) ); if (instaTocSection) { if (instance) instance.instaTocSection = instaTocSection; return true; } return false; } export function hasMultipleTocSections(editor: Editor, sections: SectionCache[]): boolean { const totalTocSections = sections.filter((section: SectionCache) => isInstaTocSection(section, editor)); return totalTocSections.length > 1; } export class Validator { private settings: InstaTocSettings; private activeFilePath: string; private previousHeadings: HeadingCache[] = []; private previousLocalSettingsRaw = ""; private cachedHeadingExcludePattern: RegExp | undefined; private cachedHeadingExcludePatternKey: string | undefined; public editorService: EditorService; public tocInsertPos!: EditorRange; // Assigned in this.isValid public fileHeadings: HeadingCache[]; public localTocSettings: LocalTocSettings; public updatedLocalSettings: LocalTocSettings | undefined; public metadata: CachedMetadata; public instaTocSection!: SectionCache; // Assigned in this.isValid constructor( editorService: EditorService, settings: InstaTocSettings, metadata: CachedMetadata, activeFilePath: string ) { this.editorService = editorService; this.settings = settings; this.metadata = metadata; this.activeFilePath = activeFilePath; this.fileHeadings = []; this.localTocSettings = getDefaultLocalSettings(); } // Method to update the validator properties while maintaining the previous state public update( editorService: EditorService, settings: InstaTocSettings, metadata: CachedMetadata, activeFilePath: string ): void { this.editorService = editorService; this.settings = settings; this.metadata = metadata; this.resetStateForFileSwitch(activeFilePath); } private resetStateForFileSwitch(activeFilePath: string): void { if (this.activeFilePath === activeFilePath) return; this.activeFilePath = activeFilePath; this.previousHeadings = []; this.previousLocalSettingsRaw = ""; this.localTocSettings = getDefaultLocalSettings(); this.updatedLocalSettings = undefined; this.fileHeadings = []; } private hasEditor(): this is this & { editorService: EditorService & { editor: Editor; }; } { return this.editorService.editor !== undefined; } private haveLocalSettingsChanged(): boolean { if (!this.hasEditor()) return false; const { editor } = this.editorService; const tocRange = editor.getRange(this.tocInsertPos.from, this.tocInsertPos.to); const tocData = tocRange.match(localTocSettingsRegex); const current = (tocData?.[1] ?? "").trim(); if (current === this.previousLocalSettingsRaw) return false; this.previousLocalSettingsRaw = current; return true; } // 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 hasSections(): this is Validator & { metadata: ValidCacheType; } { return !!this.metadata && !!this.metadata.sections; } // Finds and stores the instaTocSection // private hasInstaTocSection(): this is Validator & { // metadata: ValidCacheType; // instaTocSection: SectionCache; // } { // if (!this.hasEditor() || !this.hasSections()) return false; // const { editor } = this.editorService; // const instaTocSection: SectionCache | undefined = this.metadata.sections.find( // (section: SectionCache) => isInstaTocSection(section, editor) // ); // 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 start/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 { if (!this.hasEditor()) return; const { editor } = this.editorService; const tocRange = editor.getRange(this.tocInsertPos.from, this.tocInsertPos.to); const tocData = tocRange.match(localTocSettingsRegex); if (!tocData) return; const [ , settingString ] = tocData; this.validateLocalSettings(settingString); } /** Only called from InstaToc class if local settings are applied */ public applyLocalSettingsYaml(yml: string): boolean { const previousLocalSettings = deepMerge<LocalTocSettings>(getDefaultLocalSettings(), this.localTocSettings); const previousUpdatedSettings = this.updatedLocalSettings ? deepMerge<LocalTocSettings>(getDefaultLocalSettings(), this.updatedLocalSettings) : undefined; this.localTocSettings = getDefaultLocalSettings(); this.updatedLocalSettings = undefined; const didApply = this.validateLocalSettings(yml); if (!didApply) { this.localTocSettings = previousLocalSettings; this.updatedLocalSettings = previousUpdatedSettings ?? previousLocalSettings; return false; } return true; } private validateLocalSettings(yml: string): boolean { const result = parseLocalTocSettingsYaml(yml); if (result.errors.length > 0) { const validationErrorMsg = "Invalid properties in insta-toc settings:\n" + result.errors.join("\n"); console.error(validationErrorMsg); new Notice(validationErrorMsg); return false; } this.updatedLocalSettings = result.settings; this.localTocSettings = result.settings; return true; } private getHeadingExcludePattern(): RegExp | undefined { const cacheKey = JSON.stringify({ excludedChars: this.settings.excludedChars, localExclude: this .localTocSettings .exclude }); if (cacheKey === this.cachedHeadingExcludePatternKey) { return this.cachedHeadingExcludePattern; } const patterns: string[] = []; if (this.settings.excludedChars.length > 0) { const escapedGlobalChars = this.settings.excludedChars.map((char) => RegExp.escape(char)).join(""); if (escapedGlobalChars.length > 0) { patterns.push(`[${escapedGlobalChars}]`); } } if (this.localTocSettings.exclude && this.localTocSettings.exclude.length > 0) { const excludeStr = this.localTocSettings.exclude; if (RegExp.isRegexPattern(excludeStr)) { patterns.push(`(${excludeStr.slice(1, -1)})`); } else { const escapedLocalChars = RegExp.escape(excludeStr); if (escapedLocalChars.length > 0) { patterns.push(`[${escapedLocalChars}]`); } } } this.cachedHeadingExcludePattern = patterns.length > 0 ? new RegExp(patterns.join("|"), "g") : undefined; this.cachedHeadingExcludePatternKey = cacheKey; return this.cachedHeadingExcludePattern; } private setFileHeadings(): void { const headings: HeadingCache[] = this.metadata?.headings ?? []; const omit = new Set(this.localTocSettings.omit ?? []); const minLevel = this.localTocSettings.levels.min ?? 1; const maxLevel = this.localTocSettings.levels.max ?? 6; const headingExcludePattern = this.getHeadingExcludePattern(); // Store the file headings to reference in later code this.fileHeadings = headings .filter((headingCache: HeadingCache) => { const headingText: string = headingCache.heading.trim(); const headingLevel = headingCache.level as HeadingLevel; return ( /** * Omit headings with "<!-- omit -->" */ !headingText.match(/<!--\s*omit\s*-->/) /** * Omit headings included within local "omit" setting */ && !omit.has(headingText) /** * Omit headings with levels outside of the specified local min/max setting */ && headingLevel >= minLevel && headingLevel <= maxLevel /** * Omit empty headings */ && headingText.trim().length > 0 /** * Omit heading text specified in the global excluded heading text setting */ && !this.settings.excludedHeadingText.includes(headingText) /** * Omit heading levels specified in the global excluded heading levels setting */ && !this.settings.excludedHeadingLevels.includes(headingLevel) ); }) .map((headingCache: HeadingCache) => { let modifiedHeading = headingCache.heading; if (headingExcludePattern) { headingExcludePattern.lastIndex = 0; modifiedHeading = modifiedHeading.replace(headingExcludePattern, ""); } return { ...headingCache, heading: modifiedHeading }; }); } // Validates all conditions and asserts the type when true public isValid(forceRefresh = false): this is Validator & ValidatedInstaToc { if ( !this.hasEditor() || !this.hasSections() || !hasInstaTocSection(this.editorService.editor, this.metadata.sections, this) ) return false; const hasMultipleTocs = hasMultipleTocSections(this.editorService.editor, this.metadata.sections); if (hasMultipleTocs) { const message = "[WARN] InstaToc section already present in the current file."; new Notice(message); console.log(message); return false; } this.setTocInsertPos(); const headingsChanged = this.haveHeadingsChanged(); const localSettingsChanged = this.haveLocalSettingsChanged(); if (!forceRefresh && !headingsChanged && !localSettingsChanged) { return false; } this.configureLocalSettings(); this.setFileHeadings(); return true; } }