Something went wrong. Try again.
Fork of github.com/ccmdi/propsec
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429import { App, Plugin, PluginSettingTab, TFile, TAbstractFile,} from "obsidian";import { PropsecSettings, DEFAULT_SETTINGS, Violation,} from "./types";import { ViolationStore } from "./validation/store";import { Validator } from "./validation/validator";import { ValidationCache } from "./validation/cache";import { StatusBarItem } from "./ui/statusBar";import { ViolationsModal } from "./ui/violationsModal";import { ViolationsView, VIOLATIONS_VIEW_TYPE } from "./ui/violationsView";import { PropsecSettingTab } from "./settings";import { queryContext } from "./query/context";import { debug } from "./debug";
export default class PropsecPlugin extends Plugin { settings: PropsecSettings; private store: ViolationStore; private cache: ValidationCache; private validator: Validator; private statusBarItem: StatusBarItem | null = null; private statusBarEl: HTMLElement | null = null; private startupComplete: boolean = false; private pendingFileChanges: Set<string> = new Set(); // Files changed during startup
async onload(): Promise<void> { await this.loadSettings();
queryContext.initialize(this.app, this.manifest.id); this.store = new ViolationStore(); this.cache = new ValidationCache(this.app, this.manifest.id, () => this.settings); this.validator = new Validator(this.app, this.store, () => this.settings);
this.validator.hooks = { onFileValidated: (file, schemaIds, violations) => { this.cache.updateFile(file.path, file.stat.mtime, schemaIds, violations); }, onSchemaValidated: (schema) => { this.cache.updateSchemaHash(schema); }, onCleared: () => { this.cache.clear(); }, };
this.detectTemplatesFolder();
// Preload cache: load data IMMEDIATELY await this.preloadCache();
this.registerView( VIOLATIONS_VIEW_TYPE, (leaf) => new ViolationsView(leaf, this.store) );
this.statusBarEl = this.addStatusBarItem(); this.statusBarItem = new StatusBarItem(this.statusBarEl, this.store, () => { new ViolationsModal(this.app, this.store).open(); }); this.updateStatusBarVisibility(); this.updateStatusBarColoring(); this.updateStatusBarWarnings();
this.addCommand({ id: "validate-all-notes", name: "Validate all notes", callback: () => { void this.validator.validateAll(); }, });
this.addCommand({ id: "rebuild-and-validate", name: "Rebuild index and validate all", callback: () => { void this.rebuildAndValidate(); }, });
this.addCommand({ id: "show-violations", name: "Show violations modal", callback: () => { new ViolationsModal(this.app, this.store).open(); }, });
this.addCommand({ id: "show-violations-sidebar", name: "Show violations sidebar", callback: () => { void this.activateViolationsView(); }, });
this.addCommand({ id: "validate-current-note", name: "Validate current note", checkCallback: (checking: boolean) => { const file = this.app.workspace.getActiveFile(); if (file && file.extension === "md") { if (!checking) { this.validator.validateFileAllSchemas(file); } return true; } return false; }, });
this.addSettingTab(new PropsecSettingTabWrapper(this.app, this));
this.registerEvents();
this.app.workspace.onLayoutReady(() => { void this.initializeAndValidate(); }); }
/** * Preload cached violations into store before views open. * This prevents the empty state flash on startup. * Does NOT check mtimes (vault not ready yet) - just loads optimistically. */ private async preloadCache(): Promise<void> { const cacheLoaded = await this.cache.load(); if (!cacheLoaded) return;
// Load all cached violations optimistically (vault not ready for mtime checks) const violations = this.cache.loadCachedViolations();
// Group by file and add to store const violationsByFile = new Map<string, Violation[]>(); for (const v of violations) { const arr = violationsByFile.get(v.filePath) || []; arr.push(v); violationsByFile.set(v.filePath, arr); } for (const [filePath, fileViolations] of violationsByFile) { this.store.addFileViolations(filePath, fileViolations); }
// avoid loading state on status bar on successful cache load this.store.setLastFullValidation(1);
debug(`Preloaded ${violations.length} cached violations`); }
private async initializeAndValidate(): Promise<void> { await queryContext.index.initialize();
const startTime = performance.now();
// Now vault is ready - analyze what needs revalidation const analysis = this.cache.analyzeCache();
this.store.beginBatch(); try { if (analysis.fullRevalidationNeeded) { // Settings changed - full validation needed this.store.clear(); await this.validator.validateAll(); } else { // Remove stale violations for files that need revalidation for (const filePath of analysis.filesToRevalidate) { this.store.removeFile(filePath); }
// Revalidate changed schemas for (const schemaId of analysis.schemasToRevalidate) { const mapping = this.settings.schemaMappings.find(m => m.id === schemaId); if (mapping) { await this.validator.validateMapping(mapping); } }
// Revalidate modified files for (const filePath of analysis.filesToRevalidate) { const file = this.app.vault.getAbstractFileByPath(filePath); if (file instanceof TFile) { this.validator.validateFileAllSchemas(file); } }
this.store.setLastFullValidation(Date.now()); } } finally { this.store.endBatch(); }
debug(`Startup validation completed in ${(performance.now() - startTime).toFixed(1)}ms`); this.startupComplete = true;
// Process any file changes that occurred during startup if (this.pendingFileChanges.size > 0) { debug(`Processing ${this.pendingFileChanges.size} file changes queued during startup`); for (const path of this.pendingFileChanges) { const file = this.app.vault.getAbstractFileByPath(path); if (file instanceof TFile) { this.validator.validateFileAllSchemas(file); } } this.pendingFileChanges.clear(); } }
/** * Force rebuild the tag index and re-validate everything */ private async rebuildAndValidate(): Promise<void> { await queryContext.index.buildFullIndex(); await this.validator.validateAll(); }
onunload(): void { if (this.statusBarItem) { this.statusBarItem.destroy(); } // Save tag index on unload if (queryContext.isInitialized) { void queryContext.index.saveToDisk(); } // Flush validation cache void this.cache.flush(); }
async loadSettings(): Promise<void> { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial<PropsecSettings>); }
async saveSettings(): Promise<void> { await this.saveData(this.settings); }
private detectTemplatesFolder(): void { if (this.settings.templatesFolder) return;
const coreTemplates = this.app.internalPlugins.plugins.templates; if (coreTemplates.enabled && coreTemplates.instance.options.folder) { this.settings.templatesFolder = coreTemplates.instance.options.folder; return; }
const templater = this.app.plugins.plugins["templater-obsidian"]; if (templater && templater.settings.templates_folder) { this.settings.templatesFolder = templater.settings.templates_folder; } }
private updateStatusBarVisibility(): void { if (this.statusBarEl) { this.statusBarEl.style.display = this.settings.showInStatusBar ? "" : "none"; } }
private updateStatusBarColoring(): void { if (this.statusBarItem) { this.statusBarItem.setColorErrors(this.settings.colorStatusBarErrors); } }
private updateStatusBarWarnings(): void { if (this.statusBarItem) { this.statusBarItem.setExcludeWarnings(this.settings.excludeWarningsFromCount); } }
private registerEvents(): void { // Validate on metadata cache changes (file save, frontmatter edit) // Also update tag index this.registerEvent( this.app.metadataCache.on("changed", (file: TFile) => { if (file.extension !== "md") return;
// Always update tag index queryContext.index.updateFile(file);
// Queue changes during startup, process after if (!this.startupComplete) { this.pendingFileChanges.add(file.path); return; }
// Validate against all matching schemas (accumulation model) this.validator.validateFileAllSchemas(file); }) );
// Handle file rename this.registerEvent( this.app.vault.on("rename", (file: TAbstractFile, oldPath: string) => { if (!(file instanceof TFile) || file.extension !== "md") return;
queryContext.index.renameFile(oldPath, file.path);
// Update violation store path this.store.renameFile(oldPath, file.path);
// Update validation cache path this.cache.renameFile(oldPath, file.path);
// Wait for metadata cache to update before re-validating let handled = false; const revalidate = () => { if (handled) return; handled = true; this.app.metadataCache.off("changed", onCacheUpdate); this.validator.validateFileAllSchemas(file); }; const onCacheUpdate = (updatedFile: TFile) => { if (updatedFile.path === file.path) { revalidate(); } }; this.app.metadataCache.on("changed", onCacheUpdate);
// Fallback timeout in case cache event doesn't fire (e.g., no frontmatter) setTimeout(revalidate, 500); }) );
// Handle file delete this.registerEvent( this.app.vault.on("delete", (file: TAbstractFile) => { if (!(file instanceof TFile)) return; // Update tag index queryContext.index.removeFile(file.path); this.store.removeFile(file.path); // Update validation cache this.cache.removeFile(file.path); }) );
// Validate on file open (optional) this.registerEvent( this.app.workspace.on("file-open", (file: TFile | null) => { if (!this.startupComplete) return; // Skip until initial validation done if (!this.settings.validateOnFileOpen) return; if (!file || file.extension !== "md") return;
this.validator.validateFileAllSchemas(file); }) ); }
/** * Re-validate when schema changes */ onSchemaChange(mappingId?: string): void { if (mappingId) { // Invalidate cache for this schema before revalidating this.cache.invalidateSchema(mappingId); void this.validator.revalidateMapping(mappingId); } else { void this.validator.validateAll(); } }
/** * Called when any setting changes */ onSettingsChange(): void { this.updateStatusBarVisibility(); this.updateStatusBarColoring(); this.updateStatusBarWarnings(); }
/** * Open the violations view in the right sidebar */ async activateViolationsView(): Promise<void> { const { workspace } = this.app;
let leaf = workspace.getLeavesOfType(VIOLATIONS_VIEW_TYPE)[0];
if (!leaf) { const rightLeaf = workspace.getRightLeaf(false); if (rightLeaf) { await rightLeaf.setViewState({ type: VIOLATIONS_VIEW_TYPE, active: true, }); leaf = rightLeaf; } }
if (leaf) { await workspace.revealLeaf(leaf); } }}
/** * Settings tab wrapper */class PropsecSettingTabWrapper extends PluginSettingTab { plugin: PropsecPlugin; private settingsTab: PropsecSettingTab | null = null;
constructor(app: App, plugin: PropsecPlugin) { super(app, plugin); this.plugin = plugin; }
display(): void { this.settingsTab = new PropsecSettingTab( this.app, this.plugin, this.containerEl, this.plugin.settings, async () => { await this.plugin.saveSettings(); this.plugin.onSettingsChange(); }, (mappingId?: string) => { this.plugin.onSchemaChange(mappingId); } ); this.settingsTab.display(); }}