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:
Quorafind 2025-10-21 16:29:23 +08:00
parent cc131f071f
commit 01bbd43171
12 changed files with 819 additions and 492 deletions

View file

@ -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;
});
});
});

View file

@ -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",

View file

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

View file

@ -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,

View file

@ -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();
});
});

View file

@ -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
}
}
}

View file

@ -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;

View file

@ -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),
);
});
});

View file

@ -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();
}
}
}
}

View file

@ -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) {

View file

@ -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 {
}
}
}
}
}

View file

@ -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);
}
}
}