mirror of
https://github.com/vannamhh/progress-tracker.git
synced 2026-07-22 12:20:25 +00:00
Compare commits
No commits in common. "main" and "1.2.0" have entirely different histories.
17 changed files with 3039 additions and 5469 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -20,8 +20,3 @@ data.json
|
|||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
|
||||
.cursor
|
||||
cursorkleosr
|
||||
.bmad
|
||||
.github
|
||||
211
README.md
211
README.md
|
|
@ -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
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
503
src/main.ts
503
src/main.ts
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
styles.css
19
styles.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue