feat: Add SettingsTab for Task Progress Bar plugin with comprehensive configuration options

- Implemented a new settings tab for the Task Progress Bar plugin.
- Added options for Dataview API status check, debug info display, animation settings, performance tuning, and color scheme customization.
- Included metadata auto-update settings and Kanban integration features.
- Enabled custom checkbox states configuration for Kanban columns.
- Provided user-friendly UI elements such as sliders, toggles, and buttons for settings adjustments.
- Implemented functionality to reset settings to defaults and validate user inputs.
This commit is contained in:
Hoang Nam 2026-01-18 18:39:49 +07:00
parent 3f35a8a4e3
commit 3db00eae9b
13 changed files with 5447 additions and 6893 deletions

6905
main.ts

File diff suppressed because it is too large Load diff

109
src/interfaces/settings.ts Normal file
View file

@ -0,0 +1,109 @@
import { KanbanColumnCheckboxMapping } from "./types";
/**
* Main settings interface for the Task Progress Bar plugin
* Contains all configurable options for the plugin behavior
*/
export interface TaskProgressBarSettings {
// Debug settings
showDebugInfo: boolean;
// Progress bar color settings
progressColorScheme: "default" | "red-orange-green" | "custom";
lowProgressColor: string;
mediumProgressColor: string;
highProgressColor: string;
completeProgressColor: string;
lowProgressThreshold: number;
mediumProgressThreshold: number;
highProgressThreshold: number;
// Animation settings
showUpdateAnimation: boolean;
updateAnimationDelay: number;
// Performance settings
editorChangeDelay: number;
keyboardInputDelay: number;
checkboxClickDelay: number;
// Interface settings
maxTabsHeight: string;
// Metadata auto-update settings
autoUpdateMetadata: boolean;
autoChangeStatus: boolean;
autoUpdateFinishedDate: boolean;
// Kanban integration settings
autoUpdateKanban: boolean;
kanbanCompletedColumn: string; // Deprecated but kept for backward compatibility
statusTodo: string;
statusInProgress: string;
statusCompleted: string;
kanbanAutoDetect: boolean;
kanbanSpecificFiles: string[];
kanbanExcludeFiles: string[];
kanbanSyncWithStatus: boolean;
// Auto-add to Kanban settings
autoAddToKanban: boolean;
autoAddKanbanBoard: string;
autoAddKanbanColumn: string;
// Custom checkbox states settings
enableCustomCheckboxStates: boolean;
kanbanColumnCheckboxMappings: KanbanColumnCheckboxMapping[];
// Kanban sync settings
enableKanbanToFileSync: boolean;
enableKanbanAutoSync: boolean;
enableKanbanNormalizationProtection: boolean;
}
/**
* Default settings values for the plugin
* Used when no saved settings exist or for resetting to defaults
*/
export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
showDebugInfo: false,
progressColorScheme: "default",
lowProgressColor: "#e06c75", // Red
mediumProgressColor: "#e5c07b", // Orange/Yellow
highProgressColor: "#61afef", // Blue
completeProgressColor: "#98c379", // Green
lowProgressThreshold: 33,
mediumProgressThreshold: 66,
highProgressThreshold: 99,
showUpdateAnimation: true,
updateAnimationDelay: 150,
editorChangeDelay: 200,
keyboardInputDelay: 50,
checkboxClickDelay: 100,
maxTabsHeight: "auto",
autoUpdateMetadata: true,
autoChangeStatus: true,
autoUpdateFinishedDate: true,
autoUpdateKanban: true,
kanbanCompletedColumn: "Complete", // Deprecated
statusTodo: "Todo",
statusInProgress: "In Progress",
statusCompleted: "Completed",
kanbanAutoDetect: true,
kanbanSpecificFiles: [],
kanbanExcludeFiles: [],
kanbanSyncWithStatus: true,
autoAddToKanban: false,
autoAddKanbanBoard: "",
autoAddKanbanColumn: "Todo",
enableCustomCheckboxStates: false,
kanbanColumnCheckboxMappings: [
{ columnName: "Todo", checkboxState: "[ ]" },
{ columnName: "In Progress", checkboxState: "[/]" },
{ columnName: "Complete", checkboxState: "[x]" },
{ columnName: "Done", checkboxState: "[x]" },
],
enableKanbanToFileSync: false,
enableKanbanAutoSync: false,
enableKanbanNormalizationProtection: true,
};

112
src/interfaces/types.ts Normal file
View file

@ -0,0 +1,112 @@
import { App } from "obsidian";
/**
* Type-safe interface for Dataview API
* Provides methods for executing JavaScript in Dataview context and accessing page data
*/
export interface DataviewApi {
executeJs(
code: string,
container: HTMLElement,
sourcePath?: string
): Promise<any>;
page(path: string): any;
pages(source: string): any[];
}
/**
* Type-safe interface for accessing Obsidian plugins
* Extended App interface to safely access internal plugin APIs
*/
export interface ObsidianApp extends App {
plugins?: {
plugins?: {
dataview?: {
api?: DataviewApi;
};
};
enabledPlugins?: Set<string>;
};
}
/**
* Extended window interface for Dataview API access
* Dataview may expose its API on the global window object
*/
declare global {
interface Window {
DataviewAPI?: DataviewApi;
}
}
/**
* Interface for mapping Kanban column names to checkbox states
* Allows custom checkbox states for different workflow stages
*/
export interface KanbanColumnCheckboxMapping {
columnName: string;
checkboxState: string; // e.g., "[ ]", "[/]", "[x]", "[>]", etc.
}
/**
* Structure for storing Kanban column data with items
*/
export interface KanbanColumn {
items: Array<{ text: string }>;
}
/**
* Type for parsed Kanban board structure
*/
export type KanbanBoard = Record<string, KanbanColumn>;
/**
* Interface for card movement tracking
*/
export interface CardMovement {
card: string;
oldColumn: string;
newColumn: string;
cardIndex: number;
}
/**
* Interface for normalization detection detector state
*/
export interface NormalizationDetectorState {
preChangeCheckpoints: Map<number, string>;
lastKanbanUIInteraction: number;
pendingNormalizationCheck: number | null;
}
/**
* Interface for checkbox normalization patterns analysis result
*/
export interface CheckboxNormalizationAnalysis {
hasNormalization: boolean;
normalizedStates: Array<{ line: number; from: string; to: string }>;
}
/**
* Interface for extracted Obsidian-style links
*/
export interface ObsidianLink {
path: string;
alias?: string;
}
/**
* Interface for extracted Markdown-style links
*/
export interface MarkdownLink {
text: string;
url: string;
}
/**
* Interface for card content extraction result
*/
export interface CardContent {
content: string;
lineCount: number;
}

488
src/main.ts Normal file
View file

@ -0,0 +1,488 @@
/**
* Progress Tracker Plugin for Obsidian
*
* A plugin that tracks task progress in markdown files and integrates with Kanban boards.
* Supports custom checkbox states and automatic status updates.
*/
import {
App,
Plugin,
WorkspaceLeaf,
TFile,
MarkdownView,
debounce,
} from "obsidian";
// Import interfaces and types
import { TaskProgressBarSettings, DEFAULT_SETTINGS } from "./interfaces/settings";
import { DataviewApi } from "./interfaces/types";
// Import utilities
import { DebugLogger } from "./utils/logger";
// Import services
import { DataviewService } from "./services/DataviewService";
import { FileService } from "./services/FileService";
import { KanbanService } from "./services/KanbanService";
// Import views
import { TaskProgressBarView } from "./views/ProgressBarView";
import { TaskProgressBarSettingTab } from "./views/SettingsTab";
/**
* Main plugin class for Progress Tracker
*/
export default class TaskProgressBarPlugin extends Plugin {
settings: TaskProgressBarSettings;
dvAPI: DataviewApi | null = null;
sidebarView: TaskProgressBarView | null = null;
// Services
private dataviewService: DataviewService;
private fileService: FileService;
private kanbanService: KanbanService;
// Internal state
private lastActiveFile: TFile | null = null;
private lastFileContent: string = "";
private logger: DebugLogger;
async onload() {
await this.loadSettings();
// Initialize debug logger
this.logger = new DebugLogger(() => this.settings.showDebugInfo);
// Initialize services
this.dataviewService = new DataviewService(this.app, this.logger);
this.fileService = new FileService(this.app, this.logger);
this.kanbanService = new KanbanService(
this.app,
this.settings,
this.logger,
this.fileService
);
// Apply the max-height CSS style as soon as the plugin loads
this.applyMaxTabsHeightStyle();
// Register view type for the sidebar
this.registerView(
"progress-tracker",
(leaf) => {
this.sidebarView = new TaskProgressBarView(
leaf,
this.app,
this.settings,
this.logger,
this.dvAPI
);
// Set up callbacks for Kanban integration
this.sidebarView.setIsKanbanBoardFn((file: TFile) => this.kanbanService.isKanbanBoard(file));
return this.sidebarView;
}
);
// Add icon to the left sidebar
this.addRibbonIcon("bar-chart-horizontal", "Progress Tracker", () => {
this.activateView();
});
// Add settings tab
this.addSettingTab(new TaskProgressBarSettingTab(this.app, this));
// Check Dataview API and set up interval to check again if not found
this.checkDataviewAPI();
// Register event handlers
this.registerEventHandlers();
}
/**
* Register all event handlers for the plugin
*/
private registerEventHandlers(): void {
// Register event to update progress bar when file changes
this.registerEvent(
this.app.workspace.on("file-open", (file) => {
if (file) {
this.lastActiveFile = file;
// Handle auto-sync for Kanban boards
if (
this.settings.enableKanbanAutoSync &&
this.settings.enableCustomCheckboxStates &&
this.kanbanService.isKanbanBoard(file) &&
!this.kanbanService.hasBeenAutoSynced(file.path) &&
!this.kanbanService.getIsUpdatingFromKanban()
) {
this.logger.log(`Auto-syncing Kanban board on open: ${file.path}`);
setTimeout(async () => {
if (
!this.kanbanService.getIsUpdatingFromKanban() &&
!this.kanbanService.getLastKanbanContent(file.path)
) {
await this.kanbanService.autoSyncKanbanCheckboxStates(file);
} else {
this.logger.log(
`Skipping auto-sync - update in progress or file already tracked`
);
}
}, 800);
}
// Original progress bar update logic
setTimeout(async () => {
await this.updateLastFileContent(file);
if (this.sidebarView) {
this.sidebarView.updateProgressBar(file);
}
}, 100);
}
})
);
// Register event to listen for file modifications (for Kanban UI changes)
this.registerEvent(
this.app.vault.on("modify", async (file) => {
if (
file instanceof TFile &&
this.settings.enableKanbanToFileSync &&
this.settings.enableCustomCheckboxStates &&
this.kanbanService.isKanbanBoard(file)
) {
this.logger.log(`File modified event for Kanban board: ${file.path}`);
if (this.kanbanService.getIsUpdatingFromKanban()) {
this.logger.log("Skipping file modify - currently updating from plugin");
return;
}
setTimeout(async () => {
try {
const newContent = await this.app.vault.read(file);
await this.kanbanService.handleKanbanBoardChange(file, newContent);
} catch (error) {
this.logger.error("Error handling file modify for Kanban board:", error as Error);
}
}, 100);
}
})
);
// Register event to update progress bar when editor changes
this.registerEvent(
this.app.workspace.on(
"editor-change",
debounce(async (editor, view) => {
if (view instanceof MarkdownView && this.sidebarView) {
if (this.kanbanService.getIsUpdatingFromKanban()) {
this.logger.log("Skipping editor-change - currently updating from Kanban");
return;
}
const content = editor.getValue();
const currentFile = view.file;
// Check if this is a Kanban board file and handle card checkbox sync
if (
this.settings.enableKanbanToFileSync &&
this.settings.enableCustomCheckboxStates &&
currentFile &&
this.kanbanService.isKanbanBoard(currentFile)
) {
this.logger.log(`Detected Kanban board change: ${currentFile.path}`);
// Enhanced immediate Kanban normalization protection
if (this.settings.enableKanbanNormalizationProtection) {
const hasImmediateNormalization =
this.kanbanService.detectImmediateKanbanNormalization(
this.lastFileContent,
content
);
this.logger.log(`Immediate normalization check: ${hasImmediateNormalization}`);
if (hasImmediateNormalization) {
this.logger.log("Detected immediate Kanban normalization - reverting unwanted changes");
const revertedContent = this.kanbanService.revertKanbanNormalization(
this.lastFileContent,
content,
currentFile
);
if (revertedContent !== content) {
this.kanbanService.setIsUpdatingFromKanban(true);
setTimeout(async () => {
const syncedContent =
await this.kanbanService.syncAllCheckboxStatesToMappings(
currentFile,
revertedContent
);
await this.app.vault.modify(currentFile, syncedContent);
this.lastFileContent = syncedContent;
await this.kanbanService.forceRefreshKanbanUI(currentFile);
setTimeout(() => {
this.kanbanService.setIsUpdatingFromKanban(false);
}, 100);
}, 50);
return;
}
}
}
setTimeout(async () => {
const latestContent = await this.app.vault.read(currentFile);
await this.kanbanService.handleKanbanBoardChange(currentFile, latestContent);
}, 200);
}
// Original logic for regular file changes
if (
content.includes("- [") ||
this.lastFileContent.includes("- [") ||
/- \[[^\]]*\]/.test(content) ||
/- \[[^\]]*\]/.test(this.lastFileContent)
) {
const hasContentChanged = this.hasTaskContentChanged(
this.lastFileContent,
content
);
if (hasContentChanged) {
this.lastFileContent = content;
if (currentFile) {
this.sidebarView.updateProgressBar(currentFile, content);
}
}
}
}
}, this.settings.editorChangeDelay)
)
);
// Listen for keydown events to detect when user enters new tasks or checks/unchecks tasks
this.registerDomEvent(document, "keydown", (evt: KeyboardEvent) => {
// Check if we're in the editor
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (activeView && activeView.getMode() === "source") {
// Update immediately when pressing keys related to tasks
if (
["Enter", "Space", "]", "x", "X", "Backspace", "Delete"].includes(evt.key)
) {
// Update immediately
setTimeout(() => {
const content = activeView.editor.getValue();
// Check if content contains tasks and if they have changed (enhanced for custom states)
if (
(content.includes("- [") || /- \[[^\]]*\]/.test(content)) &&
this.hasTaskContentChanged(this.lastFileContent, content)
) {
this.lastActiveFile = activeView.file;
// Update progress bar immediately
if (this.sidebarView) {
this.sidebarView.updateProgressBar(activeView.file, content);
}
// Then update last file content
this.lastFileContent = content;
}
}, this.settings.keyboardInputDelay);
}
}
});
// Listen for click events in the editor to detect when tasks are checked/unchecked
this.registerDomEvent(document, "click", (evt: MouseEvent) => {
const target = evt.target as HTMLElement;
// Check if click is on a task checkbox
if (
target &&
target.tagName === "INPUT" &&
target.classList.contains("task-list-item-checkbox")
) {
// Wait a bit for Obsidian to update the task state in the file
setTimeout(async () => {
const activeFile = this.app.workspace.getActiveFile();
if (activeFile && this.sidebarView) {
// Read current file content
const content = await this.app.vault.read(activeFile);
// Only update if tasks have changed
if (this.hasTaskContentChanged(this.lastFileContent, content)) {
// Update progress bar immediately
this.lastActiveFile = activeFile;
this.sidebarView.updateProgressBar(activeFile, content);
// Then update last file content
this.lastFileContent = content;
}
}
}, this.settings.checkboxClickDelay);
}
});
// Activate view when plugin loads - wait a bit for Obsidian to fully start
setTimeout(() => {
this.activateView();
// We'll use a single delayed update instead of multiple updates
setTimeout(async () => {
const currentFile = this.app.workspace.getActiveFile();
if (currentFile && this.sidebarView) {
this.logger.log("Initial file load after plugin start:", currentFile.path);
await this.updateLastFileContent(currentFile);
// Use a flag to indicate this is the initial load
this.sidebarView.updateProgressBar(currentFile, undefined, true);
}
}, 1500);
}, 1000);
}
/**
* Check if task content has changed between two versions
*/
private hasTaskContentChanged(oldContent: string, newContent: string): boolean {
// Quick length check first
if (oldContent.length !== newContent.length) {
return true;
}
// Extract task lines for comparison
const oldTaskLines = oldContent
.split("\n")
.filter((line) => /- \[[^\]]*\]/.test(line));
const newTaskLines = newContent
.split("\n")
.filter((line) => /- \[[^\]]*\]/.test(line));
// Check if number of tasks changed
if (oldTaskLines.length !== newTaskLines.length) {
return true;
}
// Check if any task content changed
for (let i = 0; i < oldTaskLines.length; i++) {
if (oldTaskLines[i] !== newTaskLines[i]) {
return true;
}
}
return false;
}
/**
* Update the cached file content
*/
private async updateLastFileContent(file: TFile): Promise<void> {
try {
this.lastFileContent = await this.app.vault.read(file);
} catch (error) {
this.logger.error("Error reading file content:", error as Error);
}
}
async onunload() {
// Cleanup services
this.dataviewService.cleanup();
this.fileService.cleanup();
this.kanbanService.cleanup();
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
// Update services with new settings
if (this.kanbanService) {
this.kanbanService.updateSettings(this.settings);
}
if (this.sidebarView) {
this.sidebarView.updateSettings(this.settings);
}
// Apply max tabs height style
this.applyMaxTabsHeightStyle();
}
/**
* Apply max-height style to workspace tabs
*/
private applyMaxTabsHeightStyle(): void {
const styleId = "progress-tracker-max-tabs-height";
let styleEl = document.getElementById(styleId);
if (!styleEl) {
styleEl = document.createElement("style");
styleEl.id = styleId;
document.head.appendChild(styleEl);
}
const maxHeight = this.settings.maxTabsHeight || "auto";
styleEl.textContent = `
.workspace-tabs:has(.workspace-tab-container .progress-tracker-leaf) {
max-height: ${maxHeight};
}
`;
}
/**
* Check and initialize Dataview API
*/
checkDataviewAPI(): void {
this.dvAPI = this.dataviewService.getDataviewAPI();
if (this.dvAPI) {
this.logger.log("Dataview API found and cached");
// Update the sidebar view with the new API
if (this.sidebarView) {
this.sidebarView.updateDataviewAPI(this.dvAPI);
}
} else {
this.logger.log("Dataview API not found, starting periodic check");
this.dataviewService.startPeriodicCheck((api) => {
this.dvAPI = api;
if (this.sidebarView) {
this.sidebarView.updateDataviewAPI(this.dvAPI);
}
});
}
}
/**
* Activate the sidebar view
*/
async activateView(): Promise<void> {
const { workspace } = this.app;
let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType("progress-tracker");
if (leaves.length > 0) {
leaf = leaves[0];
} else {
leaf = workspace.getRightLeaf(false);
if (leaf) {
await leaf.setViewState({ type: "progress-tracker", active: true });
}
}
if (leaf) {
workspace.revealLeaf(leaf);
}
}
}

View file

@ -0,0 +1,126 @@
import { App } from "obsidian";
import { DataviewApi, ObsidianApp } from "../interfaces/types";
import { DebugLogger } from "../utils/logger";
/**
* Service for managing Dataview API integration
* Provides methods for safely accessing and checking Dataview availability
*/
export class DataviewService {
private app: App;
private logger: DebugLogger;
private dvAPI: DataviewApi | null = null;
private checkInterval: number | null = null;
constructor(app: App, logger: DebugLogger) {
this.app = app;
this.logger = logger;
}
/**
* Safely get Dataview API with proper type checking
* @returns DataviewApi instance or null if not available
*/
getDataviewAPI(): DataviewApi | null {
try {
// Method 1: Through window object (most reliable)
if (typeof window !== "undefined" && window.DataviewAPI) {
return window.DataviewAPI;
}
// Method 2: Through app.plugins with type safety
const obsidianApp = this.app as ObsidianApp;
const dataviewPlugin = obsidianApp.plugins?.plugins?.dataview;
if (dataviewPlugin?.api) {
return dataviewPlugin.api;
}
// Method 3: Check if plugin is enabled
const enabledPlugins = obsidianApp.plugins?.enabledPlugins;
if (enabledPlugins?.has("dataview")) {
// Plugin is enabled but API not ready yet
return null;
}
return null;
} catch (error) {
console.error("Error accessing Dataview API:", error);
return null;
}
}
/**
* Check for Dataview API availability and cache the result
* @returns DataviewApi instance or null
*/
checkAndCacheAPI(): DataviewApi | null {
this.dvAPI = this.getDataviewAPI();
return this.dvAPI;
}
/**
* Get cached Dataview API
* @returns Cached DataviewApi instance or null
*/
getCachedAPI(): DataviewApi | null {
return this.dvAPI;
}
/**
* Start periodic checking for Dataview API availability
* @param onFound - Callback when Dataview API is found, receives the API instance
* @param intervalMs - Check interval in milliseconds (default 2000)
*/
startPeriodicCheck(onFound?: (api: DataviewApi) => void, intervalMs: number = 2000): void {
// Check immediately first
this.dvAPI = this.getDataviewAPI();
// If found immediately, call callback
if (this.dvAPI) {
if (onFound) {
onFound(this.dvAPI);
}
return;
}
// If not found, set up interval to check again
this.checkInterval = window.setInterval(() => {
this.dvAPI = this.getDataviewAPI();
if (this.dvAPI) {
// If found, clear interval
this.stopPeriodicCheck();
// Call callback if provided
if (onFound) {
onFound(this.dvAPI);
}
}
}, intervalMs);
}
/**
* Stop periodic checking for Dataview API
*/
stopPeriodicCheck(): void {
if (this.checkInterval) {
clearInterval(this.checkInterval);
this.checkInterval = null;
}
}
/**
* Check if Dataview API is available
* @returns true if Dataview API is available
*/
isAvailable(): boolean {
return this.dvAPI !== null;
}
/**
* Cleanup resources
*/
cleanup(): void {
this.stopPeriodicCheck();
this.dvAPI = null;
}
}

188
src/services/FileService.ts Normal file
View file

@ -0,0 +1,188 @@
import { App, TFile, Notice } from "obsidian";
import { DebugLogger } from "../utils/logger";
import { validateContent } from "../utils/helpers";
/**
* Service for safe file operations with validation and rate limiting
*/
export class FileService {
private app: App;
private logger: DebugLogger;
private fileOperationLimiter: Map<string, number> = new Map();
private readonly FILE_OPERATION_DELAY = 100; // Minimum ms between operations per file
constructor(app: App, logger: DebugLogger) {
this.app = app;
this.logger = logger;
}
/**
* Validate file before performing operations
* @param file - File to validate
* @returns true if file is safe to operate on
*/
isValidFile(file: TFile | null): boolean {
if (!file) return false;
// Check if file path is safe (no path traversal)
if (file.path.includes("..") || file.path.includes("//")) {
this.logger.error(`Unsafe file path detected: ${file.path}`);
return false;
}
// Check if file is markdown
if (!file.path.endsWith(".md")) {
this.logger.warn(`Non-markdown file: ${file.path}`);
return false;
}
// Check file size (prevent processing extremely large files)
if (file.stat.size > 10 * 1024 * 1024) {
// 10MB limit
this.logger.error(
`File too large: ${file.path} (${file.stat.size} bytes)`
);
return false;
}
return true;
}
/**
* Validate content before processing
* @param content - Content to validate
* @returns true if content is safe to process
*/
isValidContent(content: string): boolean {
const result = validateContent(content);
if (!result.isValid) {
this.logger.error(result.error || "Invalid content");
}
return result.isValid;
}
/**
* Check if file operation is rate limited
* @param filePath - Path of file to check
* @returns true if operation should be allowed
*/
checkRateLimit(filePath: string): boolean {
const now = Date.now();
const lastOperation = this.fileOperationLimiter.get(filePath);
if (lastOperation && now - lastOperation < this.FILE_OPERATION_DELAY) {
this.logger.warn(`Rate limited file operation: ${filePath}`);
return false;
}
this.fileOperationLimiter.set(filePath, now);
return true;
}
/**
* Safe async operation wrapper with error handling
* @param operation - Async operation to execute
* @param context - Context description for error handling
* @param fallbackValue - Value to return on error
*/
async safeAsyncOperation<T>(
operation: () => Promise<T>,
context: string,
fallbackValue: T
): Promise<T> {
try {
return await operation();
} catch (error) {
this.logger.error(
`${context}: ${
error instanceof Error ? error.message : String(error)
}`,
error instanceof Error ? error : undefined
);
return fallbackValue;
}
}
/**
* Read file content safely with validation
* @param file - File to read
* @returns File content or null on error
*/
async readFileSafe(file: TFile): Promise<string | null> {
if (!this.isValidFile(file)) {
return null;
}
return this.safeAsyncOperation(
async () => {
const content = await this.app.vault.read(file);
if (!this.isValidContent(content)) {
return null;
}
return content;
},
`Reading file ${file.path}`,
null
);
}
/**
* Modify file content safely with validation and rate limiting
* @param file - File to modify
* @param content - New content
* @returns true if modification was successful
*/
async modifyFileSafe(file: TFile, content: string): Promise<boolean> {
if (!this.isValidFile(file)) {
return false;
}
if (!this.isValidContent(content)) {
return false;
}
if (!this.checkRateLimit(file.path)) {
return false;
}
return this.safeAsyncOperation(
async () => {
await this.app.vault.modify(file, content);
return true;
},
`Modifying file ${file.path}`,
false
);
}
/**
* Standardized error handler for plugin operations
* @param error - Error object or message
* @param context - Context where error occurred
* @param showNotice - Whether to show user notification
*/
handleError(
error: Error | string,
context: string,
showNotice: boolean = false
): void {
const errorMessage = error instanceof Error ? error.message : error;
const fullMessage = `${context}: ${errorMessage}`;
this.logger.error(
fullMessage,
error instanceof Error ? error : undefined
);
if (showNotice) {
new Notice(`Progress Tracker Error: ${errorMessage}`);
}
}
/**
* Cleanup resources
*/
cleanup(): void {
this.fileOperationLimiter.clear();
}
}

File diff suppressed because it is too large Load diff

246
src/utils/helpers.ts Normal file
View file

@ -0,0 +1,246 @@
/**
* Safely escape regex special characters in a string
* Used by multiple methods that need to create regex patterns
* @param string - String to escape
* @returns Escaped string safe for use in regex
*/
export function escapeRegExp(string: string): string {
if (!string) return "";
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Validate CSS value to prevent XSS injection
* @param value - CSS value to validate
* @returns true if value is safe to use
*/
export function isValidCSSValue(value: string): boolean {
if (!value || typeof value !== "string") return false;
// Allow specific safe values
if (value === "auto" || value === "none") return true;
// Allow valid CSS length values (px, em, rem, vh, %)
const validCSSPattern = /^(\d+(\.\d+)?)(px|em|rem|vh|%)$/;
return validCSSPattern.test(value.trim());
}
/**
* Check if content is safe to process
* @param content - Content to validate
* @param maxSize - Maximum allowed content size in bytes (default 5MB)
* @returns Object with isValid flag and optional error message
*/
export function validateContent(
content: string,
maxSize: number = 5 * 1024 * 1024
): { isValid: boolean; error?: string } {
if (typeof content !== "string") {
return { isValid: false, error: "Content is not a string" };
}
// Check content size
if (content.length > maxSize) {
return {
isValid: false,
error: `Content too large: ${content.length} characters`,
};
}
// Check for suspicious patterns that might indicate injection
const suspiciousPatterns = [
/<script[^>]*>/i,
/javascript:/i,
/data:text\/html/i,
/vbscript:/i,
];
for (const pattern of suspiciousPatterns) {
if (pattern.test(content)) {
return {
isValid: false,
error: "Suspicious content pattern detected",
};
}
}
return { isValid: true };
}
/**
* Check if content has tasks using extended pattern
* Supports custom checkbox states like [/], [-], [~], etc.
* @param content - Content to check
* @returns true if content contains tasks
*/
export function hasTasksInContent(content: string): boolean {
const extendedTaskRegex = /- \[[^\]]*\]/i;
return extendedTaskRegex.test(content);
}
/**
* Count tasks with different checkbox states
* @param content - Content to parse
* @returns Object with checkbox state as key and count as value
*/
export function countTasksByCheckboxState(content: string): {
[state: string]: number;
} {
const taskCounts: { [state: string]: number } = {};
const lines = content.split("\n");
for (const line of lines) {
const match = line.trim().match(/^- \[([^\]]*)\]/);
if (match) {
const state = match[1];
taskCounts[state] = (taskCounts[state] || 0) + 1;
}
}
return taskCounts;
}
/**
* Check if content changes are related to tasks
* Supports custom checkbox states like [/], [-], [~], etc.
* @param oldContent - Previous file content
* @param newContent - Current file content
* @returns true if task-related changes detected
*/
export function hasTaskContentChanged(
oldContent: string,
newContent: string
): boolean {
// Split content into lines
const oldLines = oldContent.split("\n");
const newLines = newContent.split("\n");
// Find task lines in both contents - support all checkbox states
const oldTasks = oldLines.filter((line) =>
line.trim().match(/^[-*] \[[^\]]*\]/i)
);
const newTasks = newLines.filter((line) =>
line.trim().match(/^[-*] \[[^\]]*\]/i)
);
// Compare task count
if (oldTasks.length !== newTasks.length) {
return true;
}
// Compare each task
for (let i = 0; i < oldTasks.length; i++) {
if (oldTasks[i] !== newTasks[i]) {
return true;
}
}
return false;
}
/**
* Normalize card content for comparison by removing checkbox states and extra whitespace
* This allows us to detect card movements regardless of checkbox state changes
* @param cardContent - Card content to normalize
* @returns Normalized card content
*/
export function normalizeCardContentForComparison(cardContent: string): string {
return cardContent
.replace(/^(\s*- )\[[^\]]*\](.*)$/gm, "$1$2") // Remove checkbox states
.trim(); // Remove extra whitespace
}
/**
* Update checkbox state in a single card text
* Only updates the main card checkbox, preserving sub-items and nested checkboxes
* @param cardText - Card text to update
* @param targetCheckboxState - Target checkbox state (e.g., "[x]", "[ ]", "[/]")
* @returns Updated card text
*/
export function updateCheckboxStateInCardText(
cardText: string,
targetCheckboxState: string
): string {
// Split content into lines to process only the first line (main card)
const lines = cardText.split("\n");
if (lines.length === 0) return cardText;
// Pattern to match various checkbox states: - [ ], - [x], - [/], - [>], etc.
// Remove global flag to only match once per line
const checkboxPattern = /^(\s*[-*] )\[[^\]]*\](.*)$/;
// Only update the first line if it matches the pattern (main card line)
if (checkboxPattern.test(lines[0])) {
lines[0] = lines[0].replace(
checkboxPattern,
(match, prefix, suffix) => {
return `${prefix}${targetCheckboxState}${suffix}`;
}
);
}
// Join lines back together, preserving sub-items unchanged
return lines.join("\n");
}
/**
* Extract Obsidian-style links from content
* @param content - Content to extract links from
* @returns Array of objects with path and optional alias
*/
export function extractObsidianLinks(
content: string
): Array<{ path: string; alias?: string }> {
const links: Array<{ path: string; alias?: string }> = [];
const linkPattern = /\[\[(.*?)\]\]/g;
let match;
while ((match = linkPattern.exec(content)) !== null) {
const [_, linkContent] = match;
const [path, alias] = linkContent.split("|").map((s) => s.trim());
links.push({ path, alias });
}
return links;
}
/**
* Extract Markdown-style links from content
* @param content - Content to extract links from
* @returns Array of objects with text and url
*/
export function extractMarkdownLinks(
content: string
): Array<{ text: string; url: string }> {
const links: Array<{ text: string; url: string }> = [];
const linkPattern = /\[(.*?)\]\((.*?)\)/g;
let match;
while ((match = linkPattern.exec(content)) !== null) {
const [_, text, url] = match;
links.push({ text: text.trim(), url: url.trim() });
}
return links;
}
/**
* Extract the main link content from a card
* @param cardText - Card text to extract link from
* @returns Link path or null if not found
*/
export function extractMainLinkFromCard(cardText: string): string | null {
// Look for [[link]] pattern
const obsidianMatch = cardText.match(/\[\[([^\]]+)\]\]/);
if (obsidianMatch) {
return obsidianMatch[1];
}
// Look for [text](url) pattern
const markdownMatch = cardText.match(/\[([^\]]+)\]\(([^)]+)\)/);
if (markdownMatch) {
return markdownMatch[2]; // Return the URL part
}
return null;
}

44
src/utils/logger.ts Normal file
View file

@ -0,0 +1,44 @@
/**
* Debug logger utility that only logs when debug mode is enabled
* Prevents console spam in production builds
*/
export class DebugLogger {
private isDebugEnabled: () => boolean;
constructor(isDebugEnabled: () => boolean) {
this.isDebugEnabled = isDebugEnabled;
}
/**
* Log a message with optional arguments
* @param message - Message to log
* @param args - Additional arguments to log
*/
log(message: string, ...args: any[]): void {
if (this.isDebugEnabled()) {
console.log(`[Progress Tracker] ${message}`, ...args);
}
}
/**
* Log an error message with optional error object
* @param message - Error message
* @param error - Optional error object
*/
error(message: string, error?: Error): void {
if (this.isDebugEnabled()) {
console.error(`[Progress Tracker ERROR] ${message}`, error);
}
}
/**
* Log a warning message with optional arguments
* @param message - Warning message
* @param args - Additional arguments to log
*/
warn(message: string, ...args: any[]): void {
if (this.isDebugEnabled()) {
console.warn(`[Progress Tracker WARNING] ${message}`, ...args);
}
}
}

36
src/views/FileModal.ts Normal file
View file

@ -0,0 +1,36 @@
import { App, SuggestModal, TFile } from "obsidian";
/**
* Helper class for file picking in settings
*/
export class FileSuggestModal extends SuggestModal<TFile> {
onChooseItem: (file: TFile) => void;
constructor(app: App) {
super(app);
this.onChooseItem = () => {}; // Default empty implementation
}
getSuggestions(query: string): TFile[] {
const files = this.app.vault.getMarkdownFiles();
// Filter to only show potential Kanban board files
const kanbanFiles = files.filter((file) => {
// Show all Markdown files when no query
if (!query) return true;
// Otherwise filter by name/path containing the query
return file.path.toLowerCase().includes(query.toLowerCase());
});
return kanbanFiles;
}
renderSuggestion(file: TFile, el: HTMLElement) {
el.createEl("div", { text: file.path });
}
// Implement the required abstract method
onChooseSuggestion(file: TFile, evt: MouseEvent | KeyboardEvent) {
if (this.onChooseItem) {
this.onChooseItem(file);
}
}
}

1558
src/views/ProgressBarView.ts Normal file

File diff suppressed because it is too large Load diff

997
src/views/SettingsTab.ts Normal file
View file

@ -0,0 +1,997 @@
import {
App,
PluginSettingTab,
Setting,
TFile,
Notice,
Plugin,
} from "obsidian";
import { TaskProgressBarSettings, DEFAULT_SETTINGS } from "../interfaces/settings";
import { DataviewApi } from "../interfaces/types";
import { FileSuggestModal } from "./FileModal";
import { TaskProgressBarView } from "./ProgressBarView";
/**
* Interface for the plugin to interact with the settings tab
*/
export interface SettingsTabPlugin extends Plugin {
settings: TaskProgressBarSettings;
dvAPI: DataviewApi | null;
sidebarView: TaskProgressBarView | null;
checkDataviewAPI(): void;
saveSettings(): Promise<void>;
}
/**
* Settings tab for the Task Progress Bar plugin
*/
export class TaskProgressBarSettingTab extends PluginSettingTab {
plugin: SettingsTabPlugin;
constructor(app: App, plugin: SettingsTabPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
// Add Dataview status information
const dataviewStatus = containerEl.createDiv({
cls: "dataview-status",
});
if (this.plugin.dvAPI) {
dataviewStatus.createEl("p", {
text: "✅ Dataview API is available",
cls: "dataview-available",
});
} else {
dataviewStatus.createEl("p", {
text: "❌ Dataview API is not available",
cls: "dataview-unavailable",
});
// Add button to check for Dataview again
const checkButton = dataviewStatus.createEl("button", {
text: "Check for Dataview",
cls: "mod-cta",
});
checkButton.addEventListener("click", () => {
this.plugin.checkDataviewAPI();
if (this.plugin.dvAPI) {
new Notice("Dataview API found!");
this.display(); // Refresh settings tab
} else {
new Notice(
"Dataview API not found. Make sure Dataview plugin is installed and enabled."
);
}
});
}
new Setting(containerEl)
.setName("Show debug info")
.setDesc(
"Show debug information in the sidebar to help troubleshoot task counting issues"
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.showDebugInfo)
.onChange(async (value) => {
this.plugin.settings.showDebugInfo = value;
await this.plugin.saveSettings();
// Update sidebar if open - use public method
const currentFile = this.app.workspace.getActiveFile();
if (currentFile) {
// Use public method to update UI
this.plugin.checkDataviewAPI();
}
})
);
// Animation settings section
new Setting(containerEl).setName("Animation").setHeading();
// Add new setting for animation
new Setting(containerEl)
.setName("Show update animation")
.setDesc("Show a brief animation when updating the progress bar")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.showUpdateAnimation)
.onChange(async (value) => {
this.plugin.settings.showUpdateAnimation = value;
await this.plugin.saveSettings();
})
);
// Performance settings section
new Setting(containerEl).setName("Performance").setHeading();
new Setting(containerEl)
.setName("Editor change delay")
.setDesc(
"Delay before updating after editor content changes (lower = more responsive, higher = better performance)"
)
.addSlider((slider) =>
slider
.setLimits(100, 1000, 50)
.setValue(this.plugin.settings.editorChangeDelay)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.editorChangeDelay = value;
await this.plugin.saveSettings();
new Notice(
"Editor change delay updated. Restart plugin to apply changes."
);
})
)
.addExtraButton((button) =>
button
.setIcon("reset")
.setTooltip("Reset to default (500ms)")
.onClick(async () => {
this.plugin.settings.editorChangeDelay = 500;
await this.plugin.saveSettings();
this.display();
new Notice(
"Editor change delay reset. Restart plugin to apply changes."
);
})
);
new Setting(containerEl)
.setName("Keyboard input delay")
.setDesc(
"Delay after keyboard input before updating progress (in milliseconds)"
)
.addSlider((slider) =>
slider
.setLimits(100, 1000, 50)
.setValue(this.plugin.settings.keyboardInputDelay)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.keyboardInputDelay = value;
await this.plugin.saveSettings();
})
)
.addExtraButton((button) =>
button
.setIcon("reset")
.setTooltip("Reset to default (100ms)")
.onClick(async () => {
this.plugin.settings.keyboardInputDelay = 100;
await this.plugin.saveSettings();
this.display();
})
);
new Setting(containerEl)
.setName("Checkbox click delay")
.setDesc(
"Delay after checkbox click before updating progress (in milliseconds)"
)
.addSlider((slider) =>
slider
.setLimits(100, 1000, 50)
.setValue(this.plugin.settings.checkboxClickDelay)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.checkboxClickDelay = value;
await this.plugin.saveSettings();
})
)
.addExtraButton((button) =>
button
.setIcon("reset")
.setTooltip("Reset to default (200ms)")
.onClick(async () => {
this.plugin.settings.checkboxClickDelay = 200;
await this.plugin.saveSettings();
this.display();
})
);
// Add color scheme settings
new Setting(containerEl).setName("Progress bar colors").setHeading();
new Setting(containerEl)
.setName("Color scheme")
.setDesc("Choose a color scheme for the progress bar")
.addDropdown((dropdown) =>
dropdown
.addOption("default", "Default (Theme Colors)")
.addOption("red-orange-green", "Red-Orange-Blue-Green")
.addOption("custom", "Custom Colors")
.setValue(this.plugin.settings.progressColorScheme)
.onChange(
async (
value: "default" | "red-orange-green" | "custom"
) => {
this.plugin.settings.progressColorScheme = value;
// Set preset colors if red-orange-green is selected
if (value === "red-orange-green") {
this.plugin.settings.lowProgressColor =
"#e06c75";
this.plugin.settings.mediumProgressColor =
"#e5c07b";
this.plugin.settings.highProgressColor =
"#61afef";
this.plugin.settings.completeProgressColor =
"#98c379";
this.plugin.settings.lowProgressThreshold = 30;
this.plugin.settings.mediumProgressThreshold = 60;
this.plugin.settings.highProgressThreshold = 99;
new Notice(
"Applied Red-Orange-Blue-Green color scheme"
);
}
await this.plugin.saveSettings();
this.display();
// Update the view if open
const currentFile =
this.app.workspace.getActiveFile();
if (currentFile && this.plugin.sidebarView) {
this.plugin.sidebarView.updateProgressBar(
currentFile
);
}
}
)
);
// Only show custom color settings if custom is selected
if (this.plugin.settings.progressColorScheme === "custom") {
this.displayCustomColorSettings(containerEl);
}
// Interface settings section
new Setting(containerEl).setName("Interface").setHeading();
new Setting(containerEl)
.setName("Max tabs height")
.setDesc(
"Maximum height for workspace tabs (e.g., 110px, 200px, auto)"
)
.addText((text) => {
text.setValue(this.plugin.settings.maxTabsHeight);
text.inputEl.addEventListener("blur", async () => {
const value = text.inputEl.value;
const isValid =
value === "auto" ||
value === "none" ||
/^\d+(\.\d+)?(px|em|rem|vh|%)$/.test(value);
if (isValid) {
if (this.plugin.settings.maxTabsHeight !== value) {
this.plugin.settings.maxTabsHeight = value;
await this.plugin.saveSettings();
new Notice(`Max tabs height updated to ${value}`);
}
} else {
new Notice(
"Please enter 'auto', 'none' or a valid CSS length value (e.g., 110px)"
);
text.setValue(this.plugin.settings.maxTabsHeight);
}
});
text.inputEl.addEventListener("keydown", async (event) => {
if (event.key === "Enter") {
event.preventDefault();
text.inputEl.blur();
}
});
text.inputEl.style.width = "120px";
text.inputEl.placeholder = "auto";
return text;
})
.addExtraButton((button) =>
button
.setIcon("reset")
.setTooltip("Reset to default (auto)")
.onClick(async () => {
this.plugin.settings.maxTabsHeight = "auto";
await this.plugin.saveSettings();
this.display();
new Notice("Max tabs height reset to 'auto'");
})
);
// Add Metadata Auto-Update Settings
new Setting(containerEl).setName("Metadata auto-update").setHeading();
new Setting(containerEl)
.setName("Auto-update metadata")
.setDesc(
"Automatically update metadata when all tasks are completed"
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.autoUpdateMetadata)
.onChange(async (value) => {
this.plugin.settings.autoUpdateMetadata = value;
await this.plugin.saveSettings();
this.display();
})
);
// Only show these settings if auto-update is enabled
if (this.plugin.settings.autoUpdateMetadata) {
this.displayMetadataSettings(containerEl);
}
// Add new section for auto-add to Kanban
new Setting(containerEl)
.setName("Auto-add files to Kanban board")
.setDesc(
"Automatically add files with tasks to a specified Kanban board if they're not already there"
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.autoAddToKanban)
.onChange(async (value) => {
this.plugin.settings.autoAddToKanban = value;
await this.plugin.saveSettings();
this.display();
})
);
if (this.plugin.settings.autoAddToKanban) {
this.displayAutoAddKanbanSettings(containerEl);
}
// Add new section for custom checkbox states
new Setting(containerEl)
.setName("Custom Checkbox States")
.setDesc(
"Configure custom checkbox states for different Kanban columns"
)
.setHeading();
new Setting(containerEl)
.setName("Enable custom checkbox states")
.setDesc(
"When enabled, cards will automatically update their checkbox states when moved between Kanban columns"
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.enableCustomCheckboxStates)
.onChange(async (value) => {
this.plugin.settings.enableCustomCheckboxStates = value;
await this.plugin.saveSettings();
this.display();
})
);
if (this.plugin.settings.enableCustomCheckboxStates) {
this.displayCustomCheckboxSettings(containerEl);
}
}
/**
* Display custom color settings
*/
private displayCustomColorSettings(containerEl: HTMLElement): void {
new Setting(containerEl)
.setName("Low progress color")
.setDesc(
`Color for progress below ${this.plugin.settings.lowProgressThreshold}%`
)
.addText((text) =>
text
.setValue(this.plugin.settings.lowProgressColor)
.onChange(async (value) => {
this.plugin.settings.lowProgressColor = value;
await this.plugin.saveSettings();
this.updateProgressBarView();
})
);
new Setting(containerEl)
.setName("Medium progress color")
.setDesc(
`Color for progress between ${this.plugin.settings.lowProgressThreshold}% and ${this.plugin.settings.mediumProgressThreshold}%`
)
.addText((text) =>
text
.setValue(this.plugin.settings.mediumProgressColor)
.onChange(async (value) => {
this.plugin.settings.mediumProgressColor = value;
await this.plugin.saveSettings();
this.updateProgressBarView();
})
);
new Setting(containerEl)
.setName("High progress color")
.setDesc(
`Color for progress between ${this.plugin.settings.mediumProgressThreshold}% and ${this.plugin.settings.highProgressThreshold}%`
)
.addText((text) =>
text
.setValue(this.plugin.settings.highProgressColor)
.onChange(async (value) => {
this.plugin.settings.highProgressColor = value;
await this.plugin.saveSettings();
this.updateProgressBarView();
})
);
new Setting(containerEl)
.setName("Complete progress color")
.setDesc("Color for 100% progress")
.addText((text) =>
text
.setValue(this.plugin.settings.completeProgressColor)
.onChange(async (value) => {
this.plugin.settings.completeProgressColor = value;
await this.plugin.saveSettings();
this.updateProgressBarView();
})
);
// Add threshold settings
new Setting(containerEl)
.setName("Low progress threshold")
.setDesc("Percentage below which progress is considered low")
.addSlider((slider) =>
slider
.setLimits(1, 99, 1)
.setValue(this.plugin.settings.lowProgressThreshold)
.setDynamicTooltip()
.onChange(async (value) => {
if (value >= this.plugin.settings.mediumProgressThreshold) {
value = this.plugin.settings.mediumProgressThreshold - 1;
}
this.plugin.settings.lowProgressThreshold = value;
await this.plugin.saveSettings();
this.updateProgressBarView();
})
);
new Setting(containerEl)
.setName("Medium progress threshold")
.setDesc("Percentage below which progress is considered medium")
.addSlider((slider) =>
slider
.setLimits(1, 99, 1)
.setValue(this.plugin.settings.mediumProgressThreshold)
.setDynamicTooltip()
.onChange(async (value) => {
if (value <= this.plugin.settings.lowProgressThreshold) {
value = this.plugin.settings.lowProgressThreshold + 1;
}
if (value >= this.plugin.settings.highProgressThreshold) {
value = this.plugin.settings.highProgressThreshold - 1;
}
this.plugin.settings.mediumProgressThreshold = value;
await this.plugin.saveSettings();
this.updateProgressBarView();
})
);
new Setting(containerEl)
.setName("High progress threshold")
.setDesc(
"Percentage below which progress is considered high (but not complete)"
)
.addSlider((slider) =>
slider
.setLimits(1, 99, 1)
.setValue(this.plugin.settings.highProgressThreshold)
.setDynamicTooltip()
.onChange(async (value) => {
if (value <= this.plugin.settings.mediumProgressThreshold) {
value = this.plugin.settings.mediumProgressThreshold + 1;
}
this.plugin.settings.highProgressThreshold = value;
await this.plugin.saveSettings();
this.updateProgressBarView();
})
);
}
/**
* Display metadata settings
*/
private displayMetadataSettings(containerEl: HTMLElement): void {
new Setting(containerEl)
.setName("Change status")
.setDesc(
"Change 'status: In Progress' to 'status: Completed' when tasks reach 100%"
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.autoChangeStatus)
.onChange(async (value) => {
this.plugin.settings.autoChangeStatus = value;
await this.plugin.saveSettings();
this.display();
})
);
new Setting(containerEl)
.setName("Update finished date")
.setDesc(
"Set 'finished: ' to today's date when tasks reach 100%"
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.autoUpdateFinishedDate)
.onChange(async (value) => {
this.plugin.settings.autoUpdateFinishedDate = value;
await this.plugin.saveSettings();
})
);
// Show status label settings if status changing is enabled
if (this.plugin.settings.autoChangeStatus) {
this.displayStatusLabelSettings(containerEl);
}
// Add Kanban integration settings
new Setting(containerEl).setName("Kanban integration").setHeading();
new Setting(containerEl)
.setName("Update Kanban boards")
.setDesc(
"Automatically move cards in Kanban boards based on task status"
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.autoUpdateKanban)
.onChange(async (value) => {
this.plugin.settings.autoUpdateKanban = value;
await this.plugin.saveSettings();
this.display();
})
);
if (this.plugin.settings.autoUpdateKanban) {
this.displayKanbanIntegrationSettings(containerEl);
}
}
/**
* Display status label settings
*/
private displayStatusLabelSettings(containerEl: HTMLElement): void {
new Setting(containerEl)
.setName("Todo status label")
.setDesc("Status label for files with 0% progress")
.addText((text) =>
text
.setPlaceholder("Todo")
.setValue(this.plugin.settings.statusTodo)
.onChange(async (value) => {
this.plugin.settings.statusTodo = value;
await this.plugin.saveSettings();
})
)
.addExtraButton((button) =>
button
.setIcon("reset")
.setTooltip("Reset to default")
.onClick(async () => {
this.plugin.settings.statusTodo = "Todo";
await this.plugin.saveSettings();
this.display();
})
);
new Setting(containerEl)
.setName("In progress status label")
.setDesc("Status label for files with 1-99% progress")
.addText((text) =>
text
.setPlaceholder("In Progress")
.setValue(this.plugin.settings.statusInProgress)
.onChange(async (value) => {
this.plugin.settings.statusInProgress = value;
await this.plugin.saveSettings();
})
)
.addExtraButton((button) =>
button
.setIcon("reset")
.setTooltip("Reset to default")
.onClick(async () => {
this.plugin.settings.statusInProgress = "In Progress";
await this.plugin.saveSettings();
this.display();
})
);
new Setting(containerEl)
.setName("Completed status label")
.setDesc("Status label for files with 100% progress")
.addText((text) =>
text
.setPlaceholder("Completed")
.setValue(this.plugin.settings.statusCompleted)
.onChange(async (value) => {
this.plugin.settings.statusCompleted = value;
await this.plugin.saveSettings();
})
)
.addExtraButton((button) =>
button
.setIcon("reset")
.setTooltip("Reset to default")
.onClick(async () => {
this.plugin.settings.statusCompleted = "Completed";
await this.plugin.saveSettings();
this.display();
})
);
}
/**
* Display Kanban integration settings
*/
private displayKanbanIntegrationSettings(containerEl: HTMLElement): void {
new Setting(containerEl)
.setName("Sync Kanban columns with status")
.setDesc(
"Match Kanban column names to status values (Todo, In Progress, Completed)"
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.kanbanSyncWithStatus)
.onChange(async (value) => {
this.plugin.settings.kanbanSyncWithStatus = value;
await this.plugin.saveSettings();
this.display();
})
);
// Only show this if sync with status is disabled (legacy mode)
if (!this.plugin.settings.kanbanSyncWithStatus) {
new Setting(containerEl)
.setName("Completed column name")
.setDesc(
"The name of the column where completed items should be moved to (e.g., 'Complete', 'Done', 'Finished')"
)
.addText((text) =>
text
.setPlaceholder("Complete")
.setValue(this.plugin.settings.kanbanCompletedColumn)
.onChange(async (value) => {
this.plugin.settings.kanbanCompletedColumn = value;
await this.plugin.saveSettings();
})
)
.addExtraButton((button) =>
button
.setIcon("reset")
.setTooltip("Reset to default (Complete)")
.onClick(async () => {
this.plugin.settings.kanbanCompletedColumn =
"Complete";
await this.plugin.saveSettings();
this.display();
})
);
}
// Add Auto-detect settings
new Setting(containerEl)
.setName("Auto-detect Kanban boards")
.setDesc(
"Automatically detect files that appear to be Kanban boards"
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.kanbanAutoDetect)
.onChange(async (value) => {
this.plugin.settings.kanbanAutoDetect = value;
await this.plugin.saveSettings();
this.display();
})
);
// Note about column naming
const infoDiv = containerEl.createDiv({
cls: "kanban-info",
attr: {
style: "background: var(--background-secondary-alt); padding: 10px; border-radius: 5px; margin-top: 10px;",
},
});
infoDiv.createEl("p", {
text: " Column naming tip:",
attr: {
style: "font-weight: bold; margin: 0 0 5px 0;",
},
});
infoDiv.createEl("p", {
text: `To get the best results, name your Kanban columns to match the status values: "${this.plugin.settings.statusTodo}", "${this.plugin.settings.statusInProgress}", and "${this.plugin.settings.statusCompleted}".`,
attr: {
style: "margin: 0;",
},
});
}
/**
* Display auto-add to Kanban settings
*/
private displayAutoAddKanbanSettings(containerEl: HTMLElement): void {
new Setting(containerEl)
.setName("Target Kanban board")
.setDesc(
"The path to the Kanban board where files should be added"
)
.addText((text) =>
text
.setPlaceholder("path/to/kanban.md")
.setValue(this.plugin.settings.autoAddKanbanBoard)
.onChange(async (value) => {
this.plugin.settings.autoAddKanbanBoard = value;
await this.plugin.saveSettings();
})
);
// Add file picker button
containerEl.createEl("div", {
text: "Select Kanban board file:",
attr: { style: "margin-left: 36px; margin-bottom: 8px;" },
});
const filePickerContainer = containerEl.createEl("div", {
attr: { style: "margin-left: 36px; margin-bottom: 12px;" },
});
const filePickerButton = filePickerContainer.createEl("button", {
text: "Browse...",
cls: "mod-cta",
});
filePickerButton.addEventListener("click", async () => {
try {
const modal = new FileSuggestModal(this.app);
modal.onChooseItem = (file: TFile) => {
if (file) {
this.plugin.settings.autoAddKanbanBoard = file.path;
this.plugin.saveSettings().then(() => {
this.display();
});
}
};
modal.open();
} catch (error) {
new Notice(
"Error opening file picker. Please enter the path manually."
);
console.error("File picker error:", error);
}
});
new Setting(containerEl)
.setName("Target column")
.setDesc(
"The column where new files should be added (e.g., 'Todo', 'Backlog')"
)
.addText((text) =>
text
.setPlaceholder("Todo")
.setValue(this.plugin.settings.autoAddKanbanColumn)
.onChange(async (value) => {
this.plugin.settings.autoAddKanbanColumn = value;
await this.plugin.saveSettings();
})
)
.addExtraButton((button) =>
button
.setIcon("reset")
.setTooltip("Reset to default (Todo)")
.onClick(async () => {
this.plugin.settings.autoAddKanbanColumn = "Todo";
await this.plugin.saveSettings();
this.display();
})
);
}
/**
* Display custom checkbox settings
*/
private displayCustomCheckboxSettings(containerEl: HTMLElement): void {
// Add new setting for Kanban card checkbox sync
new Setting(containerEl)
.setName("Enable Kanban card checkbox sync")
.setDesc(
"When enabled, dragging cards between Kanban columns will automatically update checkbox states of the cards in the Kanban board"
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.enableKanbanToFileSync)
.onChange(async (value) => {
this.plugin.settings.enableKanbanToFileSync = value;
await this.plugin.saveSettings();
})
);
// Add new setting for auto-sync on Kanban open
new Setting(containerEl)
.setName("Auto-sync checkbox states on Kanban open")
.setDesc(
"When enabled, automatically sync all card checkbox states to match their columns when opening a Kanban board (runs once per session for performance)"
)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.enableKanbanAutoSync)
.onChange(async (value) => {
this.plugin.settings.enableKanbanAutoSync = value;
await this.plugin.saveSettings();
})
);
// NEW: Add setting for Kanban normalization protection
new Setting(containerEl)
.setName(
"Protect custom checkbox states from Kanban normalization"
)
.setDesc(
"When enabled, prevents the Kanban plugin from automatically converting custom checkbox states (like [/], [~]) to standard states ([x]). This preserves your custom state mappings."
)
.addToggle((toggle) =>
toggle
.setValue(
this.plugin.settings.enableKanbanNormalizationProtection
)
.onChange(async (value) => {
this.plugin.settings.enableKanbanNormalizationProtection =
value;
await this.plugin.saveSettings();
})
);
// Add explanation
const infoDiv = containerEl.createDiv({
cls: "custom-checkbox-info",
attr: {
style: "background: var(--background-secondary-alt); padding: 10px; border-radius: 5px; margin: 10px 0;",
},
});
infoDiv.createEl("p", {
text: " Custom Checkbox States Configuration:",
attr: {
style: "font-weight: bold; margin: 0 0 5px 0;",
},
});
infoDiv.createEl("p", {
text: "Define which checkbox state should be used for each Kanban column. Common states include: [ ] (todo), [/] (in progress), [x] (completed), [>] (forwarded), [-] (cancelled).",
attr: {
style: "margin: 0 0 5px 0;",
},
});
// Display current mappings
this.plugin.settings.kanbanColumnCheckboxMappings.forEach(
(mapping, index) => {
const mappingContainer = containerEl.createDiv({
cls: "checkbox-mapping-container",
attr: {
style: "display: flex; gap: 10px; align-items: center; margin: 10px 0; padding: 10px; border: 1px solid var(--background-modifier-border); border-radius: 5px;",
},
});
// Column name input
const columnInput = mappingContainer.createEl("input", {
type: "text",
value: mapping.columnName,
attr: {
placeholder: "Column Name",
style: "flex: 1; padding: 5px;",
},
});
// Checkbox state input
const checkboxInput = mappingContainer.createEl("input", {
type: "text",
value: mapping.checkboxState,
attr: {
placeholder: "[ ]",
style: "width: 60px; padding: 5px; text-align: center;",
},
});
// Delete button
const deleteButton = mappingContainer.createEl("button", {
text: "Remove",
cls: "mod-warning",
attr: {
style: "padding: 5px 10px;",
},
});
// Event listeners
columnInput.addEventListener("change", async () => {
this.plugin.settings.kanbanColumnCheckboxMappings[
index
].columnName = columnInput.value;
await this.plugin.saveSettings();
});
checkboxInput.addEventListener("change", async () => {
this.plugin.settings.kanbanColumnCheckboxMappings[
index
].checkboxState = checkboxInput.value;
await this.plugin.saveSettings();
});
deleteButton.addEventListener("click", async () => {
this.plugin.settings.kanbanColumnCheckboxMappings.splice(
index,
1
);
await this.plugin.saveSettings();
this.display();
});
}
);
// Add new mapping button
const addMappingButton = containerEl.createEl("button", {
text: "Add Column Mapping",
cls: "mod-cta",
attr: {
style: "margin: 10px 0;",
},
});
addMappingButton.addEventListener("click", async () => {
this.plugin.settings.kanbanColumnCheckboxMappings.push({
columnName: "",
checkboxState: "[ ]",
});
await this.plugin.saveSettings();
this.display();
});
// Reset to defaults button
const resetButton = containerEl.createEl("button", {
text: "Reset to Defaults",
cls: "mod-warning",
attr: {
style: "margin: 10px 0;",
},
});
resetButton.addEventListener("click", async () => {
this.plugin.settings.kanbanColumnCheckboxMappings = [
{ columnName: "Todo", checkboxState: "[ ]" },
{ columnName: "In Progress", checkboxState: "[/]" },
{ columnName: "Complete", checkboxState: "[x]" },
{ columnName: "Done", checkboxState: "[x]" },
];
await this.plugin.saveSettings();
this.display();
});
}
/**
* Helper method to update the progress bar view
*/
private updateProgressBarView(): void {
const currentFile = this.app.workspace.getActiveFile();
if (currentFile && this.plugin.sidebarView) {
this.plugin.sidebarView.updateProgressBar(currentFile);
}
}
}

View file

@ -16,7 +16,8 @@ If your plugin does not need CSS, delete this file.
max-height: 110px !important;
} */
/* 1. Reset biến toàn cục quy định chiều cao tối thiểu */
.task-progress-container {
padding: 10px;
margin-bottom: 10px;
@ -322,7 +323,6 @@ If your plugin does not need CSS, delete this file.
}
}
/* Điều chỉnh chiều cao tối thiểu của sidebar */
.workspace-leaf-content[data-type="progress-tracker"] {
--sidebar-min-height: 67px !important;