mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
refactor(storage): migrate from localStorage to Obsidian app storage API
Replace direct localStorage calls with app.saveLocalStorage() and app.loadLocalStorage() across components for better integration with Obsidian's data management system. This affects workspace state management, timer storage, project view preferences, and tree component state persistence. Also enhanced fluent navigation to include scheduledDate in overdue detection and added ability to navigate directly to task files when clicking overdue notifications.
This commit is contained in:
parent
cc131f071f
commit
01bbd43171
12 changed files with 819 additions and 492 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { TaskTimerManager, TimerState, TimeSegment } from "../managers/timer-manager";
|
||||
import { TaskTimerManager } from "../managers/timer-manager";
|
||||
import { TaskTimerSettings } from "../common/setting-definition";
|
||||
|
||||
// Mock localStorage
|
||||
|
|
@ -14,12 +14,12 @@ const localStorageMock = (() => {
|
|||
},
|
||||
clear: () => {
|
||||
store = {};
|
||||
}
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
describe("TaskTimerManager - Time Segments", () => {
|
||||
|
|
@ -31,8 +31,8 @@ describe("TaskTimerManager - Time Segments", () => {
|
|||
metadataDetection: {
|
||||
frontmatter: "",
|
||||
folders: [],
|
||||
tags: []
|
||||
}
|
||||
tags: [],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -44,76 +44,76 @@ describe("TaskTimerManager - Time Segments", () => {
|
|||
const blockId = manager.startTimer("test.md");
|
||||
const taskId = `taskTimer_test.md#${blockId}`;
|
||||
const state = manager.getTimerState(taskId);
|
||||
|
||||
|
||||
expect(state).toBeTruthy();
|
||||
expect(state!.segments).toHaveLength(1);
|
||||
expect(state!.segments[0].startTime).toBeDefined();
|
||||
expect(state!.segments[0].endTime).toBeUndefined();
|
||||
expect(state!.status).toBe('running');
|
||||
expect(state!.status).toBe("running");
|
||||
});
|
||||
|
||||
test("should close segment when pausing timer", () => {
|
||||
const blockId = manager.startTimer("test.md");
|
||||
const taskId = `taskTimer_test.md#${blockId}`;
|
||||
|
||||
|
||||
// Pause the timer
|
||||
manager.pauseTimer(taskId);
|
||||
|
||||
|
||||
const state = manager.getTimerState(taskId);
|
||||
expect(state!.segments).toHaveLength(1);
|
||||
expect(state!.segments[0].endTime).toBeDefined();
|
||||
expect(state!.segments[0].duration).toBeDefined();
|
||||
expect(state!.status).toBe('paused');
|
||||
expect(state!.status).toBe("paused");
|
||||
});
|
||||
|
||||
test("should create new segment when resuming timer", () => {
|
||||
const blockId = manager.startTimer("test.md");
|
||||
const taskId = `taskTimer_test.md#${blockId}`;
|
||||
|
||||
|
||||
// Pause and resume
|
||||
manager.pauseTimer(taskId);
|
||||
manager.resumeTimer(taskId);
|
||||
|
||||
|
||||
const state = manager.getTimerState(taskId);
|
||||
expect(state!.segments).toHaveLength(2);
|
||||
expect(state!.segments[0].endTime).toBeDefined();
|
||||
expect(state!.segments[1].startTime).toBeDefined();
|
||||
expect(state!.segments[1].endTime).toBeUndefined();
|
||||
expect(state!.status).toBe('running');
|
||||
expect(state!.status).toBe("running");
|
||||
});
|
||||
|
||||
test("should calculate total duration across multiple segments", () => {
|
||||
const blockId = manager.startTimer("test.md");
|
||||
const taskId = `taskTimer_test.md#${blockId}`;
|
||||
|
||||
|
||||
// Mock segments with known durations
|
||||
const state = manager.getTimerState(taskId)!;
|
||||
state.segments = [
|
||||
{ startTime: 1000, endTime: 2000, duration: 1000 },
|
||||
{ startTime: 3000, endTime: 4500, duration: 1500 },
|
||||
{ startTime: 5000 } // Current running segment
|
||||
{ startTime: 5000 }, // Current running segment
|
||||
];
|
||||
|
||||
|
||||
// Mock current time
|
||||
const originalNow = Date.now;
|
||||
Date.now = jest.fn(() => 6000);
|
||||
|
||||
|
||||
// Save state
|
||||
localStorage.setItem(taskId, JSON.stringify(state));
|
||||
|
||||
|
||||
// Get duration
|
||||
const duration = manager.getCurrentDuration(taskId);
|
||||
|
||||
|
||||
// Should be 1000 + 1500 + 1000 = 3500
|
||||
expect(duration).toBe(3500);
|
||||
|
||||
|
||||
// Restore Date.now
|
||||
Date.now = originalNow;
|
||||
});
|
||||
|
||||
test("should migrate legacy format to segments", () => {
|
||||
const taskId = "taskTimer_test.md#legacy-123";
|
||||
|
||||
|
||||
// Store legacy format
|
||||
const legacyState = {
|
||||
taskId,
|
||||
|
|
@ -123,14 +123,14 @@ describe("TaskTimerManager - Time Segments", () => {
|
|||
pausedTime: 5000,
|
||||
totalPausedDuration: 1000,
|
||||
status: "paused",
|
||||
createdAt: 1000
|
||||
createdAt: 1000,
|
||||
};
|
||||
|
||||
|
||||
localStorage.setItem(taskId, JSON.stringify(legacyState));
|
||||
|
||||
|
||||
// Get state (should trigger migration)
|
||||
const state = manager.getTimerState(taskId);
|
||||
|
||||
|
||||
expect(state).toBeTruthy();
|
||||
expect(state!.segments).toHaveLength(1);
|
||||
expect(state!.segments[0].startTime).toBe(2000); // startTime + totalPausedDuration
|
||||
|
|
@ -144,22 +144,22 @@ describe("TaskTimerManager - Time Segments", () => {
|
|||
test("should handle multiple pause/resume cycles", () => {
|
||||
const blockId = manager.startTimer("test.md");
|
||||
const taskId = `taskTimer_test.md#${blockId}`;
|
||||
|
||||
|
||||
// Simulate multiple work sessions
|
||||
for (let i = 0; i < 3; i++) {
|
||||
manager.pauseTimer(taskId);
|
||||
manager.resumeTimer(taskId);
|
||||
}
|
||||
|
||||
|
||||
const state = manager.getTimerState(taskId);
|
||||
expect(state!.segments).toHaveLength(4); // Initial + 3 resume segments
|
||||
|
||||
|
||||
// First 3 segments should be closed
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(state!.segments[i].endTime).toBeDefined();
|
||||
expect(state!.segments[i].duration).toBeDefined();
|
||||
}
|
||||
|
||||
|
||||
// Last segment should be open
|
||||
expect(state!.segments[3].endTime).toBeUndefined();
|
||||
});
|
||||
|
|
@ -167,12 +167,12 @@ describe("TaskTimerManager - Time Segments", () => {
|
|||
test("should get segment count correctly", () => {
|
||||
const blockId = manager.startTimer("test.md");
|
||||
const taskId = `taskTimer_test.md#${blockId}`;
|
||||
|
||||
|
||||
expect(manager.getSegmentCount(taskId)).toBe(1);
|
||||
|
||||
|
||||
manager.pauseTimer(taskId);
|
||||
manager.resumeTimer(taskId);
|
||||
|
||||
|
||||
expect(manager.getSegmentCount(taskId)).toBe(2);
|
||||
});
|
||||
|
||||
|
|
@ -181,24 +181,24 @@ describe("TaskTimerManager - Time Segments", () => {
|
|||
const originalNow = Date.now;
|
||||
let currentTime = 1000;
|
||||
Date.now = jest.fn(() => currentTime);
|
||||
|
||||
|
||||
// Start timer
|
||||
const blockId = manager.startTimer("test.md");
|
||||
const taskId = `taskTimer_test.md#${blockId}`;
|
||||
|
||||
|
||||
// Work for 5 seconds
|
||||
currentTime = 6000;
|
||||
|
||||
|
||||
// Complete timer
|
||||
const formattedDuration = manager.completeTimer(taskId);
|
||||
|
||||
|
||||
// Should format 5 seconds
|
||||
expect(formattedDuration).toContain("5");
|
||||
|
||||
|
||||
// Timer should be removed
|
||||
expect(manager.getTimerState(taskId)).toBeNull();
|
||||
|
||||
|
||||
// Restore Date.now
|
||||
Date.now = originalNow;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
SearchComponent,
|
||||
Platform,
|
||||
Component,
|
||||
TFile,
|
||||
} from "obsidian";
|
||||
import TaskProgressBarPlugin from "@/index";
|
||||
import { Task } from "@/types/task";
|
||||
|
|
@ -90,10 +91,25 @@ export class TopNavigation extends Component {
|
|||
|
||||
this.notificationCount = tasks.filter((task: Task) => {
|
||||
if (task.completed) return false;
|
||||
const dueDate = task.metadata?.dueDate
|
||||
? new Date(task.metadata.dueDate)
|
||||
: null;
|
||||
return dueDate && dueDate <= today;
|
||||
|
||||
// Only include tasks with dueDate or scheduledDate
|
||||
const dueDate = task.metadata?.dueDate;
|
||||
const scheduledDate = task.metadata?.scheduledDate;
|
||||
|
||||
if (!dueDate && !scheduledDate) return false;
|
||||
|
||||
// Check if either date is overdue
|
||||
if (dueDate) {
|
||||
const dueDateObj = new Date(dueDate);
|
||||
if (dueDateObj < today) return true;
|
||||
}
|
||||
|
||||
if (scheduledDate) {
|
||||
const scheduledDateObj = new Date(scheduledDate);
|
||||
if (scheduledDateObj < today) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}).length;
|
||||
|
||||
this.updateNotificationBadge();
|
||||
|
|
@ -232,15 +248,28 @@ export class TopNavigation extends Component {
|
|||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const overdueTasks = tasks
|
||||
.filter((task: Task) => {
|
||||
if (task.completed) return false;
|
||||
const dueDate = task.metadata?.dueDate
|
||||
? new Date(task.metadata.dueDate)
|
||||
: null;
|
||||
return dueDate && dueDate <= today;
|
||||
})
|
||||
.slice(0, 10);
|
||||
const overdueTasks = tasks.filter((task: Task) => {
|
||||
if (task.completed) return false;
|
||||
|
||||
// Only include tasks with dueDate or scheduledDate
|
||||
const dueDate = task.metadata?.dueDate;
|
||||
const scheduledDate = task.metadata?.scheduledDate;
|
||||
|
||||
if (!dueDate && !scheduledDate) return false;
|
||||
|
||||
// Check if either date is overdue
|
||||
if (dueDate) {
|
||||
const dueDateObj = new Date(dueDate);
|
||||
if (dueDateObj < today) return true;
|
||||
}
|
||||
|
||||
if (scheduledDate) {
|
||||
const scheduledDateObj = new Date(scheduledDate);
|
||||
if (scheduledDateObj < today) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (overdueTasks.length === 0) {
|
||||
menu.addItem((item) => {
|
||||
|
|
@ -255,16 +284,12 @@ export class TopNavigation extends Component {
|
|||
|
||||
menu.addSeparator();
|
||||
|
||||
overdueTasks.forEach((task) => {
|
||||
overdueTasks.slice(0, 10).forEach((task) => {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(task.content || t("Untitled task"))
|
||||
.setIcon("alert-circle")
|
||||
.onClick(() => {
|
||||
new Notice(
|
||||
t("Task: {{content}}", {
|
||||
content: task.content || "",
|
||||
}),
|
||||
);
|
||||
.onClick(async () => {
|
||||
await this.navigateToTask(task);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -273,6 +298,18 @@ export class TopNavigation extends Component {
|
|||
menu.showAtMouseEvent(event);
|
||||
}
|
||||
|
||||
private async navigateToTask(task: Task): Promise<void> {
|
||||
const file = this.plugin.app.vault.getFileByPath(task.filePath);
|
||||
if (!(file instanceof TFile)) {
|
||||
new Notice(t("Task file not found"));
|
||||
return;
|
||||
}
|
||||
const leaf = this.plugin.app.workspace.getLeaf(false);
|
||||
await leaf.openFile(file, {
|
||||
eState: { line: task.line },
|
||||
});
|
||||
}
|
||||
|
||||
private updateNotificationBadge() {
|
||||
const badge = this.containerEl.querySelector(
|
||||
".fluent-notification-badge",
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export class FluentWorkspaceStateManager extends Component {
|
|||
viewMode: ViewMode;
|
||||
},
|
||||
private getCurrentFilterState: () => RootFilterState | null,
|
||||
private getLiveFilterState: () => RootFilterState | null
|
||||
private getLiveFilterState: () => RootFilterState | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
|
@ -75,9 +75,9 @@ export class FluentWorkspaceStateManager extends Component {
|
|||
|
||||
this.saveFilterStateToWorkspace(snapshot);
|
||||
|
||||
localStorage.setItem(
|
||||
this.app.saveLocalStorage(
|
||||
"task-genius-fluent-current-workspace",
|
||||
snapshot.workspaceId
|
||||
snapshot.workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -86,8 +86,8 @@ export class FluentWorkspaceStateManager extends Component {
|
|||
*/
|
||||
loadWorkspaceLayout(): string | null {
|
||||
// Load current workspace from localStorage
|
||||
const savedCurrentWorkspace = localStorage.getItem(
|
||||
"task-genius-fluent-current-workspace"
|
||||
const savedCurrentWorkspace = this.app.loadLocalStorage(
|
||||
"task-genius-fluent-current-workspace",
|
||||
);
|
||||
|
||||
if (savedCurrentWorkspace) {
|
||||
|
|
@ -217,19 +217,19 @@ export class FluentWorkspaceStateManager extends Component {
|
|||
console.log("[FluentWorkspace] overrides saved quietly", {
|
||||
workspaceId,
|
||||
viewId,
|
||||
})
|
||||
}),
|
||||
)
|
||||
.catch((e) => {
|
||||
console.error(
|
||||
"[FluentWorkspace] failed to save overrides",
|
||||
e
|
||||
e,
|
||||
);
|
||||
new Notice(
|
||||
"Failed to save workspace state. Recent changes may be lost."
|
||||
"Failed to save workspace state. Recent changes may be lost.",
|
||||
);
|
||||
});
|
||||
},
|
||||
500
|
||||
500,
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -284,31 +284,25 @@ export class FluentWorkspaceStateManager extends Component {
|
|||
effectiveSettings.fluentActiveViewId = activeViewId;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[FluentWorkspace] saveFilterStateImmediately",
|
||||
{
|
||||
workspaceId,
|
||||
viewId,
|
||||
activeViewId,
|
||||
}
|
||||
);
|
||||
console.log("[FluentWorkspace] saveFilterStateImmediately", {
|
||||
workspaceId,
|
||||
viewId,
|
||||
activeViewId,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.plugin.workspaceManager.saveOverridesQuietly(
|
||||
workspaceId,
|
||||
effectiveSettings
|
||||
);
|
||||
console.log(
|
||||
"[FluentWorkspace] immediate save completed",
|
||||
{ workspaceId, viewId }
|
||||
effectiveSettings,
|
||||
);
|
||||
console.log("[FluentWorkspace] immediate save completed", {
|
||||
workspaceId,
|
||||
viewId,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"[FluentWorkspace] immediate save failed",
|
||||
e
|
||||
);
|
||||
console.error("[FluentWorkspace] immediate save failed", e);
|
||||
new Notice(
|
||||
"Failed to save workspace state. Recent changes may be lost."
|
||||
"Failed to save workspace state. Recent changes may be lost.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -384,14 +378,16 @@ export class FluentWorkspaceStateManager extends Component {
|
|||
* Get saved workspace ID from localStorage
|
||||
*/
|
||||
getSavedWorkspaceId(): string | null {
|
||||
return localStorage.getItem("task-genius-fluent-current-workspace");
|
||||
return this.app.loadLocalStorage(
|
||||
"task-genius-fluent-current-workspace",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear workspace state from localStorage
|
||||
*/
|
||||
clearWorkspaceState(): void {
|
||||
localStorage.removeItem("task-genius-fluent-current-workspace");
|
||||
this.app.saveLocalStorage("task-genius-fluent-current-workspace", null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -78,8 +78,7 @@ export class FileFilterStep {
|
|||
await plugin.saveSettings();
|
||||
// Re-render to show/hide configuration
|
||||
this.render(
|
||||
container.parentElement
|
||||
?.previousElementSibling as HTMLElement,
|
||||
container.parentElement as HTMLElement,
|
||||
container.parentElement
|
||||
?.parentElement as HTMLElement,
|
||||
{} as OnboardingController,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
import { moment } from "obsidian";
|
||||
import { processDateTemplates } from "@/utils/file/file-operations";
|
||||
import { FileSuggest } from "@/components/ui/inputs/AutoComplete";
|
||||
import { DatePickerModal } from "@/components/ui/date-picker/DatePickerModal";
|
||||
|
||||
/**
|
||||
* Minimal Quick Capture Modal extending the base class
|
||||
|
|
@ -413,7 +414,32 @@ export class MinimalQuickCaptureModal extends BaseQuickCaptureModal {
|
|||
item.setTitle(t("Choose date..."));
|
||||
item.setIcon("calendar-days");
|
||||
item.onClick(() => {
|
||||
// TODO: Implement full date picker integration
|
||||
// Open full date picker modal
|
||||
const datePickerModal = new DatePickerModal(
|
||||
this.app,
|
||||
this.plugin,
|
||||
this.taskMetadata.dueDate
|
||||
? moment(this.taskMetadata.dueDate).format("YYYY-MM-DD")
|
||||
: undefined,
|
||||
"📅"
|
||||
);
|
||||
|
||||
datePickerModal.onDateSelected = (selectedDate: string | null) => {
|
||||
if (selectedDate) {
|
||||
this.taskMetadata.dueDate = moment(selectedDate).toDate();
|
||||
this.updateButtonState(this.dateButton!, true);
|
||||
|
||||
// If called from suggest, replace the ~ with date text
|
||||
if (cursor && this.markdownEditor) {
|
||||
this.replaceAtCursor(
|
||||
cursor,
|
||||
this.formatDate(this.taskMetadata.dueDate),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
datePickerModal.open();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import { Component, App } from "obsidian";
|
|||
import { TreeNode, ProjectNodeData } from "@/types/tree";
|
||||
import { TreeComponent } from "@/components/ui/tree/TreeComponent";
|
||||
import { Task } from "@/types/task";
|
||||
import {
|
||||
buildProjectTreeFromTasks,
|
||||
findNodeByPath,
|
||||
getAllDescendants
|
||||
import {
|
||||
buildProjectTreeFromTasks,
|
||||
findNodeByPath,
|
||||
getAllDescendants,
|
||||
} from "@/core/project-tree-builder";
|
||||
import TaskProgressBarPlugin from "@/index";
|
||||
|
||||
|
|
@ -16,101 +16,126 @@ export class ProjectTreeComponent extends Component {
|
|||
private treeComponent: TreeComponent<ProjectNodeData>;
|
||||
private projectTree: TreeNode<ProjectNodeData> | null = null;
|
||||
private allTasks: Task[] = [];
|
||||
|
||||
|
||||
// Events
|
||||
public onNodeSelected?: (selectedNodes: Set<string>, tasks: Task[]) => void;
|
||||
public onMultiSelectToggled?: (isMultiSelect: boolean) => void;
|
||||
|
||||
|
||||
constructor(
|
||||
private parentEl: HTMLElement,
|
||||
private app: App,
|
||||
private plugin: TaskProgressBarPlugin
|
||||
private plugin: TaskProgressBarPlugin,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
|
||||
onload(): void {
|
||||
// Create tree component with project-specific configuration
|
||||
this.treeComponent = new TreeComponent<ProjectNodeData>(
|
||||
this.parentEl,
|
||||
{
|
||||
classPrefix: 'project-tree',
|
||||
classPrefix: "project-tree",
|
||||
indentSize: 24, // 使用稍大的缩进以适应项目层级
|
||||
showToggle: true,
|
||||
enableSelection: true,
|
||||
enableMultiSelect: true,
|
||||
stateKey: 'task-genius-project-tree-state',
|
||||
stateKey: "task-genius-project-tree-state",
|
||||
autoExpandLevel: 1,
|
||||
|
||||
|
||||
renderContent: (node, contentEl) => {
|
||||
// Project name
|
||||
const nameEl = contentEl.createSpan({
|
||||
cls: "project-tree-item-name",
|
||||
text: node.name
|
||||
text: node.name,
|
||||
});
|
||||
|
||||
|
||||
// Task count badges
|
||||
const countsEl = contentEl.createSpan({
|
||||
cls: "project-tree-item-counts"
|
||||
cls: "project-tree-item-counts",
|
||||
});
|
||||
|
||||
|
||||
// Calculate completed count for this node
|
||||
const calculateCompletedCount = (taskIds: Set<string>) => {
|
||||
let completed = 0;
|
||||
taskIds.forEach(taskId => {
|
||||
const task = this.allTasks.find(t => t.id === taskId);
|
||||
taskIds.forEach((taskId) => {
|
||||
const task = this.allTasks.find(
|
||||
(t) => t.id === taskId,
|
||||
);
|
||||
if (task && this.isTaskCompleted(task)) {
|
||||
completed++;
|
||||
}
|
||||
});
|
||||
return completed;
|
||||
};
|
||||
|
||||
|
||||
// Direct task count
|
||||
if (node.data.directTaskCount > 0) {
|
||||
const directCompleted = calculateCompletedCount(node.data.directTaskIds);
|
||||
const directCompleted = calculateCompletedCount(
|
||||
node.data.directTaskIds,
|
||||
);
|
||||
const directCountEl = countsEl.createSpan({
|
||||
cls: "project-tree-item-count-direct",
|
||||
});
|
||||
|
||||
|
||||
if (this.plugin.settings.addProgressBarToProjectsView) {
|
||||
directCountEl.setText(`${directCompleted}/${node.data.directTaskCount}`);
|
||||
directCountEl.dataset.completed = directCompleted.toString();
|
||||
directCountEl.dataset.total = node.data.directTaskCount.toString();
|
||||
|
||||
directCountEl.setText(
|
||||
`${directCompleted}/${node.data.directTaskCount}`,
|
||||
);
|
||||
directCountEl.dataset.completed =
|
||||
directCompleted.toString();
|
||||
directCountEl.dataset.total =
|
||||
node.data.directTaskCount.toString();
|
||||
|
||||
if (directCompleted === node.data.directTaskCount) {
|
||||
directCountEl.classList.add("all-completed");
|
||||
} else if (directCompleted > 0) {
|
||||
directCountEl.classList.add("partially-completed");
|
||||
directCountEl.classList.add(
|
||||
"partially-completed",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
directCountEl.setText(node.data.directTaskCount.toString());
|
||||
directCountEl.setText(
|
||||
node.data.directTaskCount.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Total task count (if has children)
|
||||
if (node.children.length > 0 && node.data.totalTaskCount > node.data.directTaskCount) {
|
||||
const totalCompleted = calculateCompletedCount(node.data.allTaskIds);
|
||||
if (
|
||||
node.children.length > 0 &&
|
||||
node.data.totalTaskCount > node.data.directTaskCount
|
||||
) {
|
||||
const totalCompleted = calculateCompletedCount(
|
||||
node.data.allTaskIds,
|
||||
);
|
||||
const totalCountEl = countsEl.createSpan({
|
||||
cls: "project-tree-item-count-total",
|
||||
});
|
||||
|
||||
|
||||
if (this.plugin.settings.addProgressBarToProjectsView) {
|
||||
totalCountEl.setText(`${totalCompleted}/${node.data.totalTaskCount}`);
|
||||
totalCountEl.dataset.completed = totalCompleted.toString();
|
||||
totalCountEl.dataset.total = node.data.totalTaskCount.toString();
|
||||
|
||||
totalCountEl.setText(
|
||||
`${totalCompleted}/${node.data.totalTaskCount}`,
|
||||
);
|
||||
totalCountEl.dataset.completed =
|
||||
totalCompleted.toString();
|
||||
totalCountEl.dataset.total =
|
||||
node.data.totalTaskCount.toString();
|
||||
|
||||
if (totalCompleted === node.data.totalTaskCount) {
|
||||
totalCountEl.classList.add("all-completed");
|
||||
} else if (totalCompleted > 0) {
|
||||
totalCountEl.classList.add("partially-completed");
|
||||
totalCountEl.classList.add(
|
||||
"partially-completed",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
totalCountEl.setText(node.data.totalTaskCount.toString());
|
||||
totalCountEl.setText(
|
||||
node.data.totalTaskCount.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
iconResolver: (node) => {
|
||||
// Use different icons based on node state
|
||||
if (node.children.length > 0) {
|
||||
|
|
@ -118,56 +143,57 @@ export class ProjectTreeComponent extends Component {
|
|||
}
|
||||
return "file";
|
||||
},
|
||||
|
||||
|
||||
onNodeSelected: (selectedNodes) => {
|
||||
// Get tasks for selected nodes
|
||||
const tasks = this.getTasksForSelection(selectedNodes);
|
||||
|
||||
|
||||
// Trigger event
|
||||
if (this.onNodeSelected) {
|
||||
this.onNodeSelected(selectedNodes, tasks);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
onMultiSelectToggled: (isMultiSelect) => {
|
||||
if (this.onMultiSelectToggled) {
|
||||
this.onMultiSelectToggled(isMultiSelect);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
this.app,
|
||||
);
|
||||
|
||||
|
||||
this.addChild(this.treeComponent);
|
||||
this.treeComponent.load();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build tree from tasks
|
||||
*/
|
||||
public buildTree(tasks: Task[]): void {
|
||||
this.allTasks = tasks;
|
||||
|
||||
|
||||
// Build project tree
|
||||
const separator = this.plugin.settings.projectPathSeparator || "/";
|
||||
this.projectTree = buildProjectTreeFromTasks(tasks, separator);
|
||||
|
||||
|
||||
// Set tree in component
|
||||
if (this.projectTree) {
|
||||
this.treeComponent.setTree(this.projectTree);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set the project tree directly (instead of building from tasks)
|
||||
*/
|
||||
public setTree(tree: TreeNode<ProjectNodeData>, tasks: Task[]): void {
|
||||
this.projectTree = tree;
|
||||
this.allTasks = tasks;
|
||||
|
||||
|
||||
// Set tree in component
|
||||
this.treeComponent.setTree(tree);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if a task is completed based on plugin settings
|
||||
*/
|
||||
|
|
@ -213,66 +239,66 @@ export class ProjectTreeComponent extends Component {
|
|||
if (!this.projectTree || selectedNodes.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
// Collect all task IDs from selected nodes and their children
|
||||
const taskIds = new Set<string>();
|
||||
|
||||
|
||||
for (const nodePath of selectedNodes) {
|
||||
const node = findNodeByPath(this.projectTree, nodePath);
|
||||
if (node) {
|
||||
// Add all tasks from this node (includes children)
|
||||
node.data.allTaskIds.forEach(id => taskIds.add(id));
|
||||
node.data.allTaskIds.forEach((id) => taskIds.add(id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Filter tasks by collected IDs
|
||||
return this.allTasks.filter(task => taskIds.has(task.id));
|
||||
return this.allTasks.filter((task) => taskIds.has(task.id));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set multi-select mode
|
||||
*/
|
||||
public setMultiSelectMode(enabled: boolean): void {
|
||||
this.treeComponent.setMultiSelectMode(enabled);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get selected paths
|
||||
*/
|
||||
public getSelectedPaths(): Set<string> {
|
||||
return this.treeComponent.getSelectedPaths();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set selected paths
|
||||
*/
|
||||
public setSelectedPaths(paths: Set<string>): void {
|
||||
this.treeComponent.setSelectedPaths(paths);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Clear selection
|
||||
*/
|
||||
public clearSelection(): void {
|
||||
this.treeComponent.clearSelection();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Expand all nodes
|
||||
*/
|
||||
public expandAll(): void {
|
||||
this.treeComponent.expandAll();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Collapse all nodes
|
||||
*/
|
||||
public collapseAll(): void {
|
||||
this.treeComponent.collapseAll();
|
||||
}
|
||||
|
||||
|
||||
onunload(): void {
|
||||
// The tree component will be cleaned up automatically
|
||||
// as it's added as a child component
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ import TaskProgressBarPlugin from "@/index";
|
|||
import { TwoColumnViewBase, TwoColumnViewConfig } from "./TwoColumnViewBase";
|
||||
import { ProjectTreeComponent } from "./ProjectTreeComponent";
|
||||
import { TreeNode, ProjectNodeData } from "@/types/tree";
|
||||
import { buildProjectTreeFromTasks, findNodeByPath } from "@/core/project-tree-builder";
|
||||
import {
|
||||
buildProjectTreeFromTasks,
|
||||
findNodeByPath,
|
||||
} from "@/core/project-tree-builder";
|
||||
import { filterTasksByProjectPaths } from "@/core/project-filter";
|
||||
import { getEffectiveProject } from "@/utils/task/task-operations";
|
||||
|
||||
|
|
@ -17,12 +20,12 @@ export class ProjectViewComponent extends TwoColumnViewBase<string> {
|
|||
private allProjectsMap: Map<string, Set<string>> = new Map(); // 项目 -> 任务ID集合
|
||||
private projectTree: TreeNode<ProjectNodeData> | null = null; // 项目树结构
|
||||
private projectTreeComponent: ProjectTreeComponent | null = null; // 树组件
|
||||
private viewMode: 'list' | 'tree' = 'list'; // 视图模式
|
||||
private viewMode: "list" | "tree" = "list"; // 视图模式
|
||||
|
||||
constructor(
|
||||
parentEl: HTMLElement,
|
||||
app: App,
|
||||
plugin: TaskProgressBarPlugin
|
||||
plugin: TaskProgressBarPlugin,
|
||||
) {
|
||||
// 配置基类需要的参数
|
||||
const config: TwoColumnViewConfig = {
|
||||
|
|
@ -62,7 +65,9 @@ export class ProjectViewComponent extends TwoColumnViewBase<string> {
|
|||
|
||||
// 更新项目计数
|
||||
if (this.countEl) {
|
||||
const projectCount = this.projectTree ? this.projectTree.children.length : this.allProjectsMap.size;
|
||||
const projectCount = this.projectTree
|
||||
? this.projectTree.children.length
|
||||
: this.allProjectsMap.size;
|
||||
this.countEl.setText(`${projectCount} projects`);
|
||||
}
|
||||
}
|
||||
|
|
@ -75,7 +80,7 @@ export class ProjectViewComponent extends TwoColumnViewBase<string> {
|
|||
this.itemsListEl.empty();
|
||||
|
||||
// 根据视图模式渲染
|
||||
if (this.viewMode === 'tree' && this.projectTree) {
|
||||
if (this.viewMode === "tree" && this.projectTree) {
|
||||
// 渲染树状视图
|
||||
this.renderTreeView();
|
||||
} else {
|
||||
|
|
@ -156,7 +161,7 @@ export class ProjectViewComponent extends TwoColumnViewBase<string> {
|
|||
this.projectTreeComponent = new ProjectTreeComponent(
|
||||
this.itemsListEl,
|
||||
this.app,
|
||||
this.plugin
|
||||
this.plugin,
|
||||
);
|
||||
|
||||
// 设置事件处理
|
||||
|
|
@ -173,13 +178,15 @@ export class ProjectViewComponent extends TwoColumnViewBase<string> {
|
|||
|
||||
// 加载组件
|
||||
this.addChild(this.projectTreeComponent);
|
||||
|
||||
|
||||
// 构建树
|
||||
this.projectTreeComponent.buildTree(this.allTasks);
|
||||
|
||||
|
||||
// 恢复之前的选择
|
||||
if (this.selectedItems.items.length > 0) {
|
||||
this.projectTreeComponent.setSelectedPaths(new Set(this.selectedItems.items));
|
||||
this.projectTreeComponent.setSelectedPaths(
|
||||
new Set(this.selectedItems.items),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -187,11 +194,11 @@ export class ProjectViewComponent extends TwoColumnViewBase<string> {
|
|||
* 切换视图模式
|
||||
*/
|
||||
public toggleViewMode(): void {
|
||||
this.viewMode = this.viewMode === 'list' ? 'tree' : 'list';
|
||||
|
||||
this.viewMode = this.viewMode === "list" ? "tree" : "list";
|
||||
|
||||
// 重新渲染列表
|
||||
this.renderItemsList();
|
||||
|
||||
|
||||
// 保存用户偏好
|
||||
this.saveViewModePreference();
|
||||
}
|
||||
|
|
@ -201,12 +208,12 @@ export class ProjectViewComponent extends TwoColumnViewBase<string> {
|
|||
*/
|
||||
private saveViewModePreference(): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
'task-progress-bar-project-view-mode',
|
||||
this.viewMode
|
||||
this.app.saveLocalStorage(
|
||||
"task-progress-bar-project-view-mode",
|
||||
this.viewMode,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Failed to save view mode preference:', error);
|
||||
console.warn("Failed to save view mode preference:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -215,12 +222,14 @@ export class ProjectViewComponent extends TwoColumnViewBase<string> {
|
|||
*/
|
||||
private loadViewModePreference(): void {
|
||||
try {
|
||||
const savedMode = localStorage.getItem('task-progress-bar-project-view-mode');
|
||||
if (savedMode === 'tree' || savedMode === 'list') {
|
||||
const savedMode = this.app.loadLocalStorage(
|
||||
"task-progress-bar-project-view-mode",
|
||||
);
|
||||
if (savedMode === "tree" || savedMode === "list") {
|
||||
this.viewMode = savedMode;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load view mode preference:', error);
|
||||
console.warn("Failed to load view mode preference:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -235,13 +244,13 @@ export class ProjectViewComponent extends TwoColumnViewBase<string> {
|
|||
}
|
||||
|
||||
// 根据视图模式使用不同的筛选逻辑
|
||||
if (this.viewMode === 'tree' && this.projectTree) {
|
||||
if (this.viewMode === "tree" && this.projectTree) {
|
||||
// 树状模式:使用包含式筛选(选父含子)
|
||||
const separator = this.plugin.settings.projectPathSeparator || "/";
|
||||
this.filteredTasks = filterTasksByProjectPaths(
|
||||
this.allTasks,
|
||||
this.selectedItems.items,
|
||||
separator
|
||||
separator,
|
||||
);
|
||||
} else {
|
||||
// 列表模式:保持原有逻辑
|
||||
|
|
@ -258,7 +267,7 @@ export class ProjectViewComponent extends TwoColumnViewBase<string> {
|
|||
|
||||
// 将任务ID转换为实际任务对象
|
||||
this.filteredTasks = this.allTasks.filter((task) =>
|
||||
resultTaskIds.has(task.id)
|
||||
resultTaskIds.has(task.id),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -292,10 +301,10 @@ export class ProjectViewComponent extends TwoColumnViewBase<string> {
|
|||
onload(): void {
|
||||
// 加载视图模式偏好
|
||||
this.loadViewModePreference();
|
||||
|
||||
|
||||
// 调用父类的 onload
|
||||
super.onload();
|
||||
|
||||
|
||||
// 在 onload 完成后添加视图切换按钮
|
||||
this.addViewToggleButton();
|
||||
}
|
||||
|
|
@ -307,26 +316,40 @@ export class ProjectViewComponent extends TwoColumnViewBase<string> {
|
|||
// 确保 leftHeaderEl 存在
|
||||
if (this.leftHeaderEl) {
|
||||
// 查找多选按钮
|
||||
const multiSelectBtn = this.leftHeaderEl.querySelector('.projects-multi-select-btn');
|
||||
|
||||
const multiSelectBtn = this.leftHeaderEl.querySelector(
|
||||
".projects-multi-select-btn",
|
||||
);
|
||||
|
||||
// 创建视图切换按钮
|
||||
const viewToggleBtn = this.leftHeaderEl.createDiv({
|
||||
cls: 'projects-view-toggle-btn'
|
||||
cls: "projects-view-toggle-btn",
|
||||
});
|
||||
|
||||
|
||||
// 如果找到多选按钮,将视图切换按钮插入到它后面
|
||||
if (multiSelectBtn && multiSelectBtn.parentNode) {
|
||||
multiSelectBtn.parentNode.insertBefore(viewToggleBtn, multiSelectBtn.nextSibling);
|
||||
multiSelectBtn.parentNode.insertBefore(
|
||||
viewToggleBtn,
|
||||
multiSelectBtn.nextSibling,
|
||||
);
|
||||
}
|
||||
|
||||
setIcon(viewToggleBtn, this.viewMode === 'tree' ? 'git-branch' : 'list');
|
||||
viewToggleBtn.setAttribute('aria-label', t('Toggle tree/list view'));
|
||||
viewToggleBtn.setAttribute('title', t('Toggle tree/list view'));
|
||||
|
||||
this.registerDomEvent(viewToggleBtn, 'click', () => {
|
||||
|
||||
setIcon(
|
||||
viewToggleBtn,
|
||||
this.viewMode === "tree" ? "git-branch" : "list",
|
||||
);
|
||||
viewToggleBtn.setAttribute(
|
||||
"aria-label",
|
||||
t("Toggle tree/list view"),
|
||||
);
|
||||
viewToggleBtn.setAttribute("title", t("Toggle tree/list view"));
|
||||
|
||||
this.registerDomEvent(viewToggleBtn, "click", () => {
|
||||
this.toggleViewMode();
|
||||
// 更新按钮图标
|
||||
setIcon(viewToggleBtn, this.viewMode === 'tree' ? 'git-branch' : 'list');
|
||||
setIcon(
|
||||
viewToggleBtn,
|
||||
this.viewMode === "tree" ? "git-branch" : "list",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -337,7 +360,7 @@ export class ProjectViewComponent extends TwoColumnViewBase<string> {
|
|||
public updateTask(updatedTask: Task): void {
|
||||
let needsFullRefresh = false;
|
||||
const taskIndex = this.allTasks.findIndex(
|
||||
(t) => t.id === updatedTask.id
|
||||
(t) => t.id === updatedTask.id,
|
||||
);
|
||||
|
||||
if (taskIndex !== -1) {
|
||||
|
|
@ -361,7 +384,7 @@ export class ProjectViewComponent extends TwoColumnViewBase<string> {
|
|||
} else {
|
||||
// 否则,只更新过滤列表中的任务和渲染器
|
||||
const filteredIndex = this.filteredTasks.findIndex(
|
||||
(t) => t.id === updatedTask.id
|
||||
(t) => t.id === updatedTask.id,
|
||||
);
|
||||
if (filteredIndex !== -1) {
|
||||
this.filteredTasks[filteredIndex] = updatedTask;
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export class ProjectsComponent extends Component {
|
|||
onTaskCompleted?: (task: Task) => void;
|
||||
onTaskUpdate?: (task: Task, updatedTask: Task) => Promise<void>;
|
||||
onTaskContextMenu?: (event: MouseEvent, task: Task) => void;
|
||||
} = {}
|
||||
} = {},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
|
@ -105,8 +105,8 @@ export class ProjectsComponent extends Component {
|
|||
this.initializeViewMode();
|
||||
|
||||
// Load project tree view preference from localStorage
|
||||
const savedTreeView = localStorage.getItem(
|
||||
"task-genius-project-tree-view"
|
||||
const savedTreeView = this.app.loadLocalStorage(
|
||||
"task-genius-project-tree-view",
|
||||
);
|
||||
this.isProjectTreeView = savedTreeView === "true";
|
||||
|
||||
|
|
@ -116,7 +116,7 @@ export class ProjectsComponent extends Component {
|
|||
this.taskListContainerEl,
|
||||
this.plugin,
|
||||
this.app,
|
||||
"projects"
|
||||
"projects",
|
||||
);
|
||||
|
||||
// Connect event handlers
|
||||
|
|
@ -239,7 +239,7 @@ export class ProjectsComponent extends Component {
|
|||
.onClick(() => {
|
||||
this.toggleLeftColumnVisibility();
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -293,7 +293,7 @@ export class ProjectsComponent extends Component {
|
|||
public setTasks(tasks: Task[]) {
|
||||
this.allTasks = tasks;
|
||||
this.allTasksMap = new Map(
|
||||
this.allTasks.map((task) => [task.id, task])
|
||||
this.allTasks.map((task) => [task.id, task]),
|
||||
);
|
||||
this.buildProjectsIndex();
|
||||
this.renderProjectsList();
|
||||
|
|
@ -306,7 +306,7 @@ export class ProjectsComponent extends Component {
|
|||
[],
|
||||
this.isTreeView,
|
||||
this.allTasksMap,
|
||||
t("Select a project to see related tasks")
|
||||
t("Select a project to see related tasks"),
|
||||
);
|
||||
this.updateTaskListHeader(t("Tasks"), `0 ${t("tasks")}`);
|
||||
}
|
||||
|
|
@ -350,13 +350,13 @@ export class ProjectsComponent extends Component {
|
|||
this.projectTreeComponent = new ProjectTreeComponent(
|
||||
this.projectsListEl,
|
||||
this.app,
|
||||
this.plugin
|
||||
this.plugin,
|
||||
);
|
||||
|
||||
// Set up event handlers
|
||||
this.projectTreeComponent.onNodeSelected = (
|
||||
selectedNodes: Set<string>,
|
||||
tasks: Task[]
|
||||
tasks: Task[],
|
||||
) => {
|
||||
this.selectedProjects.projects = Array.from(selectedNodes);
|
||||
this.updateSelectedTasks();
|
||||
|
|
@ -368,7 +368,7 @@ export class ProjectsComponent extends Component {
|
|||
};
|
||||
|
||||
this.projectTreeComponent.onMultiSelectToggled = (
|
||||
isMultiSelect: boolean
|
||||
isMultiSelect: boolean,
|
||||
) => {
|
||||
this.selectedProjects.isMultiSelect = isMultiSelect;
|
||||
if (
|
||||
|
|
@ -379,7 +379,7 @@ export class ProjectsComponent extends Component {
|
|||
[],
|
||||
this.isTreeView,
|
||||
this.allTasksMap,
|
||||
t("Select a project to see related tasks")
|
||||
t("Select a project to see related tasks"),
|
||||
);
|
||||
this.updateTaskListHeader(t("Tasks"), `0 ${t("tasks")}`);
|
||||
}
|
||||
|
|
@ -389,7 +389,7 @@ export class ProjectsComponent extends Component {
|
|||
if (this.projectTree) {
|
||||
this.projectTreeComponent.setTree(
|
||||
this.projectTree,
|
||||
this.allTasks
|
||||
this.allTasks,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -401,7 +401,7 @@ export class ProjectsComponent extends Component {
|
|||
|
||||
// Sort projects alphabetically
|
||||
const sortedProjects = Array.from(
|
||||
this.allProjectsMap.keys()
|
||||
this.allProjectsMap.keys(),
|
||||
).sort();
|
||||
|
||||
// Render each project
|
||||
|
|
@ -477,7 +477,7 @@ export class ProjectsComponent extends Component {
|
|||
this.registerDomEvent(projectItem, "click", (e) => {
|
||||
this.handleProjectSelection(
|
||||
project,
|
||||
e.ctrlKey || e.metaKey
|
||||
e.ctrlKey || e.metaKey,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -513,7 +513,7 @@ export class ProjectsComponent extends Component {
|
|||
[],
|
||||
this.isTreeView,
|
||||
this.allTasksMap,
|
||||
t("Select a project to see related tasks")
|
||||
t("Select a project to see related tasks"),
|
||||
);
|
||||
this.updateTaskListHeader(t("Tasks"), `0 ${t("tasks")}`);
|
||||
return;
|
||||
|
|
@ -558,7 +558,7 @@ export class ProjectsComponent extends Component {
|
|||
[],
|
||||
this.isTreeView,
|
||||
this.allTasksMap,
|
||||
t("Select a project to see related tasks")
|
||||
t("Select a project to see related tasks"),
|
||||
);
|
||||
this.updateTaskListHeader(t("Tasks"), `0 ${t("tasks")}`);
|
||||
this.updateTgProjectPropsButton(null);
|
||||
|
|
@ -568,7 +568,7 @@ export class ProjectsComponent extends Component {
|
|||
// Update tree component if it exists
|
||||
if (this.projectTreeComponent) {
|
||||
this.projectTreeComponent.setMultiSelectMode(
|
||||
this.selectedProjects.isMultiSelect
|
||||
this.selectedProjects.isMultiSelect,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -580,7 +580,7 @@ export class ProjectsComponent extends Component {
|
|||
this.isTreeView = getInitialViewMode(this.app, this.plugin, "projects");
|
||||
// Update the toggle button icon to match the initial state
|
||||
const viewToggleBtn = this.taskContainerEl?.querySelector(
|
||||
".view-toggle-btn"
|
||||
".view-toggle-btn",
|
||||
) as HTMLElement;
|
||||
if (viewToggleBtn) {
|
||||
setIcon(viewToggleBtn, this.isTreeView ? "git-branch" : "list");
|
||||
|
|
@ -592,7 +592,7 @@ export class ProjectsComponent extends Component {
|
|||
|
||||
// Update toggle button icon
|
||||
const viewToggleBtn = this.taskContainerEl.querySelector(
|
||||
".view-toggle-btn"
|
||||
".view-toggle-btn",
|
||||
) as HTMLElement;
|
||||
if (viewToggleBtn) {
|
||||
setIcon(viewToggleBtn, this.isTreeView ? "git-branch" : "list");
|
||||
|
|
@ -611,7 +611,7 @@ export class ProjectsComponent extends Component {
|
|||
[],
|
||||
this.isTreeView,
|
||||
this.allTasksMap,
|
||||
t("Select a project to see related tasks")
|
||||
t("Select a project to see related tasks"),
|
||||
);
|
||||
this.updateTaskListHeader(t("Tasks"), `0 ${t("tasks")}`);
|
||||
return;
|
||||
|
|
@ -622,7 +622,7 @@ export class ProjectsComponent extends Component {
|
|||
this.filteredTasks = filterTasksByProjectPaths(
|
||||
this.allTasks,
|
||||
this.selectedProjects.projects,
|
||||
this.plugin.settings.projectPathSeparator || "/"
|
||||
this.plugin.settings.projectPathSeparator || "/",
|
||||
);
|
||||
} else {
|
||||
// Get tasks from all selected projects (OR logic)
|
||||
|
|
@ -638,18 +638,18 @@ export class ProjectsComponent extends Component {
|
|||
|
||||
// Convert task IDs to actual task objects
|
||||
this.filteredTasks = this.allTasks.filter((task) =>
|
||||
resultTaskIds.has(task.id)
|
||||
resultTaskIds.has(task.id),
|
||||
);
|
||||
}
|
||||
|
||||
const viewConfig = this.plugin.settings.viewConfiguration.find(
|
||||
(view) => view.id === "projects"
|
||||
(view) => view.id === "projects",
|
||||
);
|
||||
if (viewConfig?.sortCriteria && viewConfig.sortCriteria.length > 0) {
|
||||
this.filteredTasks = sortTasks(
|
||||
this.filteredTasks,
|
||||
viewConfig.sortCriteria,
|
||||
this.plugin.settings
|
||||
this.plugin.settings,
|
||||
);
|
||||
} else {
|
||||
// Sort tasks by priority and due date
|
||||
|
|
@ -680,14 +680,14 @@ export class ProjectsComponent extends Component {
|
|||
|
||||
private updateTaskListHeader(title: string, countText: string) {
|
||||
const taskHeaderEl = this.taskContainerEl.querySelector(
|
||||
".projects-task-title"
|
||||
".projects-task-title",
|
||||
);
|
||||
if (taskHeaderEl) {
|
||||
taskHeaderEl.textContent = title;
|
||||
}
|
||||
|
||||
const taskCountEl = this.taskContainerEl.querySelector(
|
||||
".projects-task-count"
|
||||
".projects-task-count",
|
||||
);
|
||||
if (taskCountEl) {
|
||||
taskCountEl.textContent = countText;
|
||||
|
|
@ -706,7 +706,7 @@ export class ProjectsComponent extends Component {
|
|||
) {
|
||||
// Hide progress bar container if it exists
|
||||
const progressContainer = this.taskContainerEl.querySelector(
|
||||
".projects-header-progress"
|
||||
".projects-header-progress",
|
||||
);
|
||||
if (progressContainer) {
|
||||
progressContainer.remove();
|
||||
|
|
@ -719,12 +719,12 @@ export class ProjectsComponent extends Component {
|
|||
|
||||
// Get or create progress container
|
||||
let progressContainer = this.taskContainerEl.querySelector(
|
||||
".projects-header-progress"
|
||||
".projects-header-progress",
|
||||
) as HTMLElement;
|
||||
|
||||
if (!progressContainer) {
|
||||
const headerMainContent = this.taskContainerEl.querySelector(
|
||||
".projects-header-main-content"
|
||||
".projects-header-main-content",
|
||||
);
|
||||
if (headerMainContent) {
|
||||
progressContainer = headerMainContent.createDiv({
|
||||
|
|
@ -754,22 +754,22 @@ export class ProjectsComponent extends Component {
|
|||
// Calculate percentages
|
||||
const completedPercentage =
|
||||
Math.round(
|
||||
(progressData.completed / progressData.total) * 10000
|
||||
(progressData.completed / progressData.total) * 10000,
|
||||
) / 100;
|
||||
const inProgressPercentage = progressData.inProgress
|
||||
? Math.round(
|
||||
(progressData.inProgress / progressData.total) * 10000
|
||||
) / 100
|
||||
(progressData.inProgress / progressData.total) * 10000,
|
||||
) / 100
|
||||
: 0;
|
||||
const abandonedPercentage = progressData.abandoned
|
||||
? Math.round(
|
||||
(progressData.abandoned / progressData.total) * 10000
|
||||
) / 100
|
||||
(progressData.abandoned / progressData.total) * 10000,
|
||||
) / 100
|
||||
: 0;
|
||||
const plannedPercentage = progressData.planned
|
||||
? Math.round(
|
||||
(progressData.planned / progressData.total) * 10000
|
||||
) / 100
|
||||
(progressData.planned / progressData.total) * 10000,
|
||||
) / 100
|
||||
: 0;
|
||||
|
||||
// Create progress segments
|
||||
|
|
@ -847,7 +847,7 @@ export class ProjectsComponent extends Component {
|
|||
} else if (displayMode === "both") {
|
||||
// Add text to the existing progress bar container
|
||||
const progressBarEl = progressContainer.querySelector(
|
||||
".cm-task-progress-bar"
|
||||
".cm-task-progress-bar",
|
||||
);
|
||||
if (progressBarEl) {
|
||||
const textEl = progressBarEl.createDiv({
|
||||
|
|
@ -901,7 +901,7 @@ export class ProjectsComponent extends Component {
|
|||
* Follows the same logic as progress-bar-widget.ts
|
||||
*/
|
||||
private getTaskStatus(
|
||||
task: Task
|
||||
task: Task,
|
||||
): "completed" | "inProgress" | "abandoned" | "notStarted" | "planned" {
|
||||
// If task is marked as completed in the task object
|
||||
if (task.completed) {
|
||||
|
|
@ -943,7 +943,7 @@ export class ProjectsComponent extends Component {
|
|||
* Helper to determine the non-completed status of a task mark
|
||||
*/
|
||||
private determineNonCompletedStatus(
|
||||
mark: string
|
||||
mark: string,
|
||||
): "inProgress" | "abandoned" | "notStarted" | "planned" {
|
||||
const inProgressMarks =
|
||||
this.plugin?.settings.taskStatuses?.inProgress?.split("|") || [
|
||||
|
|
@ -962,7 +962,7 @@ export class ProjectsComponent extends Component {
|
|||
}
|
||||
|
||||
const plannedMarks = this.plugin?.settings.taskStatuses?.planned?.split(
|
||||
"|"
|
||||
"|",
|
||||
) || ["?"];
|
||||
if (plannedMarks.includes(mark)) {
|
||||
return "planned";
|
||||
|
|
@ -982,7 +982,7 @@ export class ProjectsComponent extends Component {
|
|||
* Helper to determine the specific task status
|
||||
*/
|
||||
private determineTaskStatus(
|
||||
mark: string
|
||||
mark: string,
|
||||
): "completed" | "inProgress" | "abandoned" | "notStarted" | "planned" {
|
||||
const completedMarks =
|
||||
this.plugin?.settings.taskStatuses?.completed?.split("|") || [
|
||||
|
|
@ -1009,7 +1009,7 @@ export class ProjectsComponent extends Component {
|
|||
}
|
||||
|
||||
const plannedMarks = this.plugin?.settings.taskStatuses?.planned?.split(
|
||||
"|"
|
||||
"|",
|
||||
) || ["?"];
|
||||
if (plannedMarks.includes(mark)) {
|
||||
return "planned";
|
||||
|
|
@ -1041,7 +1041,7 @@ export class ProjectsComponent extends Component {
|
|||
title = this.selectedProjects.projects[0];
|
||||
} else if (this.selectedProjects.projects.length > 1) {
|
||||
title = `${this.selectedProjects.projects.length} ${t(
|
||||
"projects selected"
|
||||
"projects selected",
|
||||
)}`;
|
||||
}
|
||||
const countText = `${this.filteredTasks.length} ${t("tasks")}`;
|
||||
|
|
@ -1052,14 +1052,14 @@ export class ProjectsComponent extends Component {
|
|||
this.filteredTasks,
|
||||
this.isTreeView,
|
||||
this.allTasksMap,
|
||||
t("No tasks in the selected projects")
|
||||
t("No tasks in the selected projects"),
|
||||
);
|
||||
}
|
||||
|
||||
public updateTask(updatedTask: Task) {
|
||||
// Update in our main tasks list
|
||||
const taskIndex = this.allTasks.findIndex(
|
||||
(t) => t.id === updatedTask.id
|
||||
(t) => t.id === updatedTask.id,
|
||||
);
|
||||
let needsFullRefresh = false;
|
||||
if (taskIndex !== -1) {
|
||||
|
|
@ -1083,7 +1083,7 @@ export class ProjectsComponent extends Component {
|
|||
} else {
|
||||
// Otherwise, just update the task in the filtered list and the renderer
|
||||
const filteredIndex = this.filteredTasks.findIndex(
|
||||
(t) => t.id === updatedTask.id
|
||||
(t) => t.id === updatedTask.id,
|
||||
);
|
||||
if (filteredIndex !== -1) {
|
||||
this.filteredTasks[filteredIndex] = updatedTask;
|
||||
|
|
@ -1103,19 +1103,19 @@ export class ProjectsComponent extends Component {
|
|||
|
||||
// Update button icon
|
||||
const treeToggleBtn = this.leftColumnEl.querySelector(
|
||||
".projects-tree-toggle-btn"
|
||||
".projects-tree-toggle-btn",
|
||||
) as HTMLElement;
|
||||
if (treeToggleBtn) {
|
||||
setIcon(
|
||||
treeToggleBtn,
|
||||
this.isProjectTreeView ? "git-branch" : "list"
|
||||
this.isProjectTreeView ? "git-branch" : "list",
|
||||
);
|
||||
}
|
||||
|
||||
// Save preference to localStorage for now
|
||||
localStorage.setItem(
|
||||
this.app.saveLocalStorage(
|
||||
"task-genius-project-tree-view",
|
||||
this.isProjectTreeView.toString()
|
||||
this.isProjectTreeView.toString(),
|
||||
);
|
||||
|
||||
// Rebuild project index and re-render
|
||||
|
|
@ -1193,24 +1193,32 @@ export class ProjectsComponent extends Component {
|
|||
this.projectPropsPopoverEl = document.body.createEl("div", {
|
||||
cls: "tg-project-popover",
|
||||
attr: {
|
||||
style: "background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 8px; padding: 8px 10px; box-shadow: var(--shadow-s); max-width: 420px; z-index: 9999;"
|
||||
}
|
||||
style: "background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 8px; padding: 8px 10px; box-shadow: var(--shadow-s); max-width: 420px; z-index: 9999;",
|
||||
},
|
||||
});
|
||||
const list = this.projectPropsPopoverEl.createDiv({
|
||||
cls: "tg-project-props"
|
||||
});
|
||||
this.projectPropsPopper = createPopper(btn, this.projectPropsPopoverEl, {
|
||||
placement: "bottom-end",
|
||||
modifiers: [
|
||||
{ name: "offset", options: { offset: [0, 8] } },
|
||||
],
|
||||
cls: "tg-project-props",
|
||||
});
|
||||
this.projectPropsPopper = createPopper(
|
||||
btn,
|
||||
this.projectPropsPopoverEl,
|
||||
{
|
||||
placement: "bottom-end",
|
||||
modifiers: [
|
||||
{ name: "offset", options: { offset: [0, 8] } },
|
||||
],
|
||||
},
|
||||
);
|
||||
// Render-token to prevent stale async updates
|
||||
const gen = ++this.infoRenderGen;
|
||||
|
||||
// Close on outside click or ESC
|
||||
|
||||
this.registerDomEvent(this.projectPropsPopoverEl, "mousedown", (ev) => ev.stopPropagation());
|
||||
this.registerDomEvent(
|
||||
this.projectPropsPopoverEl,
|
||||
"mousedown",
|
||||
(ev) => ev.stopPropagation(),
|
||||
);
|
||||
|
||||
this.outsideClickHandler = (ev: MouseEvent) => {
|
||||
const target = ev.target as Node;
|
||||
|
|
@ -1227,7 +1235,7 @@ export class ProjectsComponent extends Component {
|
|||
document,
|
||||
"mousedown",
|
||||
this.outsideClickHandler,
|
||||
{ capture: true }
|
||||
{ capture: true },
|
||||
);
|
||||
|
||||
this.escKeyHandler = (ev: KeyboardEvent) => {
|
||||
|
|
@ -1290,7 +1298,7 @@ export class ProjectsComponent extends Component {
|
|||
?.projectResolver;
|
||||
if (resolver?.get) {
|
||||
const pdata = await resolver.get(
|
||||
taskForResolve.filePath
|
||||
taskForResolve.filePath,
|
||||
);
|
||||
enhanced = pdata?.enhancedMetadata || {};
|
||||
configSourcePath = pdata?.configSource;
|
||||
|
|
@ -1307,7 +1315,7 @@ export class ProjectsComponent extends Component {
|
|||
} catch (err) {
|
||||
console.warn(
|
||||
"[Projects] Failed to load project metadata:",
|
||||
err
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1353,7 +1361,7 @@ export class ProjectsComponent extends Component {
|
|||
const file = abs as any; // TFile
|
||||
const fm =
|
||||
this.app.metadataCache.getFileCache(
|
||||
file
|
||||
file,
|
||||
)?.frontmatter;
|
||||
if (fm && typeof fm === "object") {
|
||||
// record fm keys for de-duplication
|
||||
|
|
@ -1365,7 +1373,7 @@ export class ProjectsComponent extends Component {
|
|||
kl !== "projectname" &&
|
||||
kl !== "project"
|
||||
);
|
||||
})
|
||||
}),
|
||||
);
|
||||
entries.push(["—", "—"]);
|
||||
entries.push(["sourceFile", metaPath]);
|
||||
|
|
@ -1384,7 +1392,7 @@ export class ProjectsComponent extends Component {
|
|||
} catch (e) {
|
||||
console.warn(
|
||||
"[Projects] Failed to read source frontmatter:",
|
||||
e
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1452,18 +1460,18 @@ export class ProjectsComponent extends Component {
|
|||
entries.forEach(([k, v]) => {
|
||||
const row = list.createDiv({
|
||||
attr: {
|
||||
style: "display:flex; gap:8px; align-items:center; padding:2px 0"
|
||||
}
|
||||
style: "display:flex; gap:8px; align-items:center; padding:2px 0",
|
||||
},
|
||||
});
|
||||
const keyEl = row.createDiv({
|
||||
attr: {
|
||||
style: "opacity:0.7; min-width:120px;"
|
||||
}
|
||||
style: "opacity:0.7; min-width:120px;",
|
||||
},
|
||||
});
|
||||
keyEl.setText(String(k));
|
||||
const valEl = row.createDiv();
|
||||
valEl.setText(
|
||||
typeof v === "string" ? v : JSON.stringify(v)
|
||||
typeof v === "string" ? v : JSON.stringify(v),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Component } from "obsidian";
|
||||
import { App, Component } from "obsidian";
|
||||
import { TreeNode, TreeState } from "@/types/tree";
|
||||
import { TreeItemRenderer } from "./TreeItemRenderer";
|
||||
|
||||
|
|
@ -12,15 +12,15 @@ export interface TreeComponentConfig<T> {
|
|||
showToggle?: boolean;
|
||||
enableSelection?: boolean;
|
||||
enableMultiSelect?: boolean;
|
||||
|
||||
|
||||
// Content rendering
|
||||
renderContent: (node: TreeNode<T>, contentEl: HTMLElement) => void;
|
||||
iconResolver?: (node: TreeNode<T>) => string;
|
||||
|
||||
|
||||
// State persistence
|
||||
stateKey?: string;
|
||||
autoExpandLevel?: number;
|
||||
|
||||
|
||||
// Event handlers
|
||||
onNodeSelected?: (selectedNodes: Set<string>) => void;
|
||||
onNodeToggled?: (node: TreeNode<T>, isExpanded: boolean) => void;
|
||||
|
|
@ -38,144 +38,158 @@ export class TreeComponent<T> extends Component {
|
|||
private expandedNodes: Set<string> = new Set();
|
||||
private nodeRenderers: Map<string, TreeItemRenderer<T>> = new Map();
|
||||
private isMultiSelectMode: boolean = false;
|
||||
|
||||
|
||||
constructor(
|
||||
private parentEl: HTMLElement,
|
||||
private config: TreeComponentConfig<T>
|
||||
private config: TreeComponentConfig<T>,
|
||||
private app: App,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
|
||||
onload(): void {
|
||||
// Create container
|
||||
this.containerEl = this.parentEl.createDiv({
|
||||
cls: `${this.config.classPrefix || 'tree'}-container`
|
||||
cls: `${this.config.classPrefix || "tree"}-container`,
|
||||
});
|
||||
|
||||
|
||||
// Create tree container
|
||||
this.treeContainerEl = this.containerEl.createDiv({
|
||||
cls: `${this.config.classPrefix || 'tree'}`
|
||||
cls: `${this.config.classPrefix || "tree"}`,
|
||||
});
|
||||
|
||||
|
||||
// Set custom indent size if provided (as CSS variable)
|
||||
if (this.config.indentSize) {
|
||||
this.containerEl.style.setProperty('--tree-indent-size', `${this.config.indentSize}px`);
|
||||
this.containerEl.style.setProperty(
|
||||
"--tree-indent-size",
|
||||
`${this.config.indentSize}px`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Restore state if configured
|
||||
if (this.config.stateKey) {
|
||||
this.restoreTreeState();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set the tree data and render
|
||||
*/
|
||||
public setTree(tree: TreeNode<T>): void {
|
||||
this.tree = tree;
|
||||
|
||||
|
||||
// Apply auto-expand if configured
|
||||
if (this.config.autoExpandLevel !== undefined && this.expandedNodes.size === 0) {
|
||||
if (
|
||||
this.config.autoExpandLevel !== undefined &&
|
||||
this.expandedNodes.size === 0
|
||||
) {
|
||||
this.autoExpandToLevel(tree, this.config.autoExpandLevel);
|
||||
} else if (this.expandedNodes.size > 0) {
|
||||
// Restore expanded state
|
||||
this.restoreExpandedState(tree);
|
||||
}
|
||||
|
||||
|
||||
// Render the tree
|
||||
this.renderTree();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the current tree
|
||||
*/
|
||||
public getTree(): TreeNode<T> | null {
|
||||
return this.tree;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Render the tree
|
||||
*/
|
||||
private renderTree(): void {
|
||||
// Clear existing renderers
|
||||
this.clearRenderers();
|
||||
|
||||
|
||||
// Clear DOM
|
||||
this.treeContainerEl.empty();
|
||||
|
||||
|
||||
if (!this.tree) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Skip root node if it's a placeholder
|
||||
const nodesToRender = this.tree.fullPath === "" ? this.tree.children : [this.tree];
|
||||
|
||||
const nodesToRender =
|
||||
this.tree.fullPath === "" ? this.tree.children : [this.tree];
|
||||
|
||||
// Render nodes
|
||||
for (const node of nodesToRender) {
|
||||
this.renderNode(node, this.treeContainerEl);
|
||||
}
|
||||
|
||||
|
||||
// Update selection visuals
|
||||
this.updateSelectionVisuals();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Render a single node and its children
|
||||
*/
|
||||
private renderNode(node: TreeNode<T>, parentEl: HTMLElement): void {
|
||||
const container = parentEl.createDiv();
|
||||
|
||||
|
||||
const renderer = new TreeItemRenderer<T>(
|
||||
container,
|
||||
node,
|
||||
{
|
||||
renderContent: this.config.renderContent,
|
||||
iconResolver: this.config.iconResolver,
|
||||
classPrefix: this.config.classPrefix || 'tree',
|
||||
classPrefix: this.config.classPrefix || "tree",
|
||||
showToggle: this.config.showToggle !== false,
|
||||
enableSelection: this.config.enableSelection !== false
|
||||
enableSelection: this.config.enableSelection !== false,
|
||||
},
|
||||
{
|
||||
onToggle: (node, isExpanded) => {
|
||||
this.handleNodeToggle(node, isExpanded);
|
||||
},
|
||||
onSelect: (node, isMultiSelect) => {
|
||||
this.handleNodeSelection(node, isMultiSelect || this.isMultiSelectMode);
|
||||
}
|
||||
}
|
||||
this.handleNodeSelection(
|
||||
node,
|
||||
isMultiSelect || this.isMultiSelectMode,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
// Store renderer
|
||||
this.nodeRenderers.set(node.fullPath, renderer);
|
||||
|
||||
|
||||
// Apply selection state
|
||||
if (this.selectedNodes.has(node.fullPath)) {
|
||||
renderer.setSelected(true);
|
||||
}
|
||||
|
||||
|
||||
// Add to component lifecycle
|
||||
this.addChild(renderer);
|
||||
|
||||
|
||||
// Render children if expanded
|
||||
if (node.isExpanded && node.children.length > 0) {
|
||||
const childrenContainer = container.createDiv({
|
||||
cls: `${this.config.classPrefix || 'tree'}-children`
|
||||
cls: `${this.config.classPrefix || "tree"}-children`,
|
||||
});
|
||||
|
||||
|
||||
for (const child of node.children) {
|
||||
this.renderNode(child, childrenContainer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle node selection
|
||||
*/
|
||||
private handleNodeSelection(node: TreeNode<T>, isMultiSelect: boolean): void {
|
||||
private handleNodeSelection(
|
||||
node: TreeNode<T>,
|
||||
isMultiSelect: boolean,
|
||||
): void {
|
||||
if (!this.config.enableSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!isMultiSelect) {
|
||||
// Single selection
|
||||
this.selectedNodes.clear();
|
||||
|
|
@ -188,48 +202,48 @@ export class TreeComponent<T> extends Component {
|
|||
this.selectedNodes.add(node.fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Update visuals
|
||||
this.updateSelectionVisuals();
|
||||
|
||||
|
||||
// Trigger event
|
||||
if (this.config.onNodeSelected) {
|
||||
this.config.onNodeSelected(new Set(this.selectedNodes));
|
||||
}
|
||||
|
||||
|
||||
// Persist state
|
||||
if (this.config.stateKey) {
|
||||
this.persistTreeState();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle node toggle (expand/collapse)
|
||||
*/
|
||||
private handleNodeToggle(node: TreeNode<T>, isExpanded: boolean): void {
|
||||
node.isExpanded = isExpanded;
|
||||
|
||||
|
||||
// Update expanded nodes set
|
||||
if (isExpanded) {
|
||||
this.expandedNodes.add(node.fullPath);
|
||||
} else {
|
||||
this.expandedNodes.delete(node.fullPath);
|
||||
}
|
||||
|
||||
|
||||
// Re-render tree to show/hide children
|
||||
this.renderTree();
|
||||
|
||||
|
||||
// Trigger event
|
||||
if (this.config.onNodeToggled) {
|
||||
this.config.onNodeToggled(node, isExpanded);
|
||||
}
|
||||
|
||||
|
||||
// Persist state
|
||||
if (this.config.stateKey) {
|
||||
this.persistTreeState();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update visual selection state for all nodes
|
||||
*/
|
||||
|
|
@ -238,7 +252,7 @@ export class TreeComponent<T> extends Component {
|
|||
renderer.setSelected(this.selectedNodes.has(path));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Clear all renderers
|
||||
*/
|
||||
|
|
@ -248,193 +262,200 @@ export class TreeComponent<T> extends Component {
|
|||
}
|
||||
this.nodeRenderers.clear();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Auto-expand nodes to a certain level
|
||||
*/
|
||||
private autoExpandToLevel(node: TreeNode<T>, level: number, currentLevel: number = 0): void {
|
||||
private autoExpandToLevel(
|
||||
node: TreeNode<T>,
|
||||
level: number,
|
||||
currentLevel: number = 0,
|
||||
): void {
|
||||
if (currentLevel < level) {
|
||||
node.isExpanded = true;
|
||||
this.expandedNodes.add(node.fullPath);
|
||||
|
||||
|
||||
for (const child of node.children) {
|
||||
this.autoExpandToLevel(child, level, currentLevel + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Restore expanded state from saved set
|
||||
*/
|
||||
private restoreExpandedState(node: TreeNode<T>): void {
|
||||
node.isExpanded = this.expandedNodes.has(node.fullPath) || node.fullPath === "";
|
||||
|
||||
node.isExpanded =
|
||||
this.expandedNodes.has(node.fullPath) || node.fullPath === "";
|
||||
|
||||
for (const child of node.children) {
|
||||
this.restoreExpandedState(child);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Toggle multi-select mode
|
||||
*/
|
||||
public setMultiSelectMode(enabled: boolean): void {
|
||||
this.isMultiSelectMode = enabled;
|
||||
|
||||
|
||||
if (!enabled && this.selectedNodes.size === 0) {
|
||||
// Clear selection when disabling multi-select with no selection
|
||||
this.updateSelectionVisuals();
|
||||
}
|
||||
|
||||
|
||||
// Trigger event
|
||||
if (this.config.onMultiSelectToggled) {
|
||||
this.config.onMultiSelectToggled(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get multi-select mode status
|
||||
*/
|
||||
public getMultiSelectMode(): boolean {
|
||||
return this.isMultiSelectMode;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get selected node paths
|
||||
*/
|
||||
public getSelectedPaths(): Set<string> {
|
||||
return new Set(this.selectedNodes);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Set selected node paths
|
||||
*/
|
||||
public setSelectedPaths(paths: Set<string>): void {
|
||||
this.selectedNodes = new Set(paths);
|
||||
this.updateSelectionVisuals();
|
||||
|
||||
|
||||
// Trigger event
|
||||
if (this.config.onNodeSelected) {
|
||||
this.config.onNodeSelected(new Set(this.selectedNodes));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Clear selection
|
||||
*/
|
||||
public clearSelection(): void {
|
||||
this.selectedNodes.clear();
|
||||
this.updateSelectionVisuals();
|
||||
|
||||
|
||||
// Trigger event
|
||||
if (this.config.onNodeSelected) {
|
||||
this.config.onNodeSelected(new Set());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Expand all nodes
|
||||
*/
|
||||
public expandAll(): void {
|
||||
if (!this.tree) return;
|
||||
|
||||
|
||||
const expandNode = (node: TreeNode<T>) => {
|
||||
node.isExpanded = true;
|
||||
this.expandedNodes.add(node.fullPath);
|
||||
node.children.forEach(expandNode);
|
||||
};
|
||||
|
||||
|
||||
expandNode(this.tree);
|
||||
this.renderTree();
|
||||
|
||||
|
||||
// Persist state
|
||||
if (this.config.stateKey) {
|
||||
this.persistTreeState();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Collapse all nodes
|
||||
*/
|
||||
public collapseAll(): void {
|
||||
if (!this.tree) return;
|
||||
|
||||
|
||||
const collapseNode = (node: TreeNode<T>) => {
|
||||
node.isExpanded = false;
|
||||
node.children.forEach(collapseNode);
|
||||
};
|
||||
|
||||
|
||||
collapseNode(this.tree);
|
||||
this.expandedNodes.clear();
|
||||
this.renderTree();
|
||||
|
||||
|
||||
// Persist state
|
||||
if (this.config.stateKey) {
|
||||
this.persistTreeState();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Find node by path
|
||||
*/
|
||||
public findNodeByPath(path: string): TreeNode<T> | undefined {
|
||||
if (!this.tree) return undefined;
|
||||
|
||||
|
||||
const search = (node: TreeNode<T>): TreeNode<T> | undefined => {
|
||||
if (node.fullPath === path) {
|
||||
return node;
|
||||
}
|
||||
|
||||
|
||||
for (const child of node.children) {
|
||||
const found = search(child);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
|
||||
return search(this.tree);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Persist tree state to localStorage
|
||||
*/
|
||||
private persistTreeState(): void {
|
||||
if (!this.config.stateKey) return;
|
||||
|
||||
|
||||
const state: TreeState = {
|
||||
expandedNodes: Array.from(this.expandedNodes),
|
||||
selectedNodes: Array.from(this.selectedNodes)
|
||||
selectedNodes: Array.from(this.selectedNodes),
|
||||
};
|
||||
|
||||
localStorage.setItem(this.config.stateKey, JSON.stringify(state));
|
||||
|
||||
this.app.saveLocalStorage(this.config.stateKey, JSON.stringify(state));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Restore tree state from localStorage
|
||||
*/
|
||||
private restoreTreeState(): void {
|
||||
if (!this.config.stateKey) return;
|
||||
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(this.config.stateKey);
|
||||
const stored = (this.app as App).loadLocalStorage(
|
||||
this.config.stateKey,
|
||||
);
|
||||
if (stored) {
|
||||
const state: TreeState = JSON.parse(stored);
|
||||
this.expandedNodes = new Set(state.expandedNodes || []);
|
||||
this.selectedNodes = new Set(state.selectedNodes || []);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to restore tree state:', e);
|
||||
console.error("Failed to restore tree state:", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onunload(): void {
|
||||
// Clear renderers
|
||||
this.clearRenderers();
|
||||
|
||||
|
||||
// Clear DOM
|
||||
if (this.containerEl) {
|
||||
this.containerEl.empty();
|
||||
this.containerEl.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
73
src/index.ts
73
src/index.ts
|
|
@ -364,6 +364,9 @@ export default class TaskProgressBarPlugin extends Plugin {
|
|||
);
|
||||
}
|
||||
|
||||
// Check if user upgraded from intermediate version and show onboarding
|
||||
await this.checkMigrationAndMaybeShowOnboarding();
|
||||
|
||||
// Check and show onboarding for first-time users
|
||||
this.checkAndShowOnboarding();
|
||||
|
||||
|
|
@ -1759,6 +1762,76 @@ export default class TaskProgressBarPlugin extends Plugin {
|
|||
changelogLeaves.forEach((leaf) => leaf.detach());
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two semantic version strings
|
||||
* Returns: -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2
|
||||
*/
|
||||
private compareVersions(v1: string, v2: string): number {
|
||||
if (v1 === v2) return 0;
|
||||
|
||||
const v1Parts = v1.split(".").map((n) => parseInt(n, 10) || 0);
|
||||
const v2Parts = v2.split(".").map((n) => parseInt(n, 10) || 0);
|
||||
|
||||
const maxLength = Math.max(v1Parts.length, v2Parts.length);
|
||||
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const p1 = v1Parts[i] || 0;
|
||||
const p2 = v2Parts[i] || 0;
|
||||
|
||||
if (p1 < p2) return -1;
|
||||
if (p1 > p2) return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user upgraded from 9.8.14 < version < 9.9.0 and show onboarding
|
||||
* This ensures users who upgraded from intermediate versions see the setup guide
|
||||
*/
|
||||
private async checkMigrationAndMaybeShowOnboarding(): Promise<void> {
|
||||
try {
|
||||
// Get version info from VersionManager
|
||||
const versionResult =
|
||||
await this.versionManager.checkVersionChange();
|
||||
const previousVersion = versionResult.versionInfo.previous;
|
||||
|
||||
// Get last version from changelog settings
|
||||
const lastVersion = this.settings.changelog?.lastVersion || "";
|
||||
|
||||
// Get current version
|
||||
const currentVersion = this.manifest?.version;
|
||||
if (!currentVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user upgraded from >9.8.14 and <9.9.0
|
||||
// AND hasn't seen the 9.9.0 onboarding yet
|
||||
if (
|
||||
previousVersion &&
|
||||
this.compareVersions(previousVersion, "9.8.14") > 0 &&
|
||||
this.compareVersions(previousVersion, "9.9.0") < 0 &&
|
||||
lastVersion !== "9.9.0"
|
||||
) {
|
||||
console.log(
|
||||
`[TG] Migration detected: ${previousVersion} -> ${currentVersion}, opening onboarding`,
|
||||
);
|
||||
|
||||
// Directly open onboarding view (same pattern as maybeShowChangelog)
|
||||
this.openOnboardingView();
|
||||
|
||||
// Mark as shown by updating lastVersion
|
||||
this.settings.changelog.lastVersion = currentVersion;
|
||||
await this.saveSettings();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[TG] Failed to check migration onboarding:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private maybeShowChangelog(): void {
|
||||
try {
|
||||
if (!this.changelogManager) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { App } from "obsidian";
|
||||
import { TaskTimerSettings } from "../common/setting-definition";
|
||||
|
||||
/**
|
||||
|
|
@ -17,7 +18,7 @@ export interface TimerState {
|
|||
filePath: string;
|
||||
blockId: string;
|
||||
segments: TimeSegment[]; // Array of time segments
|
||||
status: 'idle' | 'running' | 'paused';
|
||||
status: "idle" | "running" | "paused";
|
||||
createdAt: number;
|
||||
// Legacy fields for backward compatibility
|
||||
legacyStartTime?: number;
|
||||
|
|
@ -35,7 +36,7 @@ export interface LegacyTimerState {
|
|||
startTime: number;
|
||||
pausedTime?: number;
|
||||
totalPausedDuration: number;
|
||||
status: 'idle' | 'running' | 'paused';
|
||||
status: "idle" | "running" | "paused";
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +60,9 @@ export class TaskTimerManager {
|
|||
public generateBlockId(prefix?: string): string {
|
||||
const actualPrefix = prefix || this.settings.blockRefPrefix;
|
||||
const timestamp = Date.now().toString().slice(-6); // Last 6 digits of timestamp
|
||||
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
|
||||
const random = Math.floor(Math.random() * 10000)
|
||||
.toString()
|
||||
.padStart(4, "0");
|
||||
return `${actualPrefix}-${timestamp}-${random}`;
|
||||
}
|
||||
|
||||
|
|
@ -81,24 +84,30 @@ export class TaskTimerManager {
|
|||
*/
|
||||
startTimer(filePath: string, existingBlockId?: string): string {
|
||||
try {
|
||||
console.log(`[TaskTimerManager] Starting timer for file: ${filePath}, blockId: ${existingBlockId || 'new'}`);
|
||||
|
||||
console.log(
|
||||
`[TaskTimerManager] Starting timer for file: ${filePath}, blockId: ${existingBlockId || "new"}`,
|
||||
);
|
||||
|
||||
const blockId = existingBlockId || this.generateBlockId();
|
||||
const taskId = this.getStorageKey(filePath, blockId);
|
||||
const now = Date.now();
|
||||
|
||||
if (!blockId) {
|
||||
console.error("[TaskTimerManager] Failed to generate or use block ID");
|
||||
console.error(
|
||||
"[TaskTimerManager] Failed to generate or use block ID",
|
||||
);
|
||||
throw new Error("Block ID generation failed");
|
||||
}
|
||||
|
||||
// Check if timer already exists
|
||||
const existingTimer = this.getTimerState(taskId);
|
||||
|
||||
|
||||
if (existingTimer) {
|
||||
console.log(`[TaskTimerManager] Found existing timer with status: ${existingTimer.status}`);
|
||||
console.log(
|
||||
`[TaskTimerManager] Found existing timer with status: ${existingTimer.status}`,
|
||||
);
|
||||
// Resume existing timer
|
||||
if (existingTimer.status === 'paused') {
|
||||
if (existingTimer.status === "paused") {
|
||||
this.resumeTimer(taskId);
|
||||
}
|
||||
return blockId;
|
||||
|
|
@ -109,26 +118,41 @@ export class TaskTimerManager {
|
|||
taskId,
|
||||
filePath,
|
||||
blockId,
|
||||
segments: [{
|
||||
startTime: now
|
||||
}],
|
||||
status: 'running',
|
||||
createdAt: now
|
||||
segments: [
|
||||
{
|
||||
startTime: now,
|
||||
},
|
||||
],
|
||||
status: "running",
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// Save timer state
|
||||
try {
|
||||
localStorage.setItem(taskId, JSON.stringify(timerState));
|
||||
((window as any).app as App).saveLocalStorage(
|
||||
taskId,
|
||||
JSON.stringify(timerState),
|
||||
);
|
||||
this.addToActiveList(taskId);
|
||||
console.log(`[TaskTimerManager] Successfully created new timer: ${taskId}`);
|
||||
console.log(
|
||||
`[TaskTimerManager] Successfully created new timer: ${taskId}`,
|
||||
);
|
||||
} catch (storageError) {
|
||||
console.error("[TaskTimerManager] Failed to save timer to localStorage:", storageError);
|
||||
throw new Error("Failed to save timer state - localStorage may be full or unavailable");
|
||||
console.error(
|
||||
"[TaskTimerManager] Failed to save timer to localStorage:",
|
||||
storageError,
|
||||
);
|
||||
throw new Error(
|
||||
"Failed to save timer state - localStorage may be full or unavailable",
|
||||
);
|
||||
}
|
||||
|
||||
return blockId;
|
||||
} catch (error) {
|
||||
console.error("[TaskTimerManager] Critical error starting timer:", error);
|
||||
console.error(
|
||||
"[TaskTimerManager] Critical error starting timer:",
|
||||
error,
|
||||
);
|
||||
throw error; // Re-throw to let caller handle
|
||||
}
|
||||
}
|
||||
|
|
@ -139,22 +163,26 @@ export class TaskTimerManager {
|
|||
*/
|
||||
pauseTimer(taskId: string): void {
|
||||
const timerState = this.getTimerState(taskId);
|
||||
if (!timerState || timerState.status !== 'running') {
|
||||
if (!timerState || timerState.status !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
// Close the current segment
|
||||
const currentSegment = timerState.segments[timerState.segments.length - 1];
|
||||
const currentSegment =
|
||||
timerState.segments[timerState.segments.length - 1];
|
||||
if (currentSegment && !currentSegment.endTime) {
|
||||
currentSegment.endTime = now;
|
||||
currentSegment.duration = now - currentSegment.startTime;
|
||||
}
|
||||
|
||||
timerState.status = 'paused';
|
||||
timerState.status = "paused";
|
||||
|
||||
localStorage.setItem(taskId, JSON.stringify(timerState));
|
||||
(window as any).app.saveLocalStorage(
|
||||
taskId,
|
||||
JSON.stringify(timerState),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -163,20 +191,23 @@ export class TaskTimerManager {
|
|||
*/
|
||||
resumeTimer(taskId: string): void {
|
||||
const timerState = this.getTimerState(taskId);
|
||||
if (!timerState || timerState.status !== 'paused') {
|
||||
if (!timerState || timerState.status !== "paused") {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
// Create a new segment for the resumed work
|
||||
timerState.segments.push({
|
||||
startTime: now
|
||||
startTime: now,
|
||||
});
|
||||
|
||||
timerState.status = 'running';
|
||||
|
||||
localStorage.setItem(taskId, JSON.stringify(timerState));
|
||||
timerState.status = "running";
|
||||
|
||||
(window as any).app.saveLocalStorage(
|
||||
taskId,
|
||||
JSON.stringify(timerState),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -190,14 +221,19 @@ export class TaskTimerManager {
|
|||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Clear all segments and start fresh
|
||||
timerState.segments = [{
|
||||
startTime: now
|
||||
}];
|
||||
timerState.status = 'running';
|
||||
|
||||
localStorage.setItem(taskId, JSON.stringify(timerState));
|
||||
// Clear all segments and start fresh
|
||||
timerState.segments = [
|
||||
{
|
||||
startTime: now,
|
||||
},
|
||||
];
|
||||
timerState.status = "running";
|
||||
|
||||
(window as any).app.saveLocalStorage(
|
||||
taskId,
|
||||
JSON.stringify(timerState),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -208,18 +244,21 @@ export class TaskTimerManager {
|
|||
completeTimer(taskId: string): string {
|
||||
try {
|
||||
console.log(`[TaskTimerManager] Completing timer: ${taskId}`);
|
||||
|
||||
|
||||
const timerState = this.getTimerState(taskId);
|
||||
if (!timerState) {
|
||||
console.warn(`[TaskTimerManager] Timer not found for completion: ${taskId}`);
|
||||
console.warn(
|
||||
`[TaskTimerManager] Timer not found for completion: ${taskId}`,
|
||||
);
|
||||
return "";
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
// Close the current segment if running
|
||||
if (timerState.status === 'running') {
|
||||
const currentSegment = timerState.segments[timerState.segments.length - 1];
|
||||
if (timerState.status === "running") {
|
||||
const currentSegment =
|
||||
timerState.segments[timerState.segments.length - 1];
|
||||
if (currentSegment && !currentSegment.endTime) {
|
||||
currentSegment.endTime = now;
|
||||
currentSegment.duration = now - currentSegment.startTime;
|
||||
|
|
@ -228,29 +267,43 @@ export class TaskTimerManager {
|
|||
|
||||
// Calculate total duration from all segments
|
||||
const totalDuration = this.calculateTotalDuration(timerState);
|
||||
console.log(`[TaskTimerManager] Total duration from ${timerState.segments.length} segments: ${totalDuration}ms`);
|
||||
console.log(
|
||||
`[TaskTimerManager] Total duration from ${timerState.segments.length} segments: ${totalDuration}ms`,
|
||||
);
|
||||
|
||||
// Validate duration
|
||||
if (totalDuration < 0) {
|
||||
console.error(`[TaskTimerManager] Invalid duration calculated: ${totalDuration}ms`);
|
||||
console.error(
|
||||
`[TaskTimerManager] Invalid duration calculated: ${totalDuration}ms`,
|
||||
);
|
||||
return this.formatDuration(0);
|
||||
}
|
||||
|
||||
// Remove from storage
|
||||
try {
|
||||
this.removeTimer(taskId);
|
||||
console.log(`[TaskTimerManager] Successfully removed completed timer from storage`);
|
||||
console.log(
|
||||
`[TaskTimerManager] Successfully removed completed timer from storage`,
|
||||
);
|
||||
} catch (removalError) {
|
||||
console.error("[TaskTimerManager] Failed to remove timer from storage:", removalError);
|
||||
console.error(
|
||||
"[TaskTimerManager] Failed to remove timer from storage:",
|
||||
removalError,
|
||||
);
|
||||
// Continue anyway - we can still return the duration
|
||||
}
|
||||
|
||||
// Format and return duration
|
||||
const formattedDuration = this.formatDuration(totalDuration);
|
||||
console.log(`[TaskTimerManager] Timer completed successfully, duration: ${formattedDuration}`);
|
||||
console.log(
|
||||
`[TaskTimerManager] Timer completed successfully, duration: ${formattedDuration}`,
|
||||
);
|
||||
return formattedDuration;
|
||||
} catch (error) {
|
||||
console.error("[TaskTimerManager] Critical error completing timer:", error);
|
||||
console.error(
|
||||
"[TaskTimerManager] Critical error completing timer:",
|
||||
error,
|
||||
);
|
||||
// Return empty string to prevent crashes, but log the issue
|
||||
return "";
|
||||
}
|
||||
|
|
@ -263,38 +316,54 @@ export class TaskTimerManager {
|
|||
*/
|
||||
getTimerState(taskId: string): TimerState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(taskId);
|
||||
const stored = (window as any).app.localStorage(taskId);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(stored);
|
||||
|
||||
|
||||
// Check if this is a legacy format that needs migration
|
||||
if (this.isLegacyFormat(parsed)) {
|
||||
console.log(`[TaskTimerManager] Migrating legacy timer state for ${taskId}`);
|
||||
const migrated = this.migrateLegacyState(parsed as LegacyTimerState);
|
||||
console.log(
|
||||
`[TaskTimerManager] Migrating legacy timer state for ${taskId}`,
|
||||
);
|
||||
const migrated = this.migrateLegacyState(
|
||||
parsed as LegacyTimerState,
|
||||
);
|
||||
// Save migrated state
|
||||
localStorage.setItem(taskId, JSON.stringify(migrated));
|
||||
(window as any).app.saveLocalStorage(
|
||||
taskId,
|
||||
JSON.stringify(migrated),
|
||||
);
|
||||
return migrated;
|
||||
}
|
||||
|
||||
|
||||
// Validate the parsed state structure
|
||||
if (!this.validateTimerState(parsed)) {
|
||||
console.error(`[TaskTimerManager] Invalid timer state structure for ${taskId}:`, parsed);
|
||||
console.error(
|
||||
`[TaskTimerManager] Invalid timer state structure for ${taskId}:`,
|
||||
parsed,
|
||||
);
|
||||
// Clean up corrupted data
|
||||
localStorage.removeItem(taskId);
|
||||
(window as any).app.saveLocalStorage(taskId, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return parsed as TimerState;
|
||||
} catch (error) {
|
||||
console.error(`[TaskTimerManager] Error retrieving timer state for ${taskId}:`, error);
|
||||
console.error(
|
||||
`[TaskTimerManager] Error retrieving timer state for ${taskId}:`,
|
||||
error,
|
||||
);
|
||||
// Clean up corrupted data
|
||||
try {
|
||||
localStorage.removeItem(taskId);
|
||||
(window as any).app.saveLocalStorage(taskId, null);
|
||||
} catch (cleanupError) {
|
||||
console.error("[TaskTimerManager] Failed to clean up corrupted timer data:", cleanupError);
|
||||
console.error(
|
||||
"[TaskTimerManager] Failed to clean up corrupted timer data:",
|
||||
cleanupError,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -308,9 +377,9 @@ export class TaskTimerManager {
|
|||
private isLegacyFormat(state: any): boolean {
|
||||
return (
|
||||
state &&
|
||||
typeof state.startTime === 'number' &&
|
||||
typeof state.startTime === "number" &&
|
||||
!state.segments &&
|
||||
typeof state.totalPausedDuration === 'number'
|
||||
typeof state.totalPausedDuration === "number"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -321,22 +390,25 @@ export class TaskTimerManager {
|
|||
*/
|
||||
private migrateLegacyState(legacy: LegacyTimerState): TimerState {
|
||||
const segments: TimeSegment[] = [];
|
||||
|
||||
|
||||
// Create segment from legacy data
|
||||
if (legacy.status === 'running') {
|
||||
if (legacy.status === "running") {
|
||||
// Running timer - create an open segment
|
||||
segments.push({
|
||||
startTime: legacy.startTime + legacy.totalPausedDuration
|
||||
startTime: legacy.startTime + legacy.totalPausedDuration,
|
||||
});
|
||||
} else if (legacy.status === 'paused' && legacy.pausedTime) {
|
||||
} else if (legacy.status === "paused" && legacy.pausedTime) {
|
||||
// Paused timer - create a closed segment
|
||||
segments.push({
|
||||
startTime: legacy.startTime + legacy.totalPausedDuration,
|
||||
endTime: legacy.pausedTime,
|
||||
duration: legacy.pausedTime - legacy.startTime - legacy.totalPausedDuration
|
||||
duration:
|
||||
legacy.pausedTime -
|
||||
legacy.startTime -
|
||||
legacy.totalPausedDuration,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
taskId: legacy.taskId,
|
||||
filePath: legacy.filePath,
|
||||
|
|
@ -347,7 +419,7 @@ export class TaskTimerManager {
|
|||
// Keep legacy fields for reference
|
||||
legacyStartTime: legacy.startTime,
|
||||
legacyPausedTime: legacy.pausedTime,
|
||||
legacyTotalPausedDuration: legacy.totalPausedDuration
|
||||
legacyTotalPausedDuration: legacy.totalPausedDuration,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -359,12 +431,12 @@ export class TaskTimerManager {
|
|||
private validateTimerState(state: any): state is TimerState {
|
||||
return (
|
||||
state &&
|
||||
typeof state.taskId === 'string' &&
|
||||
typeof state.filePath === 'string' &&
|
||||
typeof state.blockId === 'string' &&
|
||||
typeof state.taskId === "string" &&
|
||||
typeof state.filePath === "string" &&
|
||||
typeof state.blockId === "string" &&
|
||||
Array.isArray(state.segments) &&
|
||||
typeof state.createdAt === 'number' &&
|
||||
['idle', 'running', 'paused'].includes(state.status)
|
||||
typeof state.createdAt === "number" &&
|
||||
["idle", "running", "paused"].includes(state.status)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -395,7 +467,10 @@ export class TaskTimerManager {
|
|||
* @param blockId Block ID
|
||||
* @returns Timer state or null
|
||||
*/
|
||||
getTimerByFileAndBlock(filePath: string, blockId: string): TimerState | null {
|
||||
getTimerByFileAndBlock(
|
||||
filePath: string,
|
||||
blockId: string,
|
||||
): TimerState | null {
|
||||
const taskId = this.getStorageKey(filePath, blockId);
|
||||
return this.getTimerState(taskId);
|
||||
}
|
||||
|
|
@ -405,7 +480,7 @@ export class TaskTimerManager {
|
|||
* @param taskId Timer task ID
|
||||
*/
|
||||
removeTimer(taskId: string): void {
|
||||
localStorage.removeItem(taskId);
|
||||
(window as any).app.saveLocalStorage(taskId, null);
|
||||
this.removeFromActiveList(taskId);
|
||||
}
|
||||
|
||||
|
|
@ -416,10 +491,10 @@ export class TaskTimerManager {
|
|||
*/
|
||||
private calculateTotalDuration(timerState: TimerState): number {
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
return timerState.segments.reduce((total, segment) => {
|
||||
let segmentDuration: number;
|
||||
|
||||
|
||||
if (segment.duration) {
|
||||
// Use cached duration if available
|
||||
segmentDuration = segment.duration;
|
||||
|
|
@ -430,7 +505,7 @@ export class TaskTimerManager {
|
|||
// Calculate duration for running segment
|
||||
segmentDuration = now - segment.startTime;
|
||||
}
|
||||
|
||||
|
||||
return total + segmentDuration;
|
||||
}, 0);
|
||||
}
|
||||
|
|
@ -492,7 +567,7 @@ export class TaskTimerManager {
|
|||
|
||||
// Use template format from settings
|
||||
let template = this.settings.timeFormat;
|
||||
|
||||
|
||||
// Replace placeholders
|
||||
template = template.replace("{h}", hours.toString());
|
||||
template = template.replace("{m}", remainingMinutes.toString());
|
||||
|
|
@ -503,7 +578,7 @@ export class TaskTimerManager {
|
|||
// Use word boundaries to avoid matching 10hrs, 20mins etc.
|
||||
template = template.replace(/\b0hrs\b/g, "");
|
||||
template = template.replace(/\b0mins\b/g, "");
|
||||
|
||||
|
||||
// Clean up leading/trailing spaces and multiple spaces
|
||||
template = template.replace(/\s+/g, " ").trim();
|
||||
|
||||
|
|
@ -515,7 +590,9 @@ export class TaskTimerManager {
|
|||
* @returns Array of active timer task IDs
|
||||
*/
|
||||
private getActiveList(): string[] {
|
||||
const stored = localStorage.getItem(this.TIMER_LIST_KEY);
|
||||
const stored = (window as any).app.loadLocalStorage(
|
||||
this.TIMER_LIST_KEY,
|
||||
);
|
||||
if (!stored) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -536,7 +613,10 @@ export class TaskTimerManager {
|
|||
const activeList = this.getActiveList();
|
||||
if (!activeList.includes(taskId)) {
|
||||
activeList.push(taskId);
|
||||
localStorage.setItem(this.TIMER_LIST_KEY, JSON.stringify(activeList));
|
||||
(window as any).app.saveLocalStorage(
|
||||
this.TIMER_LIST_KEY,
|
||||
JSON.stringify(activeList),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -546,8 +626,11 @@ export class TaskTimerManager {
|
|||
*/
|
||||
private removeFromActiveList(taskId: string): void {
|
||||
const activeList = this.getActiveList();
|
||||
const filtered = activeList.filter(id => id !== taskId);
|
||||
localStorage.setItem(this.TIMER_LIST_KEY, JSON.stringify(filtered));
|
||||
const filtered = activeList.filter((id) => id !== taskId);
|
||||
(window as any).app.saveLocalStorage(
|
||||
this.TIMER_LIST_KEY,
|
||||
JSON.stringify(filtered),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -581,4 +664,4 @@ export class TaskTimerManager {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,10 +90,12 @@ export class TaskTimerExporter {
|
|||
const backupData = {
|
||||
version: this.EXPORT_VERSION,
|
||||
backupDate: new Date().toISOString(),
|
||||
activeTimers: activeTimers.map(timer => ({
|
||||
activeTimers: activeTimers.map((timer) => ({
|
||||
...timer,
|
||||
currentDuration: this.timerManager.getCurrentDuration(timer.taskId)
|
||||
}))
|
||||
currentDuration: this.timerManager.getCurrentDuration(
|
||||
timer.taskId,
|
||||
),
|
||||
})),
|
||||
};
|
||||
|
||||
return JSON.stringify(backupData, null, 2);
|
||||
|
|
@ -107,7 +109,7 @@ export class TaskTimerExporter {
|
|||
restoreFromBackup(backupData: string): boolean {
|
||||
try {
|
||||
const backup = JSON.parse(backupData);
|
||||
|
||||
|
||||
if (!backup.activeTimers || !Array.isArray(backup.activeTimers)) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -121,26 +123,32 @@ export class TaskTimerExporter {
|
|||
segments.push({
|
||||
startTime: timerData.startTime,
|
||||
endTime: timerData.pausedTime,
|
||||
duration: timerData.pausedTime ?
|
||||
timerData.pausedTime - timerData.startTime - (timerData.totalPausedDuration || 0) :
|
||||
undefined
|
||||
duration: timerData.pausedTime
|
||||
? timerData.pausedTime -
|
||||
timerData.startTime -
|
||||
(timerData.totalPausedDuration || 0)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const restoredTimer: TimerState = {
|
||||
taskId: timerData.taskId,
|
||||
filePath: timerData.filePath,
|
||||
blockId: timerData.blockId,
|
||||
segments: segments,
|
||||
status: timerData.status as 'idle' | 'running' | 'paused',
|
||||
status: timerData.status as "idle" | "running" | "paused",
|
||||
createdAt: timerData.createdAt,
|
||||
// Keep legacy fields for reference
|
||||
legacyStartTime: timerData.startTime,
|
||||
legacyPausedTime: timerData.pausedTime,
|
||||
legacyTotalPausedDuration: timerData.totalPausedDuration || 0
|
||||
legacyTotalPausedDuration:
|
||||
timerData.totalPausedDuration || 0,
|
||||
};
|
||||
|
||||
localStorage.setItem(timerData.taskId, JSON.stringify(restoredTimer));
|
||||
(window as any).app.saveLocalStorage(
|
||||
timerData.taskId,
|
||||
JSON.stringify(restoredTimer),
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
@ -161,7 +169,7 @@ export class TaskTimerExporter {
|
|||
newestTimer: string | null;
|
||||
} {
|
||||
const activeTimers = this.timerManager.getAllActiveTimers();
|
||||
|
||||
|
||||
let totalDuration = 0;
|
||||
let oldestTime = Number.MAX_SAFE_INTEGER;
|
||||
let newestTime = 0;
|
||||
|
|
@ -187,7 +195,7 @@ export class TaskTimerExporter {
|
|||
activeTimers: activeTimers.length,
|
||||
totalDuration,
|
||||
oldestTimer,
|
||||
newestTimer
|
||||
newestTimer,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -202,33 +210,43 @@ export class TaskTimerExporter {
|
|||
|
||||
for (const timer of activeTimers) {
|
||||
// Skip active timers if not requested
|
||||
if (!includeActive && (timer.status === 'running' || timer.status === 'paused')) {
|
||||
if (
|
||||
!includeActive &&
|
||||
(timer.status === "running" || timer.status === "paused")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentDuration = this.timerManager.getCurrentDuration(timer.taskId);
|
||||
|
||||
const currentDuration = this.timerManager.getCurrentDuration(
|
||||
timer.taskId,
|
||||
);
|
||||
|
||||
// Get the first and last segments for export
|
||||
const firstSegment = timer.segments[0];
|
||||
const lastSegment = timer.segments[timer.segments.length - 1];
|
||||
|
||||
|
||||
exportTimers.push({
|
||||
taskId: timer.taskId,
|
||||
filePath: timer.filePath,
|
||||
blockId: timer.blockId,
|
||||
startTime: firstSegment ? firstSegment.startTime : timer.createdAt,
|
||||
endTime: lastSegment && lastSegment.endTime ? lastSegment.endTime : undefined,
|
||||
startTime: firstSegment
|
||||
? firstSegment.startTime
|
||||
: timer.createdAt,
|
||||
endTime:
|
||||
lastSegment && lastSegment.endTime
|
||||
? lastSegment.endTime
|
||||
: undefined,
|
||||
duration: currentDuration,
|
||||
status: timer.status,
|
||||
createdAt: timer.createdAt,
|
||||
totalPausedDuration: timer.legacyTotalPausedDuration || 0
|
||||
totalPausedDuration: timer.legacyTotalPausedDuration || 0,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
version: this.EXPORT_VERSION,
|
||||
exportDate: new Date().toISOString(),
|
||||
timers: exportTimers
|
||||
timers: exportTimers,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -247,17 +265,24 @@ export class TaskTimerExporter {
|
|||
for (const timerData of data.timers) {
|
||||
try {
|
||||
// Only import completed timers to avoid conflicts
|
||||
if (timerData.status === 'idle' || timerData.endTime) {
|
||||
if (timerData.status === "idle" || timerData.endTime) {
|
||||
// Store as historical data (could be extended for analytics)
|
||||
const historyKey = `taskTimer_history_${timerData.blockId}_${timerData.startTime}`;
|
||||
localStorage.setItem(historyKey, JSON.stringify({
|
||||
...timerData,
|
||||
importedAt: Date.now()
|
||||
}));
|
||||
(window as any).app.saveLocalStorage(
|
||||
historyKey,
|
||||
JSON.stringify({
|
||||
...timerData,
|
||||
importedAt: Date.now(),
|
||||
}),
|
||||
);
|
||||
importedCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to import timer:", timerData.taskId, error);
|
||||
console.warn(
|
||||
"Failed to import timer:",
|
||||
timerData.taskId,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -271,7 +296,7 @@ export class TaskTimerExporter {
|
|||
* @returns true if data is valid
|
||||
*/
|
||||
private validateImportData(data: any): data is TimerExportData {
|
||||
if (!data || typeof data !== 'object') {
|
||||
if (!data || typeof data !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -281,9 +306,13 @@ export class TaskTimerExporter {
|
|||
|
||||
// Validate each timer entry
|
||||
for (const timer of data.timers) {
|
||||
if (!timer.taskId || !timer.filePath || !timer.blockId ||
|
||||
typeof timer.startTime !== 'number' ||
|
||||
typeof timer.duration !== 'number') {
|
||||
if (
|
||||
!timer.taskId ||
|
||||
!timer.filePath ||
|
||||
!timer.blockId ||
|
||||
typeof timer.startTime !== "number" ||
|
||||
typeof timer.duration !== "number"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -297,19 +326,19 @@ export class TaskTimerExporter {
|
|||
* @returns YAML string
|
||||
*/
|
||||
private convertToYAML(obj: any, indent: number = 0): string {
|
||||
const spaces = ' '.repeat(indent);
|
||||
let yaml = '';
|
||||
const spaces = " ".repeat(indent);
|
||||
let yaml = "";
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) {
|
||||
yaml += `${spaces}- ${this.convertToYAML(item, indent + 1).trim()}\n`;
|
||||
}
|
||||
} else if (obj !== null && typeof obj === 'object') {
|
||||
} else if (obj !== null && typeof obj === "object") {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (Array.isArray(value)) {
|
||||
yaml += `${spaces}${key}:\n`;
|
||||
yaml += this.convertToYAML(value, indent + 1);
|
||||
} else if (value !== null && typeof value === 'object') {
|
||||
} else if (value !== null && typeof value === "object") {
|
||||
yaml += `${spaces}${key}:\n`;
|
||||
yaml += this.convertToYAML(value, indent + 1);
|
||||
} else {
|
||||
|
|
@ -331,34 +360,34 @@ export class TaskTimerExporter {
|
|||
private parseYAML(yamlString: string): any {
|
||||
// This is a very basic YAML parser for our specific use case
|
||||
// For production use, consider using a proper YAML library
|
||||
const lines = yamlString.split('\n');
|
||||
const lines = yamlString.split("\n");
|
||||
const result: any = {};
|
||||
let currentObject = result;
|
||||
const objectStack: any[] = [result];
|
||||
let currentKey = '';
|
||||
let currentKey = "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine || trimmedLine.startsWith('#')) continue;
|
||||
if (!trimmedLine || trimmedLine.startsWith("#")) continue;
|
||||
|
||||
const indent = line.length - line.trimLeft().length;
|
||||
const colonIndex = trimmedLine.indexOf(':');
|
||||
const colonIndex = trimmedLine.indexOf(":");
|
||||
|
||||
if (colonIndex > 0) {
|
||||
const key = trimmedLine.substring(0, colonIndex).trim();
|
||||
const value = trimmedLine.substring(colonIndex + 1).trim();
|
||||
|
||||
if (value === '') {
|
||||
if (value === "") {
|
||||
// This is a parent key
|
||||
currentObject[key] = {};
|
||||
currentKey = key;
|
||||
} else if (value === '[]') {
|
||||
} else if (value === "[]") {
|
||||
currentObject[key] = [];
|
||||
} else {
|
||||
// This is a key-value pair
|
||||
currentObject[key] = this.parseYAMLValue(value);
|
||||
}
|
||||
} else if (trimmedLine.startsWith('- ')) {
|
||||
} else if (trimmedLine.startsWith("- ")) {
|
||||
// This is an array item
|
||||
if (!Array.isArray(currentObject[currentKey])) {
|
||||
currentObject[currentKey] = [];
|
||||
|
|
@ -378,11 +407,11 @@ export class TaskTimerExporter {
|
|||
*/
|
||||
private parseYAMLValue(value: string): any {
|
||||
value = value.trim();
|
||||
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
if (value === 'null') return null;
|
||||
|
||||
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
if (value === "null") return null;
|
||||
|
||||
// Try to parse as number
|
||||
const numValue = Number(value);
|
||||
if (!isNaN(numValue) && isFinite(numValue)) {
|
||||
|
|
@ -390,8 +419,10 @@ export class TaskTimerExporter {
|
|||
}
|
||||
|
||||
// Remove quotes if present
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
|
||||
|
|
@ -404,12 +435,16 @@ export class TaskTimerExporter {
|
|||
* @returns Escaped string
|
||||
*/
|
||||
private yamlEscape(value: any): string {
|
||||
if (typeof value === 'string') {
|
||||
if (typeof value === "string") {
|
||||
// Quote strings that contain special characters
|
||||
if (value.includes(':') || value.includes('\n') || value.includes('#')) {
|
||||
if (
|
||||
value.includes(":") ||
|
||||
value.includes("\n") ||
|
||||
value.includes("#")
|
||||
) {
|
||||
return `"${value.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue