vannamhh_progress-tracker/main.ts

5932 lines
183 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
App,
Plugin,
PluginSettingTab,
Setting,
WorkspaceLeaf,
TFile,
MarkdownView,
ItemView,
Editor,
MarkdownPostProcessorContext,
debounce,
Notice,
SuggestModal,
} from "obsidian";
/**
* Debug logger utility that only logs when debug mode is enabled
* Prevents console spam in production builds
*/
class DebugLogger {
private isDebugEnabled: () => boolean;
constructor(isDebugEnabled: () => boolean) {
this.isDebugEnabled = isDebugEnabled;
}
log(message: string, ...args: any[]): void {
if (this.isDebugEnabled()) {
console.log(`[Progress Tracker] ${message}`, ...args);
}
}
error(message: string, error?: Error): void {
if (this.isDebugEnabled()) {
console.error(`[Progress Tracker ERROR] ${message}`, error);
}
}
warn(message: string, ...args: any[]): void {
if (this.isDebugEnabled()) {
console.warn(`[Progress Tracker WARNING] ${message}`, ...args);
}
}
}
/**
* Type-safe interface for Dataview API
*/
interface DataviewApi {
executeJs(
code: string,
container: HTMLElement,
sourcePath?: string
): Promise<any>;
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<string>;
};
}
/**
* 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<string, string> = new Map();
private isUpdatingFromKanban: boolean = false;
// Track which Kanban files have been auto-synced to avoid repeat runs
private autoSyncedFiles: Set<string> = new Set();
// Track last update time for each file to detect timing conflicts
private lastFileUpdateMap: Map<string, number> = new Map();
// NEW: Smart Kanban interaction detection
private kanbanNormalizationDetector: Map<string, {
preChangeCheckpoints: Map<number, string>;
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<string, any> = {
"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;
// If view already exists in leaves, show it
const leaves = workspace.getLeavesOfType("progress-tracker");
if (leaves.length > 0) {
workspace.revealLeaf(leaves[0]);
return;
}
// Otherwise, create a new leaf in the left sidebar
// Check if workspace is ready
workspace.onLayoutReady(() => {
const leaf = workspace.getLeftLeaf(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) {
// Use textContent instead of innerHTML for safety
style.textContent = `
.workspace-tabs.mod-top:has(.progress-tracker-leaf) {
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<string, any>): 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 = [
/<script[^>]*>/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<string, number>();
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<T>(
operation: () => Promise<T>,
context: string,
fallbackValue: T
): Promise<T> {
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<void> {
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<string, number>();
const newColumnCounts = new Map<string, number>();
// 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<string> {
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<Record<string, { items: Array<{ text: string }> }>> {
const kanban: Record<string, { items: Array<{ text: string }> }> = {};
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, { items: Array<{ text: string }> }>
): 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<boolean> {
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<void> {
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<string> {
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<string> {
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<void> {
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<void> {
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<string, number> = new Map(); // Track last update time per file
initialLoadComplete: boolean = false; // Track if initial load is complete
completedFilesMap: Map<string, boolean> = 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
const leaf = this.leaf as any;
if (leaf && leaf.containerEl) {
leaf.containerEl.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",
});
}
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<boolean> {
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<number> {
// 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<string> {
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<string, string[]> = {
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<string | null> {
// 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<void> {
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<Record<string, { items: Array<{ text: string }> }>> {
const kanban: Record<string, { items: Array<{ text: string }> }> = {};
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<boolean> {
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<TFile> {
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);
}
}
}