🐛 fix: data-integrity and lifecycle bugs found in code review

- TOC section now identified only by heading title match (custom title +
  known aliases), never by list-content heuristics — an ordinary section
  with bullets can no longer be deleted as a false-positive TOC
- insertion position computed from the content string inside vault.process,
  not from stale metadata-cache line numbers — fixes TOC landing inside or
  above frontmatter; manual path writes content first, frontmatter second
- drop lastUpdate timestamp; auto-update is now idempotent (no edit when
  the TOC is unchanged), ending the perpetual modify→update loop
- auto-update no longer touches frontmatter; delete hand-rolled YAML
  serialization (FrontmatterManager) — processFrontMatter is the only
  frontmatter writer
- single EventRef for the modify listener; toggling auto-update off now
  takes effect immediately and repeated saveSettings no longer stacks handlers
- duplicate heading names render as plain text (wikilinks cannot address
  2nd+ occurrences); regex patterns validated in settings UI and compiled
  once per generation (timing-probe pseudo-validation removed)
- remove dead code: TOCEditorExtension, debug command, unused constants
- new command: Remove table of contents (section + frontmatter key)
This commit is contained in:
m-kk 2026-07-09 22:11:33 -05:00
parent e11b2f955b
commit 72e06490bf
2 changed files with 160 additions and 653 deletions

803
main.ts
View file

@ -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<void> {
// 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<string, number>();
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<string, unknown>, 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;
}
}

View file

@ -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.
*/
*/
.toc-pattern-errors {
color: var(--text-error);
font-size: var(--font-ui-smaller);
}
.toc-pattern-errors:empty {
display: none;
}