vannamhh_progress-tracker/main.ts

2556 lines
78 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,
} from "obsidian";
// Định nghĩa interface cho DataviewAPI
interface DataviewApi {
executeJs(
code: string,
container: HTMLElement,
sourcePath?: string
): Promise<any>;
page(path: string): any;
pages(source: string): any[];
// Không sử dụng eval vì có thể không tồn tại trong một số phiên bản Dataview
}
// Định nghĩa interface cho window object để truy cập Dataview plugin
declare global {
interface Window {
DataviewAPI?: DataviewApi;
}
}
// Hàm helper để lấy Dataview API
function getDataviewAPI(app: App): DataviewApi | null {
// Cách 1: Thông qua window object
// @ts-ignore
if (window.DataviewAPI) {
return window.DataviewAPI;
}
// Cách 2: Thông qua app.plugins
// @ts-ignore
const dataviewPlugin = app.plugins?.plugins?.dataview;
if (dataviewPlugin && dataviewPlugin.api) {
return dataviewPlugin.api;
}
// Cách 3: Kiểm tra xem plugin có được enable không
// @ts-ignore
if (app.plugins.enabledPlugins.has("dataview")) {
console.log("Dataview plugin is enabled but API is not available yet");
return null;
}
console.log("Dataview plugin is not enabled");
return null;
}
interface TaskProgressBarSettings {
mySetting: string;
showDebugInfo: boolean;
progressColorScheme: "default" | "red-orange-green" | "custom";
lowProgressColor: string;
mediumProgressColor: string;
highProgressColor: string;
completeProgressColor: string;
lowProgressThreshold: number;
mediumProgressThreshold: number;
highProgressThreshold: number;
showUpdateAnimation: boolean; // Add new setting for animation toggle
updateAnimationDelay: number; // Add animation delay setting
editorChangeDelay: number; // Add editor change delay
keyboardInputDelay: number; // Add keyboard input delay
checkboxClickDelay: number; // Add checkbox click delay
maxTabsHeight: string; // New setting for max-height of workspace tabs
autoUpdateMetadata: boolean; // Add new setting to control metadata auto-update feature
autoChangeStatus: boolean; // Control whether to change status
autoUpdateFinishedDate: boolean; // Control whether to update finished date
autoUpdateKanban: boolean; // Add new setting for Kanban board updates
kanbanCompletedColumn: string; // The name of the completed column in Kanban (deprecated, kept for backward compatibility)
statusTodo: string; // Status for 0% progress
statusInProgress: string; // Status for 1-99% progress
statusCompleted: string; // Status for 100% progress
kanbanAutoDetect: boolean; // Whether to auto-detect Kanban boards
kanbanSpecificFiles: string[]; // List of specific files/folders to treat as Kanban
kanbanExcludeFiles: string[]; // List of files/folders to exclude from Kanban detection
kanbanSyncWithStatus: boolean; // New setting to sync Kanban columns with status
}
const DEFAULT_SETTINGS: TaskProgressBarSettings = {
mySetting: "default",
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, // Default to true to maintain current behavior
updateAnimationDelay: 300, // Default animation delay (300ms)
editorChangeDelay: 500, // Default editor change delay (500ms)
keyboardInputDelay: 100, // Default keyboard input delay (10ms)
checkboxClickDelay: 200, // Default checkbox click delay (50ms)
maxTabsHeight: "auto", // Default to "auto"
autoUpdateMetadata: true, // Default to enabled
autoChangeStatus: true, // Default to enabled
autoUpdateFinishedDate: true, // Default to enabled
autoUpdateKanban: true, // Default to enabled
kanbanCompletedColumn: "Complete", // Default completed column name (deprecated)
statusTodo: "Todo", // Default status for 0% progress
statusInProgress: "In Progress", // Default status for 1-99% progress
statusCompleted: "Completed", // Default status for 100% progress
kanbanAutoDetect: true, // Default to auto-detect
kanbanSpecificFiles: [], // Empty array by default
kanbanExcludeFiles: [], // Empty array by default
kanbanSyncWithStatus: true, // Default to true for new installations
};
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;
async onload() {
await this.loadSettings();
// 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));
// Kiểm tra Dataview API và thiết lập interval để kiểm tra lại nếu chưa có
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;
// Always update when file changes to ensure accurate display
setTimeout(async () => {
await this.updateLastFileContent(file);
if (this.sidebarView) {
// Pass true to force update even for files without tasks
this.sidebarView.updateProgressBar(file);
}
}, 300);
}
})
);
// 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) {
// Lấy nội dung hiện tại của editor
const content = editor.getValue();
// Kiểm tra nếu nội dung thay đổi và có chứa task hoặc nội dung cũ có chứa task
if (
content.includes("- [") ||
content.includes("- [ ]") ||
content.includes("- [x]") ||
this.lastFileContent.includes("- [") ||
this.lastFileContent.includes("- [ ]") ||
this.lastFileContent.includes("- [x]")
) {
// Cập nhật ngay lập tức
if (this.lastActiveFile) {
// Cập nhật nội dung file cuối cùng trước khi kiểm tra thay đổi
this.lastActiveFile = view.file;
// Cập nhật thanh tiến trình ngay lập tức
this.sidebarView.updateProgressBar(
view.file,
content
);
// Sau đó mới cập nhật nội dung file cuối cùng
this.lastFileContent = content;
}
}
}
}, this.settings.editorChangeDelay)
) // Use configurable delay
);
// Lắng nghe sự kiện keydown để phát hiện khi người dùng nhập task mới hoặc check/uncheck task
this.registerDomEvent(document, "keydown", (evt: KeyboardEvent) => {
// Kiểm tra xem có đang trong editor không
const activeView =
this.app.workspace.getActiveViewOfType(MarkdownView);
if (activeView && activeView.getMode() === "source") {
// Cập nhật ngay lập tức khi nhấn các phím liên quan đến task
if (
[
"Enter",
"Space",
"]",
"x",
"X",
"Backspace",
"Delete",
].includes(evt.key)
) {
// Cập nhật ngay lập tức
setTimeout(() => {
const content = activeView.editor.getValue();
if (
content.includes("- [") ||
content.includes("- [ ]") ||
content.includes("- [x]")
) {
this.lastActiveFile = activeView.file;
// Cập nhật thanh tiến trình ngay lập tức
if (this.sidebarView) {
this.sidebarView.updateProgressBar(
activeView.file,
content
);
}
// Sau đó mới cập nhật nội dung file cuối cùng
this.lastFileContent = content;
}
}, this.settings.keyboardInputDelay); // Use configurable delay
}
}
});
// Lắng nghe sự kiện click trong editor để phát hiện khi task được check/uncheck
this.registerDomEvent(document, "click", (evt: MouseEvent) => {
const target = evt.target as HTMLElement;
// Kiểm tra xem click có phải vào checkbox của task không
if (
target &&
target.tagName === "INPUT" &&
target.classList.contains("task-list-item-checkbox")
) {
// Đợi một chút để Obsidian cập nhật trạng thái task trong file
setTimeout(async () => {
const activeFile = this.app.workspace.getActiveFile();
if (activeFile && this.sidebarView) {
// Đọc nội dung file hiện tại
const content = await this.app.vault.read(activeFile);
// Cập nhật thanh tiến trình ngay lập tức
this.lastActiveFile = activeFile;
this.sidebarView.updateProgressBar(activeFile, content);
// Sau đó mới cập nhật nội dung file cuối cùng
this.lastFileContent = content;
}
}, this.settings.checkboxClickDelay); // Use configurable delay
}
});
// Activate view when plugin loads - đợi một chút để Obsidian khởi động hoàn toàn
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) {
if (this.settings.showDebugInfo) {
console.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."
);
}
},
});
}
// Kiểm tra Dataview API và thiết lập interval để kiểm tra lại nếu chưa có
checkDataviewAPI() {
// Kiểm tra ngay lập tức
this.dvAPI = getDataviewAPI(this.app);
// Nếu không tìm thấy, thiết lập interval để kiểm tra lại
if (!this.dvAPI) {
this.dataviewCheckInterval = window.setInterval(() => {
this.dvAPI = getDataviewAPI(this.app);
if (this.dvAPI) {
console.log("Dataview API found");
// Nếu tìm thấy, xóa interval
if (this.dataviewCheckInterval) {
clearInterval(this.dataviewCheckInterval);
this.dataviewCheckInterval = null;
}
// Cập nhật sidebar nếu đang mở
if (this.sidebarView && this.lastActiveFile) {
this.sidebarView.updateProgressBar(this.lastActiveFile);
}
}
}, 2000); // Kiểm tra mỗi 2 giây
}
}
// Kiểm tra xem trạng thái task có thay đổi không
hasTaskStatusChanged(newContent: string): boolean {
// Nếu chưa có nội dung cũ, coi như đã thay đổi
if (!this.lastFileContent) return true;
// Đếm số lượng task hoàn thành trong nội dung cũ và mới
// Sử dụng regex chính xác hơn để phát hiện task
const oldCompletedTasks = (
this.lastFileContent.match(/- \[x\]/gi) || []
).length;
const newCompletedTasks = (newContent.match(/- \[x\]/gi) || []).length;
// Đếm tổng số task trong nội dung cũ và mới
// Sử dụng regex chính xác hơn để phát hiện task chưa hoàn thành
const oldIncompleteTasks = (
this.lastFileContent.match(/- \[ \]/g) || []
).length;
const newIncompleteTasks = (newContent.match(/- \[ \]/g) || []).length;
const oldTotalTasks = oldIncompleteTasks + oldCompletedTasks;
const newTotalTasks = newIncompleteTasks + newCompletedTasks;
// Nếu số lượng task hoàn thành hoặc tổng số task thay đổi, coi như đã thay đổi
return (
oldCompletedTasks !== newCompletedTasks ||
oldTotalTasks !== newTotalTasks
);
}
// Cập nhật nội dung file cuối cùng
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
// Kiểm tra xem workspace đã sẵn sàng chưa
if (!workspace.leftSplit) {
console.log("Workspace not ready yet, retrying in 500ms");
setTimeout(() => this.activateView(), 500);
return;
}
// Sử dụng getLeaf thay vì createLeaf
const leaf = workspace.getLeftLeaf(false);
if (leaf) {
await 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."
);
}
}
onunload() {
// Xóa interval nếu có
if (this.dataviewCheckInterval) {
clearInterval(this.dataviewCheckInterval);
this.dataviewCheckInterval = null;
}
// Detach the view when plugin unloads
this.app.workspace.detachLeavesOfType("progress-tracker");
// Clear any in-memory data
if (this.sidebarView) {
this.sidebarView.clearCompletedFilesCache();
}
}
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.");
}
}
// Improved method to apply the max-height style
applyMaxTabsHeightStyle() {
try {
// Remove any existing style element first
const existingStyle = document.getElementById(
"progress-tracker-max-tabs-height"
);
if (existingStyle) {
existingStyle.remove();
}
// Create a new style element
const style = document.createElement("style");
style.id = "progress-tracker-max-tabs-height";
// Set the CSS rule with the user's preference
style.textContent = `
.workspace-tabs.mod-top.mod-top-right-space:not(.mod-top-left-space) {
max-height: ${this.settings.maxTabsHeight} !important;
transition: max-height 0.3s ease;
}
`;
// Add the style to the document head
document.head.appendChild(style);
// Debug info
if (this.settings.showDebugInfo) {
console.log(
`Applied max-tabs-height: ${this.settings.maxTabsHeight}`
);
}
} catch (error) {
console.error("Error applying max tabs height style:", 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;
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",
});
// For initial load, we'll wait for the main plugin to trigger the update
// rather than trying to load here, to avoid duplicate loading issues
}
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());
// Tránh cập nhật quá nhanh
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;
}
// Thêm class để hiển thị animation chỉ khi cài đặt showUpdateAnimation được bật
if (this.plugin.settings.showUpdateAnimation) {
progressContainer.classList.add("updating");
}
try {
// Cập nhật ngay lập tức nếu có nội dung được cung cấp
if (content) {
// Always clear content to avoid showing previous file's progress
if (!this.hasTasksInContent(content)) {
progressContainer.empty();
progressContainer.createEl("p", {
text: "No tasks found in this file",
});
} else {
// Create progress bar directly with content
this.createProgressBarFromString(
progressContainer,
content,
file
);
}
} else {
// Đọc nội dung file
const fileContent = await this.plugin.app.vault.read(file);
// Always clear content and show appropriate message if no tasks
if (!this.hasTasksInContent(fileContent)) {
progressContainer.empty();
progressContainer.createEl("p", {
text: "No tasks found in this file",
});
if (this.plugin.settings.showDebugInfo) {
console.log("No tasks found in file:", file.path);
}
} else {
// Create progress bar directly with file 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 {
// Xóa class sau khi cập nhật xong, chỉ khi animation được bật
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
hasTasksInContent(content: string): boolean {
// Improved and more accurate task detection
const standardTaskRegex = /- \[[x ]\]/i;
const relaxedTaskRegex = /[-*] \[[x ]\]/i;
// Return true if either regex matches
return (
standardTaskRegex.test(content) || relaxedTaskRegex.test(content)
);
}
// Phương thức tạo thanh tiến trình từ nội dung string
async createProgressBarFromString(
container: HTMLElement,
content: string,
file: TFile
) {
// Always clear the container first to ensure clean state
container.empty();
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) {
const dataviewWarning = container.createDiv({
cls: "dataview-warning-compact",
});
dataviewWarning.createEl("span", {
text: "Dataview not available",
cls: "dataview-warning-text",
});
return;
}
// Sử dụng regex chính xác hơn để đếm task
const incompleteTasks = (content.match(/- \[ \]/g) || []).length;
const completedTasks = (content.match(/- \[x\]/gi) || []).length;
let totalTasks = incompleteTasks + completedTasks;
// Try with relaxed regex if needed
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}, total: ${totalTasks}`
);
if (relaxedIncompleteTasks > 0 || relaxedCompletedTasks > 0) {
console.log(
`Using relaxed regex - found tasks: ${
relaxedIncompleteTasks + relaxedCompletedTasks
}`
);
}
}
if (totalTasks === 0) {
// No tasks found, show message and return
container.createEl("p", {
text: "No tasks found in this file",
});
return;
}
// Calculate percentage based on which regex found tasks
let completedCount =
incompleteTasks > 0 || completedTasks > 0
? completedTasks
: relaxedCompletedTasks;
const percentage = Math.round((completedCount / totalTasks) * 100);
// Update status based on progress percentage (do this for all files)
let statusChanged = false;
if (this.plugin.settings.autoChangeStatus) {
statusChanged = await this.updateStatusBasedOnProgress(file, percentage);
}
// Update Kanban boards based on task progress, regardless of whether it's 100%
// This ensures any status change gets reflected in the Kanban board
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, content);
// 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
// This allows re-notification if the tasks are completed again after being incomplete
if (this.completedFilesMap.has(file.path)) {
this.completedFilesMap.delete(file.path);
}
}
// Create the progress bar elements
this.createProgressBarElements(
container,
completedCount,
totalTasks,
percentage
);
} catch (error) {
console.error("Error creating progress bar from string:", error);
container.empty();
container.createEl("p", {
text: `Error creating progress bar: ${error.message}`,
});
}
}
/**
* Handle Kanban board integration with files
* Updates the position of a note card in Kanban boards when all tasks in the note are completed
*/
async updateKanbanBoards(
file: TFile,
completedTasks: number,
totalTasks: number
) {
try {
// Only proceed if Kanban integration is enabled
if (!this.plugin.settings.autoUpdateKanban) {
return;
}
// Calculate the current status based on progress
let currentStatus: string;
if (totalTasks === 0) {
// No tasks, don't update Kanban
return;
} else if (completedTasks === 0) {
currentStatus = this.plugin.settings.statusTodo;
} else if (completedTasks === totalTasks) {
currentStatus = this.plugin.settings.statusCompleted;
} else {
currentStatus = this.plugin.settings.statusInProgress;
}
// Alternative method to get the status from the file's YAML frontmatter
// This is more accurate as it reflects the actual status in the file
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 all markdown files that might be Kanban boards
const markdownFiles = this.plugin.app.vault.getMarkdownFiles();
// Keep track of how many Kanban boards were updated
let updatedBoardCount = 0;
// 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
);
// Check if this file is a Kanban board
if (!this.isKanbanBoard(boardContent)) continue;
// Check if the board contains a reference to our file
if (!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 for this file to the appropriate column based on status
const updatedContent = await this.moveCardInKanbanBoard(
boardFile,
boardContent,
file,
currentStatus
);
// If the board was updated, increment the counter
if (updatedContent !== boardContent) {
updatedBoardCount++;
}
}
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}`);
}
}
}
/**
* Safely escape regex special characters in a string
* Used by multiple methods that need to create regex patterns
*/
escapeRegExp(string: string): string {
if (!string) return "";
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Check if a Kanban board content contains a reference to the given file
*/
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;
// Check for common reference patterns
const patterns = [
// Markdown link: [Note Title](path/to/note.md)
new RegExp(`\\[.*?\\]\\(${this.escapeRegExp(filePath)}\\)`, "i"),
// Obsidian link: [[path/to/note]]
new RegExp(
`\\[\\[${this.escapeRegExp(
filePathWithoutExtension
)}(\\|.*?)?\\]\\]`,
"i"
),
// Obsidian link with just filename: [[Note Title]]
new RegExp(
`\\[\\[${this.escapeRegExp(fileName)}(\\|.*?)?\\]\\]`,
"i"
),
// Plain text mention of filepath
new RegExp(`\\b${this.escapeRegExp(filePath)}\\b`, "i"),
// Plain text mention of filename
new RegExp(`\\b${this.escapeRegExp(fileName)}\\b`, "i"),
];
// Return true if any pattern matches
return patterns.some((pattern) => pattern.test(boardContent));
}
/**
* Parse Kanban board structure into columns and items with improved accuracy
*/
parseKanbanBoard(
content: string
): Record<string, { items: Array<{ text: string }> }> {
const kanban: Record<string, { items: Array<{ text: string }> }> = {};
// Check if this is a Kanban plugin file by looking for the YAML marker
const isKanbanPlugin = content.includes("---\n\nkanban-plugin: basic");
// Split content by H2 headers to get columns
const columnHeaders = content.match(/^## .+$/gm) || [];
if (columnHeaders.length < 1) {
return kanban;
}
// Extract content between column headers
for (let i = 0; i < columnHeaders.length; i++) {
const columnHeader = columnHeaders[i];
const columnName = columnHeader.substring(3).trim();
// Find the start position of this column
const columnStart = content.indexOf(columnHeader);
if (columnStart === -1) continue;
// Find the end position of this column (start of next column or end of file)
const nextColumnStart =
i < columnHeaders.length - 1
? content.indexOf(
columnHeaders[i + 1],
columnStart + columnHeader.length
)
: content.length;
// Extract column content
const columnContent = content
.substring(columnStart + columnHeader.length, nextColumnStart)
.trim();
kanban[columnName] = { items: [] };
// Extract items from column content
if (isKanbanPlugin) {
// For Kanban plugin format
this.extractKanbanPluginItems(
columnContent,
kanban[columnName].items
);
} else {
// For regular markdown format
this.extractMarkdownItems(
columnContent,
kanban[columnName].items
);
}
}
if (this.plugin.settings.showDebugInfo) {
console.log(
"Parsed Kanban board with columns:",
Object.keys(kanban)
);
Object.entries(kanban).forEach(([column, data]) => {
console.log(
`Column "${column}" has ${data.items.length} items`
);
});
}
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() });
}
}
// New method to update file metadata when tasks are completed
async updateFileMetadata(file: TFile, content: string) {
try {
// Check if file has YAML frontmatter
const yamlRegex = /^---\s*\n([\s\S]*?)\n---/;
const yamlMatch = content.match(yamlRegex);
if (!yamlMatch) {
if (this.plugin.settings.showDebugInfo) {
console.log(
"No YAML frontmatter found in file:",
file.path
);
}
return;
}
let yaml = yamlMatch[1];
let needsUpdate = false;
let updatedYaml = yaml;
// Define today's date at the beginning of the function so it's available throughout
const today = new Date().toISOString().split("T")[0]; // Format: YYYY-MM-DD
// Check for existing status
const statusRegex = /status\s*:\s*([^\n]+)/i;
const statusMatch = yaml.match(statusRegex);
const currentStatus = statusMatch ? statusMatch[1].trim() : null;
// Update status based on settings if enabled
if (this.plugin.settings.autoChangeStatus) {
const targetStatus = this.plugin.settings.statusCompleted;
// Only update if the current status isn't already the completed status
if (currentStatus !== targetStatus) {
if (statusMatch) {
// Replace existing status
updatedYaml = updatedYaml.replace(
statusRegex,
`status: ${targetStatus}`
);
needsUpdate = true;
} else {
// Add status if it doesn't exist
updatedYaml =
updatedYaml.trim() + `\nstatus: ${targetStatus}`;
needsUpdate = true;
}
if (this.plugin.settings.showDebugInfo) {
console.log(
`Updating status to ${targetStatus} in file:`,
file.path
);
}
}
}
// Update finished date functionality remains the same
// Check if finished date already exists and matches today's date
const finishedDateRegex = /finished\s*:\s*(\d{4}-\d{2}-\d{2})/i;
const finishedDateMatch = yaml.match(finishedDateRegex);
const finishedDateAlreadySet =
finishedDateMatch && finishedDateMatch[1] === today;
// Update finished date only if enabled and not already set to today's date
if (
this.plugin.settings.autoUpdateFinishedDate &&
!finishedDateAlreadySet
) {
const finishedRegex = /(finished\s*:)\s*([^\n]*)/i;
if (finishedRegex.test(yaml)) {
// Replace existing finished date with proper spacing
updatedYaml = updatedYaml.replace(
finishedRegex,
`$1 ${today}`
);
needsUpdate = true;
} else {
// Add finished date if it doesn't exist, ensuring proper spacing
updatedYaml = updatedYaml.trim() + `\nfinished: ${today}`;
needsUpdate = true;
}
if (this.plugin.settings.showDebugInfo) {
console.log(
`Updating finished date to ${today} in file:`,
file.path
);
}
}
// Update file content if changes were made
if (needsUpdate) {
const updatedContent = content.replace(
yamlRegex,
`---\n${updatedYaml}\n---`
);
await this.plugin.app.vault.modify(file, updatedContent);
}
} catch (error) {
console.error("Error updating file metadata:", error);
if (this.plugin.settings.showDebugInfo) {
new Notice(
`Error updating metadata for ${file.basename}: ${error.message}`
);
}
}
}
// Add a new method to update status based on progress percentage
// Modified to return whether the status was changed
async updateStatusBasedOnProgress(file: TFile, progressPercentage: number): Promise<boolean> {
if (!file || !this.plugin.settings.autoChangeStatus) return false;
try {
// Read the file content
const content = await this.plugin.app.vault.read(file);
// Check if file has YAML frontmatter
const yamlRegex = /^---\s*\n([\s\S]*?)\n---/;
const yamlMatch = content.match(yamlRegex);
if (!yamlMatch) return false;
let yaml = yamlMatch[1];
let updatedYaml = yaml;
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;
}
// Check for existing status
const statusRegex = /status\s*:\s*([^\n]+)/i;
const statusMatch = yaml.match(statusRegex);
const currentStatus = statusMatch ? statusMatch[1].trim() : null;
// Update if status is different
if (currentStatus !== targetStatus) {
if (statusMatch) {
// Replace existing status
updatedYaml = updatedYaml.replace(
statusRegex,
`status: ${targetStatus}`
);
} else {
// Add status if it doesn't exist
updatedYaml =
updatedYaml.trim() + `\nstatus: ${targetStatus}`;
}
needsUpdate = true;
}
// Remove finished date if progress is less than 100%
if (
progressPercentage < 100 &&
this.plugin.settings.autoUpdateFinishedDate
) {
const finishedRegex = /finished\s*:\s*[^\n]+\n?/i;
if (finishedRegex.test(updatedYaml)) {
// Remove the finished date line entirely
updatedYaml = updatedYaml.replace(finishedRegex, "");
// Remove any extra newlines that might have been left
updatedYaml = updatedYaml.replace(/\n\n+/g, "\n");
updatedYaml = updatedYaml.trim();
needsUpdate = true;
if (this.plugin.settings.showDebugInfo) {
console.log(
`Removed finished date from file ${file.path} because progress is ${progressPercentage}%`
);
}
}
}
// Update file if needed
if (needsUpdate) {
const updatedContent = content.replace(
yamlRegex,
`---\n${updatedYaml}\n---`
);
await this.plugin.app.vault.modify(file, updatedContent);
if (this.plugin.settings.showDebugInfo) {
console.log(
`Updated status to "${targetStatus}" based on progress ${progressPercentage}% for file:`,
file.path
);
}
// Return true to indicate status was changed
return true;
}
} catch (error) {
console.error("Error updating status based on progress:", error);
}
return false; // Return false if no status change occurred
}
// New helper method to create progress bar elements
createProgressBarElements(
container: HTMLElement,
completedTasks: number,
totalTasks: number,
percentage: number
) {
// Create a more compact layout
const progressLayout = container.createDiv({ cls: "progress-layout" });
// Create percentage text with smaller size
progressLayout.createEl("div", {
text: `${percentage}%`,
cls: "progress-percentage-small",
});
// Create HTML5-like progress bar
const progressBarContainer = progressLayout.createDiv({
cls: "pt-progress-bar-container",
});
// Create the outer progress element (similar to HTML <progress>)
const progressElement = progressBarContainer.createDiv({
cls: "progress-element",
});
// Create the inner value element that shows the filled portion
const progressValue = progressElement.createDiv({
cls: "progress-value",
});
progressValue.style.width = `${percentage}%`;
// Apply color based on settings
this.applyProgressColor(progressValue, percentage);
// Add a data attribute for potential CSS styling based on percentage
progressElement.setAttribute("data-percentage", percentage.toString());
// Create stats (keep this but make it more compact)
const statsContainer = container.createDiv({
cls: "progress-stats-compact",
});
statsContainer.createSpan({
text: `${completedTasks}/${totalTasks} tasks`,
});
// Thêm debug info nếu cần
if (this.plugin.settings.showDebugInfo) {
const debugInfo = container.createDiv({ cls: "debug-info" });
debugInfo.createEl("p", { text: `Debug Info:` });
debugInfo.createEl("p", {
text: `File: ${this.currentFile?.path}`,
});
debugInfo.createEl("p", {
text: `Incomplete tasks: ${totalTasks - completedTasks}`,
});
debugInfo.createEl("p", {
text: `Completed tasks: ${completedTasks}`,
});
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}`,
});
}
}
async onClose() {
this.isVisible = false;
}
// Method to apply color based on percentage and settings
applyProgressColor(progressElement: HTMLElement, percentage: number) {
const settings = this.plugin.settings;
// If using default color scheme, let CSS handle it
if (settings.progressColorScheme === "default") {
// Xóa bất kỳ màu inline nào đã được đặt trước đó
progressElement.style.backgroundColor = "";
return;
}
// Apply custom color based on percentage
if (percentage === 100) {
// Hoàn thành - màu xanh lá
progressElement.style.backgroundColor =
settings.completeProgressColor;
} else if (percentage >= settings.mediumProgressThreshold) {
// Tiến độ cao (66-99%) - màu xanh dương
progressElement.style.backgroundColor = settings.highProgressColor;
} else if (percentage >= settings.lowProgressThreshold) {
// Tiến độ trung bình (34-65%) - màu cam/vàng
progressElement.style.backgroundColor =
settings.mediumProgressColor;
} else {
// Tiến độ thấp (0-33%) - màu đỏ
progressElement.style.backgroundColor = settings.lowProgressColor;
}
// Thêm debug log nếu cần
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: ${progressElement.style.backgroundColor}`);
}
}
// Add a method to clear the completed files map when needed
// For example, when plugin is unloaded or when explicitly requested
clearCompletedFilesCache() {
this.completedFilesMap.clear();
if (this.plugin.settings.showDebugInfo) {
console.log("Cleared completed files cache");
}
}
/**
* Check if file should be treated as a Kanban board based on settings and content
*/
shouldTreatAsKanban(file: TFile, content: string): boolean {
const settings = this.plugin.settings;
// If Kanban feature is disabled, skip everything
if (!settings.autoUpdateKanban) {
return false;
}
// Check if file is in excluded list
const isExcluded = settings.kanbanExcludeFiles.some((excludePath) => {
// Check if it matches the file path directly
if (file.path === excludePath) return true;
// Check if file is in an excluded folder
if (excludePath.endsWith("/") && file.path.startsWith(excludePath))
return true;
return false;
});
if (isExcluded) {
if (this.plugin.settings.showDebugInfo) {
console.log(
`File ${file.path} is in the Kanban exclude list, skipping`
);
}
return false;
}
// Check if file is in the specific file list
const isSpecific = settings.kanbanSpecificFiles.some((includePath) => {
// Check if it matches the file path directly
if (file.path === includePath) return true;
// Check if file is in an included folder
if (includePath.endsWith("/") && file.path.startsWith(includePath))
return true;
return false;
});
// If file is explicitly included or auto-detection is enabled and the content looks like a Kanban board
return (
isSpecific ||
(settings.kanbanAutoDetect && this.isKanbanBoard(content))
);
}
/**
* Check if content appears to be a Kanban board
* This method is necessary for the shouldTreatAsKanban function
*/
isKanbanBoard(content: string): boolean {
// Look for specific indicators of a Kanban board
// Check for Kanban plugin metadata marker
if (content.includes("---\n\nkanban-plugin: basic")) {
return true;
}
// Check for typical Kanban structure - multiple columns with tasks
// Must have at least 2 columns with headers like "## Todo", "## In Progress", "## Done", etc.
const kanbanColumnHeaders = content.match(/^## .+?$/gm) || [];
if (kanbanColumnHeaders.length < 2) {
return false;
}
// Column names typically used in Kanban boards
const commonKanbanNames = [
"todo",
"to do",
"to-do",
"backlog",
"new",
"ideas",
"inbox",
"in progress",
"doing",
"working",
"current",
"ongoing",
"done",
"complete",
"completed",
"finished",
"blocked",
"waiting",
"on hold",
"review",
];
// Check if at least one column name matches common Kanban terminology
let foundKanbanName = false;
for (const header of kanbanColumnHeaders) {
const columnName = header.substring(3).toLowerCase().trim();
if (commonKanbanNames.some((name) => columnName.includes(name))) {
foundKanbanName = true;
break;
}
}
// Check for column with our target name specifically
const targetColumnExists = kanbanColumnHeaders.some(
(header) =>
header.substring(3).toLowerCase().trim() ===
this.plugin.settings.kanbanCompletedColumn.toLowerCase()
);
// If we find our target column name, it's very likely this is a Kanban board
if (targetColumnExists) {
return true;
}
// Check for items (-) within columns
// Split content by headers
const sections = content.split(/^## /m).slice(1);
let hasItems = false;
for (const section of sections) {
// Check for list items within the section
if (section.match(/^- .+/m)) {
hasItems = true;
break;
}
}
// For a file to be considered a Kanban board:
// 1. Must have at least 2 column headers
// 2. Must either have common Kanban column names OR have items in columns
return kanbanColumnHeaders.length >= 2 && (foundKanbanName || hasItems);
}
/**
* 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 {
// Parse the Kanban board structure
const kanbanColumns = this.parseKanbanBoard(boardContent);
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}"`);
}
// Look for the card containing reference to our file in each column
let cardMoved = false;
let newContent = boardContent;
// Build file reference pattern options for searching
const filePath = fileToMove.path;
const filePathWithoutExtension = filePath.replace(/\.md$/, '');
const fileName = fileToMove.basename;
// Patterns to match different kinds of file references in cards
const fileRefPatterns = [
`\\[.*?\\]\\(${this.escapeRegExp(filePath)}\\)`,
`\\[\\[${this.escapeRegExp(filePathWithoutExtension)}(\\|.*?)?\\]\\]`,
`\\[\\[${this.escapeRegExp(fileName)}(\\|.*?)?\\]\\]`,
`\\b${this.escapeRegExp(filePath)}\\b`,
`\\b${this.escapeRegExp(fileName)}\\b`
];
// Join patterns with OR for combined search
const fileRefRegex = new RegExp(fileRefPatterns.join('|'), 'i');
// Process each column
for (const columnName in kanbanColumns) {
// Skip the target column - we don't need to move items already there
if (columnName.toLowerCase() === targetColumnName.toLowerCase()) {
continue;
}
const column = kanbanColumns[columnName];
// Check each card in the column
for (let i = 0; i < column.items.length; i++) {
const card = column.items[i];
// Check if this card references our file
if (fileRefRegex.test(card.text)) {
if (this.plugin.settings.showDebugInfo) {
console.log(`Found card in column "${columnName}" that references file ${fileToMove.path}`);
}
// Remove this card from its current position
// Find this card's position in the file content
const columnRegex = new RegExp(`## ${this.escapeRegExp(columnName)}\\s*\\n`);
const columnMatch = newContent.match(columnRegex);
if (!columnMatch) continue;
const columnStart = columnMatch.index!;
const columnHeaderEnd = columnStart + columnMatch[0].length;
// Find the next column or the end of file
const nextColumnRegex = /^## /gm;
nextColumnRegex.lastIndex = columnHeaderEnd;
const nextColumnMatch = nextColumnRegex.exec(newContent);
const columnEnd = nextColumnMatch ? nextColumnMatch.index : newContent.length;
// Get this column's content
const columnContent = newContent.substring(columnHeaderEnd, columnEnd);
// Try to find the card in the column
const cleanCardText = this.escapeRegExp(card.text.trim());
const cardRegex = new RegExp(`(^|\\n)(${cleanCardText})(\\n|$)`);
const cardMatch = columnContent.match(cardRegex);
if (!cardMatch) {
if (this.plugin.settings.showDebugInfo) {
console.log(`Could not find card in column ${columnName}`);
}
continue;
}
// Calculate absolute positions in the whole content
const cardStart = columnHeaderEnd + cardMatch.index! + (cardMatch[1] === '\n' ? 1 : 0);
const cardEnd = cardStart + card.text.length;
// Safety check - don't remove a card if our indices are wrong
if (cardEnd <= cardStart || cardStart < 0 || cardEnd > newContent.length) {
console.error(`Invalid card position: start=${cardStart}, end=${cardEnd}, contentLength=${newContent.length}`);
continue;
}
// Remove this card from its current column
const beforeCard = newContent.substring(0, cardStart);
const afterCard = newContent.substring(cardEnd);
// Handle newlines correctly
newContent = beforeCard + afterCard;
// Find the target column
const targetColumnRegex = new RegExp(`## ${this.escapeRegExp(targetColumnName)}\\s*\\n`);
const targetColumnMatch = newContent.match(targetColumnRegex);
if (!targetColumnMatch) continue;
const targetInsertPosition = targetColumnMatch.index! + targetColumnMatch[0].length;
// Insert the card at the beginning of the target column
newContent =
newContent.substring(0, targetInsertPosition) +
card.text +
"\n" +
newContent.substring(targetInsertPosition);
cardMoved = true;
if (this.plugin.settings.showDebugInfo) {
console.log(`Moved card for ${fileToMove.path} from "${columnName}" to "${targetColumnName}" in ${boardFile.path}`);
}
// Break once we've found and moved the card
break;
}
}
// If we've moved the card, no need to check other columns
if (cardMoved) break;
}
// Update the file if changes were made
if (cardMoved && newContent !== boardContent) {
await this.plugin.app.vault.modify(boardFile, newContent);
return newContent;
}
return boardContent;
} catch (error) {
console.error("Error moving card in Kanban board:", error);
if (this.plugin.settings.showDebugInfo) {
console.error("Error details:", error);
}
return boardContent;
}
}
/**
* 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 all else fails, use a column that seems most appropriate
if (columnNames.length > 0) {
for (const colName of columnNames) {
const lowerName = colName.toLowerCase();
// Common backlog/todo names
if (lowerName.includes("backlog") || lowerName.includes("todo") || lowerName.includes("to do")) {
return colName;
}
}
}
// 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> {
try {
const content = await this.plugin.app.vault.read(file);
const yamlRegex = /^---\s*\n([\s\S]*?)\n---/;
const yamlMatch = content.match(yamlRegex);
if (!yamlMatch) return null;
const yaml = yamlMatch[1];
const statusRegex = /status\s*:\s*([^\n]+)/i;
const statusMatch = yaml.match(statusRegex);
if (statusMatch) {
return statusMatch[1].trim();
}
} catch (error) {
console.error("Error getting status from YAML:", error);
}
return null;
}
}
class TaskProgressBarSettingTab extends PluginSettingTab {
plugin: TaskProgressBarPlugin;
constructor(app: App, plugin: TaskProgressBarPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "Task Progress Bar Settings" });
// Thêm thông tin về trạng thái Dataview
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",
});
// Thêm nút để kiểm tra lại Dataview
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."
);
}
});
}
// General settings section
containerEl.createEl("h3", { text: "General Settings" });
new Setting(containerEl)
.setName("Setting")
.setDesc("Description of the setting")
.addText((text) =>
text
.setPlaceholder("Enter your setting")
.setValue(this.plugin.settings.mySetting)
.onChange(async (value) => {
this.plugin.settings.mySetting = value;
await this.plugin.saveSettings();
})
);
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();
// Cập nhật sidebar nếu đang mở - sử dụng phương thức public
const currentFile = this.app.workspace.getActiveFile();
if (currentFile) {
// Sử dụng phương thức public để cập nhật UI
this.plugin.checkDataviewAPI();
}
})
);
// Animation settings section
containerEl.createEl("h3", { text: "Animation Settings" });
// 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
containerEl.createEl("h3", { text: "Performance Settings" });
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
containerEl.createEl("h3", { text: "Progress Bar Colors" });
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") {
// Đặt lại các giá trị màu sắc và ngưỡng
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
// Đặt lại các ngưỡng
this.plugin.settings.lowProgressThreshold = 30;
this.plugin.settings.mediumProgressThreshold = 60;
this.plugin.settings.highProgressThreshold = 99;
// Hiển thị thông báo để xác nhận thay đổi
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)
containerEl.createEl("h3", { text: "Interface Settings" });
// 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
containerEl.createEl("h3", { text: "Metadata Auto-Update" });
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
containerEl.createEl("h3", { text: "Kanban Integration" });
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...
}
}
}
}