Compare commits

..

No commits in common. "main" and "1.2.0" have entirely different histories.
main ... 1.2.0

17 changed files with 3039 additions and 5469 deletions

5
.gitignore vendored
View file

@ -20,8 +20,3 @@ data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
.cursor
cursorkleosr
.bmad
.github

211
README.md
View file

@ -1,158 +1,115 @@
# Progress Tracker Plugin for Obsidian
# Progress Tracker
A plugin for Obsidian that helps you track progress of your tasks in real-time. It provides a visual progress bar and integrates with Kanban boards to help you manage your tasks more effectively.
## What's new in the latest update
- **Sidebar duplication bug fixed:** Only one progress tracker view is created, even after reloads.
- **Sidebar position:** Progress tracker now appears in the right sidebar by default.
- **Custom checkbox states:** Improved support and protection for custom states in Kanban boards.
- **View initialization:** Progress bar view now reliably loads and updates when opened.
Progress Tracker is an Obsidian plugin that visualizes your task completion progress and helps you stay on top of your work.
## Features
- Real-time progress tracking of tasks in your notes
- Visual progress bar showing completion percentage
- Automatic status updates based on task completion
- Kanban board integration for task management
- **Custom checkbox states for Kanban columns** (NEW!)
- Customizable colors and thresholds
- Debug mode for troubleshooting
- **Smooth Progress Bar**: Visualizes your task completion progress with an animated, color-coded progress bar
- **Task Tracking**: Automatically counts completed and incomplete tasks in your notes
- **Status Management**: Automatically updates status in the YAML frontmatter based on task progress
- **Kanban Integration**: Moves cards between columns in Kanban boards when tasks are completed
- **Metadata Updates**: Automatically adds completion dates and updates metadata when tasks reach 100%
- **Auto-Add to Kanban**: Automatically adds files with tasks to a specified Kanban board
## Screenshots
![Progress Tracker Main View](Screenshot.jpg)
## How It Works
The plugin tracks Markdown tasks (`- [ ]` and `- [x]`) in your notes and displays:
- A visual progress bar showing completion percentage
- Task count (complete/total)
- Color-coded indicators for different progress levels
## Installation
1. Open Obsidian Settings
2. Go to Community Plugins and disable Safe Mode
3. Click Browse and search for "Progress Tracker"
4. Install the plugin
5. Enable the plugin in your Community Plugins list
1. In Obsidian, go to Settings → Community plugins
2. Disable Safe mode
3. Click "Browse" and search for "Progress Tracker"
4. Install the plugin and enable it
## Usage
## Requirements
### Basic Usage
- [Dataview plugin](https://github.com/blacksmithgu/obsidian-dataview) (optional but recommended)
1. Create tasks in your notes using Markdown checkboxes:
```markdown
- [ ] Task 1
- [x] Completed task
- [ ] Task 3
```
## Configuration
2. The progress bar will automatically update as you check/uncheck tasks.
### General Settings
- Toggle debug information
- Customize update animations
### Performance
- Adjust the delay for various operations to balance responsiveness and performance
- Configure keyboard input and checkbox click update delays
### Progress Bar Colors
- Choose from preset color schemes or create your own
- Customize thresholds for low/medium/high progress states
### Metadata Auto-Update
- Automatically update YAML frontmatter when tasks are completed
- Change status fields and add completion dates
- Customize status labels
### Kanban Integration
- Automatically move cards in Kanban boards based on task status
- Match column names to status values
- Automatically detect Kanban boards in your vault
- Auto-add files with tasks to a specified Kanban board
- Choose which column new files should be added to
The plugin can automatically move cards in your Kanban boards based on task completion:
## Usage Tips
1. Set up a Kanban board in Obsidian
2. Configure the target Kanban board in plugin settings
3. As you complete tasks, cards will move to appropriate columns
### Task Format
The plugin recognizes standard Markdown task formats:
### Custom Checkbox States (NEW!)
- [] Incomplete task
- [x] Completed task
You can now configure custom checkbox states for different Kanban columns:
1. Enable "Custom Checkbox States" in plugin settings
2. Configure mappings between column names and checkbox states:
- `Todo``[ ]` (unchecked)
- `In Progress``[/]` (in progress)
- `Complete``[x]` (completed)
- `Done``[x]` (completed)
- Or any custom states like `[>]`, `[-]`, etc.
### YAML Frontmatter
For status tracking, use YAML frontmatter at the top of your notes:
```yaml
---
status: In Progress
---
```
3. When cards are moved between columns, their checkbox states will automatically update
The plugin will automatically update this to "Completed" when all tasks are done.
#### Supported Checkbox States
Kanban Integration
For the best Kanban integration:
- `[ ]` - Todo/Unchecked
- `[x]` - Completed
- `[/]` - In Progress
- `[>]` - Forwarded/Deferred
- `[-]` - Cancelled
- `[!]` - Important
- Name your columns to match your status labels (Todo, In Progress, Completed)
- Enable automatic synchronization in settings
#### Kanban Normalization Protection (NEW!)
### Keyboard Shortcuts
You can assign keyboard shortcuts to the following commands:
The plugin now includes intelligent protection against unwanted checkbox state normalization by the Kanban plugin:
- **Real-Time Detection**: Instantly detects when the Kanban plugin tries to convert custom states to standard states
- **Immediate Reversion**: Automatically reverts unwanted normalizations in real-time
- **Smart Pattern Analysis**: Distinguishes between legitimate user changes and unwanted plugin normalizations
- **Selective Protection**: Only protects states that match your column mappings while preserving legitimate changes
- **Multi-Layer Defense**: Combines immediate detection, movement analysis, and content comparison for comprehensive protection
- **Non-Invasive**: Works seamlessly without interfering with normal Kanban operations
- **Debug Support**: Comprehensive logging and testing commands for troubleshooting
**How it works:**
1. **Immediate Detection**: Monitors editor changes in real-time to catch normalization as it happens
2. **Pattern Recognition**: Analyzes content changes to identify unwanted custom state → [x] conversions
3. **Smart Reversion**: Automatically restores correct checkbox states based on column mappings
4. **Movement Protection**: Prevents card movements from triggering unwanted normalization cascades
This ensures your custom checkbox states (like `[/]`, `[~]`, etc.) remain intact when working with Kanban boards, even when the Kanban plugin tries to normalize them.
### Enhanced Custom State Support
The plugin now properly recognizes and counts all custom checkbox states with advanced conflict prevention and precision targeting. When custom checkbox states are enabled:
- **Progress Calculation**: Custom states (like `[/]`, `[-]`, `[~]`) are counted as "tasks in progress" and included in the total task count
- **Smart Auto-Sync**: Auto-sync only updates cards that don't already have the correct checkbox state for their column
- **Conflict Prevention**: Advanced timing detection prevents race conditions between auto-sync and manual card movements
- **Precision Targeting**: Only cards that actually moved between columns are updated, eliminating false positives
- **Content Normalization**: Card movements are detected by normalized content comparison, not checkbox states or fuzzy matching
- **Preserved States**: Cards with existing correct states are left unchanged to prevent unnecessary modifications
- **Enhanced Detection**: All task-related changes are properly detected regardless of checkbox state
**Note:** When updating checkbox states, only the main card checkbox is modified. Sub-items and nested checkboxes within cards are preserved to maintain data integrity. The plugin uses sophisticated position-based replacement and precision targeting to ensure only cards that actually moved are updated, completely preventing accidental modifications to other cards.
### Settings
#### Progress Bar Colors
- Choose between default theme colors or custom color schemes
- Set custom colors for different progress levels
- Configure progress thresholds
#### Performance
- Adjust update delays for different actions
- Configure animation settings
- Set maximum height for the progress bar container
#### Metadata Auto-Update
- Automatically update file metadata on task completion
- Configure status labels for different progress states
- Set up finished date tracking
#### Kanban Integration
- Enable/disable automatic Kanban board updates
- Configure target Kanban board
- Set up column mapping for task states
#### Custom Checkbox States
- Enable custom checkbox state management
- Configure column-to-checkbox mappings
- Add/remove column mappings as needed
- Reset to default mappings
- Clear completed files cache - Reset the list of files that have already triggered completion notifications
## Troubleshooting
If you encounter issues:
1. Enable Debug Mode in settings to see detailed logs
2. Check if Dataview plugin is installed and enabled
3. Verify your Kanban board configuration
4. Clear the completed files cache if needed
5. Check custom checkbox state mappings if using that feature
- Progress bar not updating: Check that your tasks use the proper Markdown format
- Kanban integration not working: Ensure your Kanban columns match your status values
- Slow updates: Adjust the performance settings to balance responsiveness with performance
## Support
If you encounter issues or have feature suggestions:
If you find this plugin helpful, you can:
- Check the GitHub Issues
- Star the repository
- Report issues on GitHub
- Submit feature requests
- Contribute to the code
## Changelog
v1.1.0
- Added smooth transitions for progress bar updates
- Fixed Kanban board detection and integration
- Improved metadata handling for completion status
- Enhanced UI responsiveness
## License
This project is licensed under the MIT License - see the LICENSE file for details.
v1.0.0
- Initial release
- Credits
- Developed by Van Nam
- Thanks to the Obsidian community for inspiration and feedback

2964
main.ts

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,10 @@
{
"id": "progress-tracker",
"name": "ProgressTracker",
"version": "1.5.4",
"version": "1.2.0",
"minAppVersion": "1.1.0",
"description": "Track task completion with a visual progress bar in your sidebar, auto update column Kanban",
"author": "VN",
"authorUrl": "https://github.com/vannamhh/",
"isDesktopOnly": false
}
}

View file

@ -1,6 +1,6 @@
{
"name": "progress-tracker",
"version": "1.5.4",
"version": "1.0.0",
"description": "Track task completion with a visual progress bar in your sidebar",
"main": "main.js",
"scripts": {
@ -8,12 +8,7 @@
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [
"obsidian",
"dataview",
"progress",
"task"
],
"keywords": ["obsidian", "dataview", "progress", "task"],
"author": "VN",
"license": "MIT",
"devDependencies": {
@ -26,4 +21,4 @@
"tslib": "2.4.0",
"typescript": "4.7.4"
}
}
}

View file

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

View file

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

View file

@ -1,503 +0,0 @@
/**
* 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
);
// Inject KanbanService for Kanban integration
this.sidebarView.setKanbanService(this.kanbanService);
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;
// Wait for workspace to be ready
if (!workspace.layoutReady) {
workspace.onLayoutReady(() => this.activateView());
return;
}
let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType("progress-tracker");
if (leaves.length > 0) {
leaf = leaves[0];
} else {
// Try to get right leaf, with fallback
const rightLeaf = workspace.getRightLeaf(false);
if (rightLeaf) {
leaf = rightLeaf;
await leaf.setViewState({ type: "progress-tracker", active: true });
} else {
// Fallback: create a new split in the right sidebar
leaf = workspace.getLeaf('split', 'vertical');
if (leaf) {
await leaf.setViewState({ type: "progress-tracker", active: true });
}
}
}
if (leaf) {
workspace.revealLeaf(leaf);
}
}
}

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,357 +0,0 @@
/**
* Remove code blocks from content to avoid counting tasks inside them
* @param content - Content with potential code blocks
* @returns Content without code blocks
*/
export function removeCodeBlocks(content: string): string {
// Remove fenced code blocks (```)
let result = content.replace(/```[\s\S]*?```/g, '');
// Remove inline code blocks (`)
result = result.replace(/`[^`\n]+`/g, '');
return result;
}
// ============================================================================
// Task Counting
// ============================================================================
/**
* Interface for task count results
*/
export interface TaskCounts {
incomplete: number;
completed: number;
customStates: number;
total: number;
}
/**
* Count all tasks in content with support for custom checkbox states
* @param content - Content to parse
* @param enableCustomCheckboxStates - Whether to use enhanced counting for custom states
* @returns TaskCounts object with breakdown of task states
*/
export function countAllTasks(content: string, enableCustomCheckboxStates: boolean): TaskCounts {
const contentWithoutCodeBlocks = removeCodeBlocks(content);
let incomplete = 0;
let completed = 0;
let customStates = 0;
if (enableCustomCheckboxStates) {
// Enhanced counting for custom checkbox states
const taskCounts = countTasksByCheckboxState(content);
incomplete = taskCounts[" "] || 0;
completed = taskCounts["x"] || 0;
// Count all custom states
for (const [state, count] of Object.entries(taskCounts)) {
if (state !== " " && state !== "x" && state.trim() !== "") {
customStates += count;
}
}
} else {
// Legacy counting - only [ ] and [x]
incomplete = (contentWithoutCodeBlocks.match(/- \[ \]/g) || []).length;
completed = (contentWithoutCodeBlocks.match(/- \[x\]/gi) || []).length;
}
let total = incomplete + completed + customStates;
// Try relaxed regex if no tasks found
if (total === 0) {
incomplete = (contentWithoutCodeBlocks.match(/[-*] \[ \]/g) || []).length;
completed = (contentWithoutCodeBlocks.match(/[-*] \[x\]/gi) || []).length;
total = incomplete + completed;
}
return { incomplete, completed, customStates, total };
}
// ============================================================================
// UI Constants
// ============================================================================
/**
* CSS class names used throughout the plugin UI
*/
export const UI_CLASSES = {
// Container classes
PROGRESS_CONTAINER: "task-progress-container",
PROGRESS_LAYOUT: "progress-layout",
PROGRESS_BAR_CONTAINER: "pt-progress-bar-container",
PROGRESS_ELEMENT: "progress-element",
PROGRESS_VALUE: "progress-value",
PROGRESS_STATS: "progress-stats-compact",
PROGRESS_PERCENTAGE: "progress-percentage-small",
// Message classes
NO_TASKS_MESSAGE: "no-tasks-message",
LOADING_INDICATOR: "loading-indicator",
DATAVIEW_WARNING: "dataview-warning-compact",
DATAVIEW_WARNING_TEXT: "dataview-warning-text",
DEBUG_INFO: "debug-info",
// Leaf classes
PROGRESS_TRACKER_LEAF: "progress-tracker-leaf",
} as const;
/**
* Safely escape regex special characters in a string
* Used by multiple methods that need to create regex patterns
* @param string - String to escape
* @returns Escaped string safe for use in regex
*/
export function escapeRegExp(string: string): string {
if (!string) return "";
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Validate CSS value to prevent XSS injection
* @param value - CSS value to validate
* @returns true if value is safe to use
*/
export function isValidCSSValue(value: string): boolean {
if (!value || typeof value !== "string") return false;
// Allow specific safe values
if (value === "auto" || value === "none") return true;
// Allow valid CSS length values (px, em, rem, vh, %)
const validCSSPattern = /^(\d+(\.\d+)?)(px|em|rem|vh|%)$/;
return validCSSPattern.test(value.trim());
}
/**
* Check if content is safe to process
* @param content - Content to validate
* @param maxSize - Maximum allowed content size in bytes (default 5MB)
* @returns Object with isValid flag and optional error message
*/
export function validateContent(
content: string,
maxSize: number = 5 * 1024 * 1024
): { isValid: boolean; error?: string } {
if (typeof content !== "string") {
return { isValid: false, error: "Content is not a string" };
}
// Check content size
if (content.length > maxSize) {
return {
isValid: false,
error: `Content too large: ${content.length} characters`,
};
}
// Check for suspicious patterns that might indicate injection
const suspiciousPatterns = [
/<script[^>]*>/i,
/javascript:/i,
/data:text\/html/i,
/vbscript:/i,
];
for (const pattern of suspiciousPatterns) {
if (pattern.test(content)) {
return {
isValid: false,
error: "Suspicious content pattern detected",
};
}
}
return { isValid: true };
}
/**
* Check if content has tasks using extended pattern
* Supports custom checkbox states like [/], [-], [~], etc.
* @param content - Content to check
* @returns true if content contains tasks
*/
export function hasTasksInContent(content: string): boolean {
// Remove code blocks before checking for tasks
const contentWithoutCodeBlocks = removeCodeBlocks(content);
const extendedTaskRegex = /- \[[^\]]*\]/i;
return extendedTaskRegex.test(contentWithoutCodeBlocks);
}
/**
* Count tasks with different checkbox states
* @param content - Content to parse
* @returns Object with checkbox state as key and count as value
*/
export function countTasksByCheckboxState(content: string): {
[state: string]: number;
} {
const taskCounts: { [state: string]: number } = {};
// Remove code blocks before counting tasks
const contentWithoutCodeBlocks = removeCodeBlocks(content);
const lines = contentWithoutCodeBlocks.split("\n");
for (const line of lines) {
const match = line.trim().match(/^- \[([^\]]*)\]/);
if (match) {
const state = match[1];
taskCounts[state] = (taskCounts[state] || 0) + 1;
}
}
return taskCounts;
}
/**
* Check if content changes are related to tasks
* Supports custom checkbox states like [/], [-], [~], etc.
* @param oldContent - Previous file content
* @param newContent - Current file content
* @returns true if task-related changes detected
*/
export function hasTaskContentChanged(
oldContent: string,
newContent: string
): boolean {
// Remove code blocks before comparing
const oldContentWithoutCodeBlocks = removeCodeBlocks(oldContent);
const newContentWithoutCodeBlocks = removeCodeBlocks(newContent);
// Split content into lines
const oldLines = oldContentWithoutCodeBlocks.split("\n");
const newLines = newContentWithoutCodeBlocks.split("\n");
// Find task lines in both contents - support all checkbox states
const oldTasks = oldLines.filter((line) =>
line.trim().match(/^[-*] \[[^\]]*\]/i)
);
const newTasks = newLines.filter((line) =>
line.trim().match(/^[-*] \[[^\]]*\]/i)
);
// Compare task count
if (oldTasks.length !== newTasks.length) {
return true;
}
// Compare each task
for (let i = 0; i < oldTasks.length; i++) {
if (oldTasks[i] !== newTasks[i]) {
return true;
}
}
return false;
}
/**
* Normalize card content for comparison by removing checkbox states and extra whitespace
* This allows us to detect card movements regardless of checkbox state changes
* @param cardContent - Card content to normalize
* @returns Normalized card content
*/
export function normalizeCardContentForComparison(cardContent: string): string {
return cardContent
.replace(/^(\s*- )\[[^\]]*\](.*)$/gm, "$1$2") // Remove checkbox states
.trim(); // Remove extra whitespace
}
/**
* Update checkbox state in a single card text
* Only updates the main card checkbox, preserving sub-items and nested checkboxes
* @param cardText - Card text to update
* @param targetCheckboxState - Target checkbox state (e.g., "[x]", "[ ]", "[/]")
* @returns Updated card text
*/
export function updateCheckboxStateInCardText(
cardText: string,
targetCheckboxState: string
): string {
// Split content into lines to process only the first line (main card)
const lines = cardText.split("\n");
if (lines.length === 0) return cardText;
// Pattern to match various checkbox states: - [ ], - [x], - [/], - [>], etc.
// Remove global flag to only match once per line
const checkboxPattern = /^(\s*[-*] )\[[^\]]*\](.*)$/;
// Only update the first line if it matches the pattern (main card line)
if (checkboxPattern.test(lines[0])) {
lines[0] = lines[0].replace(
checkboxPattern,
(match, prefix, suffix) => {
return `${prefix}${targetCheckboxState}${suffix}`;
}
);
}
// Join lines back together, preserving sub-items unchanged
return lines.join("\n");
}
/**
* Extract Obsidian-style links from content
* @param content - Content to extract links from
* @returns Array of objects with path and optional alias
*/
export function extractObsidianLinks(
content: string
): Array<{ path: string; alias?: string }> {
const links: Array<{ path: string; alias?: string }> = [];
const linkPattern = /\[\[(.*?)\]\]/g;
let match;
while ((match = linkPattern.exec(content)) !== null) {
const [_, linkContent] = match;
const [path, alias] = linkContent.split("|").map((s) => s.trim());
links.push({ path, alias });
}
return links;
}
/**
* Extract Markdown-style links from content
* @param content - Content to extract links from
* @returns Array of objects with text and url
*/
export function extractMarkdownLinks(
content: string
): Array<{ text: string; url: string }> {
const links: Array<{ text: string; url: string }> = [];
const linkPattern = /\[(.*?)\]\((.*?)\)/g;
let match;
while ((match = linkPattern.exec(content)) !== null) {
const [_, text, url] = match;
links.push({ text: text.trim(), url: url.trim() });
}
return links;
}
/**
* Extract the main link content from a card
* @param cardText - Card text to extract link from
* @returns Link path or null if not found
*/
export function extractMainLinkFromCard(cardText: string): string | null {
// Look for [[link]] pattern
const obsidianMatch = cardText.match(/\[\[([^\]]+)\]\]/);
if (obsidianMatch) {
return obsidianMatch[1];
}
// Look for [text](url) pattern
const markdownMatch = cardText.match(/\[([^\]]+)\]\(([^)]+)\)/);
if (markdownMatch) {
return markdownMatch[2]; // Return the URL part
}
return null;
}

View file

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

View file

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

View file

@ -1,566 +0,0 @@
import {
App,
WorkspaceLeaf,
TFile,
ItemView,
Notice,
} from "obsidian";
import { TaskProgressBarSettings } from "../interfaces/settings";
import { DataviewApi } from "../interfaces/types";
import { DebugLogger } from "../utils/logger";
import {
hasTasksInContent,
countAllTasks,
UI_CLASSES,
type TaskCounts
} from "../utils/helpers";
import { KanbanService } from "../services/KanbanService";
// ============================================================================
// Types
// ============================================================================
interface ProgressData {
percentage: number;
completedCount: number;
totalTasks: number;
}
// ============================================================================
// Progress Bar View
// ============================================================================
/**
* Progress bar view that displays task completion status in the sidebar
* Refactored to use centralized helpers and KanbanService
*/
export class TaskProgressBarView extends ItemView {
private settings: TaskProgressBarSettings;
private logger: DebugLogger;
private dvAPI: DataviewApi | null = null;
private kanbanService: KanbanService | null = null;
currentFile: TFile | null = null;
isVisible: boolean = false;
lastUpdateTime: number = 0;
initialLoadComplete: boolean = false;
completedFilesMap: Map<string, boolean> = new Map();
constructor(
leaf: WorkspaceLeaf,
_app: App,
settings: TaskProgressBarSettings,
logger: DebugLogger,
dvAPI: DataviewApi | null
) {
super(leaf);
this.settings = settings;
this.logger = logger;
this.dvAPI = dvAPI;
}
// ============================================================================
// Public API Methods
// ============================================================================
updateSettings(settings: TaskProgressBarSettings): void {
this.settings = settings;
}
updateDataviewAPI(dvAPI: DataviewApi | null): void {
this.dvAPI = dvAPI;
}
setKanbanService(kanbanService: KanbanService): void {
this.kanbanService = kanbanService;
}
setIsKanbanBoardFn(fn: (file: TFile) => boolean): void {
// Keep for backwards compatibility - now uses KanbanService
}
clearCompletedFilesCache(): void {
this.completedFilesMap.clear();
this.logger.log("Cleared completed files cache");
}
// ============================================================================
// ItemView Overrides
// ============================================================================
getViewType(): string {
return "progress-tracker";
}
getDisplayText(): string {
return "Task progress bar";
}
getIcon(): string {
return "bar-chart-horizontal";
}
async onOpen(): Promise<void> {
this.isVisible = true;
this.initialLoadComplete = false;
this.addLeafClasses();
this.initializeContainer();
// Trigger initial update after a short delay
setTimeout(async () => {
const currentFile = this.app.workspace.getActiveFile();
if (currentFile) {
await this.updateProgressBar(currentFile, undefined, true);
}
}, 200);
}
async onClose(): Promise<void> {
this.isVisible = false;
}
// ============================================================================
// Main Progress Bar Methods
// ============================================================================
async updateProgressBar(
file: TFile | null,
content?: string,
isInitialLoad: boolean = false
): Promise<void> {
if (!file) return;
// Throttle updates
const now = Date.now();
if (!isInitialLoad && now - this.lastUpdateTime < 100) return;
this.lastUpdateTime = now;
this.currentFile = file;
const container = this.getProgressContainer();
if (!container) return;
// Clear on initial load
if (isInitialLoad || !this.initialLoadComplete) {
container.empty();
this.initialLoadComplete = true;
}
// Add animation class if enabled
if (this.settings.showUpdateAnimation) {
container.classList.add("updating");
}
try {
const fileContent = content || await this.app.vault.read(file);
if (!hasTasksInContent(fileContent)) {
this.showNoTasksMessage(container);
this.logger.log("No tasks found in file:", file.path);
} else {
await this.createProgressBarFromContent(container, fileContent, file);
}
} catch (error) {
this.showErrorMessage(container, error as Error);
} finally {
if (this.settings.showUpdateAnimation) {
setTimeout(() => {
container.classList.remove("updating");
}, this.settings.updateAnimationDelay);
}
}
}
// ============================================================================
// Progress Bar Creation
// ============================================================================
private async createProgressBarFromContent(
container: HTMLElement,
content: string,
file: TFile
): Promise<void> {
this.logger.log(`Creating progress bar for file: ${file.path}`);
// Check Dataview API
if (!this.dvAPI) {
this.showDataviewWarning(container);
return;
}
// Count tasks using centralized helper
const taskCounts = countAllTasks(content, this.settings.enableCustomCheckboxStates);
this.logger.log(
`Task counts - incomplete: ${taskCounts.incomplete}, completed: ${taskCounts.completed}, ` +
`custom states: ${taskCounts.customStates}, total: ${taskCounts.total}`
);
if (taskCounts.total === 0) {
this.showNoTasksMessage(container);
return;
}
const progressData: ProgressData = {
completedCount: taskCounts.completed,
totalTasks: taskCounts.total,
percentage: Math.round((taskCounts.completed / taskCounts.total) * 100),
};
// Update UI first for responsiveness
this.updateProgressBarUI(container, progressData);
// Process async updates
this.processAsyncUpdates(file, progressData);
}
// ============================================================================
// UI Update Methods
// ============================================================================
private updateProgressBarUI(container: HTMLElement, data: ProgressData): void {
const { percentage, completedCount, totalTasks } = data;
// Get or create UI elements
let progressLayout = container.querySelector(`.${UI_CLASSES.PROGRESS_LAYOUT}`) as HTMLElement;
let statsContainer = container.querySelector(`.${UI_CLASSES.PROGRESS_STATS}`) as HTMLElement;
if (!progressLayout || !statsContainer) {
container.empty();
this.createProgressBarStructure(container);
progressLayout = container.querySelector(`.${UI_CLASSES.PROGRESS_LAYOUT}`) as HTMLElement;
statsContainer = container.querySelector(`.${UI_CLASSES.PROGRESS_STATS}`) as HTMLElement;
}
// Update percentage text
const percentageElement = progressLayout?.querySelector(`.${UI_CLASSES.PROGRESS_PERCENTAGE}`) as HTMLElement;
if (percentageElement) {
percentageElement.setText(`${percentage}%`);
}
// Update progress bar
const progressValue = container.querySelector(`.${UI_CLASSES.PROGRESS_VALUE}`) as HTMLElement;
if (progressValue) {
if (!progressValue.hasAttribute("data-has-transition")) {
progressValue.style.transition = "width 0.3s ease-in-out, background-color 0.3s ease";
progressValue.setAttribute("data-has-transition", "true");
}
progressValue.style.width = `${percentage}%`;
this.applyProgressColor(progressValue, percentage);
}
// Update progress element data attribute
const progressElement = container.querySelector(`.${UI_CLASSES.PROGRESS_ELEMENT}`) as HTMLElement;
if (progressElement) {
progressElement.setAttribute("data-percentage", percentage.toString());
}
// Update stats
if (statsContainer) {
statsContainer.empty();
statsContainer.createSpan({ text: `${completedCount}/${totalTasks} tasks` });
}
// Update debug info
this.updateDebugInfo(container, data);
}
private createProgressBarStructure(container: HTMLElement): void {
const progressLayout = container.createDiv({ cls: UI_CLASSES.PROGRESS_LAYOUT });
// Percentage text
progressLayout.createEl("div", { cls: UI_CLASSES.PROGRESS_PERCENTAGE });
// Progress bar
const progressBarContainer = progressLayout.createDiv({ cls: UI_CLASSES.PROGRESS_BAR_CONTAINER });
const progressElement = progressBarContainer.createDiv({ cls: UI_CLASSES.PROGRESS_ELEMENT });
progressElement.createDiv({ cls: UI_CLASSES.PROGRESS_VALUE });
// Stats container
container.createDiv({ cls: UI_CLASSES.PROGRESS_STATS });
}
private applyProgressColor(progressElement: HTMLElement, percentage: number): void {
if (this.settings.progressColorScheme === "default") {
progressElement.style.backgroundColor = "";
return;
}
let newColor = "";
if (percentage === 100) {
newColor = this.settings.completeProgressColor;
} else if (percentage >= this.settings.mediumProgressThreshold) {
newColor = this.settings.highProgressColor;
} else if (percentage >= this.settings.lowProgressThreshold) {
newColor = this.settings.mediumProgressColor;
} else {
newColor = this.settings.lowProgressColor;
}
if (progressElement.style.backgroundColor !== newColor) {
progressElement.style.backgroundColor = newColor;
}
}
private updateDebugInfo(container: HTMLElement, data: ProgressData): void {
if (!this.settings.showDebugInfo) {
const debugInfo = container.querySelector(`.${UI_CLASSES.DEBUG_INFO}`);
if (debugInfo) debugInfo.remove();
return;
}
let debugInfo = container.querySelector(`.${UI_CLASSES.DEBUG_INFO}`) as HTMLElement;
if (!debugInfo) {
debugInfo = container.createDiv({ cls: UI_CLASSES.DEBUG_INFO });
} else {
debugInfo.empty();
}
const { percentage, completedCount, totalTasks } = data;
debugInfo.createEl("p", { text: `Debug info:` });
debugInfo.createEl("p", { text: `File: ${this.currentFile?.path}` });
debugInfo.createEl("p", { text: `Incomplete tasks: ${totalTasks - completedCount}` });
debugInfo.createEl("p", { text: `Completed tasks: ${completedCount}` });
debugInfo.createEl("p", { text: `Total tasks: ${totalTasks}` });
debugInfo.createEl("p", { text: `Percentage: ${percentage}%` });
debugInfo.createEl("p", { text: `Update time: ${new Date().toISOString()}` });
debugInfo.createEl("p", { text: `Color scheme: ${this.settings.progressColorScheme}` });
}
// ============================================================================
// Message Display Methods
// ============================================================================
private showNoTasksMessage(container: HTMLElement): void {
if (!container.querySelector(`.${UI_CLASSES.NO_TASKS_MESSAGE}`)) {
container.empty();
container.createEl("p", {
text: "No tasks found in this file",
cls: UI_CLASSES.NO_TASKS_MESSAGE,
});
}
}
private showDataviewWarning(container: HTMLElement): void {
if (!container.querySelector(`.${UI_CLASSES.DATAVIEW_WARNING}`)) {
container.empty();
const warning = container.createDiv({ cls: UI_CLASSES.DATAVIEW_WARNING });
warning.createEl("span", {
text: "Dataview not available",
cls: UI_CLASSES.DATAVIEW_WARNING_TEXT,
});
}
}
private showErrorMessage(container: HTMLElement, error: Error): void {
this.logger.error("Error updating progress bar:", error);
container.empty();
container.createEl("p", {
text: `Error updating progress bar: ${error.message}`,
});
}
// ============================================================================
// Async Processing Methods
// ============================================================================
private processAsyncUpdates(file: TFile, data: ProgressData): void {
setTimeout(async () => {
try {
const { percentage, completedCount, totalTasks } = data;
// Calculate target status FIRST
let targetStatus = this.settings.statusInProgress;
if (percentage === 0) {
targetStatus = this.settings.statusTodo;
} else if (percentage === 100) {
targetStatus = this.settings.statusCompleted;
}
// Update status based on progress
let statusChanged = false;
if (this.settings.autoChangeStatus) {
statusChanged = await this.updateStatusBasedOnProgress(file, percentage);
// Wait for metadata cache to update after frontmatter change
if (statusChanged) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
// Update Kanban boards - PASS the calculated status directly
// instead of reading from YAML to avoid race condition
if (this.settings.autoUpdateKanban && this.kanbanService) {
if (statusChanged || !this.completedFilesMap.has(file.path)) {
await this.updateKanbanBoards(file, completedCount, totalTasks, targetStatus);
}
}
// Handle 100% completion
if (percentage === 100 && this.settings.autoUpdateMetadata) {
if (!this.completedFilesMap.has(file.path)) {
await this.updateFileMetadata(file);
this.completedFilesMap.set(file.path, true);
}
} else if (percentage < 100) {
this.completedFilesMap.delete(file.path);
}
// Auto-add to Kanban board
if (
this.settings.autoAddToKanban &&
this.settings.autoAddKanbanBoard &&
totalTasks > 0 &&
!this.completedFilesMap.has(file.path) &&
this.kanbanService
) {
await this.kanbanService.addFileToKanbanBoard(file);
}
} catch (error) {
this.logger.error("Error in async updates:", error as Error);
}
}, 0);
}
// ============================================================================
// Status & Metadata Methods
// ============================================================================
async updateStatusBasedOnProgress(file: TFile, progressPercentage: number): Promise<boolean> {
if (!file || !this.settings.autoChangeStatus) return false;
try {
let needsUpdate = false;
// Determine target status
let targetStatus = this.settings.statusInProgress;
if (progressPercentage === 0) {
targetStatus = this.settings.statusTodo;
} else if (progressPercentage === 100) {
targetStatus = this.settings.statusCompleted;
}
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
if (frontmatter["status"] !== targetStatus) {
frontmatter["status"] = targetStatus;
needsUpdate = true;
}
if (progressPercentage < 100 && this.settings.autoUpdateFinishedDate && frontmatter["finished"]) {
delete frontmatter["finished"];
needsUpdate = true;
}
});
if (needsUpdate) {
this.logger.log(`Updated status to "${targetStatus}" for file: ${file.path}`);
}
return needsUpdate;
} catch (error) {
this.logger.error("Error updating status:", error as Error);
return false;
}
}
async updateFileMetadata(file: TFile): Promise<void> {
try {
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
const today = new Date().toISOString().split("T")[0];
if (this.settings.autoChangeStatus && frontmatter["status"] !== this.settings.statusCompleted) {
frontmatter["status"] = this.settings.statusCompleted;
this.logger.log(`Updating status to ${this.settings.statusCompleted} in file: ${file.path}`);
}
if (this.settings.autoUpdateFinishedDate && frontmatter["finished"] !== today) {
frontmatter["finished"] = today;
this.logger.log(`Updating finished date to ${today} in file: ${file.path}`);
}
});
} catch (error) {
this.logger.error("Error updating file metadata:", error as Error);
new Notice(`Error updating metadata for ${file.basename}: ${(error as Error).message}`);
}
}
// ============================================================================
// Kanban Integration Methods
// ============================================================================
async updateKanbanBoards(
file: TFile,
completedTasks: number,
totalTasks: number,
targetStatus?: string
): Promise<void> {
if (!this.settings.autoUpdateKanban || totalTasks === 0 || !this.kanbanService) {
return;
}
try {
// Use passed targetStatus if available, otherwise calculate or read from YAML
let finalStatus: string;
if (targetStatus) {
// Use the status passed from processAsyncUpdates (already calculated)
finalStatus = targetStatus;
this.logger.log(`Using passed target status for ${file.path}: ${finalStatus}`);
} else {
// Fallback: Calculate status from progress
const currentStatus = this.kanbanService.calculateStatusFromProgress(completedTasks, totalTasks);
// Get status from YAML if available
const statusFromYaml = await this.kanbanService.getStatusFromYaml(file);
finalStatus = statusFromYaml || currentStatus;
}
this.logger.log(`Updating Kanban boards for ${file.path} with status: ${finalStatus}`);
// Process Kanban boards using the service
const updatedBoardCount = await this.kanbanService.processKanbanBoardsForFile(file, finalStatus);
if (updatedBoardCount > 0) {
new Notice(
`Updated ${updatedBoardCount} Kanban board${updatedBoardCount > 1 ? "s" : ""} ` +
`to move ${file.basename} to ${finalStatus} column`
);
}
} catch (error) {
this.logger.error("Error updating Kanban boards:", error as Error);
new Notice(`Error updating Kanban boards: ${(error as Error).message}`);
}
}
// ============================================================================
// Helper Methods
// ============================================================================
private addLeafClasses(): void {
const leaf = this.leaf as any;
if (leaf?.containerEl) {
leaf.containerEl.addClass(UI_CLASSES.PROGRESS_TRACKER_LEAF);
}
const parentLeaf = this.containerEl.closest(".workspace-leaf");
if (parentLeaf) {
parentLeaf.addClass(UI_CLASSES.PROGRESS_TRACKER_LEAF);
}
}
private initializeContainer(): void {
const container = this.containerEl.children[1];
container.empty();
const progressContainer = container.createDiv({ cls: UI_CLASSES.PROGRESS_CONTAINER });
progressContainer.createEl("p", {
text: "Loading task progress...",
cls: UI_CLASSES.LOADING_INDICATOR,
});
}
private getProgressContainer(): HTMLElement | null {
const container = this.containerEl.children[1];
return container.querySelector(`.${UI_CLASSES.PROGRESS_CONTAINER}`) as HTMLElement;
}
}

View file

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

View file

@ -16,8 +16,7 @@ If your plugin does not need CSS, delete this file.
max-height: 110px !important;
} */
/* 1. Reset biến toàn cục quy định chiều cao tối thiểu */
.task-progress-container {
padding: 10px;
margin-bottom: 10px;
@ -301,11 +300,6 @@ If your plugin does not need CSS, delete this file.
.workspace-leaf-content[data-type="progress-tracker"] .view-content {
overflow: hidden;
padding-right: 12px;
}
.workspace-leaf.progress-tracker-leaf, .workspace-leaf:has(.workspace-leaf-content[data-type="progress-tracker"]) {
width: 100%;
}
/* Thông báo Dataview nhỏ gọn */
@ -323,6 +317,7 @@ If your plugin does not need CSS, delete this file.
}
}
/* Điều chỉnh chiều cao tối thiểu của sidebar */
.workspace-leaf-content[data-type="progress-tracker"] {
--sidebar-min-height: 67px !important;
@ -353,13 +348,3 @@ If your plugin does not need CSS, delete this file.
.workspace-leaf-content[data-type="progress-tracker"] .view-content {
overflow: hidden;
}
.workspace-leaf.progress-tracker-leaf, .workspace-leaf:has(.workspace-leaf-content[data-type="progress-tracker"]) {
min-width: 100%;
}
/* Ensure content fits within constrained height */
.progress-tracker-leaf .task-progress-container {
padding: 8px;
margin: 0;
}