vannamhh_progress-tracker/src/main.ts
Hoang Nam 3db00eae9b 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.
2026-01-18 18:39:49 +07:00

488 lines
14 KiB
TypeScript

/**
* 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);
}
}
}