import { App, Plugin, PluginSettingTab, Setting, WorkspaceLeaf, TFile, MarkdownView, ItemView, Editor, MarkdownPostProcessorContext, debounce, Notice, SuggestModal, } from "obsidian"; /** * Debug logger utility that only logs when debug mode is enabled * Prevents console spam in production builds */ class DebugLogger { private isDebugEnabled: () => boolean; constructor(isDebugEnabled: () => boolean) { this.isDebugEnabled = isDebugEnabled; } log(message: string, ...args: any[]): void { if (this.isDebugEnabled()) { console.log(`[Progress Tracker] ${message}`, ...args); } } error(message: string, error?: Error): void { if (this.isDebugEnabled()) { console.error(`[Progress Tracker ERROR] ${message}`, error); } } warn(message: string, ...args: any[]): void { if (this.isDebugEnabled()) { console.warn(`[Progress Tracker WARNING] ${message}`, ...args); } } } /** * Type-safe interface for Dataview API */ interface DataviewApi { executeJs( code: string, container: HTMLElement, sourcePath?: string ): Promise; page(path: string): any; pages(source: string): any[]; } /** * Type-safe interface for accessing plugins */ interface ObsidianApp extends App { plugins?: { plugins?: { dataview?: { api?: DataviewApi; }; }; enabledPlugins?: Set; }; } /** * Extended window interface for Dataview API access */ declare global { interface Window { DataviewAPI?: DataviewApi; } } /** * Safely get Dataview API with proper type checking * @param app - Obsidian app instance * @returns DataviewApi instance or null if not available */ function getDataviewAPI(app: App): DataviewApi | null { try { // Method 1: Through window object (most reliable) if (typeof window !== "undefined" && window.DataviewAPI) { return window.DataviewAPI; } // Method 2: Through app.plugins with type safety const obsidianApp = app as ObsidianApp; const dataviewPlugin = obsidianApp.plugins?.plugins?.dataview; if (dataviewPlugin?.api) { return dataviewPlugin.api; } // Method 3: Check if plugin is enabled const enabledPlugins = obsidianApp.plugins?.enabledPlugins; if (enabledPlugins?.has("dataview")) { // Plugin is enabled but API not ready yet return null; } return null; } catch (error) { console.error("Error accessing Dataview API:", error); return null; } } // Define custom checkbox mapping interface interface KanbanColumnCheckboxMapping { columnName: string; checkboxState: string; // e.g., "[ ]", "[/]", "[x]", "[>]", etc. } interface TaskProgressBarSettings { showDebugInfo: boolean; progressColorScheme: "default" | "red-orange-green" | "custom"; lowProgressColor: string; mediumProgressColor: string; highProgressColor: string; completeProgressColor: string; lowProgressThreshold: number; mediumProgressThreshold: number; highProgressThreshold: number; showUpdateAnimation: boolean; updateAnimationDelay: number; editorChangeDelay: number; keyboardInputDelay: number; checkboxClickDelay: number; maxTabsHeight: string; autoUpdateMetadata: boolean; autoChangeStatus: boolean; autoUpdateFinishedDate: boolean; autoUpdateKanban: boolean; kanbanCompletedColumn: string; // Deprecated but kept for backward compatibility statusTodo: string; statusInProgress: string; statusCompleted: string; kanbanAutoDetect: boolean; kanbanSpecificFiles: string[]; kanbanExcludeFiles: string[]; kanbanSyncWithStatus: boolean; autoAddToKanban: boolean; autoAddKanbanBoard: string; autoAddKanbanColumn: string; // New settings for custom checkbox states enableCustomCheckboxStates: boolean; kanbanColumnCheckboxMappings: KanbanColumnCheckboxMapping[]; // New setting for reverse Kanban sync enableKanbanToFileSync: boolean; // New setting for auto-sync checkbox states on Kanban open enableKanbanAutoSync: boolean; // NEW: Protection from Kanban normalization enableKanbanNormalizationProtection: boolean; } const DEFAULT_SETTINGS: TaskProgressBarSettings = { showDebugInfo: false, progressColorScheme: "default", lowProgressColor: "#e06c75", // Red mediumProgressColor: "#e5c07b", // Orange/Yellow highProgressColor: "#61afef", // Blue completeProgressColor: "#98c379", // Green lowProgressThreshold: 33, mediumProgressThreshold: 66, highProgressThreshold: 99, showUpdateAnimation: true, updateAnimationDelay: 150, // Reduced from 300ms to 150ms editorChangeDelay: 200, // Reduced from 500ms to 200ms keyboardInputDelay: 50, // Reduced from 100ms to 50ms checkboxClickDelay: 100, // Reduced from 200ms to 100ms maxTabsHeight: "auto", autoUpdateMetadata: true, autoChangeStatus: true, autoUpdateFinishedDate: true, autoUpdateKanban: true, kanbanCompletedColumn: "Complete", // Deprecated statusTodo: "Todo", statusInProgress: "In Progress", statusCompleted: "Completed", kanbanAutoDetect: true, kanbanSpecificFiles: [], kanbanExcludeFiles: [], kanbanSyncWithStatus: true, autoAddToKanban: false, autoAddKanbanBoard: "", autoAddKanbanColumn: "Todo", enableCustomCheckboxStates: false, kanbanColumnCheckboxMappings: [ { columnName: "Todo", checkboxState: "[ ]" }, { columnName: "In Progress", checkboxState: "[/]" }, { columnName: "Complete", checkboxState: "[x]" }, { columnName: "Done", checkboxState: "[x]" }, ], enableKanbanToFileSync: false, enableKanbanAutoSync: false, enableKanbanNormalizationProtection: true, }; export default class TaskProgressBarPlugin extends Plugin { settings: TaskProgressBarSettings; dvAPI: DataviewApi | null = null; sidebarView: TaskProgressBarView | null = null; private lastActiveFile: TFile | null = null; private lastFileContent: string = ""; private dataviewCheckInterval: number | null = null; // Debug logger instance private logger: DebugLogger; // New tracking variables for Kanban-to-file sync private lastKanbanContent: Map = new Map(); private isUpdatingFromKanban: boolean = false; // Track which Kanban files have been auto-synced to avoid repeat runs private autoSyncedFiles: Set = new Set(); // Track last update time for each file to detect timing conflicts private lastFileUpdateMap: Map = new Map(); // NEW: Smart Kanban interaction detection private kanbanNormalizationDetector: Map< string, { preChangeCheckpoints: Map; lastKanbanUIInteraction: number; pendingNormalizationCheck: number | null; } > = new Map(); async onload() { await this.loadSettings(); // Initialize debug logger this.logger = new DebugLogger(() => this.settings.showDebugInfo); // Apply the max-height CSS style as soon as the plugin loads this.applyMaxTabsHeightStyle(); // Register view type for the sidebar this.registerView( "progress-tracker", (leaf) => (this.sidebarView = new TaskProgressBarView(leaf, this)) ); // Add icon to the left sidebar this.addRibbonIcon("bar-chart-horizontal", "Progress Tracker", () => { this.activateView(); }); // Add settings tab this.addSettingTab(new TaskProgressBarSettingTab(this.app, this)); // Check Dataview API and set up interval to check again if not found this.checkDataviewAPI(); // Register event to update progress bar when file changes this.registerEvent( this.app.workspace.on("file-open", (file) => { if (file) { this.lastActiveFile = file; // Handle auto-sync for Kanban boards (run once per file per session) if ( this.settings.enableKanbanAutoSync && this.settings.enableCustomCheckboxStates && this.isKanbanBoard(file) && !this.autoSyncedFiles.has(file.path) && !this.isUpdatingFromKanban ) { this.logger.log( `Auto-syncing Kanban board on open: ${file.path}` ); // Add small delay to ensure file is fully loaded and no other operations are running setTimeout(async () => { // Triple-check that we're not in the middle of an update and no recent changes if ( !this.isUpdatingFromKanban && !this.lastKanbanContent.has(file.path) ) { await this.autoSyncKanbanCheckboxStates(file); } else { this.logger.log( `Skipping auto-sync - update in progress or file already tracked` ); } }, 800); // Increased delay to avoid conflicts with editor changes } // Original progress bar update logic setTimeout(async () => { await this.updateLastFileContent(file); if (this.sidebarView) { // Pass true to force update even for files without tasks this.sidebarView.updateProgressBar(file); } }, 100); } }) ); // Register event to listen for file modifications (for Kanban UI changes) this.registerEvent( this.app.vault.on("modify", async (file) => { // Only handle TFile instances that are Kanban boards if ( file instanceof TFile && this.settings.enableKanbanToFileSync && this.settings.enableCustomCheckboxStates && this.isKanbanBoard(file) ) { this.logger.log( `File modified event for Kanban board: ${file.path}` ); // Skip if we're currently updating to avoid loops if (this.isUpdatingFromKanban) { this.logger.log( "Skipping file modify - currently updating from plugin" ); return; } // Add small delay to ensure content is properly updated setTimeout(async () => { try { const newContent = await this.app.vault.read(file); await this.handleKanbanBoardChange( file, newContent ); } catch (error) { if (this.settings.showDebugInfo) { console.error( "Error handling file modify for Kanban board:", error ); } } }, 100); } }) ); // Register event to update progress bar when editor changes this.registerEvent( this.app.workspace.on( "editor-change", debounce(async (editor, view) => { if (view instanceof MarkdownView && this.sidebarView) { // Skip if we're currently updating from Kanban to avoid loops if (this.isUpdatingFromKanban) { this.logger.log( "Skipping editor-change - currently updating from Kanban" ); return; } // Get current editor content const content = editor.getValue(); const currentFile = view.file; // Check if this is a Kanban board file and handle card checkbox sync (independent feature) if ( this.settings.enableKanbanToFileSync && this.settings.enableCustomCheckboxStates && currentFile && this.isKanbanBoard(currentFile) ) { this.logger.log( `Detected Kanban board change: ${currentFile.path}` ); this.logger.log( `Content length: ${content.length}, lastFileContent length: ${this.lastFileContent.length}` ); this.logger.log( `isUpdatingFromKanban: ${this.isUpdatingFromKanban}` ); // NEW: Enhanced immediate Kanban normalization protection (runs for ALL Kanban changes) if ( this.settings .enableKanbanNormalizationProtection ) { const hasImmediateNormalization = this.detectImmediateKanbanNormalization( this.lastFileContent, content ); this.logger.log( `Immediate normalization check: ${hasImmediateNormalization}` ); if (hasImmediateNormalization) { this.logger.log( "Detected immediate Kanban normalization - reverting unwanted changes" ); // Revert the normalization immediately const revertedContent = this.revertKanbanNormalization( this.lastFileContent, content, currentFile ); if (revertedContent !== content) { // Set flag to prevent loops this.isUpdatingFromKanban = true; // Apply the reverted content and then sync all checkbox states setTimeout(async () => { // Apply sync to ensure all checkbox states match column mappings const syncedContent = await this.syncAllCheckboxStatesToMappings( currentFile, revertedContent ); await this.app.vault.modify( currentFile, syncedContent ); this.lastFileContent = syncedContent; // Try to force refresh Kanban UI await this.forceRefreshKanbanUI( currentFile ); // Reset flag setTimeout(() => { this.isUpdatingFromKanban = false; }, 100); }, 50); return; // Skip further processing } } else { // DEBUG: If no immediate normalization detected, let's see what changed const oldLines = this.lastFileContent.split("\n"); const newLines = content.split("\n"); let changeCount = 0; for ( let i = 0; i < Math.min( oldLines.length, newLines.length ); i++ ) { const oldMatch = oldLines[i].match( /^(\s*- )\[([^\]]*)\]/ ); const newMatch = newLines[i].match( /^(\s*- )\[([^\]]*)\]/ ); if ( oldMatch && newMatch && oldMatch[2] !== newMatch[2] ) { changeCount++; this.logger.log( `Line ${i} checkbox change: [${oldMatch[2]}] → [${newMatch[2]}]` ); } } if (changeCount > 0) { this.logger.log( `Found ${changeCount} checkbox changes but no immediate normalization detected` ); } } } // Add small delay to ensure content is properly updated after drag/drop setTimeout(async () => { // Re-read the file content to ensure we have the latest version const latestContent = await this.app.vault.read( currentFile ); await this.handleKanbanBoardChange( currentFile, latestContent ); }, 200); // Don't return here - allow normal processing to continue } // Original logic for regular file changes (including file-to-kanban sync) // Check if content contains tasks (enhanced to support custom checkbox states) if ( content.includes("- [") || this.lastFileContent.includes("- [") || /- \[[^\]]*\]/.test(content) || /- \[[^\]]*\]/.test(this.lastFileContent) ) { // Check if tasks have changed if ( this.hasTaskContentChanged( this.lastFileContent, content ) ) { if (this.lastActiveFile) { // Update last file content before checking changes this.lastActiveFile = view.file; // Update progress bar immediately this.sidebarView.updateProgressBar( view.file, content ); // Then update last file content this.lastFileContent = content; } } else { this.logger.log( "Skipping update - no task changes detected" ); } } } }, this.settings.editorChangeDelay) ) ); // Listen for keydown events to detect when user enters new tasks or checks/unchecks tasks this.registerDomEvent(document, "keydown", (evt: KeyboardEvent) => { // Check if we're in the editor const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); if (activeView && activeView.getMode() === "source") { // Update immediately when pressing keys related to tasks if ( [ "Enter", "Space", "]", "x", "X", "Backspace", "Delete", ].includes(evt.key) ) { // Update immediately setTimeout(() => { const content = activeView.editor.getValue(); // Check if content contains tasks and if they have changed (enhanced for custom states) if ( (content.includes("- [") || /- \[[^\]]*\]/.test(content)) && this.hasTaskContentChanged( this.lastFileContent, content ) ) { this.lastActiveFile = activeView.file; // Update progress bar immediately if (this.sidebarView) { this.sidebarView.updateProgressBar( activeView.file, content ); } // Then update last file content this.lastFileContent = content; } }, this.settings.keyboardInputDelay); } } }); // Listen for click events in the editor to detect when tasks are checked/unchecked this.registerDomEvent(document, "click", (evt: MouseEvent) => { const target = evt.target as HTMLElement; // Check if click is on a task checkbox if ( target && target.tagName === "INPUT" && target.classList.contains("task-list-item-checkbox") ) { // Wait a bit for Obsidian to update the task state in the file setTimeout(async () => { const activeFile = this.app.workspace.getActiveFile(); if (activeFile && this.sidebarView) { // Read current file content const content = await this.app.vault.read(activeFile); // Only update if tasks have changed if ( this.hasTaskContentChanged( this.lastFileContent, content ) ) { // Update progress bar immediately this.lastActiveFile = activeFile; this.sidebarView.updateProgressBar( activeFile, content ); // Then update last file content this.lastFileContent = content; } } }, this.settings.checkboxClickDelay); } }); // Activate view when plugin loads - wait a bit for Obsidian to fully start setTimeout(() => { this.activateView(); // We'll use a single delayed update instead of multiple updates setTimeout(async () => { const currentFile = this.app.workspace.getActiveFile(); if (currentFile && this.sidebarView) { this.logger.log( "Initial file load after plugin start:", currentFile.path ); await this.updateLastFileContent(currentFile); // Use a flag to indicate this is the initial load this.sidebarView.updateProgressBar( currentFile, undefined, true ); } }, 1500); }, 1000); // Register commands this.addCommand({ id: "clear-completed-files-cache", name: "Clear completed files cache", callback: () => { if (this.sidebarView) { this.sidebarView.clearCompletedFilesCache(); new Notice( "Completed files cache cleared. Files can trigger completion notifications again." ); } }, }); // Add debug command for Kanban sync this.addCommand({ id: "debug-kanban-sync", name: "Debug Kanban sync status", callback: () => { const currentFile = this.app.workspace.getActiveFile(); if (!currentFile) { new Notice("No active file"); return; } const debugData: Record = { "Current file": currentFile.path, enableKanbanToFileSync: this.settings.enableKanbanToFileSync, enableCustomCheckboxStates: this.settings.enableCustomCheckboxStates, enableKanbanAutoSync: this.settings.enableKanbanAutoSync, enableKanbanNormalizationProtection: this.settings.enableKanbanNormalizationProtection, isKanbanBoard: this.isKanbanBoard(currentFile), "Checkbox mappings": this.settings.kanbanColumnCheckboxMappings, "Last Kanban content stored": this.lastKanbanContent.has( currentFile.path ), "Auto-synced files": Array.from(this.autoSyncedFiles), "Current file auto-synced": this.autoSyncedFiles.has( currentFile.path ), "isUpdatingFromKanban flag": this.isUpdatingFromKanban, "lastFileContent length": this.lastFileContent.length, "Last file update timestamp": this.lastFileUpdateMap.get( currentFile.path ), }; // Show current file content preview if (this.lastKanbanContent.has(currentFile.path)) { const storedContent = this.lastKanbanContent.get( currentFile.path )!; debugData[ "Stored content preview" ] = `${storedContent.substring(0, 200)}...`; } this.debugOutput("Kanban Sync Debug Info", debugData); new Notice( "Debug info logged to console. Check Developer Tools." ); }, }); // Add command to reset auto-sync cache this.addCommand({ id: "reset-kanban-autosync-cache", name: "Reset Kanban auto-sync cache", callback: () => { this.autoSyncedFiles.clear(); new Notice( "Auto-sync cache cleared. Kanban boards will be auto-synced again when opened." ); this.logger.log("Auto-sync cache cleared"); }, }); // Add command to test checkbox update function this.addCommand({ id: "test-checkbox-update", name: "Test checkbox update function", callback: () => { if (!this.settings.showDebugInfo) { new Notice("Enable debug mode first to use this command."); return; } // Test cases for the fixed function const testCases = [ { input: "- [/] Main task\n - [ ] Sub-task 1\n - [x] Sub-task 2", target: "[x]", expected: "- [x] Main task\n - [ ] Sub-task 1\n - [x] Sub-task 2", }, { input: "- [ ] Simple task", target: "[/]", expected: "- [/] Simple task", }, ]; console.log("=== Testing Checkbox Update Function ==="); testCases.forEach((testCase, index) => { const result = this.updateCheckboxStateInCardText( testCase.input, testCase.target ); const passed = result === testCase.expected; console.log( `Test ${index + 1}: ${passed ? "PASSED" : "FAILED"}` ); console.log(`Input: ${testCase.input}`); console.log(`Expected: ${testCase.expected}`); console.log(`Got: ${result}`); console.log("---"); }); // Test position finding function console.log("=== Testing Position Finding Function ==="); const testContent = [ "## Todo", "- [ ] Task 1", " - Sub item", "- [/] Task 2", "", "## In Progress", "- [/] Task 3", "- [x] Task 4", ]; const positionTests = [ { card: "- [ ] Task 1\n - Sub item", column: "Todo", expectedPos: 1, }, { card: "- [/] Task 3", column: "In Progress", expectedPos: 6, }, { card: "- [x] Non-existent", column: "Todo", expectedPos: -1, }, ]; positionTests.forEach((test, index) => { const result = this.findCardPositionInContent( test.card, testContent, test.column ); const passed = result === test.expectedPos; console.log( `Position Test ${index + 1}: ${ passed ? "PASSED" : "FAILED" }` ); console.log(`Card: ${test.card}`); console.log(`Column: ${test.column}`); console.log(`Expected Position: ${test.expectedPos}`); console.log(`Got Position: ${result}`); console.log("---"); }); new Notice( "Checkbox update and position finding tests completed. Check console for results." ); }, }); // Add command to reset conflict state this.addCommand({ id: "reset-kanban-conflicts", name: "Reset Kanban conflict state", callback: () => { this.isUpdatingFromKanban = false; this.lastKanbanContent.clear(); this.autoSyncedFiles.clear(); this.kanbanNormalizationDetector.clear(); new Notice( "Kanban conflict state reset. All tracking data cleared." ); if (this.settings.showDebugInfo) { console.log("Reset all Kanban conflict tracking:"); console.log("- isUpdatingFromKanban: false"); console.log("- lastKanbanContent: cleared"); console.log("- autoSyncedFiles: cleared"); console.log("- kanbanNormalizationDetector: cleared"); } }, }); // Add command to test normalization detection this.addCommand({ id: "test-kanban-normalization-detection", name: "Test Kanban normalization detection", callback: async () => { const currentFile = this.app.workspace.getActiveFile(); if (!currentFile) { new Notice("No active file"); return; } if (!this.isKanbanBoard(currentFile)) { new Notice("Current file is not a Kanban board"); return; } if (!this.settings.showDebugInfo) { new Notice("Enable debug mode first to use this command."); return; } const content = await this.app.vault.read(currentFile); console.log("=== Kanban Normalization Detection Test ==="); console.log(`File: ${currentFile.path}`); console.log( `Protection enabled: ${this.settings.enableKanbanNormalizationProtection}` ); // Show current detector state const detector = this.kanbanNormalizationDetector.get( currentFile.path ); if (detector) { console.log("Detector state:", { lastKanbanUIInteraction: new Date( detector.lastKanbanUIInteraction ).toISOString(), checkpointsCount: detector.preChangeCheckpoints.size, pendingCheck: detector.pendingNormalizationCheck !== null, }); } else { console.log("No detector state found for this file"); } // Test pattern detection with sample content const testOldContent = content.replace(/\[\/\]/g, "[/]"); // Ensure we have custom states const testNewContent = content.replace(/\[\/\]/g, "[x]"); // Simulate normalization const patterns = this.analyzeCheckboxNormalizationPatterns( testOldContent, testNewContent ); console.log("Pattern analysis result:", patterns); // Test immediate detection const hasImmediate = this.detectImmediateKanbanNormalization( testOldContent, testNewContent ); console.log("Immediate normalization detection:", hasImmediate); // Test revert function if (hasImmediate) { const reverted = this.revertKanbanNormalization( testOldContent, testNewContent, currentFile ); console.log("Revert test:", { originalLength: testOldContent.length, normalizedLength: testNewContent.length, revertedLength: reverted.length, revertedMatchesOriginal: reverted === testOldContent, }); } new Notice( "Normalization detection test completed. Check console for results." ); }, }); // Add command to test custom checkbox state counting this.addCommand({ id: "test-custom-checkbox-counting", name: "Test custom checkbox state counting", callback: () => { const currentFile = this.app.workspace.getActiveFile(); if (!currentFile) { new Notice("No active file"); return; } this.app.vault.read(currentFile).then((content) => { console.log("=== Custom Checkbox State Test ==="); console.log(`File: ${currentFile.path}`); console.log( `Custom checkbox states enabled: ${this.settings.enableCustomCheckboxStates}` ); if (this.settings.enableCustomCheckboxStates) { const taskCounts = this.countTasksByCheckboxState(content); console.log( "Task counts by checkbox state:", taskCounts ); let incompleteTasks = taskCounts[" "] || 0; let completedTasks = taskCounts["x"] || 0; let customStateTasks = 0; for (const [state, count] of Object.entries( taskCounts )) { if ( state !== " " && state !== "x" && state.trim() !== "" ) { customStateTasks += count; } } console.log( `Incomplete: ${incompleteTasks}, Completed: ${completedTasks}, Custom states: ${customStateTasks}` ); console.log( `Total tasks: ${ incompleteTasks + completedTasks + customStateTasks }` ); console.log( `Progress: ${Math.round( (completedTasks / (incompleteTasks + completedTasks + customStateTasks)) * 100 )}%` ); } else { console.log("Custom checkbox states are disabled"); const incompleteTasks = ( content.match(/- \[ \]/g) || [] ).length; const completedTasks = ( content.match(/- \[x\]/gi) || [] ).length; console.log( `Legacy counting - Incomplete: ${incompleteTasks}, Completed: ${completedTasks}` ); } new Notice( "Custom checkbox state test completed. Check console for results." ); }); }, }); // Add command to test card movement detection logic this.addCommand({ id: "test-card-movement-detection", name: "Test card movement detection logic", callback: () => { const currentFile = this.app.workspace.getActiveFile(); if (!currentFile) { new Notice("No active file"); return; } if (!this.isKanbanBoard(currentFile)) { new Notice("Current file is not a Kanban board"); return; } console.log("=== Card Movement Detection Test ==="); console.log(`File: ${currentFile.path}`); console.log( `Last Kanban content stored: ${this.lastKanbanContent.has( currentFile.path )}` ); console.log( `Auto-synced: ${this.autoSyncedFiles.has(currentFile.path)}` ); console.log( `isUpdatingFromKanban: ${this.isUpdatingFromKanban}` ); const lastUpdateTime = this.lastFileUpdateMap.get( currentFile.path ); if (lastUpdateTime) { const timeSinceUpdate = Date.now() - lastUpdateTime; console.log(`Time since last update: ${timeSinceUpdate}ms`); } else { console.log(`No last update time recorded`); } // Test normalize function const testCards = [ "- [x] [[Test Card]]", "- [/] [[Another Card]]", "- [ ] [[Todo Card]]", "- [-] [[Cancelled Card]]", ]; console.log("Testing card normalization:"); testCards.forEach((card) => { const normalized = this.normalizeCardContentForComparison(card); console.log(`"${card}" -> "${normalized}"`); }); new Notice( "Card movement detection test completed. Check console for results." ); }, }); // Add command to simulate card movement detection this.addCommand({ id: "simulate-card-movement", name: "Simulate card movement detection", callback: async () => { const currentFile = this.app.workspace.getActiveFile(); if (!currentFile) { new Notice("No active file"); return; } if (!this.isKanbanBoard(currentFile)) { new Notice("Current file is not a Kanban board"); return; } const oldContent = this.lastKanbanContent.get(currentFile.path); if (!oldContent) { new Notice( "No previous content stored. Try moving a card first." ); return; } const newContent = await this.app.vault.read(currentFile); console.log("=== Simulating Card Movement Detection ==="); console.log(`File: ${currentFile.path}`); try { const actualMovements = await this.detectActualCardMovements( oldContent, newContent, currentFile ); console.log( `Detected ${actualMovements.length} actual card movements:` ); actualMovements.forEach((movement, index) => { console.log( `${index + 1}. "${movement.card.substring( 0, 40 )}..." from "${movement.oldColumn}" to "${ movement.newColumn }"` ); }); if (actualMovements.length > 0) { console.log( "Testing updateCardCheckboxStatesInKanban with detected movements..." ); const updatedContent = await this.updateCardCheckboxStatesInKanban( oldContent, newContent, currentFile, actualMovements ); console.log( `Content updated. Original length: ${newContent.length}, Updated length: ${updatedContent.length}` ); if (updatedContent !== newContent) { console.log( "Content would be updated with checkbox state changes." ); } else { console.log("No checkbox state changes needed."); } } } catch (error) { console.error("Error in simulation:", error); } new Notice( "Card movement simulation completed. Check console for results." ); }, }); // Add command to debug current mappings and board state this.addCommand({ id: "debug-kanban-mappings-and-board-state", name: "Debug Kanban mappings and board state", callback: async () => { const currentFile = this.app.workspace.getActiveFile(); if (!currentFile) { new Notice("No active file"); return; } if (!this.isKanbanBoard(currentFile)) { new Notice("Current file is not a Kanban board"); return; } console.log("=== Kanban Mappings and Board State Debug ==="); console.log(`File: ${currentFile.path}`); console.log( `enableCustomCheckboxStates: ${this.settings.enableCustomCheckboxStates}` ); // Show current mappings console.log("Current checkbox mappings:"); this.settings.kanbanColumnCheckboxMappings.forEach( (mapping, index) => { console.log( ` ${index + 1}. "${mapping.columnName}" → "${ mapping.checkboxState }"` ); } ); // Parse and analyze current board state const content = await this.app.vault.read(currentFile); const kanban = await this.parseKanbanBoardContent( content, currentFile ); console.log("\nCurrent board state analysis:"); for (const [columnName, columnData] of Object.entries(kanban)) { const expectedState = this.getCheckboxStateForColumn(columnName); console.log( `\nColumn: "${columnName}" (expected state: "${expectedState}")` ); console.log(` Total cards: ${columnData.items.length}`); // Count checkbox states in this column const stateCount: { [state: string]: number } = {}; let correctCount = 0; let incorrectCount = 0; columnData.items.forEach((item, index) => { const match = item.text.match(/^(\s*- )\[([^\]]*)\]/); if (match) { const currentState = `[${match[2]}]`; stateCount[currentState] = (stateCount[currentState] || 0) + 1; if (currentState === expectedState) { correctCount++; } else { incorrectCount++; console.log( ` ${index}: "${item.text.substring( 0, 50 )}..." has "${currentState}" but should be "${expectedState}"` ); } } else { console.log( ` ${index}: "${item.text.substring( 0, 50 )}..." - no checkbox found` ); } }); console.log(` State distribution:`, stateCount); console.log( ` Correct states: ${correctCount}, Incorrect states: ${incorrectCount}` ); } new Notice( "Kanban mappings and board state debug completed. Check console for results." ); }, }); // Add command to manually trigger Kanban board change detection this.addCommand({ id: "manual-trigger-kanban-change", name: "Manually trigger Kanban board change detection", callback: async () => { const currentFile = this.app.workspace.getActiveFile(); if (!currentFile) { new Notice("No active file"); return; } if (!this.isKanbanBoard(currentFile)) { new Notice("Current file is not a Kanban board"); return; } console.log("=== Manual Kanban Change Trigger ==="); console.log(`File: ${currentFile.path}`); try { // Read current content const currentContent = await this.app.vault.read( currentFile ); console.log( `Current content length: ${currentContent.length}` ); // Trigger the change detection manually await this.handleKanbanBoardChange( currentFile, currentContent ); new Notice( "Manually triggered Kanban board change detection. Check console for logs." ); } catch (error) { console.error("Error in manual trigger:", error); new Notice(`Error: ${error.message}`); } }, }); // Add command to fix all checkbox states in current Kanban board this.addCommand({ id: "fix-all-checkbox-states", name: "Fix all checkbox states in current Kanban board", callback: async () => { const currentFile = this.app.workspace.getActiveFile(); if (!currentFile) { new Notice("No active file"); return; } if (!this.isKanbanBoard(currentFile)) { new Notice("Current file is not a Kanban board"); return; } if (!this.settings.enableCustomCheckboxStates) { new Notice( "Custom checkbox states are disabled. Enable them first in settings." ); return; } try { // Set flag to prevent conflicts this.isUpdatingFromKanban = true; console.log("=== Fixing All Checkbox States ==="); console.log(`File: ${currentFile.path}`); // Read current file content const content = await this.app.vault.read(currentFile); const kanban = await this.parseKanbanBoardContent( content, currentFile ); // Split content into lines for position-based replacement const lines = content.split("\n"); let totalChanges = 0; // Process each column and fix all cards for (const [columnName, columnData] of Object.entries( kanban )) { const targetCheckboxState = this.getCheckboxStateForColumn(columnName); console.log( `Fixing column "${columnName}" to checkbox state "${targetCheckboxState}" (${columnData.items.length} cards)` ); // Update each card in this column for (const item of columnData.items) { const currentCheckboxMatch = item.text.match(/^(\s*- )\[([^\]]*)\]/); const currentCheckboxState = currentCheckboxMatch ? `[${currentCheckboxMatch[2]}]` : null; // Only update if the current state is different from target state if (currentCheckboxState !== targetCheckboxState) { const updatedCardText = this.updateCheckboxStateInCardText( item.text, targetCheckboxState ); if (updatedCardText !== item.text) { const cardPosition = this.findCardPositionInContent( item.text, lines, columnName ); if (cardPosition !== -1) { // Replace only the specific card at the found position const cardLines = item.text.split("\n"); const updatedCardLines = updatedCardText.split("\n"); // Replace the card lines at the specific position lines.splice( cardPosition, cardLines.length, ...updatedCardLines ); totalChanges++; console.log( ` Fixed card: "${item.text.substring( 0, 30 )}..." from "${currentCheckboxState}" to "${targetCheckboxState}"` ); } } } } } // Update the file if changes were made if (totalChanges > 0) { const updatedContent = lines.join("\n"); await this.app.vault.modify( currentFile, updatedContent ); // Update stored content this.lastKanbanContent.set( currentFile.path, updatedContent ); console.log( `Fixed ${totalChanges} checkbox states in ${currentFile.basename}` ); new Notice( `Fixed ${totalChanges} checkbox states in ${currentFile.basename}` ); } else { console.log( `No changes needed - all checkbox states are already correct` ); new Notice("All checkbox states are already correct"); } } catch (error) { console.error("Error fixing checkbox states:", error); new Notice( `Error fixing checkbox states: ${error.message}` ); } finally { // Reset flag setTimeout(() => { this.isUpdatingFromKanban = false; }, 200); } }, }); } /** * Check for Dataview API availability and set up retry interval if not found * This ensures the plugin can integrate with Dataview when it becomes available */ checkDataviewAPI() { // Check immediately this.dvAPI = getDataviewAPI(this.app); // If not found, set up interval to check again if (!this.dvAPI) { this.dataviewCheckInterval = window.setInterval(() => { this.dvAPI = getDataviewAPI(this.app); if (this.dvAPI) { // If found, clear interval if (this.dataviewCheckInterval) { clearInterval(this.dataviewCheckInterval); this.dataviewCheckInterval = null; } // Update sidebar if open if (this.sidebarView && this.lastActiveFile) { this.sidebarView.updateProgressBar(this.lastActiveFile); } } }, 2000); // Check every 2 seconds } } /** * Update cached file content for comparison in future changes * @param file File to cache content for */ async updateLastFileContent(file: TFile) { if (file) { this.lastFileContent = await this.app.vault.read(file); } } async activateView() { try { const { workspace } = this.app; // Wait for layout to be ready before checking for existing views workspace.onLayoutReady(() => { // Check for existing leaves after layout is ready const leaves = workspace.getLeavesOfType("progress-tracker"); // If multiple views exist, detach duplicates and keep only the first one if (leaves.length > 1) { this.logger.log( `Found ${leaves.length} progress-tracker views, removing duplicates` ); // Keep the first leaf, detach the rest for (let i = 1; i < leaves.length; i++) { leaves[i].detach(); } // Reveal the remaining leaf workspace.revealLeaf(leaves[0]); return; } // If view already exists, just reveal it if (leaves.length === 1) { workspace.revealLeaf(leaves[0]); return; } // Otherwise, create a new leaf in the right sidebar (as user preference) const leaf = workspace.getRightLeaf(false); if (leaf) { leaf.setViewState({ type: "progress-tracker", active: true, }); // reveal the leaf workspace.revealLeaf(leaf); } }); } catch (error) { console.error("Error activating view:", error); new Notice( "Error activating Task Progress Bar view. Please try again later." ); } } /** * Cleanup all resources when plugin is unloaded * Prevents memory leaks and ensures proper cleanup */ onunload() { try { // Clear interval if it exists if (this.dataviewCheckInterval) { clearInterval(this.dataviewCheckInterval); this.dataviewCheckInterval = null; } // Clear any in-memory data if (this.sidebarView) { this.sidebarView.clearCompletedFilesCache(); } // Clear all Maps and Sets to prevent memory leaks this.lastKanbanContent.clear(); this.autoSyncedFiles.clear(); this.lastFileUpdateMap.clear(); this.kanbanNormalizationDetector.clear(); this.fileOperationLimiter.clear(); // Reset flags this.isUpdatingFromKanban = false; this.lastActiveFile = null; this.lastFileContent = ""; // Remove custom CSS styles const existingStyle = document.getElementById( "progress-tracker-max-tabs-height" ); if (existingStyle) { existingStyle.remove(); } this.logger.log("Plugin cleanup completed successfully"); } catch (error) { console.error("Error during plugin cleanup:", error); } } async loadSettings() { this.settings = Object.assign( {}, DEFAULT_SETTINGS, await this.loadData() ); } async saveSettings() { try { await this.saveData(this.settings); // Apply the max-height style whenever settings are saved this.applyMaxTabsHeightStyle(); } catch (error) { console.error("Error saving settings:", error); new Notice("Error saving settings. See console for details."); } } /** * Safely apply max-height CSS style with input validation * Prevents XSS by validating CSS values before DOM insertion */ applyMaxTabsHeightStyle() { try { // Remove any existing style element first const existingStyle = document.getElementById( "progress-tracker-max-tabs-height" ); if (existingStyle) { existingStyle.remove(); } // Validate CSS value to prevent XSS const maxHeight = this.settings.maxTabsHeight; if (!this.isValidCSSValue(maxHeight)) { this.logger.error( `Invalid CSS value for maxTabsHeight: ${maxHeight}` ); return; } // Create a new style element const style = document.createElement("style"); style.id = "progress-tracker-max-tabs-height"; // Target only workspace-tabs containing our plugin view if (!this.settings.showDebugInfo) { // Target only the progress-tracker view content, not the entire tabs container // Use textContent instead of innerHTML for safety: .workspace-tabs.mod-top .workspace-tab-container:has(.progress-tracker-leaf) { style.textContent = ` .workspace-tabs.mod-top.mod-top-right-space { max-height: ${maxHeight} !important; } `; // Add the style to the document head document.head.appendChild(style); } this.logger.log( `Applied max-tabs-height: ${maxHeight} to Progress Tracker view` ); } catch (error) { this.logger.error("Error applying max tabs height style", error); } } /** * Validate CSS value to prevent XSS injection * @param value CSS value to validate * @returns true if value is safe to use */ private isValidCSSValue(value: string): boolean { if (!value || typeof value !== "string") return false; // Allow specific safe values if (value === "auto" || value === "none") return true; // Allow valid CSS length values (px, em, rem, vh, %) const validCSSPattern = /^(\d+(\.\d+)?)(px|em|rem|vh|%)$/; return validCSSPattern.test(value.trim()); } /** * Safe debug output for commands - only shows when debug is enabled * @param title Debug section title * @param data Debug data to display */ private debugOutput(title: string, data: Record): void { if (!this.settings.showDebugInfo) return; this.logger.log(`=== ${title} ===`); Object.entries(data).forEach(([key, value]) => { this.logger.log( `${key}: ${ typeof value === "object" ? JSON.stringify(value) : value }` ); }); } /** * Validate file before performing operations * @param file File to validate * @returns true if file is safe to operate on */ private isValidFile(file: TFile): boolean { if (!file) return false; // Check if file path is safe (no path traversal) if (file.path.includes("..") || file.path.includes("//")) { this.logger.error(`Unsafe file path detected: ${file.path}`); return false; } // Check if file is markdown if (!file.path.endsWith(".md")) { this.logger.warn(`Non-markdown file: ${file.path}`); return false; } // Check file size (prevent processing extremely large files) if (file.stat.size > 10 * 1024 * 1024) { // 10MB limit this.logger.error( `File too large: ${file.path} (${file.stat.size} bytes)` ); return false; } return true; } /** * Validate content before processing * @param content Content to validate * @returns true if content is safe to process */ private isValidContent(content: string): boolean { if (typeof content !== "string") return false; // Check content size if (content.length > 5 * 1024 * 1024) { // 5MB limit this.logger.error( `Content too large: ${content.length} characters` ); return false; } // Check for suspicious patterns that might indicate injection const suspiciousPatterns = [ /]*>/i, /javascript:/i, /data:text\/html/i, /vbscript:/i, ]; for (const pattern of suspiciousPatterns) { if (pattern.test(content)) { this.logger.error(`Suspicious content pattern detected`); return false; } } return true; } /** * Rate limiter for file operations */ private fileOperationLimiter = new Map(); private readonly FILE_OPERATION_DELAY = 100; // Minimum ms between operations per file /** * Check if file operation is rate limited * @param filePath Path of file to check * @returns true if operation should be allowed */ private checkRateLimit(filePath: string): boolean { const now = Date.now(); const lastOperation = this.fileOperationLimiter.get(filePath); if (lastOperation && now - lastOperation < this.FILE_OPERATION_DELAY) { this.logger.warn(`Rate limited file operation: ${filePath}`); return false; } this.fileOperationLimiter.set(filePath, now); return true; } /** * Standardized error handler for plugin operations * @param error Error object or message * @param context Context where error occurred * @param showNotice Whether to show user notification */ private handleError( error: Error | string, context: string, showNotice: boolean = false ): void { const errorMessage = error instanceof Error ? error.message : error; const fullMessage = `${context}: ${errorMessage}`; this.logger.error( fullMessage, error instanceof Error ? error : undefined ); if (showNotice) { new Notice(`Progress Tracker Error: ${errorMessage}`); } } /** * Safe async operation wrapper with error handling * @param operation Async operation to execute * @param context Context description for error handling * @param fallbackValue Value to return on error */ private async safeAsyncOperation( operation: () => Promise, context: string, fallbackValue: T ): Promise { try { return await operation(); } catch (error) { this.handleError(error as Error, context, false); return fallbackValue; } } /** * Check if content changes are related to tasks * Supports custom checkbox states like [/], [-], [~], etc. * @param oldContent Previous file content * @param newContent Current file content * @returns true if task-related changes detected */ private hasTaskContentChanged( oldContent: string, newContent: string ): boolean { // Split content into lines const oldLines = oldContent.split("\n"); const newLines = newContent.split("\n"); // Find task lines in both contents - support all checkbox states const oldTasks = oldLines.filter((line) => line.trim().match(/^[-*] \[[^\]]*\]/i) ); const newTasks = newLines.filter((line) => line.trim().match(/^[-*] \[[^\]]*\]/i) ); // Compare task count if (oldTasks.length !== newTasks.length) { if (this.settings.showDebugInfo) { console.log( "Task count changed:", oldTasks.length, "->", newTasks.length ); } return true; } // Compare each task for (let i = 0; i < oldTasks.length; i++) { if (oldTasks[i] !== newTasks[i]) { if (this.settings.showDebugInfo) { console.log( "Task content changed:", oldTasks[i], "->", newTasks[i] ); } return true; } } if (this.settings.showDebugInfo) { console.log("No task-related changes detected"); } return false; } /** * Check if a file is a Kanban board * Uses heuristics to detect Kanban structure and plugin metadata * @param file File to check * @returns true if file appears to be a Kanban board */ public isKanbanBoard(file: TFile): boolean { if (!this.sidebarView) return false; return (this.sidebarView as any).isKanbanBoard(file); } /** * Handle changes in Kanban board files to detect card movements and update card checkbox states */ async handleKanbanBoardChange( kanbanFile: TFile, newContent: string ): Promise { try { // Validate inputs if (!this.isValidFile(kanbanFile)) { this.logger.error( `Invalid file for Kanban board change: ${kanbanFile?.path}` ); return; } if (!this.isValidContent(newContent)) { this.logger.error(`Invalid content for Kanban board change`); return; } // Check rate limit if (!this.checkRateLimit(kanbanFile.path)) { return; } this.logger.log( `handleKanbanBoardChange called for: ${kanbanFile.path}` ); this.logger.log( `Settings - enableKanbanToFileSync: ${this.settings.enableKanbanToFileSync}, enableCustomCheckboxStates: ${this.settings.enableCustomCheckboxStates}` ); if ( !this.settings.enableKanbanToFileSync || !this.settings.enableCustomCheckboxStates ) { if (this.settings.showDebugInfo) { console.log("Kanban sync disabled, skipping..."); } return; } const filePath = kanbanFile.path; const oldContent = this.lastKanbanContent.get(filePath) || ""; // CRITICAL FIX: Check if we're currently updating from auto-sync or other operations if (this.isUpdatingFromKanban) { if (this.settings.showDebugInfo) { console.log( "Skipping card movement detection - currently updating from auto-sync or other operations" ); } return; } // CRITICAL FIX: Check if auto-sync recently ran on this file if (this.autoSyncedFiles.has(filePath)) { const timeSinceLastUpdate = Date.now() - (this.lastFileUpdateMap.get(filePath) || 0); if (timeSinceLastUpdate < 2000) { // If auto-sync ran within last 2 seconds if (this.settings.showDebugInfo) { console.log( `Skipping card movement detection - auto-sync ran recently (${timeSinceLastUpdate}ms ago)` ); } // Still update the stored content for next comparison this.lastKanbanContent.set(filePath, newContent); return; } } // Update the last file update timestamp this.lastFileUpdateMap.set(filePath, Date.now()); if (this.settings.showDebugInfo) { console.log( `Old content length: ${oldContent.length}, New content length: ${newContent.length}` ); } // Store the new content for next comparison this.lastKanbanContent.set(filePath, newContent); // Skip if this is the first time we see this file if (!oldContent) { if (this.settings.showDebugInfo) { console.log( `First time seeing Kanban board: ${filePath}, storing content for next time` ); } return; } // Skip if content is identical if (oldContent === newContent) { if (this.settings.showDebugInfo) { console.log("Content unchanged, skipping..."); } return; } // STEP 1: Detect actual card movements first const actualCardMovements = await this.detectActualCardMovements( oldContent, newContent, kanbanFile ); if (this.settings.showDebugInfo) { console.log( `Detected ${actualCardMovements.length} actual card movements:`, actualCardMovements ); } // STEP 2: Process legitimate card movements and update their checkbox states let finalContent = newContent; if (actualCardMovements.length > 0) { finalContent = await this.updateCardCheckboxStatesInKanban( oldContent, newContent, kanbanFile, actualCardMovements ); if (this.settings.showDebugInfo) { console.log( `Updated content for ${actualCardMovements.length} card movements` ); } } // STEP 3: Apply protection for non-moved cards (only if protection is enabled) if (this.settings.enableKanbanNormalizationProtection) { const isKanbanNormalization = await this.detectKanbanNormalization( kanbanFile, oldContent, finalContent, actualCardMovements ); if (isKanbanNormalization) { if (this.settings.showDebugInfo) { console.log( "Detected Kanban plugin normalization - protecting custom checkbox states for non-moved cards" ); } finalContent = await this.protectCustomCheckboxStatesSelective( kanbanFile, oldContent, finalContent, actualCardMovements ); } } // STEP 4: Proactively sync all checkbox states to match column mappings finalContent = await this.syncAllCheckboxStatesToMappings( kanbanFile, finalContent ); // STEP 5: Update the Kanban board file if content changed if (finalContent !== newContent) { if (this.settings.showDebugInfo) { console.log( `Content will be updated. Original length: ${newContent.length}, Final length: ${finalContent.length}` ); } // Set flag to prevent infinite loops this.isUpdatingFromKanban = true; await this.app.vault.modify(kanbanFile, finalContent); // Update our stored content this.lastKanbanContent.set(filePath, finalContent); // Try to force refresh Kanban UI await this.forceRefreshKanbanUI(kanbanFile); if (this.settings.showDebugInfo) { console.log( `Successfully updated checkbox states in Kanban board: ${kanbanFile.basename}` ); } // Reset flag after a short delay setTimeout(() => { this.isUpdatingFromKanban = false; }, 300); } else { if (this.settings.showDebugInfo) { console.log("No checkbox state changes needed"); } } } catch (error) { this.handleError(error as Error, "handleKanbanBoardChange", false); } } /** * Detect actual card movements between columns (not just checkbox state changes) * Returns array of card movements with old and new column information */ private async detectActualCardMovements( oldContent: string, newContent: string, kanbanFile: TFile ): Promise< Array<{ card: string; oldColumn: string; newColumn: string; cardIndex: number; }> > { try { const movements: Array<{ card: string; oldColumn: string; newColumn: string; cardIndex: number; }> = []; // Parse both old and new Kanban structures const oldKanban = await this.parseKanbanBoardContent( oldContent, kanbanFile ); const newKanban = await this.parseKanbanBoardContent( newContent, kanbanFile ); // Create more precise card tracking with position information const oldCardPositions = new Map< string, Array<{ column: string; index: number; originalText: string }> >(); const newCardPositions = new Map< string, Array<{ column: string; index: number; originalText: string }> >(); // Populate old card positions map for (const [columnName, columnData] of Object.entries(oldKanban)) { columnData.items.forEach((item, index) => { const normalizedCard = this.normalizeCardContentForComparison(item.text); if (!oldCardPositions.has(normalizedCard)) { oldCardPositions.set(normalizedCard, []); } oldCardPositions.get(normalizedCard)!.push({ column: columnName, index: index, originalText: item.text, }); }); } // Populate new card positions and detect movements for (const [columnName, columnData] of Object.entries(newKanban)) { columnData.items.forEach((item, index) => { const normalizedCard = this.normalizeCardContentForComparison(item.text); if (!newCardPositions.has(normalizedCard)) { newCardPositions.set(normalizedCard, []); } newCardPositions.get(normalizedCard)!.push({ column: columnName, index: index, originalText: item.text, }); }); } // Detect movements by comparing card distributions across columns for (const [normalizedCard, newPositions] of newCardPositions) { const oldPositions = oldCardPositions.get(normalizedCard) || []; // Check for cards that appear in new columns where they weren't before for (const newPos of newPositions) { const wasInThisColumn = oldPositions.some( (oldPos) => oldPos.column === newPos.column ); if (!wasInThisColumn && oldPositions.length > 0) { // This card appears in a new column - it's a movement // Find the most likely source column (the one that lost a card) const oldColumnCounts = new Map(); const newColumnCounts = new Map(); // Count cards in each column oldPositions.forEach((pos) => { oldColumnCounts.set( pos.column, (oldColumnCounts.get(pos.column) || 0) + 1 ); }); newPositions.forEach((pos) => { newColumnCounts.set( pos.column, (newColumnCounts.get(pos.column) || 0) + 1 ); }); // Find column that lost a card for (const [oldColumn, oldCount] of oldColumnCounts) { const newCount = newColumnCounts.get(oldColumn) || 0; if (newCount < oldCount) { // This column lost a card - it's likely the source movements.push({ card: normalizedCard, oldColumn: oldColumn, newColumn: newPos.column, cardIndex: newPos.index, }); if (this.settings.showDebugInfo) { console.log( `Detected card movement: "${normalizedCard.substring( 0, 30 )}..." from "${oldColumn}" to "${ newPos.column }"` ); } break; // Only record one movement per card instance } } } } } return movements; } catch (error) { this.handleError( error as Error, "detectActualCardMovements", false ); return []; } } /** * Normalize card content for comparison by removing checkbox states and extra whitespace * This allows us to detect card movements regardless of checkbox state changes */ private normalizeCardContentForComparison(cardContent: string): string { return cardContent .replace(/^(\s*- )\[[^\]]*\](.*)$/gm, "$1$2") // Remove checkbox states .trim(); // Remove extra whitespace } /** * Update checkbox states in Kanban board based on card movements * Uses position-based replacement to avoid affecting wrong cards */ private async updateCardCheckboxStatesInKanban( oldContent: string, newContent: string, kanbanFile: TFile, actualCardMovements: Array<{ card: string; oldColumn: string; newColumn: string; cardIndex: number; }> ): Promise { try { if (this.settings.showDebugInfo) { console.log("Starting card checkbox state update process..."); console.log( `Processing ${actualCardMovements.length} actual card movements` ); } // If no actual card movements, return original content if (actualCardMovements.length === 0) { if (this.settings.showDebugInfo) { console.log( "No actual card movements to process, returning original content" ); } return newContent; } // Parse new Kanban structure to find cards to update const newKanban = await this.parseKanbanBoardContent( newContent, kanbanFile ); // Split content into lines for position-based replacement const lines = newContent.split("\n"); let changesFound = 0; // Process only the cards that actually moved for (const movement of actualCardMovements) { const { card: normalizedCard, oldColumn, newColumn, cardIndex, } = movement; if (this.settings.showDebugInfo) { console.log( `Processing movement: "${normalizedCard.substring( 0, 30 )}..." from "${oldColumn}" to "${newColumn}"` ); } // Find the actual card in the new content using the specific index const targetColumn = newKanban[newColumn]; if (!targetColumn) { if (this.settings.showDebugInfo) { console.log( `Target column "${newColumn}" not found in new content` ); } continue; } // Use the specific card index to get the exact card that moved if (cardIndex >= targetColumn.items.length) { if (this.settings.showDebugInfo) { console.log( `Card index ${cardIndex} out of range for column "${newColumn}" (has ${targetColumn.items.length} items)` ); } continue; } const foundCard = targetColumn.items[cardIndex]; // Double-check that this is the right card const itemNormalized = this.normalizeCardContentForComparison( foundCard.text ); if (itemNormalized !== normalizedCard) { if (this.settings.showDebugInfo) { console.log( `Card at index ${cardIndex} doesn't match expected content. Expected: "${normalizedCard}", Found: "${itemNormalized}"` ); } continue; } // Update the checkbox state for this card const targetCheckboxState = this.getCheckboxStateForColumn(newColumn); const updatedCardText = this.updateCheckboxStateInCardText( foundCard.text, targetCheckboxState ); if (this.settings.showDebugInfo) { console.log( `Target checkbox state: "${targetCheckboxState}"` ); console.log(`Original card: ${foundCard.text}`); console.log(`Updated card: ${updatedCardText}`); } // Use position-based replacement to update only this specific card if (updatedCardText !== foundCard.text) { // Use the specific card index to find the exact position in the content const cardPosition = this.findCardPositionByIndex( lines, newColumn, cardIndex ); if (cardPosition !== -1) { // Replace only the specific card at the found position const cardLines = foundCard.text.split("\n"); const updatedCardLines = updatedCardText.split("\n"); // Replace the card lines at the specific position lines.splice( cardPosition, cardLines.length, ...updatedCardLines ); changesFound++; if (this.settings.showDebugInfo) { console.log( `Successfully updated card checkbox state at position ${cardPosition} (index ${cardIndex}) from "${oldColumn}" to "${newColumn}": ${targetCheckboxState}` ); } } else { if (this.settings.showDebugInfo) { console.log( `Could not find position for card at index ${cardIndex} in column "${newColumn}"` ); } } } else { if (this.settings.showDebugInfo) { console.log( `No changes needed for card (already has correct checkbox state)` ); } } } if (this.settings.showDebugInfo) { console.log( `Card checkbox update complete. Changes found: ${changesFound} out of ${actualCardMovements.length} movements` ); } return lines.join("\n"); } catch (error) { this.handleError( error as Error, "updateCardCheckboxStatesInKanban", false ); return newContent; // Return original content if there's an error } } /** * Parse Kanban board content into structure (simplified implementation) */ private async parseKanbanBoardContent( content: string, file: TFile ): Promise }>> { const kanban: Record }> = {}; try { if (this.settings.showDebugInfo) { console.log(`Parsing Kanban content for: ${file.path}`); } // Split content into lines const lines = content.split("\n"); let currentColumn = ""; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Check for column header (## Column Name) if (line.startsWith("## ")) { currentColumn = line.substring(3).trim(); if (!kanban[currentColumn]) { kanban[currentColumn] = { items: [] }; } if (this.settings.showDebugInfo) { console.log(`Found column: ${currentColumn}`); } continue; } // Check for list item (starts with "- ") if (currentColumn && line.trim().startsWith("- ")) { // Get complete card content including sub-items let cardText = line; let j = i + 1; // Include indented sub-items while ( j < lines.length && (lines[j].startsWith(" ") || lines[j].startsWith("\t") || lines[j].trim() === "") ) { cardText += "\n" + lines[j]; j++; } kanban[currentColumn].items.push({ text: cardText }); if (this.settings.showDebugInfo) { console.log( `Found card in ${currentColumn}: ${cardText.substring( 0, 50 )}...` ); } // Skip the lines we already processed i = j - 1; } } if (this.settings.showDebugInfo) { console.log( `Parsed ${Object.keys(kanban).length} columns:`, Object.keys(kanban) ); Object.entries(kanban).forEach(([col, data]) => { console.log(` ${col}: ${data.items.length} items`); data.items.forEach((item, index) => { console.log( ` ${index}: ${item.text.substring(0, 50)}...` ); }); }); } } catch (error) { console.error("Error parsing Kanban content:", error); if (this.settings.showDebugInfo) { console.error("Error details:", error); } } return kanban; } /** * Find which column a card is in within a Kanban structure */ private findCardInKanban( cardText: string, kanban: Record }> ): string | null { const trimmedCardText = cardText.trim(); for (const [columnName, columnData] of Object.entries(kanban)) { for (const item of columnData.items) { const trimmedItemText = item.text.trim(); if (trimmedItemText === trimmedCardText) { if (this.settings.showDebugInfo) { console.log( `Found exact match for card "${trimmedCardText.substring( 0, 30 )}..." in column "${columnName}"` ); } return columnName; } } } // Try fuzzy matching if exact match fails for (const [columnName, columnData] of Object.entries(kanban)) { for (const item of columnData.items) { const trimmedItemText = item.text.trim(); // Check if the core content matches (ignoring potential whitespace differences) if (this.areCardsEquivalent(trimmedCardText, trimmedItemText)) { if (this.settings.showDebugInfo) { console.log( `Found fuzzy match for card "${trimmedCardText.substring( 0, 30 )}..." in column "${columnName}"` ); } return columnName; } } } if (this.settings.showDebugInfo) { console.log( `No match found for card "${trimmedCardText.substring( 0, 30 )}..." in any column` ); } return null; } /** * Check if two cards are equivalent (accounting for minor formatting differences) */ private areCardsEquivalent(card1: string, card2: string): boolean { // Extract the main link content from both cards const link1 = this.extractMainLinkFromCard(card1); const link2 = this.extractMainLinkFromCard(card2); return !!(link1 && link2 && link1 === link2); } /** * Extract the main link content from a card */ private extractMainLinkFromCard(cardText: string): string | null { // Look for [[link]] pattern const obsidianMatch = cardText.match(/\[\[([^\]]+)\]\]/); if (obsidianMatch) { return obsidianMatch[1]; } // Look for [text](url) pattern const markdownMatch = cardText.match(/\[([^\]]+)\]\(([^)]+)\)/); if (markdownMatch) { return markdownMatch[2]; // Return the URL part } return null; } /** * Extract Obsidian-style links from content (reused logic) */ private extractObsidianLinks( content: string ): Array<{ path: string; alias?: string }> { const links: Array<{ path: string; alias?: string }> = []; const linkPattern = /\[\[(.*?)\]\]/g; let match; while ((match = linkPattern.exec(content)) !== null) { const [_, linkContent] = match; const [path, alias] = linkContent.split("|").map((s) => s.trim()); links.push({ path, alias }); } return links; } /** * Extract Markdown-style links from content (reused logic) */ private extractMarkdownLinks( content: string ): Array<{ text: string; url: string }> { const links: Array<{ text: string; url: string }> = []; const linkPattern = /\[(.*?)\]\((.*?)\)/g; let match; while ((match = linkPattern.exec(content)) !== null) { const [_, text, url] = match; links.push({ text: text.trim(), url: url.trim() }); } return links; } /** * Get checkbox state for a specific kanban column (reused logic) */ private getCheckboxStateForColumn(columnName: string): string { if (!this.settings.enableCustomCheckboxStates) { return "[ ]"; // Default unchecked state } const mapping = this.settings.kanbanColumnCheckboxMappings.find( (m) => m.columnName.toLowerCase() === columnName.toLowerCase() ); return mapping ? mapping.checkboxState : "[ ]"; } /** * Find the exact position of a card within content lines under a specific column */ private findCardPositionInContent( cardText: string, lines: string[], targetColumn: string ): number { let currentColumn = ""; let inTargetColumn = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Check for column header if (line.startsWith("## ")) { currentColumn = line.substring(3).trim(); inTargetColumn = currentColumn.toLowerCase() === targetColumn.toLowerCase(); continue; } // Only check for cards when we're in the target column if (inTargetColumn && line.trim().startsWith("- ")) { // Get complete card content including sub-items let completeCardText = line; let j = i + 1; // Include indented sub-items while ( j < lines.length && (lines[j].startsWith(" ") || lines[j].startsWith("\t") || lines[j].trim() === "") ) { completeCardText += "\n" + lines[j]; j++; } // Check if this matches our target card if (completeCardText.trim() === cardText.trim()) { if (this.settings.showDebugInfo) { console.log( `Found card at position ${i} in column "${targetColumn}"` ); } return i; } // Skip the lines we already processed i = j - 1; } } if (this.settings.showDebugInfo) { console.log(`Card not found in column "${targetColumn}"`); } return -1; } /** * Find the exact position of a card by its index within a specific column * This is more precise than content matching for avoiding duplicates */ private findCardPositionByIndex( lines: string[], targetColumn: string, cardIndex: number ): number { let currentColumn = ""; let inTargetColumn = false; let cardCount = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Check for column header if (line.startsWith("## ")) { currentColumn = line.substring(3).trim(); inTargetColumn = currentColumn.toLowerCase() === targetColumn.toLowerCase(); cardCount = 0; // Reset card count for new column continue; } // Only check for cards when we're in the target column if (inTargetColumn && line.trim().startsWith("- ")) { // Check if this is the card we're looking for if (cardCount === cardIndex) { if (this.settings.showDebugInfo) { console.log( `Found card at position ${i} (index ${cardIndex}) in column "${targetColumn}"` ); } return i; } cardCount++; // Skip sub-items to avoid counting them as separate cards let j = i + 1; while ( j < lines.length && (lines[j].startsWith(" ") || lines[j].startsWith("\t") || lines[j].trim() === "") ) { j++; } i = j - 1; } } if (this.settings.showDebugInfo) { console.log( `Card at index ${cardIndex} not found in column "${targetColumn}" (found ${cardCount} cards total)` ); } return -1; } /** * Update checkbox state in a single card text * Only updates the main card checkbox, preserving sub-items and nested checkboxes */ private updateCheckboxStateInCardText( cardText: string, targetCheckboxState: string ): string { // Split content into lines to process only the first line (main card) const lines = cardText.split("\n"); if (lines.length === 0) return cardText; // Pattern to match various checkbox states: - [ ], - [x], - [/], - [>], etc. // Remove global flag to only match once per line const checkboxPattern = /^(\s*[-*] )\[[^\]]*\](.*)$/; // Only update the first line if it matches the pattern (main card line) if (checkboxPattern.test(lines[0])) { lines[0] = lines[0].replace( checkboxPattern, (match, prefix, suffix) => { return `${prefix}${targetCheckboxState}${suffix}`; } ); } // Join lines back together, preserving sub-items unchanged return lines.join("\n"); } /** * Count tasks with different checkbox states */ private countTasksByCheckboxState(content: string): { [state: string]: number; } { const taskCounts: { [state: string]: number } = {}; const lines = content.split("\n"); for (const line of lines) { const match = line.trim().match(/^- \[([^\]]*)\]/); if (match) { const state = match[1]; taskCounts[state] = (taskCounts[state] || 0) + 1; } } return taskCounts; } /** * NEW: Detect if a change is caused by Kanban plugin normalization * Returns true if this appears to be a normalization rather than user-intended change */ private async detectKanbanNormalization( kanbanFile: TFile, oldContent: string, newContent: string, knownMovements: Array<{ card: string; oldColumn: string; newColumn: string; cardIndex: number; }> = [] ): Promise { try { const filePath = kanbanFile.path; const now = Date.now(); // Initialize detector state if not exists if (!this.kanbanNormalizationDetector.has(filePath)) { this.kanbanNormalizationDetector.set(filePath, { preChangeCheckpoints: new Map(), lastKanbanUIInteraction: 0, pendingNormalizationCheck: null, }); } const detector = this.kanbanNormalizationDetector.get(filePath)!; // Analyze the pattern of changes first const normalizationPatterns = this.analyzeCheckboxNormalizationPatterns( oldContent, newContent ); // Enhanced detection logic - focus on content patterns rather than timing const hasUnwantedNormalization = this.detectUnwantedKanbanNormalization( oldContent, newContent, knownMovements ); // Update interaction timestamp for future detections detector.lastKanbanUIInteraction = now; if (this.settings.showDebugInfo) { console.log(`Kanban normalization analysis for ${filePath}:`, { normalizationPatterns, hasUnwantedNormalization, contentLengthSame: oldContent.length === newContent.length, }); } // Return true if this looks like unwanted Kanban normalization return ( hasUnwantedNormalization || normalizationPatterns.hasNormalization ); } catch (error) { console.error("Error detecting Kanban normalization:", error); return false; } } /** * NEW: Detect unwanted Kanban normalization based on content patterns */ private detectUnwantedKanbanNormalization( oldContent: string, newContent: string, knownMovements: Array<{ card: string; oldColumn: string; newColumn: string; cardIndex: number; }> = [] ): boolean { // Check if this is a case where custom states are being converted to [x] inappropriately const oldLines = oldContent.split("\n"); const newLines = newContent.split("\n"); // Look for patterns where custom states in non-completed columns get converted to [x] for (let i = 0; i < Math.min(oldLines.length, newLines.length); i++) { const oldLine = oldLines[i]; const newLine = newLines[i]; // Find what column this line is in let currentColumn = ""; for (let j = i; j >= 0; j--) { if (oldLines[j].startsWith("## ")) { currentColumn = oldLines[j].substring(3).trim(); break; } } // Check for unwanted normalization const oldMatch = oldLine.match(/^(\s*- )\[([^\]]*)\]/); const newMatch = newLine.match(/^(\s*- )\[([^\]]*)\]/); if (oldMatch && newMatch && currentColumn) { const oldState = oldMatch[2]; const newState = newMatch[2]; // Get expected state for this column const expectedState = this.getCheckboxStateForColumn(currentColumn); const expectedStateChar = expectedState.replace(/[\[\]]/g, ""); // Check if this change is part of a legitimate movement const isLegitimateMovement = knownMovements.some( (movement) => movement.newColumn.toLowerCase() === currentColumn.toLowerCase() ); // Detect unwanted conversion: custom state → [x] when it should be something else // BUT only if it's not part of a legitimate movement if ( oldState === expectedStateChar && // old state was correct for column newState === "x" && // new state is [x] expectedStateChar !== "x" && // but column shouldn't be [x] !isLegitimateMovement ) { // and it's not a legitimate movement if (this.settings.showDebugInfo) { console.log( `Detected unwanted normalization in column "${currentColumn}": [${oldState}] → [${newState}] (expected: ${expectedState})` ); } return true; } } } return false; } /** * NEW: Analyze checkbox normalization patterns */ private analyzeCheckboxNormalizationPatterns( oldContent: string, newContent: string ): { hasNormalization: boolean; normalizedStates: Array<{ line: number; from: string; to: string }>; } { const oldLines = oldContent.split("\n"); const newLines = newContent.split("\n"); const normalizedStates: Array<{ line: number; from: string; to: string; }> = []; for (let i = 0; i < Math.min(oldLines.length, newLines.length); i++) { const oldLine = oldLines[i]; const newLine = newLines[i]; // Check if this line had a custom checkbox state that got normalized const oldCheckboxMatch = oldLine.match(/^(\s*- )\[([^\]]*)\]/); const newCheckboxMatch = newLine.match(/^(\s*- )\[([^\]]*)\]/); if (oldCheckboxMatch && newCheckboxMatch) { const oldState = oldCheckboxMatch[2]; const newState = newCheckboxMatch[2]; // Detect normalization: custom state → standard state if ( oldState !== newState && oldState !== " " && oldState !== "x" && // old was custom (newState === "x" || newState === " ") ) { // new is standard normalizedStates.push({ line: i, from: `[${oldState}]`, to: `[${newState}]`, }); } } } return { hasNormalization: normalizedStates.length > 0, normalizedStates, }; } /** * NEW: Protect custom checkbox states from unwanted normalization * Restores custom states while preserving legitimate changes */ private async protectCustomCheckboxStates( kanbanFile: TFile, oldContent: string, newContent: string ): Promise { try { const filePath = kanbanFile.path; // Analyze what was normalized const analysis = this.analyzeCheckboxNormalizationPatterns( oldContent, newContent ); if (!analysis.hasNormalization) { if (this.settings.showDebugInfo) { console.log( "No normalization detected, no protection needed" ); } return; } // Create protected content by restoring custom states const protectedContent = this.restoreCustomCheckboxStates( oldContent, newContent, analysis.normalizedStates, kanbanFile ); if (protectedContent !== newContent) { if (this.settings.showDebugInfo) { console.log( `Protecting ${analysis.normalizedStates.length} custom checkbox states from normalization` ); analysis.normalizedStates.forEach((state) => { console.log( ` Line ${state.line}: ${state.from} → ${state.to} (restoring ${state.from})` ); }); } // Set flag to prevent infinite loops this.isUpdatingFromKanban = true; // Apply protection await this.app.vault.modify(kanbanFile, protectedContent); // Update stored content this.lastKanbanContent.set(filePath, protectedContent); // Reset flag after delay setTimeout(() => { this.isUpdatingFromKanban = false; }, 300); if (this.settings.showDebugInfo) { console.log( `Successfully protected custom checkbox states in: ${kanbanFile.basename}` ); } } } catch (error) { console.error("Error protecting custom checkbox states:", error); if (this.settings.showDebugInfo) { console.error("Protection error details:", error); } } } /** * NEW: Selective protection that only protects non-moved cards from unwanted normalization * Allows legitimate checkbox state changes for moved cards while protecting others */ private async protectCustomCheckboxStatesSelective( kanbanFile: TFile, oldContent: string, newContent: string, knownMovements: Array<{ card: string; oldColumn: string; newColumn: string; cardIndex: number; }> ): Promise { try { // Analyze what was normalized const analysis = this.analyzeCheckboxNormalizationPatterns( oldContent, newContent ); if (!analysis.hasNormalization) { if (this.settings.showDebugInfo) { console.log( "No normalization detected, no selective protection needed" ); } return newContent; } // Filter out normalizations that correspond to legitimate movements const legitimateNormalizations = this.filterLegitimateNormalizations( analysis.normalizedStates, knownMovements, newContent, kanbanFile ); // Only protect states that are NOT part of legitimate movements const statesNeedingProtection = analysis.normalizedStates.filter( (state: { line: number; from: string; to: string }) => !legitimateNormalizations.includes(state) ); if (statesNeedingProtection.length === 0) { if (this.settings.showDebugInfo) { console.log( "All normalizations are legitimate movements, no protection needed" ); } return newContent; } // Create protected content by restoring only non-legitimate changes const protectedContent = this.restoreCustomCheckboxStates( oldContent, newContent, statesNeedingProtection, kanbanFile ); if (this.settings.showDebugInfo) { console.log( `Selectively protecting ${statesNeedingProtection.length} out of ${analysis.normalizedStates.length} normalized states` ); console.log( `Allowing ${legitimateNormalizations.length} legitimate state changes from card movements` ); statesNeedingProtection.forEach( (state: { line: number; from: string; to: string }) => { console.log( ` Protecting line ${state.line}: ${state.from} → ${state.to} (restoring ${state.from})` ); } ); legitimateNormalizations.forEach( (state: { line: number; from: string; to: string }) => { console.log( ` Allowing line ${state.line}: ${state.from} → ${state.to} (legitimate movement)` ); } ); } return protectedContent; } catch (error) { console.error("Error in selective protection:", error); if (this.settings.showDebugInfo) { console.error("Selective protection error details:", error); } return newContent; // Return original content if there's an error } } /** * NEW: Restore custom checkbox states while preserving other changes */ private restoreCustomCheckboxStates( oldContent: string, newContent: string, normalizedStates: Array<{ line: number; from: string; to: string }>, kanbanFile: TFile ): string { const lines = newContent.split("\n"); let restoredCount = 0; for (const normalizedState of normalizedStates) { const lineIndex = normalizedState.line; if (lineIndex < lines.length) { const line = lines[lineIndex]; // Only restore if the column mapping supports this custom state const columnName = this.findLineColumn(line, lines, lineIndex); if (columnName) { const expectedState = this.getCheckboxStateForColumn(columnName); // If the old state matches what we expect for this column, restore it if (normalizedState.from === expectedState) { lines[lineIndex] = line.replace( normalizedState.to, normalizedState.from ); restoredCount++; if (this.settings.showDebugInfo) { console.log( `Restored line ${lineIndex}: ${normalizedState.to} → ${normalizedState.from} for column "${columnName}"` ); } } } } } if (this.settings.showDebugInfo) { console.log( `Restored ${restoredCount} out of ${normalizedStates.length} normalized states` ); } return lines.join("\n"); } /** * NEW: Filter out normalizations that correspond to legitimate card movements */ private filterLegitimateNormalizations( normalizedStates: Array<{ line: number; from: string; to: string }>, knownMovements: Array<{ card: string; oldColumn: string; newColumn: string; cardIndex: number; }>, newContent: string, kanbanFile: TFile ): Array<{ line: number; from: string; to: string }> { const legitimateNormalizations: Array<{ line: number; from: string; to: string; }> = []; const lines = newContent.split("\n"); for (const normalizedState of normalizedStates) { const lineIndex = normalizedState.line; if (lineIndex < lines.length) { const line = lines[lineIndex]; // Find which column this line is in const columnName = this.findLineColumn(line, lines, lineIndex); if (columnName) { // Check if there's a known movement to this column const hasMovementToColumn = knownMovements.some( (movement) => movement.newColumn.toLowerCase() === columnName.toLowerCase() ); if (hasMovementToColumn) { // Get expected state for this column const expectedState = this.getCheckboxStateForColumn(columnName); // If the normalization results in the expected state for this column, it's legitimate if (normalizedState.to === expectedState) { legitimateNormalizations.push(normalizedState); if (this.settings.showDebugInfo) { console.log( `Legitimate normalization at line ${lineIndex}: ${normalizedState.from} → ${normalizedState.to} for column "${columnName}"` ); } } } } } } return legitimateNormalizations; } /** * NEW: Check if card movements would cause unwanted normalization */ private checkMovementsForUnwantedNormalization( cardMovements: Array<{ card: string; oldColumn: string; newColumn: string; }>, newContent: string, kanbanFile: TFile ): boolean { for (const movement of cardMovements) { const { newColumn } = movement; // Get expected checkbox state for target column const expectedState = this.getCheckboxStateForColumn(newColumn); const expectedStateChar = expectedState.replace(/[\[\]]/g, ""); // Check if the current content already has cards with correct states for this column const lines = newContent.split("\n"); let inTargetColumn = false; let currentColumn = ""; for (const line of lines) { if (line.startsWith("## ")) { currentColumn = line.substring(3).trim(); inTargetColumn = currentColumn === newColumn; continue; } if (inTargetColumn && line.trim().startsWith("- [")) { const match = line.match(/^(\s*- )\[([^\]]*)\]/); if (match) { const currentState = match[2]; // If we find cards in target column that already have the expected state, // and they would be changed to [x], this is unwanted normalization if ( currentState === expectedStateChar && expectedStateChar !== "x" ) { if (this.settings.showDebugInfo) { console.log( `Movement to column "${newColumn}" would cause unwanted normalization: [${currentState}] → [x] (expected: ${expectedState})` ); } return true; } } } } } return false; } /** * NEW: Find which column a line belongs to */ private findLineColumn( line: string, allLines: string[], lineIndex: number ): string | null { // Search backwards from current line to find the column header for (let i = lineIndex; i >= 0; i--) { const currentLine = allLines[i]; if (currentLine.startsWith("## ")) { return currentLine.substring(3).trim(); } } return null; } /** * NEW: Detect immediate Kanban normalization (real-time detection) * Enhanced to detect multiple-line normalization patterns */ private detectImmediateKanbanNormalization( oldContent: string, newContent: string ): boolean { const oldLines = oldContent.split("\n"); const newLines = newContent.split("\n"); let normalizationCount = 0; let customToXConversions = 0; // Look for lines where custom checkbox states were converted to [x] for (let i = 0; i < Math.min(oldLines.length, newLines.length); i++) { const oldLine = oldLines[i]; const newLine = newLines[i]; const oldMatch = oldLine.match(/^(\s*- )\[([^\]]*)\]/); const newMatch = newLine.match(/^(\s*- )\[([^\]]*)\]/); if (oldMatch && newMatch) { const oldState = oldMatch[2]; const newState = newMatch[2]; // Count any checkbox state changes if (oldState !== newState) { normalizationCount++; // Specifically track custom state → [x] conversions if ( oldState !== " " && oldState !== "x" && newState === "x" ) { customToXConversions++; if (this.settings.showDebugInfo) { console.log( `Immediate normalization detected: [${oldState}] → [${newState}] on line ${i}` ); } } } } } // Enhanced detection criteria: // 1. Multiple custom states converted to [x] (strong indicator of Kanban normalization) // 2. High ratio of custom→[x] conversions vs total changes const hasMultipleCustomToX = customToXConversions >= 2; const hasHighCustomToXRatio = normalizationCount > 0 && customToXConversions / normalizationCount >= 0.5; if (this.settings.showDebugInfo) { console.log( `Enhanced normalization detection: ${customToXConversions} custom→[x] out of ${normalizationCount} total changes` ); console.log( `hasMultipleCustomToX: ${hasMultipleCustomToX}, hasHighCustomToXRatio: ${hasHighCustomToXRatio}` ); } return hasMultipleCustomToX || hasHighCustomToXRatio; } /** * NEW: Revert Kanban normalization by restoring custom states * Enhanced to handle multiple normalizations and better column detection */ private revertKanbanNormalization( oldContent: string, newContent: string, kanbanFile: TFile ): string { const oldLines = oldContent.split("\n"); const newLines = newContent.split("\n"); const revertedLines = [...newLines]; let revertCount = 0; let totalNormalizations = 0; // Find and revert unwanted normalizations for (let i = 0; i < Math.min(oldLines.length, newLines.length); i++) { const oldLine = oldLines[i]; const newLine = newLines[i]; const oldMatch = oldLine.match(/^(\s*- )\[([^\]]*)\]/); const newMatch = newLine.match(/^(\s*- )\[([^\]]*)\]/); if (oldMatch && newMatch) { const oldState = oldMatch[2]; const newState = newMatch[2]; // Check if this is any normalization (not just custom → [x]) if (oldState !== newState) { totalNormalizations++; // Find which column this line is in let currentColumn = ""; for (let j = i; j >= 0; j--) { if (newLines[j].startsWith("## ")) { // Use newLines to get current column structure currentColumn = newLines[j].substring(3).trim(); break; } } if (currentColumn) { // Get expected state for this column const expectedState = this.getCheckboxStateForColumn(currentColumn); const expectedStateChar = expectedState.replace( /[\[\]]/g, "" ); // Revert if: // 1. Old state was correct for this column AND // 2. New state is wrong for this column if ( oldState === expectedStateChar && newState !== expectedStateChar ) { // CRITICAL FIX: Only change the checkbox state, preserve the card content const newPrefix = newMatch[1]; // "- " part const newSuffix = newLine.substring( newMatch[0].length ); // Everything after checkbox revertedLines[ i ] = `${newPrefix}[${oldState}]${newSuffix}`; revertCount++; if (this.settings.showDebugInfo) { console.log( `Reverted line ${i} in column "${currentColumn}": [${newState}] → [${oldState}] (preserving content)` ); } } else if (this.settings.showDebugInfo) { console.log( `Line ${i} in column "${currentColumn}": [${oldState}] → [${newState}] (not reverting - expected: [${expectedStateChar}])` ); } } } } } if (this.settings.showDebugInfo) { console.log( `Reverted ${revertCount} out of ${totalNormalizations} normalizations` ); } return revertedLines.join("\n"); } /** * Proactively sync all checkbox states in Kanban board to match column mappings * This ensures all cards have the correct checkbox state for their column */ private async syncAllCheckboxStatesToMappings( kanbanFile: TFile, content: string ): Promise { try { if (this.settings.showDebugInfo) { console.log( `Syncing all checkbox states to mappings for: ${kanbanFile.path}` ); } // Parse Kanban board structure const kanban = await this.parseKanbanBoardContent( content, kanbanFile ); if (!kanban || Object.keys(kanban).length === 0) { if (this.settings.showDebugInfo) { console.log( `Could not parse Kanban board structure, returning original content` ); } return content; } // Split content into lines for position-based replacement const lines = content.split("\n"); let totalChanges = 0; // Process each column and sync all cards to have correct checkbox states for (const [columnName, columnData] of Object.entries(kanban)) { const targetCheckboxState = this.getCheckboxStateForColumn(columnName); if (this.settings.showDebugInfo) { console.log( `Syncing column "${columnName}" to checkbox state "${targetCheckboxState}" (${columnData.items.length} cards)` ); } // Update each card in this column using position-based replacement for (const item of columnData.items) { // Check if the card already has the correct checkbox state const currentCheckboxMatch = item.text.match(/^(\s*- )\[([^\]]*)\]/); const currentCheckboxState = currentCheckboxMatch ? `[${currentCheckboxMatch[2]}]` : null; // Only update if the current state is different from target state if (currentCheckboxState !== targetCheckboxState) { const updatedCardText = this.updateCheckboxStateInCardText( item.text, targetCheckboxState ); if (updatedCardText !== item.text) { const cardPosition = this.findCardPositionInContent( item.text, lines, columnName ); if (cardPosition !== -1) { // Replace only the specific card at the found position const cardLines = item.text.split("\n"); const updatedCardLines = updatedCardText.split("\n"); // Replace the card lines at the specific position lines.splice( cardPosition, cardLines.length, ...updatedCardLines ); totalChanges++; if (this.settings.showDebugInfo) { console.log( ` Synced card at position ${cardPosition}: ${item.text.substring( 0, 30 )}... → ${targetCheckboxState} (was ${currentCheckboxState})` ); } } } } else { if (this.settings.showDebugInfo) { console.log( ` Skipping card (already has correct state ${targetCheckboxState}): ${item.text.substring( 0, 30 )}...` ); } } } } if (this.settings.showDebugInfo) { console.log( `Sync complete: ${totalChanges} cards updated to match column mappings` ); } return lines.join("\n"); } catch (error) { console.error("Error syncing checkbox states to mappings:", error); if (this.settings.showDebugInfo) { console.error("Error details:", error); } return content; // Return original content if there's an error } } /** * Auto-sync all checkbox states in a Kanban board to match column mappings * Runs once per file per session for performance optimization * Uses position-based replacement to avoid conflicts */ private async autoSyncKanbanCheckboxStates( kanbanFile: TFile ): Promise { try { // Check if we're already updating to prevent conflicts if (this.isUpdatingFromKanban) { if (this.settings.showDebugInfo) { console.log( `Auto-sync skipped - update already in progress for: ${kanbanFile.path}` ); } return; } if (this.settings.showDebugInfo) { console.log( `Starting auto-sync for Kanban board: ${kanbanFile.path}` ); } // Set flag to prevent conflicts this.isUpdatingFromKanban = true; // Mark this file as auto-synced to prevent repeat runs this.autoSyncedFiles.add(kanbanFile.path); // Update timestamp to track when auto-sync ran this.lastFileUpdateMap.set(kanbanFile.path, Date.now()); // Store initial content for comparison const initialContent = await this.app.vault.read(kanbanFile); this.lastKanbanContent.set(kanbanFile.path, initialContent); // Read current file content const content = await this.app.vault.read(kanbanFile); // Parse Kanban board structure const kanban = await this.parseKanbanBoardContent( content, kanbanFile ); // Split content into lines for position-based replacement const lines = content.split("\n"); let totalChanges = 0; // Process each column and update all cards to have correct checkbox states for (const [columnName, columnData] of Object.entries(kanban)) { const targetCheckboxState = this.getCheckboxStateForColumn(columnName); if (this.settings.showDebugInfo) { console.log( `Auto-syncing column "${columnName}" to checkbox state "${targetCheckboxState}" (${columnData.items.length} cards)` ); } // Update each card in this column using position-based replacement for (const item of columnData.items) { // Check if the card already has the correct checkbox state const currentCheckboxMatch = item.text.match(/^(\s*- )\[([^\]]*)\]/); const currentCheckboxState = currentCheckboxMatch ? `[${currentCheckboxMatch[2]}]` : null; // Only update if the current state is different from target state if (currentCheckboxState !== targetCheckboxState) { const updatedCardText = this.updateCheckboxStateInCardText( item.text, targetCheckboxState ); if (updatedCardText !== item.text) { const cardPosition = this.findCardPositionInContent( item.text, lines, columnName ); if (cardPosition !== -1) { // Replace only the specific card at the found position const cardLines = item.text.split("\n"); const updatedCardLines = updatedCardText.split("\n"); // Replace the card lines at the specific position lines.splice( cardPosition, cardLines.length, ...updatedCardLines ); totalChanges++; if (this.settings.showDebugInfo) { console.log( ` Updated card at position ${cardPosition}: ${item.text.substring( 0, 30 )}... → ${targetCheckboxState} (was ${currentCheckboxState})` ); } } } } else { if (this.settings.showDebugInfo) { console.log( ` Skipping card (already has correct state ${targetCheckboxState}): ${item.text.substring( 0, 30 )}...` ); } } } } // Update the file if changes were made if (totalChanges > 0) { const updatedContent = lines.join("\n"); await this.app.vault.modify(kanbanFile, updatedContent); // CRITICAL: Update lastKanbanContent to prevent conflicts with card movement detection this.lastKanbanContent.set(kanbanFile.path, updatedContent); if (this.settings.showDebugInfo) { console.log( `Auto-sync complete: ${totalChanges} cards updated in ${kanbanFile.basename}` ); console.log( `Updated lastKanbanContent for ${kanbanFile.path} to prevent conflicts` ); } // Show notification to user new Notice( `Auto-synced ${totalChanges} card checkbox states in ${kanbanFile.basename}` ); } else { if (this.settings.showDebugInfo) { console.log( `Auto-sync complete: No changes needed for ${kanbanFile.basename}` ); } } } catch (error) { console.error("Error in auto-sync Kanban checkbox states:", error); if (this.settings.showDebugInfo) { console.error("Error details:", error); } // Remove from auto-synced set so it can be retried this.autoSyncedFiles.delete(kanbanFile.path); } finally { // Always reset flag after operation completes setTimeout(() => { this.isUpdatingFromKanban = false; if (this.settings.showDebugInfo) { console.log(`Auto-sync flag reset for: ${kanbanFile.path}`); } }, 200); // Slightly longer delay to ensure all operations complete } } /** * Force refresh Kanban UI after modifying file content * Tries multiple approaches to ensure UI reflects the updated checkbox states */ private async forceRefreshKanbanUI(kanbanFile: TFile): Promise { try { if (this.settings.showDebugInfo) { console.log( `Attempting to force refresh Kanban UI for: ${kanbanFile.path}` ); } // Method 1: Skip MetadataCache trigger to avoid conflicts // this.app.metadataCache.trigger("changed", kanbanFile); // Can cause errors // Method 2: If this is the currently active file, try to refresh the view const activeFile = this.app.workspace.getActiveFile(); if (activeFile && activeFile.path === kanbanFile.path) { // Try to refresh the active view const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); if (activeView) { // Force re-render by triggering a view update setTimeout(() => { activeView.requestSave(); }, 100); } } // Method 3: Trigger workspace layout change to force refresh setTimeout(() => { this.app.workspace.trigger("layout-change"); }, 150); // Method 4: If file is open in a leaf, try to refresh that leaf const leaves = this.app.workspace.getLeavesOfType("markdown"); for (const leaf of leaves) { const view = leaf.view as MarkdownView; if (view.file && view.file.path === kanbanFile.path) { setTimeout(() => { // Force view to re-read file content view.load(); }, 200); break; } } if (this.settings.showDebugInfo) { console.log( `Force refresh attempts completed for: ${kanbanFile.path}` ); } } catch (error) { if (this.settings.showDebugInfo) { console.error("Error forcing Kanban UI refresh:", error); } } } } class TaskProgressBarView extends ItemView { plugin: TaskProgressBarPlugin; currentFile: TFile | null = null; isVisible: boolean = false; lastUpdateTime: number = 0; lastFileUpdateMap: Map = new Map(); // Track last update time per file initialLoadComplete: boolean = false; // Track if initial load is complete completedFilesMap: Map = new Map(); // Track which files have been marked as completed constructor(leaf: WorkspaceLeaf, plugin: TaskProgressBarPlugin) { super(leaf); this.plugin = plugin; } getViewType(): string { return "progress-tracker"; } getDisplayText(): string { return "Task progress bar"; } getIcon(): string { return "bar-chart-horizontal"; } async onOpen() { this.isVisible = true; this.initialLoadComplete = false; // Add custom class to parent workspace-leaf (using multiple methods for reliability) const leaf = this.leaf as any; if (leaf && leaf.containerEl) { leaf.containerEl.addClass("progress-tracker-leaf"); } // Also try to add class to parent element directly const parentLeaf = this.containerEl.closest(".workspace-leaf"); if (parentLeaf) { parentLeaf.addClass("progress-tracker-leaf"); } const container = this.containerEl.children[1]; container.empty(); // Create progress container const progressContainer = container.createDiv({ cls: "task-progress-container", }); // Add loading indicator that we'll keep until the first real data loads progressContainer.createEl("p", { text: "Loading task progress...", cls: "loading-indicator", }); // Trigger initial update after a short delay to ensure workspace is ready setTimeout(async () => { const currentFile = this.plugin.app.workspace.getActiveFile(); if (currentFile) { await this.updateProgressBar(currentFile, undefined, true); } }, 200); } async updateProgressBar( file: TFile | null, content?: string, isInitialLoad: boolean = false ) { if (!file) return; // Track the update time for this file this.lastFileUpdateMap.set(file.path, Date.now()); // Avoid updating too quickly const now = Date.now(); if (!isInitialLoad && now - this.lastUpdateTime < 100) return; this.lastUpdateTime = now; this.currentFile = file; const container = this.containerEl.children[1]; const progressContainer = container.querySelector( ".task-progress-container" ) as HTMLElement; if (!progressContainer) return; // Clear the container once at the start if this is initial load // This prevents showing the loading indicator and then progress bar if (isInitialLoad || !this.initialLoadComplete) { progressContainer.empty(); this.initialLoadComplete = true; } // Add class to show animation only when showUpdateAnimation setting is enabled if (this.plugin.settings.showUpdateAnimation) { progressContainer.classList.add("updating"); } try { // Update immediately if content is provided if (content) { if (!this.hasTasksInContentExtended(content)) { // Only clear if not already showing "no tasks" message if (!progressContainer.querySelector(".no-tasks-message")) { progressContainer.empty(); progressContainer.createEl("p", { text: "No tasks found in this file", cls: "no-tasks-message", }); } } else { // Create/update progress bar with content this.createProgressBarFromString( progressContainer, content, file ); } } else { // Read file content const fileContent = await this.plugin.app.vault.read(file); if (!this.hasTasksInContentExtended(fileContent)) { // Only clear if not already showing "no tasks" message if (!progressContainer.querySelector(".no-tasks-message")) { progressContainer.empty(); progressContainer.createEl("p", { text: "No tasks found in this file", cls: "no-tasks-message", }); } if (this.plugin.settings.showDebugInfo) { console.log("No tasks found in file:", file.path); } } else { // Create/update progress bar with content this.createProgressBarFromString( progressContainer, fileContent, file ); } } } catch (error) { console.error("Error updating progress bar:", error); progressContainer.empty(); progressContainer.createEl("p", { text: `Error updating progress bar: ${error.message}`, }); } finally { // Remove class after update completes, only if animation is enabled if (this.plugin.settings.showUpdateAnimation) { setTimeout(() => { progressContainer.classList.remove("updating"); }, this.plugin.settings.updateAnimationDelay); // Use configurable delay } } } // Helper method to quickly check if content has tasks hasTasksInContentExtended(content: string): boolean { // Extended pattern to match custom checkbox states const extendedTaskRegex = /- \[[^\]]*\]/i; return extendedTaskRegex.test(content); } // Method to create progress bar from string content async createProgressBarFromString( container: HTMLElement, content: string, file: TFile ) { try { // Log for debugging if enabled if (this.plugin.settings.showDebugInfo) { console.log(`Creating progress bar for file: ${file.path}`); console.log(`Content length: ${content.length}`); } // Get Dataview API - only check for warning display const dvAPI = this.plugin.dvAPI; if (!dvAPI) { // Only clear and create warning if not already showing dataview warning if (!container.querySelector(".dataview-warning-compact")) { container.empty(); const dataviewWarning = container.createDiv({ cls: "dataview-warning-compact", }); dataviewWarning.createEl("span", { text: "Dataview not available", cls: "dataview-warning-text", }); } return; } // Count tasks with support for custom checkbox states let incompleteTasks = 0; let completedTasks = 0; let customStateTasks = 0; if (this.plugin.settings.enableCustomCheckboxStates) { // When custom checkbox states are enabled, use enhanced counting const taskCounts = this.countTasksByCheckboxState(content); // Count standard states incompleteTasks = taskCounts[" "] || 0; // [ ] completedTasks = taskCounts["x"] || 0; // [x] // Count all custom states as tasks in progress for (const [state, count] of Object.entries(taskCounts)) { if (state !== " " && state !== "x" && state.trim() !== "") { customStateTasks += count; } } if (this.plugin.settings.showDebugInfo) { console.log("Custom checkbox state counts:", taskCounts); console.log( `Incomplete: ${incompleteTasks}, Completed: ${completedTasks}, Custom states: ${customStateTasks}` ); } } else { // Legacy counting - only [ ] and [x] incompleteTasks = (content.match(/- \[ \]/g) || []).length; completedTasks = (content.match(/- \[x\]/gi) || []).length; } let totalTasks = incompleteTasks + completedTasks + customStateTasks; // Try with relaxed regex if needed (for legacy compatibility) let relaxedIncompleteTasks = 0; let relaxedCompletedTasks = 0; if (totalTasks === 0) { relaxedIncompleteTasks = (content.match(/[-*] \[ \]/g) || []) .length; relaxedCompletedTasks = (content.match(/[-*] \[x\]/gi) || []) .length; totalTasks = relaxedIncompleteTasks + relaxedCompletedTasks; } // Log task counts for debugging if (this.plugin.settings.showDebugInfo) { console.log( `Task counts - incomplete: ${incompleteTasks}, completed: ${completedTasks}, custom states: ${customStateTasks}, total: ${totalTasks}` ); if (relaxedIncompleteTasks > 0 || relaxedCompletedTasks > 0) { console.log( `Using relaxed regex - found tasks: ${ relaxedIncompleteTasks + relaxedCompletedTasks }` ); } } if (totalTasks === 0) { // No tasks found, show message - but only if not already showing the message if (!container.querySelector(".no-tasks-message")) { container.empty(); container.createEl("p", { text: "No tasks found in this file", cls: "no-tasks-message", }); } return; } // Calculate percentage based on which regex found tasks let completedCount = incompleteTasks > 0 || completedTasks > 0 || customStateTasks > 0 ? completedTasks : relaxedCompletedTasks; const percentage = Math.round((completedCount / totalTasks) * 100); // Update UI first for better responsiveness this.updateProgressBarUI( container, percentage, completedCount, totalTasks ); // Then process status and Kanban updates asynchronously this.processStatusAndKanbanUpdates( file, percentage, completedCount, totalTasks ); } catch (error) { console.error("Error creating progress bar from string:", error); container.empty(); container.createEl("p", { text: `Error creating progress bar: ${error.message}`, }); } } /** * Update UI elements first for better responsiveness, then process status and Kanban updates asynchronously */ private updateProgressBarUI( container: HTMLElement, percentage: number, completedCount: number, totalTasks: number ) { // Check if we already have progress elements let progressLayout = container.querySelector( ".progress-layout" ) as HTMLElement; let statsContainer = container.querySelector( ".progress-stats-compact" ) as HTMLElement; // If no existing elements, create container structure if (!progressLayout || !statsContainer) { container.empty(); // Create a more compact layout progressLayout = container.createDiv({ cls: "progress-layout", }); // Create percentage text element progressLayout.createEl("div", { cls: "progress-percentage-small", }); // Create HTML5-like progress bar const progressBarContainer = progressLayout.createDiv({ cls: "pt-progress-bar-container", }); // Create the outer progress element const progressElement = progressBarContainer.createDiv({ cls: "progress-element", }); // Create the inner value element that will be animated progressElement.createDiv({ cls: "progress-value", }); // Create stats container at the bottom statsContainer = container.createDiv({ cls: "progress-stats-compact", }); } // Update percentage text const percentageElement = progressLayout.querySelector( ".progress-percentage-small" ) as HTMLElement; if (percentageElement) { percentageElement.setText(`${percentage}%`); } // Update progress bar width with smooth transition const progressValue = container.querySelector( ".progress-value" ) as HTMLElement; if (progressValue) { // Add transition style if not already present if (!progressValue.hasAttribute("data-has-transition")) { progressValue.style.transition = "width 0.3s ease-in-out, background-color 0.3s ease"; progressValue.setAttribute("data-has-transition", "true"); } progressValue.style.width = `${percentage}%`; this.applyProgressColor(progressValue, percentage); } // Update progress element data attribute const progressElement = container.querySelector( ".progress-element" ) as HTMLElement; if (progressElement) { progressElement.setAttribute( "data-percentage", percentage.toString() ); } // Update stats text if (statsContainer) { statsContainer.empty(); statsContainer.createSpan({ text: `${completedCount}/${totalTasks} tasks`, }); } // Update debug info if needed if (this.plugin.settings.showDebugInfo) { let debugInfo = container.querySelector( ".debug-info" ) as HTMLElement; if (!debugInfo) { debugInfo = container.createDiv({ cls: "debug-info" }); } else { debugInfo.empty(); } debugInfo.createEl("p", { text: `Debug info:` }); debugInfo.createEl("p", { text: `File: ${this.currentFile?.path}`, }); debugInfo.createEl("p", { text: `Incomplete tasks: ${totalTasks - completedCount}`, }); debugInfo.createEl("p", { text: `Completed tasks: ${completedCount}`, }); debugInfo.createEl("p", { text: `Total tasks: ${totalTasks}` }); debugInfo.createEl("p", { text: `Percentage: ${percentage}%` }); debugInfo.createEl("p", { text: `Update time: ${new Date().toISOString()}`, }); debugInfo.createEl("p", { text: `Color scheme: ${this.plugin.settings.progressColorScheme}`, }); } else { // Remove debug info if it exists but debug is disabled const debugInfo = container.querySelector(".debug-info"); if (debugInfo) debugInfo.remove(); } } /** * Process status and Kanban updates asynchronously */ private async processStatusAndKanbanUpdates( file: TFile, percentage: number, completedCount: number, totalTasks: number ) { // Use setTimeout to process these updates in the next tick setTimeout(async () => { try { // Update status based on progress percentage let statusChanged = false; if (this.plugin.settings.autoChangeStatus) { statusChanged = await this.updateStatusBasedOnProgress( file, percentage ); } // Update Kanban boards based on task progress if ( this.plugin.settings.autoUpdateKanban && (statusChanged || !this.completedFilesMap.has(file.path)) ) { await this.updateKanbanBoards( file, completedCount, totalTasks ); } // Additional special handling for 100% completion if ( percentage === 100 && this.plugin.settings.autoUpdateMetadata ) { // Only update metadata and show notification if this file hasn't been marked as completed yet if (!this.completedFilesMap.has(file.path)) { await this.updateFileMetadata(file); // Mark this file as completed to avoid repeated updates this.completedFilesMap.set(file.path, true); } } else if (percentage < 100) { // If percentage is less than 100%, remove from completed files map if (this.completedFilesMap.has(file.path)) { this.completedFilesMap.delete(file.path); } } // Auto-add to Kanban board if enabled and file has tasks if ( this.plugin.settings.autoAddToKanban && this.plugin.settings.autoAddKanbanBoard && totalTasks > 0 && !this.completedFilesMap.has(file.path) ) { await this.addFileToKanbanBoard(file); } } catch (error) { console.error("Error in status and Kanban updates:", error); if (this.plugin.settings.showDebugInfo) { console.error("Error details:", error); } } }, 0); } // Method to apply color based on percentage and settings - keep logic but add smooth transition applyProgressColor(progressElement: HTMLElement, percentage: number) { const settings = this.plugin.settings; // If using default color scheme, let CSS handle it if (settings.progressColorScheme === "default") { // Remove any previously set inline colors progressElement.style.backgroundColor = ""; return; } // Apply custom color based on percentage let newColor = ""; if (percentage === 100) { // Complete - green newColor = settings.completeProgressColor; } else if (percentage >= settings.mediumProgressThreshold) { // High progress (66-99%) - blue newColor = settings.highProgressColor; } else if (percentage >= settings.lowProgressThreshold) { // Medium progress (34-65%) - orange/yellow newColor = settings.mediumProgressColor; } else { // Low progress (0-33%) - red newColor = settings.lowProgressColor; } // Only update if color has changed if (progressElement.style.backgroundColor !== newColor) { progressElement.style.backgroundColor = newColor; } // Add debug log if needed if (this.plugin.settings.showDebugInfo) { console.log(`Applied color for ${percentage}%: Color scheme: ${settings.progressColorScheme}, Low threshold: ${settings.lowProgressThreshold}%, Medium threshold: ${settings.mediumProgressThreshold}%, High threshold: ${settings.highProgressThreshold}%, Applied color: ${newColor}`); } } /** * Clear the completed files cache * Used to reset notifications for completed files */ clearCompletedFilesCache() { this.completedFilesMap.clear(); if (this.plugin.settings.showDebugInfo) { console.log("Cleared completed files cache"); } } async updateStatusBasedOnProgress( file: TFile, progressPercentage: number ): Promise { if (!file || !this.plugin.settings.autoChangeStatus) return false; try { let needsUpdate = false; // Determine target status based on progress percentage let targetStatus = this.plugin.settings.statusInProgress; if (progressPercentage === 0) { targetStatus = this.plugin.settings.statusTodo; } else if (progressPercentage === 100) { targetStatus = this.plugin.settings.statusCompleted; } // Use processFrontMatter API to update frontmatter await this.app.fileManager.processFrontMatter( file, (frontmatter) => { // Check current status const currentStatus = frontmatter["status"]; // Update if status is different if (currentStatus !== targetStatus) { frontmatter["status"] = targetStatus; needsUpdate = true; } // Remove finished date if progress is less than 100% if ( progressPercentage < 100 && this.plugin.settings.autoUpdateFinishedDate ) { if (frontmatter["finished"]) { delete frontmatter["finished"]; needsUpdate = true; } } } ); if (needsUpdate && this.plugin.settings.showDebugInfo) { console.log( `Updated status to "${targetStatus}" based on progress ${progressPercentage}% for file:`, file.path ); } return needsUpdate; } catch (error) { console.error("Error updating status based on progress:", error); return false; } } // New method to update file metadata when tasks are completed async updateFileMetadata(file: TFile) { try { // use processFrontMatter API to update metadata await this.app.fileManager.processFrontMatter( file, (frontmatter) => { let needsUpdate = false; const today = new Date().toISOString().split("T")[0]; // Format: YYYY-MM-DD // update status if it enabled if (this.plugin.settings.autoChangeStatus) { const targetStatus = this.plugin.settings.statusCompleted; if (frontmatter["status"] !== targetStatus) { frontmatter["status"] = targetStatus; needsUpdate = true; if (this.plugin.settings.showDebugInfo) { console.log( `Updating status to ${targetStatus} in file:`, file.path ); } } } // update finished date if enabled if (this.plugin.settings.autoUpdateFinishedDate) { if (frontmatter["finished"] !== today) { frontmatter["finished"] = today; needsUpdate = true; if (this.plugin.settings.showDebugInfo) { console.log( `Updating finished date to ${today} in file:`, file.path ); } } } return needsUpdate; } ); } catch (error) { console.error("Error updating file metadata:", error); if (this.plugin.settings.showDebugInfo) { new Notice( `Error updating metadata for ${file.basename}: ${error.message}` ); } } } /** * Handle Kanban board integration with files * Updates the position of a note card in Kanban boards when tasks are completed or status changes */ async updateKanbanBoards( file: TFile, completedTasks: number, totalTasks: number ) { try { // Only proceed if Kanban integration is enabled if (!this.plugin.settings.autoUpdateKanban || totalTasks === 0) { return; } // Calculate the current status based on progress let currentStatus = this.calculateStatusFromProgress( completedTasks, totalTasks ); // Wait for MetadataCache to update await this.waitForCacheUpdate(file); // Get status from YAML frontmatter if available - more accurate let statusFromYaml = await this.getStatusFromYaml(file); if (statusFromYaml) { currentStatus = statusFromYaml; if (this.plugin.settings.showDebugInfo) { console.log( `Using status from YAML: ${currentStatus} instead of calculated status` ); } } if (this.plugin.settings.showDebugInfo) { console.log( `Searching for Kanban boards that might contain ${file.path}...` ); console.log( `Current status is: ${currentStatus} (${completedTasks}/${totalTasks} tasks)` ); } // Get and process Kanban boards const updatedBoardCount = await this.processKanbanBoards( file, currentStatus ); if (updatedBoardCount > 0) { new Notice( `Updated ${updatedBoardCount} Kanban board${ updatedBoardCount > 1 ? "s" : "" } to move ${file.basename} to ${currentStatus} column` ); } } catch (error) { console.error("Error updating Kanban boards:", error); if (this.plugin.settings.showDebugInfo) { console.error("Error details:", error); new Notice(`Error updating Kanban boards: ${error.message}`); } } } /** * Calculate status based on task progress */ private calculateStatusFromProgress( completedTasks: number, totalTasks: number ): string { if (totalTasks === 0) { return this.plugin.settings.statusTodo; } else if (completedTasks === 0) { return this.plugin.settings.statusTodo; } else if (completedTasks === totalTasks) { return this.plugin.settings.statusCompleted; } else { return this.plugin.settings.statusInProgress; } } /** * Process all Kanban boards that might reference the target file * Returns the number of boards that were updated */ private async processKanbanBoards( file: TFile, currentStatus: string ): Promise { // Skip plugin files and obvious Kanban files to avoid self-reference issues const filePath = file.path.toLowerCase(); const configDir = this.plugin.app.vault.configDir.toLowerCase(); if ( filePath.includes(`${configDir}/plugins/progress-tracker`) || filePath.includes("kanban") ) { if (this.plugin.settings.showDebugInfo) { console.log( `Skipping plugin or kanban file for kanban processing: ${file.path}` ); } return 0; } let updatedBoardCount = 0; // If there is a target board selected in settings, only process that board if (this.plugin.settings.autoAddKanbanBoard) { const targetBoard = this.plugin.app.vault.getAbstractFileByPath( this.plugin.settings.autoAddKanbanBoard ); if (targetBoard instanceof TFile) { // Skip if trying to update the target file itself if (targetBoard.path === file.path) { if (this.plugin.settings.showDebugInfo) { console.log( `Skipping target board as it's the file being updated: ${file.path}` ); } return 0; } // Read and process the target board const boardContent = await this.plugin.app.vault.read( targetBoard ); // Skip if not a Kanban board or doesn't reference our file if ( !this.isKanbanBoard(targetBoard) || !this.containsFileReference(boardContent, file) ) { if (this.plugin.settings.showDebugInfo) { console.log( `Target board ${targetBoard.path} is not a valid Kanban board or doesn't reference ${file.path}` ); } return 0; } if (this.plugin.settings.showDebugInfo) { console.log( `Processing target Kanban board: ${targetBoard.path}` ); } // Update the Kanban board const updatedContent = await this.moveCardInKanbanBoard( targetBoard, boardContent, file, currentStatus ); // If the board was updated, increment the counter if (updatedContent !== boardContent) { updatedBoardCount++; } return updatedBoardCount; } else { if (this.plugin.settings.showDebugInfo) { console.log( `Target board not found: ${this.plugin.settings.autoAddKanbanBoard}` ); } return 0; } } // If no target board is set, search all possible boards if (this.plugin.settings.showDebugInfo) { console.log( `No target board set, searching all potential Kanban boards...` ); } // Get all markdown files that might be Kanban boards const markdownFiles = this.plugin.app.vault.getMarkdownFiles(); // Check each potential Kanban board file for (const boardFile of markdownFiles) { // Skip checking the current file itself if (boardFile.path === file.path) continue; // Read the content of the potential Kanban board const boardContent = await this.plugin.app.vault.read(boardFile); // Skip if not a Kanban board or doesn't reference our file if ( !this.isKanbanBoard(boardFile) || !this.containsFileReference(boardContent, file) ) continue; if (this.plugin.settings.showDebugInfo) { console.log( `Found Kanban board "${boardFile.path}" that references "${file.path}"` ); } // Update the Kanban board by moving the card const updatedContent = await this.moveCardInKanbanBoard( boardFile, boardContent, file, currentStatus ); // If the board was updated, increment the counter if (updatedContent !== boardContent) { updatedBoardCount++; } } return updatedBoardCount; } /** * Safely escape regex special characters in a string * Used by multiple methods that need to create regex patterns */ private escapeRegExp(string: string): string { if (!string) return ""; return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** * Check if a Kanban board content contains a reference to the given file */ private containsFileReference(boardContent: string, file: TFile): boolean { // Different ways a file might be referenced in a Kanban board const filePath = file.path; const filePathWithoutExtension = filePath.replace(/\.md$/, ""); const fileName = file.basename; // First try to find exact matches in Obsidian-style links const obsidianLinks = this.extractObsidianLinks(boardContent); for (const link of obsidianLinks) { const { path, alias } = link; if ( path === fileName || path === filePathWithoutExtension || path === filePath ) { if (this.plugin.settings.showDebugInfo) { console.log( `Found exact Obsidian link match for ${fileName}: ${path}` ); } return true; } } // Then check for Markdown-style links const markdownLinks = this.extractMarkdownLinks(boardContent); for (const link of markdownLinks) { const { text, url } = link; if (url === filePath || url === filePathWithoutExtension) { if (this.plugin.settings.showDebugInfo) { console.log( `Found exact Markdown link match for ${fileName}: ${url}` ); } return true; } } // Finally check for exact filepath mentions (with strict boundaries) const filepathPattern = new RegExp( `(?:^|\\s|\\()${this.escapeRegExp(filePath)}(?:$|\\s|\\))`, "i" ); if (filepathPattern.test(boardContent)) { if (this.plugin.settings.showDebugInfo) { console.log(`Found exact filepath match for ${filePath}`); } return true; } return false; } /** * Extract all Obsidian-style links from content * Returns array of {path, alias} objects */ private extractObsidianLinks( content: string ): Array<{ path: string; alias?: string }> { const links: Array<{ path: string; alias?: string }> = []; const linkPattern = /\[\[(.*?)\]\]/g; let match; while ((match = linkPattern.exec(content)) !== null) { const [_, linkContent] = match; const [path, alias] = linkContent.split("|").map((s) => s.trim()); links.push({ path, alias }); } if (this.plugin.settings.showDebugInfo) { console.log("Extracted Obsidian links:", links); } return links; } /** * Extract all Markdown-style links from content * Returns array of {text, url} objects */ private extractMarkdownLinks( content: string ): Array<{ text: string; url: string }> { const links: Array<{ text: string; url: string }> = []; const linkPattern = /\[(.*?)\]\((.*?)\)/g; let match; while ((match = linkPattern.exec(content)) !== null) { const [_, text, url] = match; links.push({ text: text.trim(), url: url.trim() }); } if (this.plugin.settings.showDebugInfo) { console.log("Extracted Markdown links:", links); } return links; } /** * Move a card in a Kanban board to the appropriate column based on status */ async moveCardInKanbanBoard( boardFile: TFile, boardContent: string, fileToMove: TFile, targetStatus: string ): Promise { try { // Wait for MetadataCache to update await this.waitForCacheUpdate(fileToMove); // Parse the Kanban board structure const kanbanColumns = await this.parseKanbanBoard(boardFile); if (!kanbanColumns || Object.keys(kanbanColumns).length === 0) { if (this.plugin.settings.showDebugInfo) { console.log( `Could not parse Kanban board structure in ${boardFile.path}` ); } return boardContent; } // Determine the target column name based on settings let targetColumnName: string | undefined; if (this.plugin.settings.kanbanSyncWithStatus) { // When syncing with status, use exact status name as the column name targetColumnName = Object.keys(kanbanColumns).find( (name) => name.toLowerCase() === targetStatus.toLowerCase() ); // If the exact status name isn't found, try a fuzzy match if (!targetColumnName) { targetColumnName = this.findClosestColumnName( Object.keys(kanbanColumns), targetStatus ); } } else { // Legacy behavior: When not syncing, use the completed column setting, but only for completed tasks if (targetStatus === this.plugin.settings.statusCompleted) { targetColumnName = Object.keys(kanbanColumns).find( (name) => name.toLowerCase() === this.plugin.settings.kanbanCompletedColumn.toLowerCase() ); } } // If no matching column found, log and return original content if (!targetColumnName) { if (this.plugin.settings.showDebugInfo) { console.log( `Could not find column for status "${targetStatus}" in Kanban board ${boardFile.path}` ); console.log( `Available columns: ${Object.keys(kanbanColumns).join( ", " )}` ); } return boardContent; } if (this.plugin.settings.showDebugInfo) { console.log( `Target column for status "${targetStatus}" is "${targetColumnName}"` ); } // Process each column to find and move the card let cardMoved = false; let newContent = boardContent; // Split content into lines for more accurate processing const lines = newContent.split("\n"); let currentColumn = ""; let cardStartIndex = -1; let cardEndIndex = -1; // First pass: Find the exact card that references our file for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Check for column header if (line.startsWith("## ")) { currentColumn = line.substring(3).trim(); continue; } // Skip if we're already in target column if ( currentColumn.toLowerCase() === targetColumnName.toLowerCase() ) { continue; } // Check if line is a card (starts with "- ") if (line.trim().startsWith("- ")) { // Extract the card content const cardContent = this.getCompleteCardContent(lines, i); // Check if this card references our file if ( this.isExactCardForFile(cardContent.content, fileToMove) ) { cardStartIndex = i; cardEndIndex = i + cardContent.lineCount - 1; break; } // Skip to end of card i += cardContent.lineCount - 1; } } // If we found the card, move it if (cardStartIndex !== -1 && cardEndIndex !== -1) { // Extract the card content let cardLines = lines.slice(cardStartIndex, cardEndIndex + 1); // Update checkbox states in the card if custom checkbox states are enabled if ( this.plugin.settings.enableCustomCheckboxStates && targetColumnName ) { const columnName = targetColumnName; // Type assertion for TypeScript cardLines = cardLines.map((line) => { return this.updateCheckboxStatesInCard( line, columnName ); }); } // Remove the card from its current position lines.splice(cardStartIndex, cardEndIndex - cardStartIndex + 1); // Find the target column and insert the card let targetInsertIndex = -1; for (let i = 0; i < lines.length; i++) { if (lines[i].startsWith(`## ${targetColumnName}`)) { targetInsertIndex = i + 1; break; } } if (targetInsertIndex !== -1) { // Insert the card at the beginning of the target column lines.splice(targetInsertIndex, 0, ...cardLines); cardMoved = true; if (this.plugin.settings.showDebugInfo) { console.log( `Moved card for ${fileToMove.path} to column "${targetColumnName}" in ${boardFile.path}` ); if (this.plugin.settings.enableCustomCheckboxStates) { const targetCheckboxState = this.getCheckboxStateForColumn( targetColumnName ); console.log( `Applied checkbox state "${targetCheckboxState}" to card` ); } } } } // If card was moved, update the file if (cardMoved) { newContent = lines.join("\n"); await this.plugin.app.vault.modify(boardFile, newContent); } return newContent; } catch (error) { console.error("Error moving card in Kanban board:", error); if (this.plugin.settings.showDebugInfo) { console.error("Error details:", error); } return boardContent; } } /** * Get the complete content of a card, including any sub-items */ private getCompleteCardContent( lines: string[], startIndex: number ): { content: string; lineCount: number } { let content = lines[startIndex]; let lineCount = 1; // Check subsequent lines for sub-items (indented) for (let i = startIndex + 1; i < lines.length; i++) { const line = lines[i]; if ( line.trim() === "" || line.startsWith(" ") || line.startsWith("\t") ) { content += "\n" + line; lineCount++; } else { break; } } return { content, lineCount }; } /** * Check if a card content exactly matches a file reference */ private isExactCardForFile(cardContent: string, file: TFile): boolean { // Extract all links from the card const obsidianLinks = this.extractObsidianLinks(cardContent); const markdownLinks = this.extractMarkdownLinks(cardContent); const fileName = file.basename; const filePath = file.path; const filePathWithoutExtension = filePath.replace(/\.md$/, ""); // Check Obsidian links first for (const link of obsidianLinks) { const { path } = link; // Only match if it's an exact match with the full path or filename if ( path === fileName || path === filePathWithoutExtension || path === filePath ) { if (this.plugin.settings.showDebugInfo) { console.log( `Found exact Obsidian link match in card: ${path} for file: ${fileName}` ); } return true; } } // Then check Markdown links for (const link of markdownLinks) { const { url } = link; if (url === filePath || url === filePathWithoutExtension) { if (this.plugin.settings.showDebugInfo) { console.log( `Found exact Markdown link match in card: ${url} for file: ${fileName}` ); } return true; } } return false; } /** * Find the closest column name match for a given status * This helps when Kanban columns don't exactly match status names */ findClosestColumnName( columnNames: string[], targetStatus: string ): string | undefined { const targetLower = targetStatus.toLowerCase(); // Define common variants of status names and their corresponding column names const statusVariants: Record = { todo: [ "to do", "todo", "backlog", "new", "not started", "pending", "open", "to-do", ], "in progress": [ "progress", "doing", "working", "ongoing", "started", "in work", "active", "current", "wip", ], completed: [ "done", "complete", "finished", "closed", "resolved", "ready", "completed", ], }; // First try exact match (case-insensitive) const exactMatch = columnNames.find( (name) => name.toLowerCase() === targetLower ); if (exactMatch) return exactMatch; // Special handling for common status values if (targetLower === this.plugin.settings.statusTodo.toLowerCase()) { for (const colName of columnNames) { const colNameLower = colName.toLowerCase(); if ( statusVariants["todo"].some( (v) => colNameLower.includes(v) || v === colNameLower ) ) { return colName; } } } else if ( targetLower === this.plugin.settings.statusInProgress.toLowerCase() ) { for (const colName of columnNames) { const colNameLower = colName.toLowerCase(); if ( statusVariants["in progress"].some( (v) => colNameLower.includes(v) || v === colNameLower ) ) { return colName; } } } else if ( targetLower === this.plugin.settings.statusCompleted.toLowerCase() ) { for (const colName of columnNames) { const colNameLower = colName.toLowerCase(); if ( statusVariants["completed"].some( (v) => colNameLower.includes(v) || v === colNameLower ) ) { return colName; } } } // Then try fuzzy variant matching if status is not a standard one for (const [status, variants] of Object.entries(statusVariants)) { if ( variants.some( (v) => targetLower.includes(v) || v.includes(targetLower) ) ) { for (const colName of columnNames) { const colNameLower = colName.toLowerCase(); if ( variants.some( (v) => colNameLower.includes(v) || v.includes(colNameLower) ) ) { return colName; } } } } // If there's a direct word match, use that for (const colName of columnNames) { if (colName.toLowerCase() === targetLower) { return colName; } } // If no matches are found, try to find any column with a partial match for (const colName of columnNames) { if ( colName.toLowerCase().includes(targetLower) || targetLower.includes(colName.toLowerCase()) ) { return colName; } } // If still no matches, and this is a "Todo" status with no matching column, // return the first column as a likely match for the starting column if ( targetLower === this.plugin.settings.statusTodo.toLowerCase() && columnNames.length > 0 ) { return columnNames[0]; // First column is often the "Todo" equivalent } // If no matches are found, return undefined return undefined; } /** * Get the status from the file's YAML frontmatter */ async getStatusFromYaml(file: TFile): Promise { // Get the file cache from MetadataCache let fileCache = this.app.metadataCache.getFileCache(file); // If no cache or frontmatter, log and return null if (!fileCache?.frontmatter) { if (this.plugin.settings.showDebugInfo) { console.log(`No frontmatter found for file: ${file.path}`); } return null; } try { // Retrieve status from frontmatter const status = fileCache.frontmatter["status"]; if (typeof status === "string" && status.trim()) { if (this.plugin.settings.showDebugInfo) { console.log(`Status found for ${file.path}: ${status}`); } return status.trim(); } // Log if no valid status is found if (this.plugin.settings.showDebugInfo) { console.log( `No valid status in frontmatter for file: ${file.path}` ); } } catch (error) { // Log any errors during frontmatter access console.error( `Error accessing frontmatter for ${file.path}:`, error ); if (this.plugin.settings.showDebugInfo) { console.error("Error details:", error); } } return null; } // Utility function to wait for MetadataCache to update for a specific file async waitForCacheUpdate( file: TFile, timeoutMs: number = 1000 ): Promise { return new Promise((resolve, reject) => { // Set a timeout to prevent hanging const timeout = setTimeout(() => { if (this.plugin.settings.showDebugInfo) { console.log( `Timeout waiting for cache update for ${file.path}` ); } resolve(); }, timeoutMs); // Listen for cache changes const handler = (updatedFile: TFile) => { if (updatedFile.path === file.path) { // Cache updated, cleanup and resolve this.app.metadataCache.off("changed", handler); clearTimeout(timeout); if (this.plugin.settings.showDebugInfo) { console.log(`Cache updated for ${file.path}`); } resolve(); } }; // Register the listener this.app.metadataCache.on("changed", handler); // Trigger a cache refresh (optional, depends on Obsidian version) // this.app.metadataCache.trigger("changed", file); }); } /** * Parse Kanban board structure into columns and items with improved accuracy */ private async parseKanbanBoard( file: TFile ): Promise }>> { const kanban: Record }> = {}; try { // Use MetadataCache to get frontmatter and headers const fileCache = this.app.metadataCache.getFileCache(file); if (!fileCache) { if (this.plugin.settings.showDebugInfo) { console.log(`No cache found for file: ${file.path}`); } return kanban; } // Check if this is a Kanban plugin file using frontmatter const isKanbanPlugin = fileCache.frontmatter?.["kanban-plugin"] === "basic"; // Get H2 headers (level 2) to identify columns const columnHeaders = fileCache.headings?.filter((h) => h.level === 2) || []; if (columnHeaders.length < 1) { if (this.plugin.settings.showDebugInfo) { console.log(`No H2 headers found in file: ${file.path}`); } return kanban; } // Read file content only when necessary to extract items const content = await this.plugin.app.vault.read(file); // Process each column for (let i = 0; i < columnHeaders.length; i++) { const columnHeader = columnHeaders[i]; const columnName = columnHeader.heading.trim(); kanban[columnName] = { items: [] }; // Determine column boundaries using header positions const columnStart = columnHeader.position.start.offset; const columnEnd = i < columnHeaders.length - 1 ? columnHeaders[i + 1].position.start.offset : content.length; // Extract column content const columnContent = content .substring( columnStart + columnHeader.heading.length + 4, columnEnd ) .trim(); // +4 accounts for "## " and newline // Extract items based on format if (isKanbanPlugin) { this.extractKanbanPluginItems( columnContent, kanban[columnName].items ); } else { this.extractMarkdownItems( columnContent, kanban[columnName].items ); } } if (this.plugin.settings.showDebugInfo) { console.log( `Parsed Kanban board ${file.path} with columns:`, Object.keys(kanban) ); Object.entries(kanban).forEach(([column, data]) => { console.log( `Column "${column}" has ${data.items.length} items` ); }); } } catch (error) { console.error(`Error parsing Kanban board ${file.path}:`, error); if (this.plugin.settings.showDebugInfo) { console.error("Error details:", error); } } return kanban; } /** * Extract items from Kanban plugin format */ private extractKanbanPluginItems( columnContent: string, items: Array<{ text: string }> ) { // Split by top-level list items (those that start with "- " at beginning of line) const listItemsRaw = columnContent.split(/^- /m).slice(1); for (const rawItem of listItemsRaw) { const itemText = "- " + rawItem.trim(); items.push({ text: itemText }); } } /** * Extract items from regular markdown format */ private extractMarkdownItems( columnContent: string, items: Array<{ text: string }> ) { // Each item starts with "- " at the beginning of a line // And continues until the next item or end of content let lines = columnContent.split("\n"); let currentItem = ""; let inItem = false; for (const line of lines) { if (line.trim().startsWith("- ")) { // If we were already in an item, save the previous one if (inItem) { items.push({ text: currentItem.trim() }); } // Start a new item currentItem = line; inItem = true; } else if (inItem) { // Continue current item currentItem += "\n" + line; } } // Add the last item if there is one if (inItem) { items.push({ text: currentItem.trim() }); } } /** * Check if content appears to be a Kanban board * This method is necessary for the processKanbanBoards function */ private isKanbanBoard(file: TFile): boolean { try { // Use MetadataCache to get file cache const fileCache = this.app.metadataCache.getFileCache(file); if (!fileCache) { if (this.plugin.settings.showDebugInfo) { console.log(`No cache found for file: ${file.path}`); } return false; } // Check for Kanban plugin frontmatter if (fileCache.frontmatter?.["kanban-plugin"] === "basic") { if (this.plugin.settings.showDebugInfo) { console.log(`Detected Kanban plugin board: ${file.path}`); } return true; } // Check for Kanban-like structure via headers const headers = fileCache.headings || []; if (headers.length < 2) { if (this.plugin.settings.showDebugInfo) { console.log(`Insufficient headers in file: ${file.path}`); } return false; } const commonKanbanNames = [ "todo", "to do", "to-do", "backlog", "new", "ideas", "inbox", "in progress", "doing", "working", "current", "ongoing", "done", "complete", "completed", "finished", "blocked", "waiting", ]; let kanbanColumnCount = 0; const completedColumnLower = this.plugin.settings.kanbanCompletedColumn.toLowerCase(); for (const header of headers) { if (header.level !== 2) continue; const columnName = header.heading.toLowerCase(); if ( commonKanbanNames.some((name) => columnName.includes(name) ) || columnName === completedColumnLower ) { kanbanColumnCount++; } } const isKanban = kanbanColumnCount >= 2; if (this.plugin.settings.showDebugInfo) { console.log( `File ${file.path} is ${ isKanban ? "" : "not " }a Kanban board ` + `(columns detected: ${kanbanColumnCount})` ); } return isKanban; } catch (error) { console.error( `Error checking if ${file.path} is a Kanban board:`, error ); if (this.plugin.settings.showDebugInfo) { console.error("Error details:", error); } return false; } } /** * Add a file to the specified Kanban board if it's not already there */ async addFileToKanbanBoard(file: TFile): Promise { try { // Skip if auto-add setting is disabled or board path is empty if ( !this.plugin.settings.autoAddToKanban || !this.plugin.settings.autoAddKanbanBoard ) { return false; } // Skip plugin files and Kanban board files to avoid self-reference const filePath = file.path.toLowerCase(); const configDir = this.plugin.app.vault.configDir.toLowerCase(); if ( filePath.includes(`${configDir}/plugins/progress-tracker`) || filePath.includes("kanban") || filePath === this.plugin.settings.autoAddKanbanBoard ) { if (this.plugin.settings.showDebugInfo) { console.log(`Skipping plugin or kanban file: ${file.path}`); } return false; } // Get the Kanban board file const boardPath = this.plugin.settings.autoAddKanbanBoard; const kanbanFile = this.plugin.app.vault.getAbstractFileByPath(boardPath); if (!kanbanFile || !(kanbanFile instanceof TFile)) { if (this.plugin.settings.showDebugInfo) { console.log( `Could not find Kanban board at path: ${boardPath}` ); } return false; } // Skip if trying to add the kanban board to itself if (file.path === kanbanFile.path) { if (this.plugin.settings.showDebugInfo) { console.log( `Skipping adding kanban board to itself: ${file.path}` ); } return false; } // Read the board content const boardContent = await this.plugin.app.vault.read(kanbanFile); // Skip if this is not a Kanban board if (!this.isKanbanBoard(kanbanFile)) { if (this.plugin.settings.showDebugInfo) { console.log( `File at path ${boardPath} is not a Kanban board` ); } return false; } // Check if the file is already referenced in the board if (this.containsFileReference(boardContent, file)) { if (this.plugin.settings.showDebugInfo) { console.log( `File ${file.path} is already in Kanban board ${boardPath}` ); } return false; } // Get the target column name const targetColumn = this.plugin.settings.autoAddKanbanColumn || "Todo"; // Wait for MetadataCache to update await this.waitForCacheUpdate(file); // Parse the Kanban board to find the column const kanbanColumns = await this.parseKanbanBoard(kanbanFile); if (!kanbanColumns || Object.keys(kanbanColumns).length === 0) { if (this.plugin.settings.showDebugInfo) { console.log( `Could not parse Kanban board structure in ${boardPath}` ); } return false; } // Find the exact or closest column match let targetColumnName = Object.keys(kanbanColumns).find( (name) => name.toLowerCase() === targetColumn.toLowerCase() ); if (!targetColumnName) { targetColumnName = this.findClosestColumnName( Object.keys(kanbanColumns), targetColumn ); } if (!targetColumnName) { if (this.plugin.settings.showDebugInfo) { console.log( `Could not find column "${targetColumn}" in Kanban board ${boardPath}` ); } return false; } // Find the position to insert the card const columnRegex = new RegExp( `## ${this.escapeRegExp(targetColumnName)}\\s*\\n` ); const columnMatch = boardContent.match(columnRegex); if (!columnMatch) { if (this.plugin.settings.showDebugInfo) { console.log( `Could not find column "${targetColumnName}" in Kanban board content` ); } return false; } const insertPosition = columnMatch.index! + columnMatch[0].length; // Create card text with link to the file let cardText = `- [[${file.basename}]]\n`; // Apply custom checkbox state if enabled if (this.plugin.settings.enableCustomCheckboxStates) { const checkboxState = this.getCheckboxStateForColumn(targetColumnName); cardText = `- ${checkboxState} [[${file.basename}]]\n`; } // Insert the card const newContent = boardContent.substring(0, insertPosition) + cardText + boardContent.substring(insertPosition); // Update the file await this.plugin.app.vault.modify(kanbanFile, newContent); // Show notice new Notice( `Added ${file.basename} to "${targetColumnName}" column in ${kanbanFile.basename}` ); return true; } catch (error) { console.error("Error adding file to Kanban board:", error); if (this.plugin.settings.showDebugInfo) { console.error("Error details:", error); } return false; } } /** * Get checkbox state for a specific kanban column */ private getCheckboxStateForColumn(columnName: string): string { if (!this.plugin.settings.enableCustomCheckboxStates) { return "[ ]"; // Default unchecked state } const mapping = this.plugin.settings.kanbanColumnCheckboxMappings.find( (m) => m.columnName.toLowerCase() === columnName.toLowerCase() ); return mapping ? mapping.checkboxState : "[ ]"; } /** * Update checkbox states in card content based on target column * Only updates the main card checkbox, preserving sub-items and nested checkboxes */ private updateCheckboxStatesInCard( cardContent: string, targetColumnName: string ): string { if (!this.plugin.settings.enableCustomCheckboxStates) { return cardContent; } const targetCheckboxState = this.getCheckboxStateForColumn(targetColumnName); // Split content into lines to process only the first line (main card) const lines = cardContent.split("\n"); if (lines.length === 0) return cardContent; // Pattern to match various checkbox states: - [ ], - [x], - [/], - [>], etc. // Remove global flag to only match once per line const checkboxPattern = /^(\s*- )\[[^\]]*\](.*)$/; // Only update the first line if it matches the pattern (main card line) if (checkboxPattern.test(lines[0])) { const originalFirstLine = lines[0]; lines[0] = lines[0].replace( checkboxPattern, (match, prefix, suffix) => { return `${prefix}${targetCheckboxState}${suffix}`; } ); if ( this.plugin.settings.showDebugInfo && lines[0] !== originalFirstLine ) { console.log( `Updated checkbox states in card for column "${targetColumnName}":`, { original: originalFirstLine, updated: lines[0], targetState: targetCheckboxState, } ); } } // Join lines back together, preserving sub-items unchanged return lines.join("\n"); } /** * Count tasks with different checkbox states */ private countTasksByCheckboxState(content: string): { [state: string]: number; } { const taskCounts: { [state: string]: number } = {}; const lines = content.split("\n"); for (const line of lines) { const match = line.trim().match(/^- \[([^\]]*)\]/); if (match) { const state = match[1]; taskCounts[state] = (taskCounts[state] || 0) + 1; } } return taskCounts; } } class TaskProgressBarSettingTab extends PluginSettingTab { plugin: TaskProgressBarPlugin; constructor(app: App, plugin: TaskProgressBarPlugin) { super(app, plugin); this.plugin = plugin; } display(): void { const { containerEl } = this; containerEl.empty(); // Add Dataview status information const dataviewStatus = containerEl.createDiv({ cls: "dataview-status", }); if (this.plugin.dvAPI) { dataviewStatus.createEl("p", { text: "✅ Dataview API is available", cls: "dataview-available", }); } else { dataviewStatus.createEl("p", { text: "❌ Dataview API is not available", cls: "dataview-unavailable", }); // Add button to check for Dataview again const checkButton = dataviewStatus.createEl("button", { text: "Check for Dataview", cls: "mod-cta", }); checkButton.addEventListener("click", () => { this.plugin.checkDataviewAPI(); if (this.plugin.dvAPI) { new Notice("Dataview API found!"); this.display(); // Refresh settings tab } else { new Notice( "Dataview API not found. Make sure Dataview plugin is installed and enabled." ); } }); } new Setting(containerEl) .setName("Show debug info") .setDesc( "Show debug information in the sidebar to help troubleshoot task counting issues" ) .addToggle((toggle) => toggle .setValue(this.plugin.settings.showDebugInfo) .onChange(async (value) => { this.plugin.settings.showDebugInfo = value; await this.plugin.saveSettings(); // Update sidebar if open - use public method const currentFile = this.app.workspace.getActiveFile(); if (currentFile) { // Use public method to update UI this.plugin.checkDataviewAPI(); } }) ); // Animation settings section new Setting(containerEl).setName("Animation").setHeading(); // Add new setting for animation new Setting(containerEl) .setName("Show update animation") .setDesc("Show a brief animation when updating the progress bar") .addToggle((toggle) => toggle .setValue(this.plugin.settings.showUpdateAnimation) .onChange(async (value) => { this.plugin.settings.showUpdateAnimation = value; await this.plugin.saveSettings(); // No need to update the view here as it will only affect future updates }) ); // Performance settings section new Setting(containerEl).setName("Performance").setHeading(); new Setting(containerEl) .setName("Editor change delay") .setDesc( "Delay before updating after editor content changes (lower = more responsive, higher = better performance)" ) .addSlider((slider) => slider .setLimits(100, 1000, 50) // From 100ms to 1000ms in steps of 50ms .setValue(this.plugin.settings.editorChangeDelay) .setDynamicTooltip() .onChange(async (value) => { this.plugin.settings.editorChangeDelay = value; await this.plugin.saveSettings(); // Note: This will take effect after plugin reload new Notice( "Editor change delay updated. Restart plugin to apply changes." ); }) ) .addExtraButton((button) => button .setIcon("reset") .setTooltip("Reset to default (500ms)") .onClick(async () => { this.plugin.settings.editorChangeDelay = 500; await this.plugin.saveSettings(); this.display(); // Refresh the settings panel to update the slider new Notice( "Editor change delay reset. Restart plugin to apply changes." ); }) ); new Setting(containerEl) .setName("Keyboard input delay") .setDesc( "Delay after keyboard input before updating progress (in milliseconds)" ) .addSlider((slider) => slider .setLimits(100, 1000, 50) // From 0ms to 100ms in steps of 5ms .setValue(this.plugin.settings.keyboardInputDelay) .setDynamicTooltip() .onChange(async (value) => { this.plugin.settings.keyboardInputDelay = value; await this.plugin.saveSettings(); }) ) .addExtraButton((button) => button .setIcon("reset") .setTooltip("Reset to default (100ms)") .onClick(async () => { this.plugin.settings.keyboardInputDelay = 100; await this.plugin.saveSettings(); this.display(); // Refresh the settings panel to update the slider }) ); new Setting(containerEl) .setName("Checkbox click delay") .setDesc( "Delay after checkbox click before updating progress (in milliseconds)" ) .addSlider((slider) => slider .setLimits(100, 1000, 50) // From 10ms to 200ms in steps of 10ms .setValue(this.plugin.settings.checkboxClickDelay) .setDynamicTooltip() .onChange(async (value) => { this.plugin.settings.checkboxClickDelay = value; await this.plugin.saveSettings(); }) ) .addExtraButton((button) => button .setIcon("reset") .setTooltip("Reset to default (200ms)") .onClick(async () => { this.plugin.settings.checkboxClickDelay = 200; await this.plugin.saveSettings(); this.display(); // Refresh the settings panel to update the slider }) ); // Add color scheme settings new Setting(containerEl).setName("Progress bar colors").setHeading(); new Setting(containerEl) .setName("Color scheme") .setDesc("Choose a color scheme for the progress bar") .addDropdown((dropdown) => dropdown .addOption("default", "Default (Theme Colors)") .addOption("red-orange-green", "Red-Orange-Blue-Green") .addOption("custom", "Custom Colors") .setValue(this.plugin.settings.progressColorScheme) .onChange( async ( value: "default" | "red-orange-green" | "custom" ) => { this.plugin.settings.progressColorScheme = value; // Set preset colors if red-orange-green is selected if (value === "red-orange-green") { // Reset color values and thresholds this.plugin.settings.lowProgressColor = "#e06c75"; // Red this.plugin.settings.mediumProgressColor = "#e5c07b"; // Orange/Yellow this.plugin.settings.highProgressColor = "#61afef"; // Blue this.plugin.settings.completeProgressColor = "#98c379"; // Green // Reset thresholds this.plugin.settings.lowProgressThreshold = 30; this.plugin.settings.mediumProgressThreshold = 60; this.plugin.settings.highProgressThreshold = 99; // Show notice to confirm changes new Notice( "Applied Red-Orange-Blue-Green color scheme" ); } await this.plugin.saveSettings(); this.display(); // Refresh to show/hide custom color options // Update the view if open const currentFile = this.app.workspace.getActiveFile(); if (currentFile && this.plugin.sidebarView) { this.plugin.sidebarView.updateProgressBar( currentFile ); } } ) ); // Only show custom color settings if custom is selected if (this.plugin.settings.progressColorScheme === "custom") { new Setting(containerEl) .setName("Low progress color") .setDesc( `Color for progress below ${this.plugin.settings.lowProgressThreshold}%` ) .addText((text) => text .setValue(this.plugin.settings.lowProgressColor) .onChange(async (value) => { this.plugin.settings.lowProgressColor = value; await this.plugin.saveSettings(); // Update the view if open const currentFile = this.app.workspace.getActiveFile(); if (currentFile && this.plugin.sidebarView) { this.plugin.sidebarView.updateProgressBar( currentFile ); } }) ); new Setting(containerEl) .setName("Medium progress color") .setDesc( `Color for progress between ${this.plugin.settings.lowProgressThreshold}% and ${this.plugin.settings.mediumProgressThreshold}%` ) .addText((text) => text .setValue(this.plugin.settings.mediumProgressColor) .onChange(async (value) => { this.plugin.settings.mediumProgressColor = value; await this.plugin.saveSettings(); // Update the view if open const currentFile = this.app.workspace.getActiveFile(); if (currentFile && this.plugin.sidebarView) { this.plugin.sidebarView.updateProgressBar( currentFile ); } }) ); new Setting(containerEl) .setName("High progress color") .setDesc( `Color for progress between ${this.plugin.settings.mediumProgressThreshold}% and ${this.plugin.settings.highProgressThreshold}%` ) .addText((text) => text .setValue(this.plugin.settings.highProgressColor) .onChange(async (value) => { this.plugin.settings.highProgressColor = value; await this.plugin.saveSettings(); // Update the view if open const currentFile = this.app.workspace.getActiveFile(); if (currentFile && this.plugin.sidebarView) { this.plugin.sidebarView.updateProgressBar( currentFile ); } }) ); new Setting(containerEl) .setName("Complete progress color") .setDesc("Color for 100% progress") .addText((text) => text .setValue(this.plugin.settings.completeProgressColor) .onChange(async (value) => { this.plugin.settings.completeProgressColor = value; await this.plugin.saveSettings(); // Update the view if open const currentFile = this.app.workspace.getActiveFile(); if (currentFile && this.plugin.sidebarView) { this.plugin.sidebarView.updateProgressBar( currentFile ); } }) ); // Add threshold settings new Setting(containerEl) .setName("Low progress threshold") .setDesc("Percentage below which progress is considered low") .addSlider((slider) => slider .setLimits(1, 99, 1) .setValue(this.plugin.settings.lowProgressThreshold) .setDynamicTooltip() .onChange(async (value) => { // Ensure thresholds don't overlap if ( value >= this.plugin.settings.mediumProgressThreshold ) { value = this.plugin.settings .mediumProgressThreshold - 1; } this.plugin.settings.lowProgressThreshold = value; await this.plugin.saveSettings(); // Update the view if open const currentFile = this.app.workspace.getActiveFile(); if (currentFile && this.plugin.sidebarView) { this.plugin.sidebarView.updateProgressBar( currentFile ); } }) ); new Setting(containerEl) .setName("Medium progress threshold") .setDesc("Percentage below which progress is considered medium") .addSlider((slider) => slider .setLimits(1, 99, 1) .setValue(this.plugin.settings.mediumProgressThreshold) .setDynamicTooltip() .onChange(async (value) => { // Ensure thresholds don't overlap if ( value <= this.plugin.settings.lowProgressThreshold ) { value = this.plugin.settings.lowProgressThreshold + 1; } if ( value >= this.plugin.settings.highProgressThreshold ) { value = this.plugin.settings.highProgressThreshold - 1; } this.plugin.settings.mediumProgressThreshold = value; await this.plugin.saveSettings(); // Update the view if open const currentFile = this.app.workspace.getActiveFile(); if (currentFile && this.plugin.sidebarView) { this.plugin.sidebarView.updateProgressBar( currentFile ); } }) ); new Setting(containerEl) .setName("High progress threshold") .setDesc( "Percentage below which progress is considered high (but not complete)" ) .addSlider((slider) => slider .setLimits(1, 99, 1) .setValue(this.plugin.settings.highProgressThreshold) .setDynamicTooltip() .onChange(async (value) => { // Ensure thresholds don't overlap if ( value <= this.plugin.settings.mediumProgressThreshold ) { value = this.plugin.settings .mediumProgressThreshold + 1; } this.plugin.settings.highProgressThreshold = value; await this.plugin.saveSettings(); // Update the view if open const currentFile = this.app.workspace.getActiveFile(); if (currentFile && this.plugin.sidebarView) { this.plugin.sidebarView.updateProgressBar( currentFile ); } }) ); } // Interface settings section (Add this before or after Animation Settings) new Setting(containerEl).setName("Interface").setHeading(); // Fix the Max Tabs Height setting to allow proper input new Setting(containerEl) .setName("Max tabs height") .setDesc( "Maximum height for workspace tabs (e.g., 110px, 200px, auto)" ) .addText((text) => { // Set initial value text.setValue(this.plugin.settings.maxTabsHeight); // Apply improved validation only when the field loses focus // This allows typing intermediary values that may not be valid yet text.inputEl.addEventListener("blur", async () => { const value = text.inputEl.value; // Validate only when user is done editing const isValid = value === "auto" || value === "none" || /^\d+(\.\d+)?(px|em|rem|vh|%)$/.test(value); if (isValid) { // Only update if changed to avoid unnecessary saves if (this.plugin.settings.maxTabsHeight !== value) { this.plugin.settings.maxTabsHeight = value; await this.plugin.saveSettings(); new Notice(`Max tabs height updated to ${value}`); } } else { new Notice( "Please enter 'auto', 'none' or a valid CSS length value (e.g., 110px)" ); // Reset to previous valid value text.setValue(this.plugin.settings.maxTabsHeight); } }); // Add Enter key handling text.inputEl.addEventListener("keydown", async (event) => { if (event.key === "Enter") { event.preventDefault(); text.inputEl.blur(); // Trigger the blur event to validate } }); // Improve UX text.inputEl.style.width = "120px"; text.inputEl.placeholder = "auto"; return text; }) .addExtraButton((button) => button .setIcon("reset") .setTooltip("Reset to default (auto)") .onClick(async () => { this.plugin.settings.maxTabsHeight = "auto"; await this.plugin.saveSettings(); this.display(); // Refresh the settings panel new Notice("Max tabs height reset to 'auto'"); }) ); // Add Metadata Auto-Update Settings new Setting(containerEl).setName("Metadata auto-update").setHeading(); new Setting(containerEl) .setName("Auto-update metadata") .setDesc( "Automatically update metadata when all tasks are completed" ) .addToggle((toggle) => toggle .setValue(this.plugin.settings.autoUpdateMetadata) .onChange(async (value) => { this.plugin.settings.autoUpdateMetadata = value; await this.plugin.saveSettings(); // Refresh the display to show/hide related settings this.display(); }) ); // Only show these settings if auto-update is enabled if (this.plugin.settings.autoUpdateMetadata) { new Setting(containerEl) .setName("Change status") .setDesc( "Change 'status: In Progress' to 'status: Completed' when tasks reach 100%" ) .addToggle((toggle) => toggle .setValue(this.plugin.settings.autoChangeStatus) .onChange(async (value) => { this.plugin.settings.autoChangeStatus = value; await this.plugin.saveSettings(); }) ); new Setting(containerEl) .setName("Update finished date") .setDesc( "Set 'finished: ' to today's date when tasks reach 100%" ) .addToggle((toggle) => toggle .setValue(this.plugin.settings.autoUpdateFinishedDate) .onChange(async (value) => { this.plugin.settings.autoUpdateFinishedDate = value; await this.plugin.saveSettings(); }) ); // Show status label settings if status changing is enabled if (this.plugin.settings.autoChangeStatus) { new Setting(containerEl) .setName("Todo status label") .setDesc("Status label for files with 0% progress") .addText((text) => text .setPlaceholder("Todo") .setValue(this.plugin.settings.statusTodo) .onChange(async (value) => { this.plugin.settings.statusTodo = value; await this.plugin.saveSettings(); }) ) .addExtraButton((button) => button .setIcon("reset") .setTooltip("Reset to default") .onClick(async () => { this.plugin.settings.statusTodo = "Todo"; await this.plugin.saveSettings(); this.display(); }) ); new Setting(containerEl) .setName("In progress status label") .setDesc("Status label for files with 1-99% progress") .addText((text) => text .setPlaceholder("In Progress") .setValue(this.plugin.settings.statusInProgress) .onChange(async (value) => { this.plugin.settings.statusInProgress = value; await this.plugin.saveSettings(); }) ) .addExtraButton((button) => button .setIcon("reset") .setTooltip("Reset to default") .onClick(async () => { this.plugin.settings.statusInProgress = "In Progress"; await this.plugin.saveSettings(); this.display(); }) ); new Setting(containerEl) .setName("Completed status label") .setDesc("Status label for files with 100% progress") .addText((text) => text .setPlaceholder("Completed") .setValue(this.plugin.settings.statusCompleted) .onChange(async (value) => { this.plugin.settings.statusCompleted = value; await this.plugin.saveSettings(); }) ) .addExtraButton((button) => button .setIcon("reset") .setTooltip("Reset to default") .onClick(async () => { this.plugin.settings.statusCompleted = "Completed"; await this.plugin.saveSettings(); this.display(); }) ); } // Add Kanban integration settings new Setting(containerEl).setName("Kanban integration").setHeading(); new Setting(containerEl) .setName("Update Kanban boards") .setDesc( "Automatically move cards in Kanban boards based on task status" ) .addToggle((toggle) => toggle .setValue(this.plugin.settings.autoUpdateKanban) .onChange(async (value) => { this.plugin.settings.autoUpdateKanban = value; await this.plugin.saveSettings(); // Refresh to show/hide related settings this.display(); }) ); if (this.plugin.settings.autoUpdateKanban) { new Setting(containerEl) .setName("Sync Kanban columns with status") .setDesc( "Match Kanban column names to status values (Todo, In Progress, Completed)" ) .addToggle((toggle) => toggle .setValue(this.plugin.settings.kanbanSyncWithStatus) .onChange(async (value) => { this.plugin.settings.kanbanSyncWithStatus = value; await this.plugin.saveSettings(); this.display(); // Refresh settings }) ); // Only show this if sync with status is disabled (legacy mode) if (!this.plugin.settings.kanbanSyncWithStatus) { new Setting(containerEl) .setName("Completed column name") .setDesc( "The name of the column where completed items should be moved to (e.g., 'Complete', 'Done', 'Finished')" ) .addText((text) => text .setPlaceholder("Complete") .setValue( this.plugin.settings.kanbanCompletedColumn ) .onChange(async (value) => { this.plugin.settings.kanbanCompletedColumn = value; await this.plugin.saveSettings(); }) ) .addExtraButton((button) => button .setIcon("reset") .setTooltip("Reset to default (Complete)") .onClick(async () => { this.plugin.settings.kanbanCompletedColumn = "Complete"; await this.plugin.saveSettings(); this.display(); }) ); } // Add Auto-detect settings and other Kanban settings new Setting(containerEl) .setName("Auto-detect Kanban boards") .setDesc( "Automatically detect files that appear to be Kanban boards" ) .addToggle((toggle) => toggle .setValue(this.plugin.settings.kanbanAutoDetect) .onChange(async (value) => { this.plugin.settings.kanbanAutoDetect = value; await this.plugin.saveSettings(); this.display(); // Refresh settings }) ); // Note about column naming const infoDiv = containerEl.createDiv({ cls: "kanban-info", attr: { style: "background: var(--background-secondary-alt); padding: 10px; border-radius: 5px; margin-top: 10px;", }, }); infoDiv.createEl("p", { text: "ℹ️ Column naming tip:", attr: { style: "font-weight: bold; margin: 0 0 5px 0;", }, }); infoDiv.createEl("p", { text: `To get the best results, name your Kanban columns to match the status values: "${this.plugin.settings.statusTodo}", "${this.plugin.settings.statusInProgress}", and "${this.plugin.settings.statusCompleted}".`, attr: { style: "margin: 0;", }, }); // Keep the remaining Kanban settings (specific files, exclude files, file picker) // ...existing code... } } // Add new section for auto-add to Kanban new Setting(containerEl) .setName("Auto-add files to Kanban board") .setDesc( "Automatically add files with tasks to a specified Kanban board if they're not already there" ) .addToggle((toggle) => toggle .setValue(this.plugin.settings.autoAddToKanban) .onChange(async (value) => { this.plugin.settings.autoAddToKanban = value; await this.plugin.saveSettings(); // Refresh to show/hide related settings this.display(); }) ); if (this.plugin.settings.autoAddToKanban) { new Setting(containerEl) .setName("Target Kanban board") .setDesc( "The path to the Kanban board where files should be added" ) .addText((text) => text .setPlaceholder("path/to/kanban.md") .setValue(this.plugin.settings.autoAddKanbanBoard) .onChange(async (value) => { this.plugin.settings.autoAddKanbanBoard = value; await this.plugin.saveSettings(); }) ); // Add file picker button containerEl.createEl("div", { text: "Select Kanban board file:", attr: { style: "margin-left: 36px; margin-bottom: 8px;" }, }); const filePickerContainer = containerEl.createEl("div", { attr: { style: "margin-left: 36px; margin-bottom: 12px;" }, }); const filePickerButton = filePickerContainer.createEl("button", { text: "Browse...", cls: "mod-cta", }); filePickerButton.addEventListener("click", async () => { // Remove the problematic code that references app.plugins try { const modal = new FileSuggestModal(this.app, this.plugin); modal.onChooseItem = (file: TFile) => { if (file) { this.plugin.settings.autoAddKanbanBoard = file.path; this.plugin.saveSettings().then(() => { this.display(); }); } }; modal.open(); } catch (error) { new Notice( "Error opening file picker. Please enter the path manually." ); console.error("File picker error:", error); } }); new Setting(containerEl) .setName("Target column") .setDesc( "The column where new files should be added (e.g., 'Todo', 'Backlog')" ) .addText((text) => text .setPlaceholder("Todo") .setValue(this.plugin.settings.autoAddKanbanColumn) .onChange(async (value) => { this.plugin.settings.autoAddKanbanColumn = value; await this.plugin.saveSettings(); }) ) .addExtraButton((button) => button .setIcon("reset") .setTooltip("Reset to default (Todo)") .onClick(async () => { this.plugin.settings.autoAddKanbanColumn = "Todo"; await this.plugin.saveSettings(); this.display(); }) ); } // Add new section for custom checkbox states new Setting(containerEl) .setName("Custom Checkbox States") .setDesc( "Configure custom checkbox states for different Kanban columns" ) .setHeading(); new Setting(containerEl) .setName("Enable custom checkbox states") .setDesc( "When enabled, cards will automatically update their checkbox states when moved between Kanban columns" ) .addToggle((toggle) => toggle .setValue(this.plugin.settings.enableCustomCheckboxStates) .onChange(async (value) => { this.plugin.settings.enableCustomCheckboxStates = value; await this.plugin.saveSettings(); // Refresh to show/hide related settings this.display(); }) ); if (this.plugin.settings.enableCustomCheckboxStates) { // Add new setting for Kanban card checkbox sync new Setting(containerEl) .setName("Enable Kanban card checkbox sync") .setDesc( "When enabled, dragging cards between Kanban columns will automatically update checkbox states of the cards in the Kanban board" ) .addToggle((toggle) => toggle .setValue(this.plugin.settings.enableKanbanToFileSync) .onChange(async (value) => { this.plugin.settings.enableKanbanToFileSync = value; await this.plugin.saveSettings(); }) ); // Add new setting for auto-sync on Kanban open new Setting(containerEl) .setName("Auto-sync checkbox states on Kanban open") .setDesc( "When enabled, automatically sync all card checkbox states to match their columns when opening a Kanban board (runs once per session for performance)" ) .addToggle((toggle) => toggle .setValue(this.plugin.settings.enableKanbanAutoSync) .onChange(async (value) => { this.plugin.settings.enableKanbanAutoSync = value; await this.plugin.saveSettings(); }) ); // NEW: Add setting for Kanban normalization protection new Setting(containerEl) .setName( "Protect custom checkbox states from Kanban normalization" ) .setDesc( "When enabled, prevents the Kanban plugin from automatically converting custom checkbox states (like [/], [~]) to standard states ([x]). This preserves your custom state mappings." ) .addToggle((toggle) => toggle .setValue( this.plugin.settings .enableKanbanNormalizationProtection ) .onChange(async (value) => { this.plugin.settings.enableKanbanNormalizationProtection = value; await this.plugin.saveSettings(); }) ); // Add explanation const infoDiv = containerEl.createDiv({ cls: "custom-checkbox-info", attr: { style: "background: var(--background-secondary-alt); padding: 10px; border-radius: 5px; margin: 10px 0;", }, }); infoDiv.createEl("p", { text: "ℹ️ Custom Checkbox States Configuration:", attr: { style: "font-weight: bold; margin: 0 0 5px 0;", }, }); infoDiv.createEl("p", { text: "Define which checkbox state should be used for each Kanban column. Common states include: [ ] (todo), [/] (in progress), [x] (completed), [>] (forwarded), [-] (cancelled).", attr: { style: "margin: 0 0 5px 0;", }, }); // Display current mappings this.plugin.settings.kanbanColumnCheckboxMappings.forEach( (mapping, index) => { const mappingContainer = containerEl.createDiv({ cls: "checkbox-mapping-container", attr: { style: "display: flex; gap: 10px; align-items: center; margin: 10px 0; padding: 10px; border: 1px solid var(--background-modifier-border); border-radius: 5px;", }, }); // Column name input const columnInput = mappingContainer.createEl("input", { type: "text", value: mapping.columnName, attr: { placeholder: "Column Name", style: "flex: 1; padding: 5px;", }, }); // Checkbox state input const checkboxInput = mappingContainer.createEl("input", { type: "text", value: mapping.checkboxState, attr: { placeholder: "[ ]", style: "width: 60px; padding: 5px; text-align: center;", }, }); // Delete button const deleteButton = mappingContainer.createEl("button", { text: "Remove", cls: "mod-warning", attr: { style: "padding: 5px 10px;", }, }); // Event listeners columnInput.addEventListener("change", async () => { this.plugin.settings.kanbanColumnCheckboxMappings[ index ].columnName = columnInput.value; await this.plugin.saveSettings(); }); checkboxInput.addEventListener("change", async () => { this.plugin.settings.kanbanColumnCheckboxMappings[ index ].checkboxState = checkboxInput.value; await this.plugin.saveSettings(); }); deleteButton.addEventListener("click", async () => { this.plugin.settings.kanbanColumnCheckboxMappings.splice( index, 1 ); await this.plugin.saveSettings(); this.display(); // Refresh the settings panel }); } ); // Add new mapping button const addMappingButton = containerEl.createEl("button", { text: "Add Column Mapping", cls: "mod-cta", attr: { style: "margin: 10px 0;", }, }); addMappingButton.addEventListener("click", async () => { this.plugin.settings.kanbanColumnCheckboxMappings.push({ columnName: "", checkboxState: "[ ]", }); await this.plugin.saveSettings(); this.display(); // Refresh the settings panel }); // Reset to defaults button const resetButton = containerEl.createEl("button", { text: "Reset to Defaults", cls: "mod-warning", attr: { style: "margin: 10px 0;", }, }); resetButton.addEventListener("click", async () => { this.plugin.settings.kanbanColumnCheckboxMappings = [ { columnName: "Todo", checkboxState: "[ ]" }, { columnName: "In Progress", checkboxState: "[/]" }, { columnName: "Complete", checkboxState: "[x]" }, { columnName: "Done", checkboxState: "[x]" }, ]; await this.plugin.saveSettings(); this.display(); // Refresh the settings panel }); } } } // Helper class for file picking class FileSuggestModal extends SuggestModal { plugin: TaskProgressBarPlugin; onChooseItem: (file: TFile) => void; constructor(app: App, plugin: TaskProgressBarPlugin) { super(app); this.plugin = plugin; this.onChooseItem = () => {}; // Default empty implementation } getSuggestions(query: string): TFile[] { const files = this.app.vault.getMarkdownFiles(); // Filter to only show potential Kanban board files const kanbanFiles = files.filter((file) => { // Show all Markdown files when no query if (!query) return true; // Otherwise filter by name/path containing the query return file.path.toLowerCase().includes(query.toLowerCase()); }); return kanbanFiles; } renderSuggestion(file: TFile, el: HTMLElement) { el.createEl("div", { text: file.path }); } // Implement the required abstract method onChooseSuggestion(file: TFile, evt: MouseEvent | KeyboardEvent) { if (this.onChooseItem) { this.onChooseItem(file); } } }