/** * Vault Triage Scan Modal - UI for vault triage scan backlog. */ import { Modal, Setting, Notice } from "obsidian"; import type { App } from "obsidian"; import type { ChainRolesConfig, ChainTemplatesConfig } from "../dictionary/types"; import type { MindnetSettings } from "../settings"; import type { EdgeVocabulary } from "../vocab/types"; import { scanVaultForChainGaps, saveScanState, type ScanState, type ScanItem } from "../workbench/vaultTriageScan"; export class VaultTriageScanModal extends Modal { private chainRoles: ChainRolesConfig | null; private chainTemplates: ChainTemplatesConfig | null; private edgeVocabulary: EdgeVocabulary | null; private settings: MindnetSettings; private pluginInstance: any; private existingState: ScanState | null; private items: ScanItem[] = []; private filterStatus: string | null = null; private filterHasMissingSlot: boolean = false; private filterHasMissingLink: boolean = false; private filterWeak: boolean = false; private filterCandidateCleanup: boolean = false; private searchQuery: string = ""; private isScanning: boolean = false; private shouldCancel: boolean = false; constructor( app: App, chainRoles: ChainRolesConfig | null, chainTemplates: ChainTemplatesConfig | null, edgeVocabulary: EdgeVocabulary | null, settings: MindnetSettings, pluginInstance: any, existingState: ScanState | null ) { super(app); this.chainRoles = chainRoles; this.chainTemplates = chainTemplates; this.edgeVocabulary = edgeVocabulary; this.settings = settings; this.pluginInstance = pluginInstance; this.existingState = existingState; if (existingState) { this.items = existingState.items; } } onOpen(): void { const { contentEl } = this; contentEl.empty(); // Header contentEl.createEl("h2", { text: "Vault Triage Scan" }); if (this.existingState && !this.existingState.completed) { contentEl.createEl("p", { text: `Resuming scan: ${this.existingState.progress.current}/${this.existingState.progress.total}`, cls: "scan-progress-info", }); } // Controls const controlsContainer = contentEl.createDiv({ cls: "scan-controls" }); // Start/Resume scan button if (!this.isScanning && (this.items.length === 0 || !this.existingState?.completed)) { const scanBtn = controlsContainer.createEl("button", { text: this.items.length === 0 ? "Start Scan" : "Resume Scan", cls: "mod-cta", }); scanBtn.addEventListener("click", () => { this.startScan(); }); } // Filters const filtersContainer = contentEl.createDiv({ cls: "scan-filters" }); filtersContainer.createEl("h3", { text: "Filters" }); // Status filter const statusSelect = filtersContainer.createEl("select"); statusSelect.createEl("option", { text: "All", value: "" }); statusSelect.createEl("option", { text: "Near Complete", value: "near_complete" }); statusSelect.createEl("option", { text: "Partial", value: "partial" }); statusSelect.createEl("option", { text: "Weak", value: "weak" }); statusSelect.createEl("option", { text: "Deprioritized", value: "deprioritized" }); statusSelect.value = this.filterStatus || ""; statusSelect.addEventListener("change", (e) => { const target = e.target as HTMLSelectElement; this.filterStatus = target.value || null; this.render(); }); // Gap filters new Setting(filtersContainer) .setName("Has Missing Slot") .addToggle((toggle) => { toggle.setValue(this.filterHasMissingSlot); toggle.onChange((value) => { this.filterHasMissingSlot = value; this.render(); }); }); new Setting(filtersContainer) .setName("Has Missing Link") .addToggle((toggle) => { toggle.setValue(this.filterHasMissingLink); toggle.onChange((value) => { this.filterHasMissingLink = value; this.render(); }); }); new Setting(filtersContainer) .setName("Weak") .addToggle((toggle) => { toggle.setValue(this.filterWeak); toggle.onChange((value) => { this.filterWeak = value; this.render(); }); }); new Setting(filtersContainer) .setName("Candidate Cleanup") .addToggle((toggle) => { toggle.setValue(this.filterCandidateCleanup); toggle.onChange((value) => { this.filterCandidateCleanup = value; this.render(); }); }); // Search filtersContainer.createEl("label", { text: "Search:" }); const searchInput = filtersContainer.createEl("input", { type: "text", placeholder: "File name, heading, template...", }); searchInput.value = this.searchQuery; searchInput.addEventListener("input", (e) => { const target = e.target as HTMLInputElement; this.searchQuery = target.value.toLowerCase(); this.render(); }); // Results list const resultsContainer = contentEl.createDiv({ cls: "scan-results" }); resultsContainer.createEl("h3", { text: "Results" }); this.render(); } private async startScan(): Promise { this.isScanning = true; this.shouldCancel = false; this.render(); try { const items = await scanVaultForChainGaps( this.app, this.chainRoles, this.chainTemplates, this.edgeVocabulary, this.settings, (progress) => { // Update progress const state: ScanState = { items: this.items, timestamp: Date.now(), progress, completed: false, }; saveScanState(this.app, state, this.pluginInstance); this.render(); }, () => this.shouldCancel ); this.items = items; // Save completed state const state: ScanState = { items, timestamp: Date.now(), progress: { current: items.length, total: items.length, currentFile: null, }, completed: true, }; await saveScanState(this.app, state, this.pluginInstance); this.isScanning = false; this.render(); } catch (e) { this.isScanning = false; const msg = e instanceof Error ? e.message : String(e); console.error("[Vault Triage Scan] Error:", e); new Notice(`Scan failed: ${msg}`); this.render(); } } private render(): void { const resultsContainer = this.contentEl.querySelector(".scan-results"); if (!resultsContainer) return; // Clear previous results const existingList = resultsContainer.querySelector(".scan-items-list"); if (existingList) { existingList.remove(); } // Filter items let filteredItems = this.items; if (this.filterStatus) { filteredItems = filteredItems.filter((item) => item.status === this.filterStatus); } if (this.filterHasMissingSlot) { filteredItems = filteredItems.filter((item) => item.gapCounts.missingSlots > 0); } if (this.filterHasMissingLink) { filteredItems = filteredItems.filter((item) => item.gapCounts.missingLinks > 0); } if (this.filterWeak) { filteredItems = filteredItems.filter((item) => item.status === "weak"); } if (this.filterCandidateCleanup) { filteredItems = filteredItems.filter((item) => item.gapCounts.candidateCleanup > 0); } if (this.searchQuery) { filteredItems = filteredItems.filter((item) => { const fileMatch = item.file.toLowerCase().includes(this.searchQuery); const headingMatch = item.heading?.toLowerCase().includes(this.searchQuery); const templateMatch = item.matches.some((m) => m.templateName.toLowerCase().includes(this.searchQuery) ); return fileMatch || headingMatch || templateMatch; }); } // Sort: near_complete first, then by score descending filteredItems.sort((a, b) => { if (a.status === "near_complete" && b.status !== "near_complete") return -1; if (a.status !== "near_complete" && b.status === "near_complete") return 1; const aMaxScore = Math.max(...a.matches.map((m) => m.score)); const bMaxScore = Math.max(...b.matches.map((m) => m.score)); return bMaxScore - aMaxScore; }); // Render list const listContainer = resultsContainer.createDiv({ cls: "scan-items-list" }); listContainer.createEl("p", { text: `${filteredItems.length} item(s)${this.isScanning ? " (scanning...)" : ""}`, }); for (const item of filteredItems) { const itemEl = listContainer.createDiv({ cls: "scan-item" }); const headerEl = itemEl.createDiv({ cls: "scan-item-header" }); headerEl.createEl("div", { cls: "scan-item-title", text: `${item.file}${item.heading ? `#${item.heading}` : ""}`, }); const statusBadge = headerEl.createEl("span", { cls: `scan-item-status status-${item.status}`, text: item.status, }); // Gap counts const gapCountsEl = itemEl.createDiv({ cls: "scan-item-gaps" }); if (item.gapCounts.missingSlots > 0) { gapCountsEl.createEl("span", { text: `Missing Slots: ${item.gapCounts.missingSlots}`, cls: "gap-badge", }); } if (item.gapCounts.missingLinks > 0) { gapCountsEl.createEl("span", { text: `Missing Links: ${item.gapCounts.missingLinks}`, cls: "gap-badge", }); } if (item.gapCounts.weakRoles > 0) { gapCountsEl.createEl("span", { text: `Weak Roles: ${item.gapCounts.weakRoles}`, cls: "gap-badge", }); } if (item.gapCounts.candidateCleanup > 0) { gapCountsEl.createEl("span", { text: `Candidate Cleanup: ${item.gapCounts.candidateCleanup}`, cls: "gap-badge", }); } // Matches summary const matchesEl = itemEl.createDiv({ cls: "scan-item-matches" }); matchesEl.createEl("div", { text: `${item.matches.length} template match(es)`, }); // Actions const actionsEl = itemEl.createDiv({ cls: "scan-item-actions" }); const openBtn = actionsEl.createEl("button", { text: "Open Workbench", }); openBtn.addEventListener("click", async () => { // Open file and section, then open workbench await this.app.workspace.openLinkText(item.file, "", true); // Wait a bit for file to open, then trigger workbench setTimeout(async () => { const { executeChainWorkbench } = await import("../commands/chainWorkbenchCommand"); const activeFile = this.app.workspace.getActiveFile(); const activeEditor = this.app.workspace.activeEditor?.editor; if (activeFile && activeEditor) { await executeChainWorkbench( this.app, activeEditor, activeFile.path, this.chainRoles, this.chainTemplates, undefined, this.settings, this.pluginInstance ); } }, 500); }); const deprioritizeBtn = actionsEl.createEl("button", { text: item.status === "deprioritized" ? "Prioritize" : "Deprioritize", }); deprioritizeBtn.addEventListener("click", () => { item.status = item.status === "deprioritized" ? "near_complete" : "deprioritized"; this.render(); }); } } onClose(): void { const { contentEl } = this; contentEl.empty(); } }