diff --git a/main.ts b/main.ts index 61384c0..0234ae2 100644 --- a/main.ts +++ b/main.ts @@ -1,106 +1,43 @@ import { App, + Debouncer, Editor, - MarkdownView, + EventRef, MarkdownFileInfo, - Plugin, - Setting, - PluginSettingTab, + MarkdownView, Notice, - TFile, - MetadataCache, - stripHeadingForLink, + Plugin, + PluginSettingTab, + Setting, debounce, + stripHeadingForLink, } from 'obsidian'; - -// TOC configuration -const TOC_CONFIG = { - FRONTMATTER_KEY: 'toc', - TOC_SECTION_IDENTIFIER: '## Table of Contents' -} as const; +import { DEFAULT_SETTINGS, Heading, TOC_FRONTMATTER_KEY, TOCSettings } from './src/types'; +import { IsTOCTitle, findTOCSection, makeIsTOCTitle, removeTOCSection, replaceTOC } from './src/toc-section'; +import { generateTOC } from './src/toc-generator'; +import { compilePatterns, filterHeadings, validatePattern } from './src/heading-filter'; const DEBOUNCE_DELAY = 2000; -const DEFAULT_MAX_DEPTH = 4; - -// Pre-compiled patterns for performance optimization -const TOC_CONTENT_PATTERNS = [ - /^-\s+\[\[#.*\|.*\]\]$/, // Obsidian-style links - /^ {2}-\s+\[\[#.*\|.*\]\]$/, // Indented Obsidian-style links - /^-\s+\[.*\]\(#.*\)$/, // Markdown links - /^ {2}-\s+\[.*\]\(#.*\)$/, // Indented markdown links - /^-\s+[^[].+$/, // Plain text TOC items - /^ {2}-\s+[^[].+$/ // Indented plain text -] as const; - -interface TOCSettings { - tocTitle: string; - excludeH1: boolean; - maxDepth: number; - includeLinks: boolean; - excludePatterns: string[]; - updateOnSave: boolean; -} - -interface TOCMetadata { - generated: boolean; - lastUpdate: string; -} - -interface ValidationResult { - isValid: boolean; - error?: string; -} - -const DEFAULT_SETTINGS: TOCSettings = { - tocTitle: 'Table of Contents', - excludeH1: true, - maxDepth: DEFAULT_MAX_DEPTH, - includeLinks: true, - excludePatterns: [], - updateOnSave: false -}; - -interface Heading { - level: number; - text: string; - line: number; -} export default class TableOfContentsPlugin extends Plugin { settings!: TOCSettings; private isGenerating = false; - private tocGenerator!: TOCGenerator; - private headingParser!: HeadingParser; - private frontmatterManager!: FrontmatterManager; - private editorExtension!: TOCEditorExtension; - private debouncedAutoUpdate!: (editor: Editor, view: MarkdownView) => void; + private autoUpdateRef: EventRef | null = null; + private debouncedAutoUpdate!: Debouncer<[Editor, MarkdownView], void>; async onload() { await this.loadSettings(); - // Initialize services - this.tocGenerator = new TOCGenerator(this.settings); - this.headingParser = new HeadingParser(); - this.frontmatterManager = new FrontmatterManager(); - this.editorExtension = new TOCEditorExtension(this.app, this.settings); - - // Initialize debounced auto-update function this.debouncedAutoUpdate = debounce( - async (editor: Editor, view: MarkdownView) => { - try { - await this.autoUpdateTOC(editor, view); - } catch (error) { - console.error('Error in auto-update:', error); - } + (editor: Editor, view: MarkdownView) => { + this.autoUpdateTOC(editor, view).catch(error => { + console.error('Error in TOC auto-update:', error); + }); }, DEBOUNCE_DELAY, true ); - // Register editor extension for live preview enhancements - this.registerEditorExtension(this.editorExtension.getExtension()); - - // Add command to generate TOC this.addCommand({ id: 'generate-toc', name: 'Generate table of contents', @@ -111,16 +48,16 @@ export default class TableOfContentsPlugin extends Plugin { } }); - // Add debug command to help troubleshoot TOC issues this.addCommand({ - id: 'debug-toc-markers', - name: 'Debug table of contents metadata', - callback: () => { - this.debugTOCMarkers(); + id: 'remove-toc', + name: 'Remove table of contents', + editorCallback: (editor: Editor, ctx: MarkdownView | MarkdownFileInfo) => { + if (ctx instanceof MarkdownView) { + void this.removeTableOfContents(ctx); + } } }); - // Add ribbon icon this.addRibbonIcon('list', 'Generate table of contents', () => { const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); if (activeView) { @@ -128,55 +65,9 @@ export default class TableOfContentsPlugin extends Plugin { } }); - // Add settings tab this.addSettingTab(new TOCSettingTab(this.app, this)); - - // Register auto-update functionality if enabled - if (this.settings.updateOnSave) { - this.registerAutoUpdate(); - } - } - - onunload() { - // Cleanup handled by Obsidian's event system - } - - - - - - - - - /** - * Debug method to help identify where TOC markers are appearing - */ - private debugTOCMarkers(): void { - try { - const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); - if (!activeView || !activeView.file) { - new Notice('No active Markdown view for debugging'); - return; - } - - const cache = this.app.metadataCache.getFileCache(activeView.file); - const tocMetadata = cache?.frontmatter?.[TOC_CONFIG.FRONTMATTER_KEY] as TOCMetadata | undefined; - - console.debug('=== TOC Debug Info ==='); - console.debug('File:', activeView.file.name); - console.debug('TOC Metadata:', tocMetadata); - console.debug('Has TOC:', !!tocMetadata); - - if (tocMetadata) { - console.debug('Last Update:', tocMetadata.lastUpdate); - } - - new Notice('Debug info logged to console'); - } catch (error) { - console.error('Error in debug method:', error); - new Notice('Debug failed - check console for details'); - } + this.setAutoUpdate(this.settings.updateOnSave); } async loadSettings() { @@ -184,94 +75,87 @@ export default class TableOfContentsPlugin extends Plugin { } async saveSettings() { - // Validate settings before saving this.settings.maxDepth = Math.max(1, Math.min(6, this.settings.maxDepth)); this.settings.tocTitle = this.settings.tocTitle.trim() || DEFAULT_SETTINGS.tocTitle; - + await this.saveData(this.settings); - - // Update TOC generator with new settings - if (this.tocGenerator) { - this.tocGenerator.updateSettings(this.settings); - } - - // Re-register auto-update based on settings - if (this.settings.updateOnSave) { - this.registerAutoUpdate(); - } + this.setAutoUpdate(this.settings.updateOnSave); } - private registerAutoUpdate() { - this.registerEvent( - this.app.vault.on('modify', (file) => { + /** Register or unregister the single auto-update listener. Idempotent. */ + private setAutoUpdate(enabled: boolean): void { + if (enabled && !this.autoUpdateRef) { + this.autoUpdateRef = this.app.vault.on('modify', (file) => { + if (this.isGenerating) return; const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); if (activeView && activeView.file === file) { this.debouncedAutoUpdate(activeView.editor, activeView); } - }) - ); + }); + this.registerEvent(this.autoUpdateRef); + } else if (!enabled && this.autoUpdateRef) { + this.app.vault.offref(this.autoUpdateRef); + this.autoUpdateRef = null; + } } private async autoUpdateTOC(editor: Editor, view: MarkdownView) { - if (!editor || !view || !view.file) return; + if (!editor || !view.file) return; - // Only auto-update if TOC already exists + // Only auto-update notes that were opted in via manual generation. const cache = this.app.metadataCache.getFileCache(view.file); - const hasTOC = cache?.frontmatter?.[TOC_CONFIG.FRONTMATTER_KEY]; - - if (hasTOC) { + if (cache?.frontmatter?.[TOC_FRONTMATTER_KEY]) { await this.generateTableOfContents(editor, view, true); } } async generateTableOfContents(editor: Editor, view: MarkdownView, isAutoUpdate = false) { - // Prevent concurrent generation (race condition protection) if (this.isGenerating) { if (!isAutoUpdate) { new Notice('Table of contents generation already in progress'); } return; } - + if (!editor || !view || !view.file) { if (!isAutoUpdate) { new Notice('Unable to generate table of contents: invalid editor or view'); } return; } - + this.isGenerating = true; - + try { const file = view.file; - const content = isAutoUpdate ? editor.getValue() : await this.app.vault.read(file); - - if (!content.trim()) { - if (!isAutoUpdate) { - new Notice('Document is empty'); - } - return; - } - - const headings = this.headingParser.parse(file, content, this.app.metadataCache); - const filteredHeadings = this.filterHeadings(headings); - - if (filteredHeadings.length === 0 && !isAutoUpdate) { + const cache = this.app.metadataCache.getFileCache(file); + const headings: Heading[] = (cache?.headings ?? []).map(h => ({ level: h.level, text: h.heading })); + + const patterns = compilePatterns(this.settings.excludePatterns, (pattern, error) => { + console.warn(`Skipping invalid exclude pattern '${pattern}': ${error}`); + }); + const isTitle = makeIsTOCTitle(this.settings.tocTitle); + const filtered = filterHeadings(headings, this.settings, patterns, isTitle); + + if (filtered.length === 0 && !isAutoUpdate) { new Notice('No headings found in the document'); return; } - const toc = this.createTOC(filteredHeadings); + const toc = generateTOC(filtered, this.settings, stripHeadingForLink); if (isAutoUpdate) { - // For auto-updates, use editor operations to avoid external modification popup - const newContent = this.insertOrUpdateTOC(content, toc, file); + // Editor-based update to avoid the external-modification popup. + const content = editor.getValue(); + const newContent = replaceTOC(content, toc, isTitle); if (newContent !== content) { - this.updateEditorContent(editor, newContent); + this.updateEditorContent(editor, content, newContent, isTitle); } } else { - // For manual updates, use vault.process with atomic frontmatter updates - await this.insertOrUpdateTOCWithProcess(file, toc); + await this.app.vault.process(file, (content) => replaceTOC(content, toc, isTitle)); + await this.app.fileManager.processFrontMatter(file, (frontmatter) => { + frontmatter[TOC_FRONTMATTER_KEY] = { generated: true }; + }); new Notice('Table of contents updated'); } } catch (error) { @@ -284,371 +168,100 @@ export default class TableOfContentsPlugin extends Plugin { } } - private validateRegexPattern(pattern: string): ValidationResult { - if (!pattern.trim()) { - return { isValid: false, error: 'Empty pattern' }; - } - - // Basic ReDoS protection: limit pattern complexity - if (pattern.length > 100) { - return { isValid: false, error: 'Pattern too long' }; - } - - // Enhanced ReDoS protection with comprehensive dangerous patterns - const dangerousPatterns = [ - /\(\*\+/, // Nested quantifiers - /\+\*\+/, // Multiple quantifiers - /\{\d+,\}\+/, // Large quantifier ranges - /\(\.\*\)\+/, // Dangerous (.*)+ - /\(\.\+\)\*/, // Dangerous (.+)* - /\(\[\^\]\*\)\+/, // Dangerous ([^]*)+ - /\{\d{3,},\d{3,}\}/, // Very large quantifier ranges - /\(\.\*\?\)\+/, // Dangerous (.*?)+ - /\(\?=.*\(\?=/, // Nested lookaheads - /\(\?!.*\(\?!/ // Nested lookbehinds - ]; - - if (dangerousPatterns.some(dangerous => dangerous.test(pattern))) { - return { isValid: false, error: 'Pattern contains potentially dangerous constructs' }; - } - + private async removeTableOfContents(view: MarkdownView) { + if (this.isGenerating || !view.file) return; + + this.isGenerating = true; try { - // Test the regex with timeout simulation - const regex = new RegExp(pattern, 'i'); - // Test with a complex string that could trigger ReDoS - const testString = 'a'.repeat(1000); - const startTime = Date.now(); - regex.test(testString); - const endTime = Date.now(); - - if (endTime - startTime > 100) { - return { isValid: false, error: 'Pattern execution too slow' }; - } - - return { isValid: true }; - } catch (e) { - return { isValid: false, error: `Invalid regex: ${e instanceof Error ? e.message : String(e)}` }; - } - } - - private isTOCHeading(headingText: string): boolean { - // Check if the heading text looks like a TOC heading - const tocLikePatterns = [ - /^table of contents$/i, - /^toc$/i, - /^contents$/i, - /^index$/i, - /^outline$/i, - headingText === this.settings.tocTitle // Current TOC title - ]; - - return tocLikePatterns.some(pattern => { - if (typeof pattern === 'boolean') return pattern; - return pattern.test(headingText); - }); - } - - private filterHeadings(headings: Heading[]): Heading[] { - const { excludeH1, maxDepth, excludePatterns } = this.settings; - - return headings.filter(heading => { - // Exclude any H2 heading that looks like a TOC heading to prevent self-inclusion - // This handles both current and previous TOC titles - if (heading.level === 2 && this.isTOCHeading(heading.text)) { - return false; - } - - // Apply level filters - if ((excludeH1 && heading.level === 1) || heading.level > maxDepth) { - return false; - } - - // Apply pattern exclusions with validation - return !excludePatterns.some(pattern => { - const validation = this.validateRegexPattern(pattern); - if (!validation.isValid) { - console.warn(`Skipping invalid regex pattern '${pattern}': ${validation.error}`); - return false; - } - - try { - return new RegExp(pattern, 'i').test(heading.text); - } catch (e) { - console.warn(`Error testing regex pattern '${pattern}': ${e instanceof Error ? e.message : String(e)}`); - return false; - } - }); - }); - } - - - private createTOC(headings: Heading[]): string { - return this.tocGenerator.generate(headings); - } - - private async insertOrUpdateTOCWithProcess(file: TFile, toc: string): Promise { - // Update frontmatter using FileManager.processFrontMatter - await this.app.fileManager.processFrontMatter(file, (frontmatter) => { - frontmatter[TOC_CONFIG.FRONTMATTER_KEY] = { - generated: true, - lastUpdate: new Date().toISOString() - }; - }); - - // Update content using Vault.process - await this.app.vault.process(file, (content) => { - // Remove existing TOC if present - const contentWithoutTOC = this.removeTOCFromContent(content); - - // Get frontmatter position from cache + const file = view.file; + const isTitle = makeIsTOCTitle(this.settings.tocTitle); const cache = this.app.metadataCache.getFileCache(file); - const frontmatterEnd = cache?.frontmatterPosition?.end?.line ?? -1; + const hasSection = findTOCSection((await this.app.vault.read(file)).split('\n'), isTitle) !== null; + const hasMetadata = !!cache?.frontmatter?.[TOC_FRONTMATTER_KEY]; - // Insert new TOC at optimal position - return this.insertTOCAtOptimalPositionWithCache(contentWithoutTOC, toc, file, frontmatterEnd); - }); - } - - private static isTOCContentLine(line: string): boolean { - const trimmed = line.trim(); - return TOC_CONTENT_PATTERNS.some(pattern => pattern.test(trimmed)); - } - - private static findTOCSection(lines: string[], startIndex = 0): { start: number, end: number } | null { - // Look for H2 heading followed by TOC-like content - for (let i = startIndex; i < lines.length; i++) { - const line = lines[i]?.trim() || ''; - if (line.startsWith('## ') && line.length > 3) { - // Check if next few lines contain TOC content - let hasTOCContent = false; - const searchLimit = Math.min(i + 10, lines.length); - - for (let j = i + 1; j < searchLimit; j++) { - const nextLine = lines[j]; - if (!nextLine) continue; - - if (TableOfContentsPlugin.isTOCContentLine(nextLine)) { - hasTOCContent = true; - break; - } - - // Stop if we hit significant non-TOC content - const trimmed = nextLine.trim(); - if (trimmed.startsWith('#') || (trimmed.length > 0 && !trimmed.startsWith('-') && !trimmed.startsWith(' '))) { - break; - } - } - - if (hasTOCContent) { - // Find end of TOC section - let endIndex = i; - for (let k = i + 1; k < lines.length; k++) { - const line = lines[k]; - if (line && line.startsWith('#')) { - endIndex = k - 1; - break; - } - endIndex = k; - } - return { start: i, end: endIndex }; - } + if (!hasSection && !hasMetadata) { + new Notice('No table of contents found'); + return; } + + if (hasSection) { + await this.app.vault.process(file, (content) => removeTOCSection(content, isTitle)); + } + if (hasMetadata) { + await this.app.fileManager.processFrontMatter(file, (frontmatter) => { + delete frontmatter[TOC_FRONTMATTER_KEY]; + }); + } + new Notice('Table of contents removed'); + } catch (error) { + console.error('Error removing TOC:', error); + new Notice(`Failed to remove table of contents: ${error instanceof Error ? error.message : String(error)}`); + } finally { + this.isGenerating = false; } - return null; } - private removeTOCFromContent(content: string): string { - const lines = content.split('\n'); - const tocSection = TableOfContentsPlugin.findTOCSection(lines); - - if (!tocSection) return content; - - // Remove TOC section and preserve document formatting - const beforeTOC = lines.slice(0, tocSection.start); - const afterTOC = lines.slice(tocSection.end + 1); - - return [...beforeTOC, ...afterTOC].join('\n'); - } - - private insertTOCAtOptimalPosition(content: string, toc: string, file: TFile, originalContent: string): string { - const lines = content.split('\n'); - - // Use Obsidian's metadata cache to get actual H1 headings (not code blocks) - const cache = this.app.metadataCache.getFileCache(file); - const h1Headings = cache?.headings?.filter(h => h.level === 1) || []; - - // If no H1s exist, insert at position 0 - if (h1Headings.length === 0) { - return ['', toc, '', ...lines].join('\n'); - } - - // Calculate frontmatter offset efficiently using cache - const frontmatterEnd = cache?.frontmatterPosition?.end?.line ?? -1; - const frontmatterOffset = frontmatterEnd >= 0 ? frontmatterEnd + 1 : 0; - - // Find the last H1 heading line and adjust for frontmatter offset - const lastH1LineInOriginal = Math.max(...h1Headings.map(h => h.position.start.line)); - const lastH1LineInContent = lastH1LineInOriginal - frontmatterOffset; - - // Insert TOC after the H1 heading - const beforeLastH1 = lines.slice(0, lastH1LineInContent + 1); - const afterLastH1 = lines.slice(lastH1LineInContent + 1); - - return [...beforeLastH1, '', toc, '', ...afterLastH1].join('\n'); - } - - private insertTOCAtOptimalPositionWithCache(content: string, toc: string, file: TFile, frontmatterEndLine: number): string { - const lines = content.split('\n'); - - // Use Obsidian's metadata cache to get actual H1 headings (not code blocks) - const cache = this.app.metadataCache.getFileCache(file); - const h1Headings = cache?.headings?.filter(h => h.level === 1) || []; - - // If no H1s exist, insert at position 0 (after frontmatter) - if (h1Headings.length === 0) { - return ['', toc, '', ...lines].join('\n'); - } - - // Calculate frontmatter offset using provided cache data - const frontmatterOffset = frontmatterEndLine >= 0 ? frontmatterEndLine + 1 : 0; - - // Find the last H1 heading line and adjust for frontmatter offset - const lastH1LineInOriginal = Math.max(...h1Headings.map(h => h.position.start.line)); - const lastH1LineInContent = lastH1LineInOriginal - frontmatterOffset; - - // Insert TOC after the H1 heading - const beforeLastH1 = lines.slice(0, lastH1LineInContent + 1); - const afterLastH1 = lines.slice(lastH1LineInContent + 1); - - return [...beforeLastH1, '', toc, '', ...afterLastH1].join('\n'); - } - - private insertTOCAtPosition0(content: string, toc: string): string { - const lines = content.split('\n'); - return ['', toc, '', ...lines].join('\n'); - } - - - private insertOrUpdateTOC(content: string, toc: string, file: TFile): string { - // Use Obsidian's metadata cache to read frontmatter - const cache = this.app.metadataCache.getFileCache(file); - const existingFrontmatter = cache?.frontmatter || {}; - - // Create a copy to avoid mutating cached data - const frontmatter = { ...existingFrontmatter }; - - // Update frontmatter with TOC metadata - const tocMetadata: TOCMetadata = { - generated: true, - lastUpdate: new Date().toISOString() - }; - - frontmatter[TOC_CONFIG.FRONTMATTER_KEY] = tocMetadata; - - // Get content without frontmatter using frontmatterPosition from cache - const frontmatterEnd = cache?.frontmatterPosition?.end?.line ?? -1; - const lines = content.split('\n'); - const contentWithoutFrontmatter = frontmatterEnd >= 0 - ? lines.slice(frontmatterEnd + 1).join('\n') - : content; - - // Remove existing TOC if present - const contentWithoutTOC = this.removeTOCFromContent(contentWithoutFrontmatter); - - // Insert new TOC at optimal position (position 0 or after last H1) - const contentWithNewTOC = this.insertTOCAtOptimalPosition(contentWithoutTOC, toc, file, content); - - // Rebuild content with updated frontmatter - return this.frontmatterManager.buildContentWithFrontmatter(frontmatter, contentWithNewTOC); - } - - private updateEditorContent(editor: Editor, newContent: string): void { + private updateEditorContent(editor: Editor, originalContent: string, newContent: string, isTitle: IsTOCTitle): void { try { - const originalContent = editor.getValue(); const cursor = editor.getCursor(); - - // For auto-updates, use surgical replacement instead of full document replacement - if (originalContent !== newContent) { - const tocReplacement = this.performSurgicalTOCReplacement(editor, originalContent, newContent, cursor); - - if (!tocReplacement.success) { - // Fallback to full replacement if surgical approach fails - editor.setValue(newContent); - - // Try to restore cursor at the same line if it still exists - const newLastLine = editor.lastLine(); - if (cursor.line <= newLastLine) { - const lineLength = editor.getLine(cursor.line)?.length || 0; - const targetCh = Math.min(cursor.ch, lineLength); - editor.setCursor({ line: cursor.line, ch: targetCh }); - } - } + if (this.replaceTOCSectionInEditor(editor, originalContent, newContent, cursor, isTitle)) { + return; + } + + // Fallback (e.g. first insertion): full replacement with cursor restore. + editor.setValue(newContent); + const newLastLine = editor.lastLine(); + if (cursor.line <= newLastLine) { + const lineLength = editor.getLine(cursor.line)?.length || 0; + editor.setCursor({ line: cursor.line, ch: Math.min(cursor.ch, lineLength) }); } } catch (error) { console.error('Error updating editor content:', error); - // Fallback: simple setValue without cursor restoration editor.setValue(newContent); } } - private performSurgicalTOCReplacement( - editor: Editor, - originalContent: string, - newContent: string, - cursor: { line: number, ch: number } - ): { success: boolean } { - try { - // Find TOC boundaries in both versions - const originalTOC = this.findTOCBoundariesInContent(originalContent); - const newTOC = this.findTOCBoundariesInContent(newContent); - - if (originalTOC && newTOC) { - // Extract the new TOC section - const newTOCLines = newContent.split('\n').slice(newTOC.startLine, newTOC.endLine + 1); - const newTOCContent = newTOCLines.join('\n'); - - // Replace only the TOC section in the editor - const originalStartLine = originalTOC.startLine; - const originalEndLine = originalTOC.endLine; - - editor.replaceRange( - newTOCContent, - { line: originalStartLine, ch: 0 }, - { line: originalEndLine, ch: editor.getLine(originalEndLine)?.length || 0 } - ); - - // Adjust cursor position based on TOC size change - const linesDelta = (newTOC.endLine - newTOC.startLine) - (originalTOC.endLine - originalTOC.startLine); - - if (cursor.line > originalEndLine) { - // Cursor is after TOC section - adjust for TOC size change - editor.setCursor({ line: cursor.line + linesDelta, ch: cursor.ch }); - } else if (cursor.line >= originalStartLine && cursor.line <= originalEndLine) { - // Cursor was within TOC section - move to end of new TOC - const newTOCEndLine = originalStartLine + (newTOC.endLine - newTOC.startLine); - editor.setCursor({ line: newTOCEndLine, ch: 0 }); - } - // If cursor is before TOC section (cursor.line < originalStartLine), no adjustment needed - - return { success: true }; - } - - return { success: false }; - } catch (error) { - console.error('Error in surgical TOC replacement:', error); - return { success: false }; + /** + * Replace only the TOC section in the editor, leaving the rest of the + * document (and the user's cursor) alone. Returns false when there is no + * existing section to replace. + */ + private replaceTOCSectionInEditor( + editor: Editor, + originalContent: string, + newContent: string, + cursor: { line: number, ch: number }, + isTitle: IsTOCTitle + ): boolean { + const originalSection = findTOCSection(originalContent.split('\n'), isTitle); + const newSection = findTOCSection(newContent.split('\n'), isTitle); + if (!originalSection || !newSection) return false; + + const originalTOC = originalContent.split('\n') + .slice(originalSection.start, originalSection.end + 1).join('\n'); + const newTOC = newContent.split('\n') + .slice(newSection.start, newSection.end + 1).join('\n'); + + // Identical section: nothing to do. This is what terminates the + // auto-update cycle — no edit, no modify event, no re-trigger. + if (originalTOC === newTOC) return true; + + editor.replaceRange( + newTOC, + { line: originalSection.start, ch: 0 }, + { line: originalSection.end, ch: editor.getLine(originalSection.end)?.length || 0 } + ); + + const linesDelta = (newSection.end - newSection.start) - (originalSection.end - originalSection.start); + if (cursor.line > originalSection.end) { + editor.setCursor({ line: cursor.line + linesDelta, ch: cursor.ch }); + } else if (cursor.line >= originalSection.start) { + editor.setCursor({ line: originalSection.start + (newSection.end - newSection.start), ch: 0 }); } - } - - private findTOCBoundariesInContent(content: string): { startLine: number, endLine: number } | null { - const lines = content.split('\n'); - const tocSection = TableOfContentsPlugin.findTOCSection(lines); - - return tocSection ? { startLine: tocSection.start, endLine: tocSection.end } : null; + return true; } } -// Settings tab class TOCSettingTab extends PluginSettingTab { plugin: TableOfContentsPlugin; @@ -704,24 +317,30 @@ class TOCSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); })); + let patternErrorsEl: HTMLElement; new Setting(containerEl) .setName('Exclude patterns') - .setDesc('Ignore headings matching these regular expressions (one per line)') + .setDesc('Ignore headings matching these regular expressions (one per line). Invalid patterns are ignored.') .addTextArea(text => { text.setValue(this.plugin.settings.excludePatterns.join('\n')) .onChange(async (value) => { - this.plugin.settings.excludePatterns = value - .split('\n') - .map(p => p.trim()) - .filter(Boolean); + const patterns = value.split('\n').map(p => p.trim()).filter(Boolean); + const invalid: string[] = []; + this.plugin.settings.excludePatterns = patterns.filter(pattern => { + const result = validatePattern(pattern); + if (!result.isValid) invalid.push(`${pattern} — ${result.error}`); + return result.isValid; + }); + patternErrorsEl.setText(invalid.length ? `Invalid (not saved): ${invalid.join('; ')}` : ''); await this.plugin.saveSettings(); }); text.inputEl.rows = 3; }); + patternErrorsEl = containerEl.createDiv({ cls: 'toc-pattern-errors' }); new Setting(containerEl) .setName('Auto-update table of contents') - .setDesc('Automatically update existing table of contents when the document changes') + .setDesc('Refresh an existing table of contents automatically whenever the note changes on disk (edits, sync, or other plugins)') .addToggle(toggle => toggle .setValue(this.plugin.settings.updateOnSave) .onChange(async (value) => { @@ -729,134 +348,14 @@ class TOCSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); })); - - - - // Usage information (no heading per Obsidian guidelines) containerEl.createEl('p', { - text: 'Use the command "Generate table of contents" or click the ribbon icon to insert a table of contents at the optimal position (top of document or after the last H1 heading). ' + - 'The table of contents is tracked using frontmatter metadata for clean, invisible state management.' + text: 'Use the command "Generate table of contents" or click the ribbon icon to insert a table of contents after the last level 1 heading (or at the top of the note). ' + + 'Notes with a generated table of contents are tracked via a "toc" key in their frontmatter.' }); containerEl.createEl('p', { - text: 'To manually update an existing table of contents, simply run the command again. ' + - 'With auto-update enabled, the table of contents will refresh automatically as you edit.', + text: 'Run the command again to refresh, or use "Remove table of contents" to delete the section and its frontmatter key.', cls: 'setting-item-description' }); } } - -// Service classes for better separation of concerns - -class TOCGenerator { - constructor(private settings: TOCSettings) {} - - generate(headings: Heading[]): string { - if (headings.length === 0) { - return this.createEmptyTOC(); - } - - const lines: string[] = [`## ${this.settings.tocTitle}`, '']; - - // Find minimum heading level for proper indentation - const minLevel = Math.min(...headings.map((h: Heading) => h.level)); - - // Track heading counts for duplicate handling (fixed: use space instead of dash) - const headingCounts = new Map(); - - for (const heading of headings) { - const indent = ' '.repeat(heading.level - minLevel); - const linkText = stripHeadingForLink(heading.text); - - // Handle duplicate headings - Obsidian format uses space, not dash - const count = headingCounts.get(linkText) || 0; - headingCounts.set(linkText, count + 1); - - const uniqueLinkText = count > 0 ? `${linkText} ${count}` : linkText; - - let listItem = `${indent}- `; - if (this.settings.includeLinks) { - listItem += `[[#${uniqueLinkText}|${heading.text}]]`; - } else { - listItem += heading.text; - } - - lines.push(listItem); - } - - return lines.join('\n'); - } - - private createEmptyTOC(): string { - return [ - `## ${this.settings.tocTitle}`, - '', - '*No headings found*' - ].join('\n'); - } - - updateSettings(settings: TOCSettings): void { - this.settings = settings; - } -} - - -class HeadingParser { - parse(file: TFile, content: string, metadataCache: MetadataCache): Heading[] { - try { - const cache = metadataCache.getFileCache(file); - if (!cache?.headings) return []; - - // Return all headings - TOC exclusion will be handled by content parsing - return cache.headings.map((h) => ({ - level: h.level, - text: h.heading, - line: h.position.start.line - })); - } catch (error) { - console.error('Error parsing headings:', error); - return []; - } - } -} - -// New service classes for enhanced functionality - -class FrontmatterManager { - buildContentWithFrontmatter(frontmatter: Record, content: string): string { - if (Object.keys(frontmatter).length === 0) { - return content; - } - - const frontmatterLines = ['---']; - - for (const [key, value] of Object.entries(frontmatter)) { - if (typeof value === 'object' && value !== null) { - frontmatterLines.push(`${key}: ${JSON.stringify(value)}`); - } else if (typeof value === 'string' && (value.includes(':') || value.includes(' '))) { - frontmatterLines.push(`${key}: "${value}"`); - } else { - frontmatterLines.push(`${key}: ${String(value)}`); - } - } - - frontmatterLines.push('---', ''); - - return frontmatterLines.join('\n') + content; - } -} - -class TOCEditorExtension { - constructor(private app: App, private settings: TOCSettings) {} - - getExtension() { - // For now, return an empty extension array - // In a full implementation, this would include CodeMirror 6 decorations - // to hide TOC markers in live preview mode - return []; - } - - updateSettings(settings: TOCSettings): void { - this.settings = settings; - } -} diff --git a/styles.css b/styles.css index 99e7826..32a9be9 100644 --- a/styles.css +++ b/styles.css @@ -6,4 +6,12 @@ All TOC visibility is now handled through frontmatter metadata. Note: This plugin generates standard markdown content (headings and lists). Styling is minimal to avoid conflicts with themes. -*/ \ No newline at end of file +*/ +.toc-pattern-errors { + color: var(--text-error); + font-size: var(--font-ui-smaller); +} + +.toc-pattern-errors:empty { + display: none; +}