From f7be28f798ce64467040666a95227dfa7841c1c8 Mon Sep 17 00:00:00 2001 From: Hoang Nam Date: Sat, 12 Jul 2025 15:57:43 +0700 Subject: [PATCH] feat: add custom checkbox states and Kanban normalization protection --- README.md | 67 +- main.ts | 2624 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 2657 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 2648708..94ef96b 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,13 @@ A plugin for Obsidian that helps you track progress of your tasks in real-time. It provides a visual progress bar and integrates with Kanban boards to help you manage your tasks more effectively. -![Progress Tracker Main View](Screenshot.jpg) - ## Features - Real-time progress tracking of tasks in your notes - Visual progress bar showing completion percentage - Automatic status updates based on task completion - Kanban board integration for task management +- **Custom checkbox states for Kanban columns** (NEW!) - Customizable colors and thresholds - Debug mode for troubleshooting @@ -42,6 +41,63 @@ The plugin can automatically move cards in your Kanban boards based on task comp 2. Configure the target Kanban board in plugin settings 3. As you complete tasks, cards will move to appropriate columns +### Custom Checkbox States (NEW!) + +You can now configure custom checkbox states for different Kanban columns: + +1. Enable "Custom Checkbox States" in plugin settings +2. Configure mappings between column names and checkbox states: + - `Todo` → `[ ]` (unchecked) + - `In Progress` → `[/]` (in progress) + - `Complete` → `[x]` (completed) + - `Done` → `[x]` (completed) + - Or any custom states like `[>]`, `[-]`, etc. + +3. When cards are moved between columns, their checkbox states will automatically update + +#### Supported Checkbox States + +- `[ ]` - Todo/Unchecked +- `[x]` - Completed +- `[/]` - In Progress +- `[>]` - Forwarded/Deferred +- `[-]` - Cancelled +- `[!]` - Important + +#### Kanban Normalization Protection (NEW!) + +The plugin now includes intelligent protection against unwanted checkbox state normalization by the Kanban plugin: + +- **Real-Time Detection**: Instantly detects when the Kanban plugin tries to convert custom states to standard states +- **Immediate Reversion**: Automatically reverts unwanted normalizations in real-time +- **Smart Pattern Analysis**: Distinguishes between legitimate user changes and unwanted plugin normalizations +- **Selective Protection**: Only protects states that match your column mappings while preserving legitimate changes +- **Multi-Layer Defense**: Combines immediate detection, movement analysis, and content comparison for comprehensive protection +- **Non-Invasive**: Works seamlessly without interfering with normal Kanban operations +- **Debug Support**: Comprehensive logging and testing commands for troubleshooting + +**How it works:** +1. **Immediate Detection**: Monitors editor changes in real-time to catch normalization as it happens +2. **Pattern Recognition**: Analyzes content changes to identify unwanted custom state → [x] conversions +3. **Smart Reversion**: Automatically restores correct checkbox states based on column mappings +4. **Movement Protection**: Prevents card movements from triggering unwanted normalization cascades + +This ensures your custom checkbox states (like `[/]`, `[~]`, etc.) remain intact when working with Kanban boards, even when the Kanban plugin tries to normalize them. + +### Enhanced Custom State Support + +The plugin now properly recognizes and counts all custom checkbox states with advanced conflict prevention and precision targeting. When custom checkbox states are enabled: + +- **Progress Calculation**: Custom states (like `[/]`, `[-]`, `[~]`) are counted as "tasks in progress" and included in the total task count +- **Smart Auto-Sync**: Auto-sync only updates cards that don't already have the correct checkbox state for their column +- **Conflict Prevention**: Advanced timing detection prevents race conditions between auto-sync and manual card movements +- **Precision Targeting**: Only cards that actually moved between columns are updated, eliminating false positives +- **Content Normalization**: Card movements are detected by normalized content comparison, not checkbox states or fuzzy matching +- **Preserved States**: Cards with existing correct states are left unchanged to prevent unnecessary modifications +- **Enhanced Detection**: All task-related changes are properly detected regardless of checkbox state + +**Note:** When updating checkbox states, only the main card checkbox is modified. Sub-items and nested checkboxes within cards are preserved to maintain data integrity. The plugin uses sophisticated position-based replacement and precision targeting to ensure only cards that actually moved are updated, completely preventing accidental modifications to other cards. + ### Settings #### Progress Bar Colors @@ -64,6 +120,12 @@ The plugin can automatically move cards in your Kanban boards based on task comp - Configure target Kanban board - Set up column mapping for task states +#### Custom Checkbox States +- Enable custom checkbox state management +- Configure column-to-checkbox mappings +- Add/remove column mappings as needed +- Reset to default mappings + ## Troubleshooting If you encounter issues: @@ -72,6 +134,7 @@ If you encounter issues: 2. Check if Dataview plugin is installed and enabled 3. Verify your Kanban board configuration 4. Clear the completed files cache if needed +5. Check custom checkbox state mappings if using that feature ## Support diff --git a/main.ts b/main.ts index 180aac0..a18571c 100644 --- a/main.ts +++ b/main.ts @@ -59,6 +59,12 @@ function getDataviewAPI(app: App): DataviewApi | null { 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"; @@ -90,6 +96,15 @@ interface TaskProgressBarSettings { 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 = { @@ -123,6 +138,16 @@ const DEFAULT_SETTINGS: TaskProgressBarSettings = { 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 { @@ -132,6 +157,19 @@ export default class TaskProgressBarPlugin extends Plugin { private lastActiveFile: TFile | null = null; private lastFileContent: string = ""; private dataviewCheckInterval: number | null = null; + // 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; + lastKanbanUIInteraction: number; + pendingNormalizationCheck: number | null; + }> = new Map(); async onload() { await this.loadSettings(); @@ -162,7 +200,29 @@ export default class TaskProgressBarPlugin extends Plugin { if (file) { this.lastActiveFile = file; - // Giảm delay từ 300ms xuống 100ms + // 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) { + + if (this.settings.showDebugInfo) { + console.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 if (this.settings.showDebugInfo) { + console.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) { @@ -174,23 +234,140 @@ export default class TaskProgressBarPlugin extends Plugin { }) ); + // 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)) { + + if (this.settings.showDebugInfo) { + console.log(`File modified event for Kanban board: ${file.path}`); + } + + // Skip if we're currently updating to avoid loops + if (this.isUpdatingFromKanban) { + if (this.settings.showDebugInfo) { + console.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) { + if (this.settings.showDebugInfo) { + console.log('Skipping editor-change - currently updating from Kanban'); + } + return; + } + // Get current editor content const content = editor.getValue(); + const currentFile = view.file; - // Check if content contains tasks + // 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)) { + if (this.settings.showDebugInfo) { + console.log(`Detected Kanban board change: ${currentFile.path}`); + console.log(`Content length: ${content.length}, lastFileContent length: ${this.lastFileContent.length}`); + console.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); + if (this.settings.showDebugInfo) { + console.log(`Immediate normalization check: ${hasImmediateNormalization}`); + } + if (hasImmediateNormalization) { + if (this.settings.showDebugInfo) { + console.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 + if (this.settings.showDebugInfo) { + 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++; + console.log(`DEBUG: Line ${i} checkbox change: [${oldMatch[2]}] → [${newMatch[2]}]`); + } + } + if (changeCount > 0) { + console.log(`DEBUG: 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("- [") || - content.includes("- [ ]") || - content.includes("- [x]") || this.lastFileContent.includes("- [") || - this.lastFileContent.includes("- [ ]") || - this.lastFileContent.includes("- [x]") + /- \[[^\]]*\]/.test(content) || + /- \[[^\]]*\]/.test(this.lastFileContent) ) { // Check if tasks have changed if (this.hasTaskContentChanged(this.lastFileContent, content)) { @@ -238,11 +415,10 @@ export default class TaskProgressBarPlugin extends Plugin { setTimeout(() => { const content = activeView.editor.getValue(); - // Check if content contains tasks and if they have changed + // Check if content contains tasks and if they have changed (enhanced for custom states) if ( (content.includes("- [") || - content.includes("- [ ]") || - content.includes("- [x]")) && + /- \[[^\]]*\]/.test(content)) && this.hasTaskContentChanged(this.lastFileContent, content) ) { this.lastActiveFile = activeView.file; @@ -333,6 +509,554 @@ export default class TaskProgressBarPlugin extends Plugin { } }, }); + + // 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; + } + + console.log("=== Kanban Sync Debug Info ==="); + console.log(`Current file: ${currentFile.path}`); + console.log(`enableKanbanToFileSync: ${this.settings.enableKanbanToFileSync}`); + console.log(`enableCustomCheckboxStates: ${this.settings.enableCustomCheckboxStates}`); + console.log(`enableKanbanAutoSync: ${this.settings.enableKanbanAutoSync}`); + console.log(`enableKanbanNormalizationProtection: ${this.settings.enableKanbanNormalizationProtection}`); + console.log(`isKanbanBoard: ${this.isKanbanBoard(currentFile)}`); + console.log(`Checkbox mappings:`, this.settings.kanbanColumnCheckboxMappings); + console.log(`Last Kanban content stored: ${this.lastKanbanContent.has(currentFile.path)}`); + console.log(`Auto-synced files: ${Array.from(this.autoSyncedFiles)}`); + console.log(`Current file auto-synced: ${this.autoSyncedFiles.has(currentFile.path)}`); + console.log(`isUpdatingFromKanban flag: ${this.isUpdatingFromKanban}`); + console.log(`lastFileContent length: ${this.lastFileContent.length}`); + console.log(`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)!; + console.log(`Stored content preview (first 200 chars): ${storedContent.substring(0, 200)}...`); + } + + 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."); + + if (this.settings.showDebugInfo) { + console.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 Dataview API and set up interval to check again if not found @@ -411,6 +1135,9 @@ export default class TaskProgressBarPlugin extends Plugin { if (this.sidebarView) { this.sidebarView.clearCompletedFilesCache(); } + + // NEW: Clear normalization detector state + this.kanbanNormalizationDetector.clear(); } async loadSettings() { @@ -470,15 +1197,16 @@ export default class TaskProgressBarPlugin extends Plugin { /** * Check if content changes are related to tasks + * Updated to support custom checkbox states like [/], [-], [~], etc. */ 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 - const oldTasks = oldLines.filter(line => line.trim().match(/^[-*] \[[x ]\]/i)); - const newTasks = newLines.filter(line => line.trim().match(/^[-*] \[[x ]\]/i)); + // 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) { @@ -503,6 +1231,1541 @@ export default class TaskProgressBarPlugin extends Plugin { } return false; } + + /** + * Check if a file is a Kanban board (public wrapper for sidebar view method) + */ + 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 { + if (this.settings.showDebugInfo) { + console.log(`handleKanbanBoardChange called for: ${kanbanFile.path}`); + console.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) { + console.error("Error handling Kanban board change:", error); + if (this.settings.showDebugInfo) { + console.error("Error details:", error); + } + } + } + + /** + * 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> { + 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>(); + const newCardPositions = new Map>(); + + // 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) { + console.error("Error detecting card movements:", error); + 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) { + console.error("Error updating card checkbox states in Kanban:", error); + if (this.settings.showDebugInfo) { + console.error("Error details:", error); + } + 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 { @@ -594,7 +2857,7 @@ class TaskProgressBarView extends ItemView { try { // Update immediately if content is provided if (content) { - if (!this.hasTasksInContent(content)) { + if (!this.hasTasksInContentExtended(content)) { // Only clear if not already showing "no tasks" message if (!progressContainer.querySelector(".no-tasks-message")) { progressContainer.empty(); @@ -615,7 +2878,7 @@ class TaskProgressBarView extends ItemView { // Read file content const fileContent = await this.plugin.app.vault.read(file); - if (!this.hasTasksInContent(fileContent)) { + if (!this.hasTasksInContentExtended(fileContent)) { // Only clear if not already showing "no tasks" message if (!progressContainer.querySelector(".no-tasks-message")) { progressContainer.empty(); @@ -653,15 +2916,10 @@ class TaskProgressBarView extends ItemView { } // Helper method to quickly check if content has tasks - hasTasksInContent(content: string): boolean { - // Improved and more accurate task detection - const standardTaskRegex = /- \[[x ]\]/i; - const relaxedTaskRegex = /[-*] \[[x ]\]/i; - - // Return true if either regex matches - return ( - standardTaskRegex.test(content) || relaxedTaskRegex.test(content) - ); + 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 @@ -694,12 +2952,39 @@ class TaskProgressBarView extends ItemView { return; } - // Use more accurate regex to count tasks - const incompleteTasks = (content.match(/- \[ \]/g) || []).length; - const completedTasks = (content.match(/- \[x\]/gi) || []).length; - let totalTasks = incompleteTasks + completedTasks; + // 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 + // Try with relaxed regex if needed (for legacy compatibility) let relaxedIncompleteTasks = 0; let relaxedCompletedTasks = 0; if (totalTasks === 0) { @@ -713,7 +2998,7 @@ class TaskProgressBarView extends ItemView { // Log task counts for debugging if (this.plugin.settings.showDebugInfo) { console.log( - `Task counts - incomplete: ${incompleteTasks}, completed: ${completedTasks}, total: ${totalTasks}` + `Task counts - incomplete: ${incompleteTasks}, completed: ${completedTasks}, custom states: ${customStateTasks}, total: ${totalTasks}` ); if (relaxedIncompleteTasks > 0 || relaxedCompletedTasks > 0) { console.log( @@ -738,7 +3023,7 @@ class TaskProgressBarView extends ItemView { // Calculate percentage based on which regex found tasks let completedCount = - incompleteTasks > 0 || completedTasks > 0 + incompleteTasks > 0 || completedTasks > 0 || customStateTasks > 0 ? completedTasks : relaxedCompletedTasks; @@ -1550,7 +3835,15 @@ class TaskProgressBarView extends ItemView { // If we found the card, move it if (cardStartIndex !== -1 && cardEndIndex !== -1) { // Extract the card content - const cardLines = lines.slice(cardStartIndex, cardEndIndex + 1); + 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); @@ -1573,6 +3866,10 @@ class TaskProgressBarView extends ItemView { 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`); + } } } } @@ -2242,7 +4539,13 @@ class TaskProgressBarView extends ItemView { const insertPosition = columnMatch.index! + columnMatch[0].length; // Create card text with link to the file - const cardText = `- [[${file.basename}]]\n`; + 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 = @@ -2267,6 +4570,78 @@ class TaskProgressBarView extends ItemView { 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 { @@ -3092,6 +5467,191 @@ class TaskProgressBarSettingTab extends PluginSettingTab { }) ); } + + // 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 + }); + } } }