mirror of
https://github.com/vannamhh/progress-tracker.git
synced 2026-07-22 05:43:38 +00:00
- 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.
488 lines
14 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|