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
text/typescript
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;
}
}