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