mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
This commit introduces the UTC Anchor principle to eliminate timezone-related bugs and provide consistent date handling across all user timezones. ## Key Changes: ### Core Implementation - Add `parseDateToUTC()` function that creates UTC anchors for date-only strings - Add `parseDateToLocal` alias for existing `parseDate` function - Deprecate direct `parseDate` usage with clear migration path - Update date comparison functions to use UTC anchors ### Updated Components - **dateUtils.ts**: New UTC parsing functions and updated comparisons - **main.ts**: Use UTC anchor for selectedDate initialization - **MiniCalendarView**: Navigate using UTC anchors - **TimeblockCreationModal**: Fix timezone handling - **Multiple test files**: Fix timezone assumptions in tests ### Refactored parseDate Usage - **UnscheduledTasksSelectorModal**: Use parseDateToLocal for UI display - **AdvancedCalendarView**: Use parseDateToLocal for calendar events - **helpers.ts**: Use parseDateToUTC for internal date logic - **FilterService**: Already using updated utilities ### Documentation - Add comprehensive UTC Anchor implementation docs - Update development guidelines with date handling best practices - Add migration guide for existing code - Include quick reference for date functions ## Benefits: - Eliminates "off-by-one day" errors across timezones - Provides consistent sorting and filtering behavior - Simplifies date comparison logic - Future-proofs against timezone-related bugs ## Testing: - All 798 tests passing - New UTC anchor test suite (11 tests) - No regression in existing functionality Fixes #327, #322, #314 and other timezone-related issues
1709 lines
52 KiB
TypeScript
1709 lines
52 KiB
TypeScript
import { Notice, Plugin, WorkspaceLeaf, Editor, MarkdownView, TFile } from 'obsidian';
|
|
import { EditorView } from '@codemirror/view';
|
|
import { format } from 'date-fns';
|
|
import {
|
|
createDailyNote,
|
|
getDailyNote,
|
|
getAllDailyNotes,
|
|
appHasDailyNotesPluginLoaded
|
|
} from 'obsidian-daily-notes-interface';
|
|
import {
|
|
TaskNotesSettings,
|
|
DEFAULT_SETTINGS,
|
|
TaskNotesSettingTab
|
|
} from './settings/settings';
|
|
import {
|
|
MINI_CALENDAR_VIEW_TYPE,
|
|
ADVANCED_CALENDAR_VIEW_TYPE,
|
|
NOTES_VIEW_TYPE,
|
|
TASK_LIST_VIEW_TYPE,
|
|
AGENDA_VIEW_TYPE,
|
|
POMODORO_VIEW_TYPE,
|
|
POMODORO_STATS_VIEW_TYPE,
|
|
KANBAN_VIEW_TYPE,
|
|
TaskInfo,
|
|
EVENT_DATE_SELECTED,
|
|
EVENT_DATA_CHANGED,
|
|
EVENT_TASK_UPDATED
|
|
} from './types';
|
|
import { MiniCalendarView } from './views/MiniCalendarView';
|
|
import { AdvancedCalendarView } from './views/AdvancedCalendarView';
|
|
import { TaskListView } from './views/TaskListView';
|
|
import { NotesView } from './views/NotesView';
|
|
import { AgendaView } from './views/AgendaView';
|
|
import { PomodoroView } from './views/PomodoroView';
|
|
import { PomodoroStatsView } from './views/PomodoroStatsView';
|
|
import { KanbanView } from './views/KanbanView';
|
|
import { TaskCreationModal } from './modals/TaskCreationModal';
|
|
import { TaskEditModal } from './modals/TaskEditModal';
|
|
import { TaskSelectorModal } from './modals/TaskSelectorModal';
|
|
import { PomodoroService } from './services/PomodoroService';
|
|
import {
|
|
formatTime,
|
|
getActiveTimeEntry
|
|
} from './utils/helpers';
|
|
import { MinimalNativeCache } from './utils/MinimalNativeCache';
|
|
import { RequestDeduplicator, PredictivePrefetcher } from './utils/RequestDeduplicator';
|
|
import { DOMReconciler, UIStateManager } from './utils/DOMReconciler';
|
|
import { perfMonitor } from './utils/PerformanceMonitor';
|
|
import { FieldMapper } from './services/FieldMapper';
|
|
import { StatusManager } from './services/StatusManager';
|
|
import { PriorityManager } from './services/PriorityManager';
|
|
import { TaskService } from './services/TaskService';
|
|
import { FilterService } from './services/FilterService';
|
|
import { ViewStateManager } from './services/ViewStateManager';
|
|
import { createTaskLinkOverlay, dispatchTaskUpdate } from './editor/TaskLinkOverlay';
|
|
import { createReadingModeTaskLinkProcessor } from './editor/ReadingModeTaskLinkProcessor';
|
|
import { createProjectNoteDecorations, dispatchProjectSubtasksUpdate } from './editor/ProjectNoteDecorations';
|
|
import { DragDropManager } from './utils/DragDropManager';
|
|
import { formatDateForStorage, getTodayLocal, createUTCDateFromLocalCalendarDate } from './utils/dateUtils';
|
|
import { ICSSubscriptionService } from './services/ICSSubscriptionService';
|
|
import { ICSNoteService } from './services/ICSNoteService';
|
|
import { MigrationService } from './services/MigrationService';
|
|
import { showMigrationPrompt } from './modals/MigrationModal';
|
|
import { StatusBarService } from './services/StatusBarService';
|
|
import { ProjectSubtasksService } from './services/ProjectSubtasksService';
|
|
|
|
// Type definitions for better type safety
|
|
interface TaskUpdateEventData {
|
|
path?: string;
|
|
originalTask?: TaskInfo;
|
|
updatedTask?: TaskInfo;
|
|
}
|
|
|
|
export default class TaskNotesPlugin extends Plugin {
|
|
settings: TaskNotesSettings;
|
|
|
|
// Track cache-related settings to avoid unnecessary re-indexing
|
|
private previousCacheSettings: {
|
|
taskTag: string;
|
|
excludedFolders: string;
|
|
disableNoteIndexing: boolean;
|
|
storeTitleInFilename: boolean;
|
|
fieldMapping: any;
|
|
} | null = null;
|
|
|
|
// Track time tracking settings to avoid unnecessary listener updates
|
|
private previousTimeTrackingSettings: {
|
|
autoStopTimeTrackingOnComplete: boolean;
|
|
} | null = null;
|
|
|
|
// Ready promise to signal when initialization is complete
|
|
private readyPromise: Promise<void>;
|
|
private resolveReady: () => void;
|
|
|
|
// Shared state between views
|
|
// Initialize with UTC anchor for today's calendar date
|
|
selectedDate: Date = createUTCDateFromLocalCalendarDate(getTodayLocal());
|
|
|
|
// Minimal native cache manager (also handles events)
|
|
cacheManager: MinimalNativeCache;
|
|
emitter: MinimalNativeCache;
|
|
|
|
// Performance optimization utilities
|
|
requestDeduplicator: RequestDeduplicator;
|
|
predictivePrefetcher: PredictivePrefetcher;
|
|
domReconciler: DOMReconciler;
|
|
uiStateManager: UIStateManager;
|
|
|
|
// Pomodoro service
|
|
pomodoroService: PomodoroService;
|
|
|
|
// Customization services
|
|
fieldMapper: FieldMapper;
|
|
statusManager: StatusManager;
|
|
priorityManager: PriorityManager;
|
|
|
|
// Business logic services
|
|
taskService: TaskService;
|
|
filterService: FilterService;
|
|
viewStateManager: ViewStateManager;
|
|
projectSubtasksService: ProjectSubtasksService;
|
|
|
|
// Editor services
|
|
taskLinkDetectionService?: import('./services/TaskLinkDetectionService').TaskLinkDetectionService;
|
|
instantTaskConvertService?: import('./services/InstantTaskConvertService').InstantTaskConvertService;
|
|
|
|
// Drag and drop manager
|
|
dragDropManager: DragDropManager;
|
|
|
|
// ICS subscription service
|
|
icsSubscriptionService: ICSSubscriptionService;
|
|
|
|
// ICS note service for creating notes/tasks from ICS events
|
|
icsNoteService: ICSNoteService;
|
|
|
|
// Migration service
|
|
migrationService: MigrationService;
|
|
|
|
// Status bar service
|
|
statusBarService: StatusBarService;
|
|
|
|
// Event listener cleanup
|
|
private taskUpdateListenerForEditor: import('obsidian').EventRef | null = null;
|
|
|
|
// Initialization guard to prevent duplicate initialization
|
|
private initializationComplete = false;
|
|
|
|
// Migration state management
|
|
private migrationComplete = false;
|
|
private migrationPromise: Promise<void> | null = null;
|
|
|
|
async onload() {
|
|
// Create the promise and store its resolver
|
|
this.readyPromise = new Promise(resolve => {
|
|
this.resolveReady = resolve;
|
|
});
|
|
|
|
await this.loadSettings();
|
|
|
|
// Initialize only essential services that are needed for app registration
|
|
this.fieldMapper = new FieldMapper(this.settings.fieldMapping);
|
|
this.statusManager = new StatusManager(this.settings.customStatuses);
|
|
this.priorityManager = new PriorityManager(this.settings.customPriorities);
|
|
|
|
// Initialize performance optimization utilities (lightweight)
|
|
this.requestDeduplicator = new RequestDeduplicator();
|
|
this.predictivePrefetcher = new PredictivePrefetcher(this.requestDeduplicator);
|
|
this.domReconciler = new DOMReconciler();
|
|
this.uiStateManager = new UIStateManager();
|
|
|
|
// Initialize minimal native cache manager
|
|
this.cacheManager = new MinimalNativeCache(
|
|
this.app,
|
|
this.settings.taskTag,
|
|
this.settings.excludedFolders,
|
|
this.fieldMapper,
|
|
this.settings.disableNoteIndexing,
|
|
this.settings.storeTitleInFilename
|
|
);
|
|
|
|
// Use same instance for event emitting
|
|
this.emitter = this.cacheManager;
|
|
|
|
// Initialize business logic services (lightweight constructors)
|
|
this.taskService = new TaskService(this);
|
|
this.filterService = new FilterService(
|
|
this.cacheManager,
|
|
this.statusManager,
|
|
this.priorityManager,
|
|
this
|
|
);
|
|
this.viewStateManager = new ViewStateManager(this.app, this);
|
|
this.projectSubtasksService = new ProjectSubtasksService(this);
|
|
this.dragDropManager = new DragDropManager(this);
|
|
this.migrationService = new MigrationService(this.app);
|
|
this.statusBarService = new StatusBarService(this);
|
|
|
|
// Note: View registration and heavy operations moved to onLayoutReady
|
|
|
|
// Add ribbon icons
|
|
this.addRibbonIcon('calendar-days', 'Open mini calendar', async () => {
|
|
await this.activateCalendarView();
|
|
});
|
|
|
|
this.addRibbonIcon('calendar', 'Open advanced calendar', async () => {
|
|
await this.activateAdvancedCalendarView();
|
|
});
|
|
|
|
this.addRibbonIcon('check-square', 'Open task list', async () => {
|
|
await this.activateTasksView();
|
|
});
|
|
|
|
this.addRibbonIcon('sticky-note', 'Open notes', async () => {
|
|
await this.activateNotesView();
|
|
});
|
|
|
|
this.addRibbonIcon('list', 'Open agenda', async () => {
|
|
await this.activateAgendaView();
|
|
});
|
|
|
|
this.addRibbonIcon('columns-3', 'Open kanban board', async () => {
|
|
await this.activateKanbanView();
|
|
});
|
|
|
|
this.addRibbonIcon('timer', 'Open pomodoro', async () => {
|
|
await this.activatePomodoroView();
|
|
});
|
|
|
|
this.addRibbonIcon('bar-chart-3', 'Open pomodoro stats', async () => {
|
|
await this.activatePomodoroStatsView();
|
|
});
|
|
|
|
this.addRibbonIcon('plus', 'Create new task', () => {
|
|
this.openTaskCreationModal();
|
|
});
|
|
|
|
// Add commands
|
|
this.addCommands();
|
|
|
|
// Add settings tab
|
|
this.addSettingTab(new TaskNotesSettingTab(this.app, this));
|
|
|
|
|
|
// Start migration check early (before views can be opened)
|
|
this.migrationPromise = this.performEarlyMigrationCheck();
|
|
|
|
// Defer expensive initialization until layout is ready
|
|
this.app.workspace.onLayoutReady(() => {
|
|
this.initializeAfterLayoutReady();
|
|
});
|
|
|
|
// At the very end of onload, resolve the promise to signal readiness
|
|
this.resolveReady();
|
|
}
|
|
|
|
/**
|
|
* Initialize expensive operations after layout is ready
|
|
*/
|
|
private async initializeAfterLayoutReady(): Promise<void> {
|
|
// Guard against multiple initialization calls
|
|
if (this.initializationComplete) {
|
|
return;
|
|
}
|
|
this.initializationComplete = true;
|
|
|
|
try {
|
|
// Inject dynamic styles for custom statuses and priorities
|
|
this.injectCustomStyles();
|
|
|
|
// Register view types (now safe after layout ready)
|
|
this.registerView(
|
|
MINI_CALENDAR_VIEW_TYPE,
|
|
(leaf) => new MiniCalendarView(leaf, this)
|
|
);
|
|
this.registerView(
|
|
ADVANCED_CALENDAR_VIEW_TYPE,
|
|
(leaf) => new AdvancedCalendarView(leaf, this)
|
|
);
|
|
this.registerView(
|
|
TASK_LIST_VIEW_TYPE,
|
|
(leaf) => new TaskListView(leaf, this)
|
|
);
|
|
this.registerView(
|
|
NOTES_VIEW_TYPE,
|
|
(leaf) => new NotesView(leaf, this)
|
|
);
|
|
this.registerView(
|
|
AGENDA_VIEW_TYPE,
|
|
(leaf) => new AgendaView(leaf, this)
|
|
);
|
|
this.registerView(
|
|
POMODORO_VIEW_TYPE,
|
|
(leaf) => new PomodoroView(leaf, this)
|
|
);
|
|
this.registerView(
|
|
POMODORO_STATS_VIEW_TYPE,
|
|
(leaf) => new PomodoroStatsView(leaf, this)
|
|
);
|
|
this.registerView(
|
|
KANBAN_VIEW_TYPE,
|
|
(leaf) => new KanbanView(leaf, this)
|
|
);
|
|
|
|
// Register essential editor extensions (now safe after layout ready)
|
|
this.registerEditorExtension(createTaskLinkOverlay(this));
|
|
|
|
// Register project note decorations for live preview
|
|
this.registerEditorExtension(createProjectNoteDecorations(this));
|
|
|
|
// Register reading mode task link processor
|
|
this.registerMarkdownPostProcessor(createReadingModeTaskLinkProcessor(this));
|
|
|
|
// Initialize native cache system (lightweight - no index building)
|
|
this.cacheManager.initialize();
|
|
|
|
// Initialize FilterService and set up event listeners (lightweight)
|
|
this.filterService.initialize();
|
|
|
|
// Initialize status bar service
|
|
this.statusBarService.initialize();
|
|
|
|
// Defer heavy service initialization until needed
|
|
this.initializeServicesLazily();
|
|
|
|
} catch (error) {
|
|
console.error('Error during post-layout initialization:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize heavy services lazily in the background
|
|
*/
|
|
private initializeServicesLazily(): void {
|
|
// Use setTimeout to defer initialization to next tick
|
|
setTimeout(async () => {
|
|
try {
|
|
// Initialize Pomodoro service
|
|
this.pomodoroService = new PomodoroService(this);
|
|
await this.pomodoroService.initialize();
|
|
|
|
// Initialize ICS subscription service
|
|
this.icsSubscriptionService = new ICSSubscriptionService(this);
|
|
await this.icsSubscriptionService.initialize();
|
|
|
|
// Initialize ICS note service
|
|
this.icsNoteService = new ICSNoteService(this);
|
|
|
|
// Initialize editor services (async imports)
|
|
const { TaskLinkDetectionService } = await import('./services/TaskLinkDetectionService');
|
|
this.taskLinkDetectionService = new TaskLinkDetectionService(this);
|
|
|
|
const { InstantTaskConvertService } = await import('./services/InstantTaskConvertService');
|
|
this.instantTaskConvertService = new InstantTaskConvertService(this, this.statusManager, this.priorityManager);
|
|
|
|
// Register additional editor extensions
|
|
const { createInstantConvertButtons } = await import('./editor/InstantConvertButtons');
|
|
this.registerEditorExtension(createInstantConvertButtons(this));
|
|
|
|
// Set up global event listener for task updates to refresh editor decorations
|
|
this.taskUpdateListenerForEditor = this.emitter.on(EVENT_TASK_UPDATED, (data: { path?: string; updatedTask?: TaskInfo }) => {
|
|
|
|
// Trigger decoration refresh in all active markdown views using proper state effects
|
|
this.app.workspace.iterateRootLeaves((leaf) => {
|
|
// Use instanceof check for deferred view compatibility
|
|
if (leaf.view && leaf.view.getViewType() === 'markdown') {
|
|
const editor = (leaf.view as MarkdownView).editor;
|
|
if (editor && (editor as Editor & { cm?: EditorView }).cm) {
|
|
// Use the proper CodeMirror state effect pattern
|
|
// Pass the updated task path to ensure specific widget refreshing
|
|
const taskPath = data?.path || data?.updatedTask?.path;
|
|
dispatchTaskUpdate((editor as Editor & { cm: EditorView }).cm, taskPath);
|
|
|
|
// Also update project subtasks widgets
|
|
dispatchProjectSubtasksUpdate((editor as Editor & { cm: EditorView }).cm);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Set up workspace event listener for active leaf changes to refresh task overlays
|
|
this.registerEvent(this.app.workspace.on('active-leaf-change', (leaf) => {
|
|
// Small delay to ensure editor is fully initialized
|
|
setTimeout(() => {
|
|
if (leaf && leaf.view && leaf.view.getViewType() === 'markdown') {
|
|
const editor = (leaf.view as MarkdownView).editor;
|
|
if (editor && (editor as Editor & { cm?: EditorView }).cm) {
|
|
// Dispatch task update to refresh overlays when returning to a note
|
|
dispatchTaskUpdate((editor as Editor & { cm: EditorView }).cm);
|
|
|
|
// Also update project subtasks widgets
|
|
dispatchProjectSubtasksUpdate((editor as Editor & { cm: EditorView }).cm);
|
|
}
|
|
}
|
|
}, 50);
|
|
}));
|
|
|
|
// Set up workspace event listener for layout changes to detect mode switches
|
|
this.registerEvent(this.app.workspace.on('layout-change', () => {
|
|
// Small delay to ensure mode switch is complete
|
|
setTimeout(() => {
|
|
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
|
if (activeView) {
|
|
const editor = activeView.editor;
|
|
if (editor && (editor as Editor & { cm?: EditorView }).cm) {
|
|
// Refresh overlays when switching to Live Preview mode
|
|
dispatchTaskUpdate((editor as Editor & { cm: EditorView }).cm);
|
|
|
|
// Also update project subtasks widgets
|
|
dispatchProjectSubtasksUpdate((editor as Editor & { cm: EditorView }).cm);
|
|
}
|
|
}
|
|
}, 100);
|
|
}));
|
|
|
|
// Set up status bar event listeners for real-time updates
|
|
this.setupStatusBarEventListeners();
|
|
|
|
// Set up time tracking event listeners
|
|
this.setupTimeTrackingEventListeners();
|
|
|
|
// Migration check was moved to early startup - just show prompts here if needed
|
|
await this.showMigrationPromptsIfNeeded();
|
|
|
|
} catch (error) {
|
|
console.error('Error during lazy service initialization:', error);
|
|
}
|
|
}, 10); // Small delay to ensure startup completes first
|
|
}
|
|
|
|
/**
|
|
* Public method for views to wait for readiness
|
|
*/
|
|
async onReady(): Promise<void> {
|
|
// If readyPromise doesn't exist, plugin hasn't started onload yet
|
|
if (!this.readyPromise) {
|
|
throw new Error('Plugin not yet initialized');
|
|
}
|
|
|
|
await this.readyPromise;
|
|
}
|
|
|
|
/**
|
|
* Set up event listeners for status bar updates
|
|
*/
|
|
private setupStatusBarEventListeners(): void {
|
|
if (!this.statusBarService) {
|
|
return;
|
|
}
|
|
|
|
// Listen for task updates that might affect time tracking
|
|
this.registerEvent(this.emitter.on(EVENT_TASK_UPDATED, () => {
|
|
// Small delay to ensure task state changes are fully propagated
|
|
setTimeout(() => {
|
|
this.statusBarService.requestUpdate();
|
|
}, 100);
|
|
}));
|
|
|
|
|
|
// Listen for general data changes
|
|
this.registerEvent(this.emitter.on(EVENT_DATA_CHANGED, () => {
|
|
// Small delay to ensure data changes are fully propagated
|
|
setTimeout(() => {
|
|
this.statusBarService.requestUpdate();
|
|
}, 100);
|
|
}));
|
|
|
|
// Listen for Pomodoro events if Pomodoro service is available
|
|
if (this.pomodoroService) {
|
|
// Listen for Pomodoro start events
|
|
this.registerEvent(this.emitter.on('pomodoro-start', () => {
|
|
setTimeout(() => {
|
|
this.statusBarService.requestUpdate();
|
|
}, 100);
|
|
}));
|
|
|
|
// Listen for Pomodoro stop events
|
|
this.registerEvent(this.emitter.on('pomodoro-stop', () => {
|
|
setTimeout(() => {
|
|
this.statusBarService.requestUpdate();
|
|
}, 100);
|
|
}));
|
|
|
|
// Listen for Pomodoro state changes
|
|
this.registerEvent(this.emitter.on('pomodoro-state-changed', () => {
|
|
setTimeout(() => {
|
|
this.statusBarService.requestUpdate();
|
|
}, 100);
|
|
}));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set up time tracking event listeners based on settings
|
|
*/
|
|
private setupTimeTrackingEventListeners(): void {
|
|
// Only set up listener if auto-stop is enabled
|
|
if (this.settings.autoStopTimeTrackingOnComplete) {
|
|
const eventRef = this.emitter.on(EVENT_TASK_UPDATED, async (data: TaskUpdateEventData) => {
|
|
await this.handleAutoStopTimeTracking(data);
|
|
});
|
|
this.registerEvent(eventRef);
|
|
}
|
|
|
|
// Update tracking of time tracking settings
|
|
this.updatePreviousTimeTrackingSettings();
|
|
}
|
|
|
|
|
|
/**
|
|
* Handle auto-stop time tracking logic
|
|
*/
|
|
private async handleAutoStopTimeTracking(data: TaskUpdateEventData): Promise<void> {
|
|
const { originalTask, updatedTask } = data;
|
|
if (!originalTask || !updatedTask) {
|
|
return;
|
|
}
|
|
|
|
// Check if status changed from non-completed to completed
|
|
const wasCompleted = this.statusManager.isCompletedStatus(originalTask.status);
|
|
const isNowCompleted = this.statusManager.isCompletedStatus(updatedTask.status);
|
|
|
|
if (!wasCompleted && isNowCompleted) {
|
|
// Task was just marked as completed - check if it has active time tracking
|
|
const activeSession = this.getActiveTimeSession(updatedTask);
|
|
if (activeSession) {
|
|
try {
|
|
await this.stopTimeTracking(updatedTask);
|
|
|
|
// Show notification if enabled
|
|
if (this.settings.autoStopTimeTrackingNotification) {
|
|
new Notice(`Auto-stopped time tracking for: ${updatedTask.title}`);
|
|
}
|
|
|
|
console.log(`Auto-stopped time tracking for completed task: ${updatedTask.title}`);
|
|
} catch (error) {
|
|
console.error('Error auto-stopping time tracking:', error);
|
|
// Don't show error notice to user as this is an automatic action
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if time tracking settings have changed since last save
|
|
*/
|
|
private haveTimeTrackingSettingsChanged(): boolean {
|
|
if (!this.previousTimeTrackingSettings) {
|
|
return true; // First time, assume changed
|
|
}
|
|
|
|
return this.settings.autoStopTimeTrackingOnComplete !== this.previousTimeTrackingSettings.autoStopTimeTrackingOnComplete;
|
|
}
|
|
|
|
/**
|
|
* Update tracking of time tracking settings
|
|
*/
|
|
private updatePreviousTimeTrackingSettings(): void {
|
|
this.previousTimeTrackingSettings = {
|
|
autoStopTimeTrackingOnComplete: this.settings.autoStopTimeTrackingOnComplete
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Perform early migration check and state preparation
|
|
* This runs before any views can be opened to prevent race conditions
|
|
*/
|
|
private async performEarlyMigrationCheck(): Promise<void> {
|
|
try {
|
|
console.log('TaskNotes: Starting early migration check...');
|
|
|
|
// Initialize saved views (handles migration if needed)
|
|
await this.viewStateManager.initializeSavedViews();
|
|
|
|
// Perform view state migration if needed (this is silent and fast)
|
|
if (this.viewStateManager.needsMigration()) {
|
|
console.log('TaskNotes: Performing view state migration...');
|
|
await this.viewStateManager.performMigration();
|
|
}
|
|
|
|
// Check if recurrence migration has already been completed
|
|
if (this.settings.recurrenceMigrated === true) {
|
|
this.migrationComplete = true;
|
|
return;
|
|
}
|
|
|
|
// Check if recurrence migration is needed
|
|
const needsRecurrenceMigration = await this.migrationService.needsMigration();
|
|
if (!needsRecurrenceMigration) {
|
|
// No migration needed - mark as migrated to prevent future checks
|
|
this.settings.recurrenceMigrated = true;
|
|
await this.saveSettings();
|
|
this.migrationComplete = true;
|
|
return;
|
|
}
|
|
|
|
// Recurrence migration is needed but will be prompted later
|
|
// For now, just mark that migration check is complete
|
|
this.migrationComplete = true;
|
|
console.log('TaskNotes: Early migration check complete. Will show prompts after UI initialization.');
|
|
|
|
} catch (error) {
|
|
console.error('Error during early migration check:', error);
|
|
// Don't fail the entire plugin load due to migration check issues
|
|
this.migrationComplete = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show migration prompts after UI is ready (only if needed)
|
|
*/
|
|
private async showMigrationPromptsIfNeeded(): Promise<void> {
|
|
try {
|
|
// Check if recurrence migration has already been completed or dismissed this session
|
|
if (this.settings.recurrenceMigrated === true) {
|
|
return;
|
|
}
|
|
|
|
// Check if migration is needed
|
|
const needsMigration = await this.migrationService.needsMigration();
|
|
if (needsMigration) {
|
|
// Show migration prompt after a small delay to ensure UI is ready
|
|
setTimeout(() => {
|
|
showMigrationPrompt(this.app, this.migrationService);
|
|
}, 1000);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error showing migration prompts:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Public method for views to wait for migration completion
|
|
*/
|
|
async waitForMigration(): Promise<void> {
|
|
if (this.migrationPromise) {
|
|
await this.migrationPromise;
|
|
}
|
|
|
|
// Additional safety check - wait until migration is marked complete
|
|
while (!this.migrationComplete) {
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
}
|
|
}
|
|
|
|
// Methods for updating shared state and emitting events
|
|
|
|
/**
|
|
* Update the selected date and notify all views
|
|
*/
|
|
setSelectedDate(date: Date): void {
|
|
this.selectedDate = date;
|
|
this.emitter.trigger(EVENT_DATE_SELECTED, date);
|
|
}
|
|
|
|
/**
|
|
* Notify views that data has changed and views should refresh
|
|
* @param filePath Optional path of the file that changed (for targeted cache invalidation)
|
|
* @param force Whether to force a full cache rebuild
|
|
* @param triggerRefresh Whether to trigger a full UI refresh (default true)
|
|
*/
|
|
notifyDataChanged(filePath?: string, force = false, triggerRefresh = true): void {
|
|
// Clear cache entries for native cache manager
|
|
if (filePath) {
|
|
this.cacheManager.clearCacheEntry(filePath);
|
|
|
|
// Clear task link detection cache for this file
|
|
if (this.taskLinkDetectionService) {
|
|
this.taskLinkDetectionService.clearCacheForFile(filePath);
|
|
}
|
|
} else if (force) {
|
|
// Full cache clear if forcing
|
|
this.cacheManager.clearAllCaches();
|
|
|
|
// Clear task link detection cache completely
|
|
if (this.taskLinkDetectionService) {
|
|
this.taskLinkDetectionService.clearCache();
|
|
}
|
|
}
|
|
|
|
// Only emit refresh event if triggerRefresh is true
|
|
if (triggerRefresh) {
|
|
// Use requestAnimationFrame for better UI timing instead of setTimeout
|
|
requestAnimationFrame(() => {
|
|
this.emitter.trigger(EVENT_DATA_CHANGED);
|
|
});
|
|
}
|
|
}
|
|
|
|
onunload() {
|
|
// Clean up performance monitoring
|
|
const cacheStats = perfMonitor.getStats('cache-initialization');
|
|
if (cacheStats && cacheStats.count > 0) {
|
|
perfMonitor.logSummary();
|
|
}
|
|
|
|
|
|
// Clean up Pomodoro service
|
|
if (this.pomodoroService) {
|
|
this.pomodoroService.cleanup();
|
|
}
|
|
|
|
// Clean up FilterService
|
|
if (this.filterService) {
|
|
this.filterService.cleanup();
|
|
}
|
|
|
|
// Clean up ICS subscription service
|
|
if (this.icsSubscriptionService) {
|
|
this.icsSubscriptionService.destroy();
|
|
}
|
|
|
|
// Clean up TaskLinkDetectionService
|
|
if (this.taskLinkDetectionService) {
|
|
this.taskLinkDetectionService.cleanup();
|
|
}
|
|
|
|
// Clean up drag and drop manager
|
|
if (this.dragDropManager) {
|
|
this.dragDropManager.destroy();
|
|
}
|
|
|
|
// Clean up ViewStateManager
|
|
if (this.viewStateManager) {
|
|
this.viewStateManager.cleanup();
|
|
}
|
|
|
|
// Clean up status bar service
|
|
if (this.statusBarService) {
|
|
this.statusBarService.destroy();
|
|
}
|
|
|
|
// Clean up native cache manager
|
|
if (this.cacheManager) {
|
|
this.cacheManager.destroy();
|
|
}
|
|
|
|
// Clean up request deduplicator
|
|
if (this.requestDeduplicator) {
|
|
this.requestDeduplicator.cancelAll();
|
|
}
|
|
|
|
// Clean up DOM reconciler
|
|
if (this.domReconciler) {
|
|
this.domReconciler.destroy();
|
|
}
|
|
|
|
// Clean up UI state manager
|
|
if (this.uiStateManager) {
|
|
this.uiStateManager.destroy();
|
|
}
|
|
|
|
// Clean up performance monitor
|
|
if (typeof perfMonitor !== 'undefined') {
|
|
perfMonitor.destroy();
|
|
}
|
|
|
|
// Clean up task update listener for editor
|
|
if (this.taskUpdateListenerForEditor) {
|
|
this.emitter.offref(this.taskUpdateListenerForEditor);
|
|
}
|
|
|
|
// Clean up the event emitter (native Events class)
|
|
if (this.emitter && typeof this.emitter.off === 'function') {
|
|
// Native Events cleanup happens automatically
|
|
}
|
|
|
|
// Reset initialization flag for potential reload
|
|
this.initializationComplete = false;
|
|
}
|
|
|
|
async loadSettings() {
|
|
const loadedData = await this.loadData();
|
|
|
|
// Migration: Remove old useNativeMetadataCache setting if it exists
|
|
if (loadedData && 'useNativeMetadataCache' in loadedData) {
|
|
delete loadedData.useNativeMetadataCache;
|
|
}
|
|
|
|
// Deep merge settings with proper migration for nested objects
|
|
this.settings = {
|
|
...DEFAULT_SETTINGS,
|
|
...loadedData,
|
|
// Deep merge field mapping to ensure new fields get default values
|
|
fieldMapping: {
|
|
...DEFAULT_SETTINGS.fieldMapping,
|
|
...(loadedData?.fieldMapping || {})
|
|
},
|
|
// Deep merge custom statuses array
|
|
customStatuses: loadedData?.customStatuses || DEFAULT_SETTINGS.customStatuses,
|
|
// Deep merge custom priorities array
|
|
customPriorities: loadedData?.customPriorities || DEFAULT_SETTINGS.customPriorities,
|
|
// Deep merge calendar view settings to ensure new fields get default values
|
|
calendarViewSettings: {
|
|
...DEFAULT_SETTINGS.calendarViewSettings,
|
|
...(loadedData?.calendarViewSettings || {})
|
|
}
|
|
};
|
|
|
|
// Check if we added any new field mappings or calendar settings and save if needed
|
|
const hasNewFields = Object.keys(DEFAULT_SETTINGS.fieldMapping).some(key =>
|
|
!(loadedData?.fieldMapping?.[key])
|
|
);
|
|
const hasNewCalendarSettings = Object.keys(DEFAULT_SETTINGS.calendarViewSettings).some(key =>
|
|
!(loadedData?.calendarViewSettings?.[key as keyof typeof DEFAULT_SETTINGS.calendarViewSettings])
|
|
);
|
|
|
|
if (hasNewFields || hasNewCalendarSettings) {
|
|
// Save the migrated settings to include new field mappings (non-blocking)
|
|
setTimeout(async () => {
|
|
try {
|
|
const data = await this.loadData() || {};
|
|
// Merge only settings properties, preserving non-settings data
|
|
const settingsKeys = Object.keys(DEFAULT_SETTINGS) as (keyof TaskNotesSettings)[];
|
|
for (const key of settingsKeys) {
|
|
data[key] = this.settings[key];
|
|
}
|
|
await this.saveData(data);
|
|
} catch (error) {
|
|
console.error('Failed to save migrated settings:', error);
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
// Cache setting migration is no longer needed (native cache only)
|
|
|
|
// Capture initial cache settings for change detection
|
|
this.updatePreviousCacheSettings();
|
|
}
|
|
|
|
async saveSettings() {
|
|
// Load existing plugin data to preserve non-settings data like pomodoroHistory
|
|
const data = await this.loadData() || {};
|
|
// Merge only settings properties, preserving non-settings data
|
|
const settingsKeys = Object.keys(DEFAULT_SETTINGS) as (keyof TaskNotesSettings)[];
|
|
for (const key of settingsKeys) {
|
|
data[key] = this.settings[key];
|
|
}
|
|
await this.saveData(data);
|
|
|
|
// Check if cache-related settings have changed
|
|
const cacheSettingsChanged = this.haveCacheSettingsChanged();
|
|
|
|
// Check if time tracking settings have changed
|
|
const timeTrackingSettingsChanged = this.haveTimeTrackingSettingsChanged();
|
|
|
|
// Update customization services with new settings
|
|
if (this.fieldMapper) {
|
|
this.fieldMapper.updateMapping(this.settings.fieldMapping);
|
|
}
|
|
if (this.statusManager) {
|
|
this.statusManager.updateStatuses(this.settings.customStatuses);
|
|
}
|
|
if (this.priorityManager) {
|
|
this.priorityManager.updatePriorities(this.settings.customPriorities);
|
|
}
|
|
|
|
// Only update cache manager if cache-related settings actually changed
|
|
if (cacheSettingsChanged) {
|
|
console.debug('Cache-related settings changed, updating cache configuration');
|
|
this.cacheManager.updateConfig(
|
|
this.settings.taskTag,
|
|
this.settings.excludedFolders,
|
|
this.fieldMapper,
|
|
this.settings.disableNoteIndexing,
|
|
this.settings.storeTitleInFilename
|
|
);
|
|
|
|
// Update our tracking of cache settings
|
|
this.updatePreviousCacheSettings();
|
|
}
|
|
|
|
// Update custom styles
|
|
this.injectCustomStyles();
|
|
|
|
// Note: Event listeners are automatically cleaned up and re-registered by this.register()
|
|
// when settings change, so we just need to set them up again
|
|
if (timeTrackingSettingsChanged) {
|
|
this.setupTimeTrackingEventListeners();
|
|
}
|
|
|
|
// Update status bar service visibility
|
|
if (this.statusBarService) {
|
|
this.statusBarService.updateVisibility();
|
|
}
|
|
|
|
// If settings have changed, notify views to refresh their data
|
|
this.notifyDataChanged();
|
|
|
|
// Emit settings-changed event for specific settings updates
|
|
this.emitter.trigger('settings-changed', this.settings);
|
|
}
|
|
|
|
addCommands() {
|
|
// View commands
|
|
this.addCommand({
|
|
id: 'open-calendar-view',
|
|
name: 'Open mini calendar view',
|
|
callback: async () => {
|
|
await this.activateCalendarView();
|
|
}
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'open-advanced-calendar-view',
|
|
name: 'Open advanced calendar view',
|
|
callback: async () => {
|
|
await this.activateAdvancedCalendarView();
|
|
}
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'open-tasks-view',
|
|
name: 'Open tasks view',
|
|
callback: async () => {
|
|
await this.activateTasksView();
|
|
}
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'open-notes-view',
|
|
name: 'Open notes view',
|
|
callback: async () => {
|
|
await this.activateNotesView();
|
|
}
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'open-agenda-view',
|
|
name: 'Open agenda view',
|
|
callback: async () => {
|
|
await this.activateAgendaView();
|
|
}
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'open-pomodoro-view',
|
|
name: 'Open pomodoro timer',
|
|
callback: async () => {
|
|
await this.activatePomodoroView();
|
|
}
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'open-kanban-view',
|
|
name: 'Open kanban board',
|
|
callback: async () => {
|
|
await this.activateKanbanView();
|
|
}
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'open-pomodoro-stats',
|
|
name: 'Open pomodoro statistics',
|
|
callback: async () => {
|
|
await this.activatePomodoroStatsView();
|
|
}
|
|
});
|
|
|
|
// Task commands
|
|
this.addCommand({
|
|
id: 'create-new-task',
|
|
name: 'Create new task',
|
|
callback: () => {
|
|
this.openTaskCreationModal();
|
|
}
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'convert-to-tasknote',
|
|
name: 'Convert task to TaskNote',
|
|
editorCallback: async (editor: Editor) => {
|
|
await this.convertTaskToTaskNote(editor);
|
|
}
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'batch-convert-all-tasks',
|
|
name: 'Convert all tasks in note',
|
|
editorCallback: async (editor: Editor) => {
|
|
await this.batchConvertAllTasks(editor);
|
|
}
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'insert-tasknote-link',
|
|
name: 'Insert tasknote link',
|
|
editorCallback: (editor: Editor) => {
|
|
this.insertTaskNoteLink(editor);
|
|
}
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'quick-actions-current-task',
|
|
name: 'Quick actions for current task',
|
|
callback: async () => {
|
|
await this.openQuickActionsForCurrentTask();
|
|
}
|
|
});
|
|
|
|
// Note commands
|
|
this.addCommand({
|
|
id: 'go-to-today',
|
|
name: 'Go to today\'s note',
|
|
callback: async () => {
|
|
await this.navigateToCurrentDailyNote();
|
|
}
|
|
});
|
|
|
|
// Pomodoro commands
|
|
this.addCommand({
|
|
id: 'start-pomodoro',
|
|
name: 'Start pomodoro timer',
|
|
callback: async () => {
|
|
await this.pomodoroService.startPomodoro();
|
|
}
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'stop-pomodoro',
|
|
name: 'Stop pomodoro timer',
|
|
callback: async () => {
|
|
await this.pomodoroService.stopPomodoro();
|
|
}
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'pause-pomodoro',
|
|
name: 'Pause/resume pomodoro timer',
|
|
callback: async () => {
|
|
const state = this.pomodoroService.getState();
|
|
if (state.isRunning) {
|
|
await this.pomodoroService.pausePomodoro();
|
|
} else if (state.currentSession) {
|
|
await this.pomodoroService.resumePomodoro();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Cache management commands
|
|
this.addCommand({
|
|
id: 'refresh-cache',
|
|
name: 'Refresh cache',
|
|
callback: async () => {
|
|
await this.refreshCache();
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
// Helper method to create or activate a view of specific type
|
|
async activateView(viewType: string) {
|
|
const { workspace } = this.app;
|
|
|
|
// Use existing view if it exists
|
|
let leaf = this.getLeafOfType(viewType);
|
|
|
|
if (!leaf) {
|
|
// Simple approach - create a new tab
|
|
// This is more reliable for tab behavior
|
|
leaf = workspace.getLeaf('tab');
|
|
|
|
// Set the view state for this leaf
|
|
await leaf.setViewState({
|
|
type: viewType,
|
|
active: true,
|
|
});
|
|
}
|
|
|
|
// Make this leaf active and ensure it's visible
|
|
workspace.setActiveLeaf(leaf, { focus: true });
|
|
workspace.revealLeaf(leaf);
|
|
|
|
return leaf;
|
|
}
|
|
|
|
async activateCalendarView() {
|
|
return this.activateView(MINI_CALENDAR_VIEW_TYPE);
|
|
}
|
|
|
|
async activateAdvancedCalendarView() {
|
|
return this.activateView(ADVANCED_CALENDAR_VIEW_TYPE);
|
|
}
|
|
|
|
async activateTasksView() {
|
|
return this.activateView(TASK_LIST_VIEW_TYPE);
|
|
}
|
|
|
|
async activateNotesView() {
|
|
return this.activateView(NOTES_VIEW_TYPE);
|
|
}
|
|
|
|
async activateAgendaView() {
|
|
return this.activateView(AGENDA_VIEW_TYPE);
|
|
}
|
|
|
|
async activatePomodoroView() {
|
|
return this.activateView(POMODORO_VIEW_TYPE);
|
|
}
|
|
|
|
async activatePomodoroStatsView() {
|
|
return this.activateView(POMODORO_STATS_VIEW_TYPE);
|
|
}
|
|
|
|
async activateKanbanView() {
|
|
return this.activateView(KANBAN_VIEW_TYPE);
|
|
}
|
|
|
|
|
|
getLeafOfType(viewType: string): WorkspaceLeaf | null {
|
|
const { workspace } = this.app;
|
|
const leaves = workspace.getLeavesOfType(viewType);
|
|
// Find the first leaf with an actually loaded view (not deferred)
|
|
for (const leaf of leaves) {
|
|
if (leaf.view && leaf.view.getViewType() === viewType) {
|
|
return leaf;
|
|
}
|
|
}
|
|
// If no loaded view found, return the first leaf (might be deferred)
|
|
return leaves.length > 0 ? leaves[0] : null;
|
|
}
|
|
|
|
getCalendarLeaf(): WorkspaceLeaf | null {
|
|
return this.getLeafOfType(MINI_CALENDAR_VIEW_TYPE);
|
|
}
|
|
|
|
async navigateToCurrentDailyNote() {
|
|
const date = new Date();
|
|
await this.navigateToDailyNote(date);
|
|
}
|
|
|
|
async navigateToDailyNote(date: Date) {
|
|
try {
|
|
// Check if Daily Notes plugin is enabled
|
|
if (!appHasDailyNotesPluginLoaded()) {
|
|
new Notice('Daily Notes core plugin is not enabled. Please enable it in Settings > Core plugins.');
|
|
return;
|
|
}
|
|
|
|
// Convert date to moment for the API
|
|
const moment = (window as Window & { moment: (date: Date) => any }).moment(date);
|
|
|
|
// Get all daily notes to check if one exists for this date
|
|
const allDailyNotes = getAllDailyNotes();
|
|
let dailyNote = getDailyNote(moment, allDailyNotes);
|
|
let noteWasCreated = false;
|
|
|
|
// If no daily note exists for this date, create one
|
|
if (!dailyNote) {
|
|
try {
|
|
dailyNote = await createDailyNote(moment);
|
|
noteWasCreated = true;
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
console.error('Failed to create daily note:', error);
|
|
new Notice(`Failed to create daily note: ${errorMessage}`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Open the daily note
|
|
if (dailyNote) {
|
|
await this.app.workspace.getLeaf(false).openFile(dailyNote);
|
|
|
|
// If we created a new daily note, refresh the cache to ensure it shows up in views
|
|
if (noteWasCreated) {
|
|
// Note: Cache rebuilding happens automatically on data change notification
|
|
|
|
// Notify views that data has changed to trigger a UI refresh
|
|
this.notifyDataChanged(dailyNote.path, false, true);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
console.error('Failed to navigate to daily note:', error);
|
|
new Notice(`Failed to navigate to daily note: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Inject dynamic CSS for custom statuses and priorities
|
|
*/
|
|
private injectCustomStyles(): void {
|
|
// Remove existing custom styles
|
|
const existingStyle = document.getElementById('tasknotes-custom-styles');
|
|
if (existingStyle) {
|
|
existingStyle.remove();
|
|
}
|
|
|
|
// Generate new styles
|
|
const statusStyles = this.statusManager.getStatusStyles();
|
|
const priorityStyles = this.priorityManager.getPriorityStyles();
|
|
|
|
// Create style element
|
|
const styleEl = document.createElement('style');
|
|
styleEl.id = 'tasknotes-custom-styles';
|
|
styleEl.textContent = `
|
|
${statusStyles}
|
|
${priorityStyles}
|
|
`;
|
|
|
|
// Inject into document head
|
|
document.head.appendChild(styleEl);
|
|
}
|
|
|
|
async updateTaskProperty(task: TaskInfo, property: keyof TaskInfo, value: TaskInfo[keyof TaskInfo], options: { silent?: boolean } = {}): Promise<TaskInfo> {
|
|
try {
|
|
const updatedTask = await this.taskService.updateProperty(task, property, value, options);
|
|
|
|
// Provide user feedback unless silent
|
|
if (!options.silent) {
|
|
if (property === 'status') {
|
|
const statusValue = typeof value === 'string' ? value : String(value);
|
|
const statusConfig = this.statusManager.getStatusConfig(statusValue);
|
|
new Notice(`Task marked as '${statusConfig?.label || statusValue}'`);
|
|
} else {
|
|
new Notice(`Task ${property} updated`);
|
|
}
|
|
}
|
|
|
|
return updatedTask;
|
|
} catch (error) {
|
|
console.error(`Failed to update task ${property}:`, error);
|
|
new Notice(`Failed to update task ${property}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggles a recurring task's completion status for the selected date
|
|
*/
|
|
async toggleRecurringTaskComplete(task: TaskInfo, date?: Date): Promise<TaskInfo> {
|
|
try {
|
|
const targetDate = date || this.selectedDate;
|
|
const updatedTask = await this.taskService.toggleRecurringTaskComplete(task, date);
|
|
|
|
// Determine if task was completed or marked incomplete
|
|
const dateStr = formatDateForStorage(targetDate);
|
|
const wasCompleted = updatedTask.complete_instances?.includes(dateStr);
|
|
const action = wasCompleted ? 'completed' : 'marked incomplete';
|
|
|
|
new Notice(`Recurring task ${action} for ${format(targetDate, 'MMM d')}`);
|
|
return updatedTask;
|
|
} catch (error) {
|
|
console.error('Failed to toggle recurring task completion:', error);
|
|
new Notice('Failed to update recurring task');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async toggleTaskArchive(task: TaskInfo): Promise<TaskInfo> {
|
|
try {
|
|
const updatedTask = await this.taskService.toggleArchive(task);
|
|
const action = updatedTask.archived ? 'archived' : 'unarchived';
|
|
new Notice(`Task ${action}`);
|
|
return updatedTask;
|
|
} catch (error) {
|
|
console.error('Failed to toggle task archive:', error);
|
|
new Notice('Failed to update task archive status');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async toggleTaskStatus(task: TaskInfo): Promise<TaskInfo> {
|
|
try {
|
|
const updatedTask = await this.taskService.toggleStatus(task);
|
|
const statusConfig = this.statusManager.getStatusConfig(updatedTask.status);
|
|
new Notice(`Task marked as '${statusConfig?.label || updatedTask.status}'`);
|
|
return updatedTask;
|
|
} catch (error) {
|
|
console.error('Failed to toggle task status:', error);
|
|
new Notice('Failed to update task status');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
openTaskCreationModal(prePopulatedValues?: Partial<TaskInfo>) {
|
|
new TaskCreationModal(this.app, this, { prePopulatedValues }).open();
|
|
}
|
|
|
|
/**
|
|
* Apply a filter to show subtasks of a project
|
|
*/
|
|
async applyProjectSubtaskFilter(projectTask: TaskInfo): Promise<void> {
|
|
try {
|
|
const file = this.app.vault.getAbstractFileByPath(projectTask.path);
|
|
if (!file) {
|
|
new Notice('Project file not found');
|
|
return;
|
|
}
|
|
|
|
// Get the current active view that has a FilterBar
|
|
const activeView = this.app.workspace.getActiveViewOfType(TaskListView) ||
|
|
this.app.workspace.getActiveViewOfType(KanbanView) ||
|
|
this.app.workspace.getActiveViewOfType(AgendaView);
|
|
|
|
if (!activeView || !('filterBar' in activeView)) {
|
|
new Notice('No compatible view active to apply filter');
|
|
return;
|
|
}
|
|
|
|
const filterBar = (activeView as any).filterBar;
|
|
if (!filterBar) {
|
|
new Notice('Filter bar not available');
|
|
return;
|
|
}
|
|
|
|
// Use the same pattern as the search box - add a condition to the current query
|
|
this.addProjectCondition(filterBar, (file as TFile).basename);
|
|
|
|
new Notice(`Filtered to show subtasks of: ${projectTask.title}`);
|
|
} catch (error) {
|
|
console.error('Error applying project subtask filter:', error);
|
|
new Notice('Failed to apply project filter');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a project filter condition to the FilterBar (similar to search)
|
|
*/
|
|
private addProjectCondition(filterBar: any, projectName: string): void {
|
|
// Remove existing project conditions first
|
|
this.removeProjectConditions(filterBar);
|
|
|
|
// Create condition for wikilink format [[Project Name]]
|
|
const projectCondition = {
|
|
type: 'condition',
|
|
id: `project_${this.generateFilterId()}`,
|
|
property: 'projects',
|
|
operator: 'contains',
|
|
value: `[[${projectName}]]`
|
|
};
|
|
|
|
// Add to the current query (same pattern as search)
|
|
if (!Array.isArray(filterBar.currentQuery.children)) {
|
|
filterBar.currentQuery.children = [];
|
|
}
|
|
|
|
filterBar.currentQuery.children.unshift(projectCondition);
|
|
|
|
// Update the filter bar UI and emit changes
|
|
filterBar.updateFilterBuilder();
|
|
filterBar.emit('queryChange', filterBar.currentQuery);
|
|
}
|
|
|
|
/**
|
|
* Remove existing project filter conditions
|
|
*/
|
|
private removeProjectConditions(filterBar: any): void {
|
|
if (!Array.isArray(filterBar.currentQuery.children)) {
|
|
filterBar.currentQuery.children = [];
|
|
return;
|
|
}
|
|
|
|
filterBar.currentQuery.children = filterBar.currentQuery.children.filter((child: any) => {
|
|
if (child.type === 'condition') {
|
|
return !(child.property === 'projects' && child.operator === 'contains' &&
|
|
child.id.startsWith('project_'));
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Generate a unique filter ID
|
|
*/
|
|
private generateFilterId(): string {
|
|
return `filter-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
* Starts a time tracking session for a task
|
|
*/
|
|
async startTimeTracking(task: TaskInfo, description?: string): Promise<TaskInfo> {
|
|
try {
|
|
const updatedTask = await this.taskService.startTimeTracking(task);
|
|
new Notice('Time tracking started');
|
|
|
|
// Update status bar after a small delay to ensure task state is persisted
|
|
if (this.statusBarService) {
|
|
setTimeout(() => {
|
|
this.statusBarService.requestUpdate();
|
|
}, 50);
|
|
}
|
|
|
|
return updatedTask;
|
|
} catch (error) {
|
|
console.error('Failed to start time tracking:', error);
|
|
if (error.message === 'Time tracking is already active for this task') {
|
|
new Notice('Time tracking is already active for this task');
|
|
} else {
|
|
new Notice('Failed to start time tracking');
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stops the active time tracking session for a task
|
|
*/
|
|
async stopTimeTracking(task: TaskInfo): Promise<TaskInfo> {
|
|
try {
|
|
const updatedTask = await this.taskService.stopTimeTracking(task);
|
|
new Notice('Time tracking stopped');
|
|
|
|
// Update status bar after a small delay to ensure task state is persisted
|
|
if (this.statusBarService) {
|
|
setTimeout(() => {
|
|
this.statusBarService.requestUpdate();
|
|
}, 50);
|
|
}
|
|
|
|
return updatedTask;
|
|
} catch (error) {
|
|
console.error('Failed to stop time tracking:', error);
|
|
if (error.message === 'No active time tracking session for this task') {
|
|
new Notice('No active time tracking session for this task');
|
|
} else {
|
|
new Notice('Failed to stop time tracking');
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the active time tracking session for a task
|
|
*/
|
|
getActiveTimeSession(task: TaskInfo) {
|
|
return getActiveTimeEntry(task.timeEntries || []);
|
|
}
|
|
|
|
/**
|
|
* Check if a recurring task is completed for a specific date
|
|
*/
|
|
isRecurringTaskCompleteForDate(task: TaskInfo, date: Date): boolean {
|
|
if (!task.recurrence) return false;
|
|
const dateStr = formatDateForStorage(date);
|
|
const completeInstances = Array.isArray(task.complete_instances) ? task.complete_instances : [];
|
|
return completeInstances.includes(dateStr);
|
|
}
|
|
|
|
/**
|
|
* Formats time in minutes to a readable string
|
|
*/
|
|
formatTime(minutes: number): string {
|
|
return formatTime(minutes);
|
|
}
|
|
|
|
/**
|
|
* Opens the task edit modal for a specific task
|
|
*/
|
|
async openTaskEditModal(task: TaskInfo) {
|
|
// With native cache, task data is always current - no need to refetch
|
|
new TaskEditModal(this.app, this, { task }).open();
|
|
}
|
|
|
|
/**
|
|
* Opens a simple due date modal (placeholder for now)
|
|
*/
|
|
async openDueDateModal(task: TaskInfo) {
|
|
try {
|
|
const { DueDateModal } = await import('./modals/DueDateModal');
|
|
const modal = new DueDateModal(this.app, task, this);
|
|
modal.open();
|
|
} catch (error) {
|
|
console.error('Error loading DueDateModal:', error);
|
|
}
|
|
}
|
|
|
|
async openScheduledDateModal(task: TaskInfo) {
|
|
try {
|
|
const { ScheduledDateModal } = await import('./modals/ScheduledDateModal');
|
|
const modal = new ScheduledDateModal(this.app, task, this);
|
|
modal.open();
|
|
} catch (error) {
|
|
console.error('Error loading ScheduledDateModal:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refreshes the TaskNotes cache by clearing all cached data and re-initializing
|
|
*/
|
|
async refreshCache(): Promise<void> {
|
|
try {
|
|
// Show loading notice
|
|
const loadingNotice = new Notice('Refreshing TaskNotes cache...', 0);
|
|
|
|
// Clear all caches
|
|
await this.cacheManager.clearAllCaches();
|
|
|
|
// Notify all views to refresh
|
|
this.notifyDataChanged(undefined, true, true);
|
|
|
|
// Hide loading notice and show success
|
|
loadingNotice.hide();
|
|
new Notice('TaskNotes cache refreshed successfully');
|
|
|
|
} catch (error) {
|
|
console.error('Error refreshing cache:', error);
|
|
new Notice('Failed to refresh cache. Please try again.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert any checkbox task on current line to TaskNotes task
|
|
* Supports multi-line selection where additional lines become task details
|
|
*/
|
|
async convertTaskToTaskNote(editor: Editor): Promise<void> {
|
|
try {
|
|
const cursor = editor.getCursor();
|
|
|
|
// Check if instant convert service is available
|
|
if (!this.instantTaskConvertService) {
|
|
new Notice('Task conversion service not available. Please try again.');
|
|
return;
|
|
}
|
|
|
|
// Use the instant convert service for immediate conversion without modal
|
|
await this.instantTaskConvertService.instantConvertTask(editor, cursor.line);
|
|
|
|
} catch (error) {
|
|
console.error('Error converting task:', error);
|
|
new Notice('Failed to convert task. Please try again.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Batch convert all checkbox tasks in the current note to TaskNotes
|
|
*/
|
|
async batchConvertAllTasks(editor: Editor): Promise<void> {
|
|
try {
|
|
// Check if instant convert service is available
|
|
if (!this.instantTaskConvertService) {
|
|
new Notice('Task conversion service not available. Please try again.');
|
|
return;
|
|
}
|
|
|
|
// Use the instant convert service for batch conversion
|
|
await this.instantTaskConvertService.batchConvertAllTasks(editor);
|
|
|
|
} catch (error) {
|
|
console.error('Error batch converting tasks:', error);
|
|
new Notice('Failed to batch convert tasks. Please try again.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Insert a wikilink to a selected tasknote at the current cursor position
|
|
*/
|
|
async insertTaskNoteLink(editor: Editor): Promise<void> {
|
|
try {
|
|
// Get all tasks
|
|
const allTasks = await this.cacheManager.getAllTasks();
|
|
const unarchivedTasks = allTasks.filter(task => !task.archived);
|
|
|
|
// Open task selector modal
|
|
const modal = new TaskSelectorModal(this.app, this, unarchivedTasks, (selectedTask) => {
|
|
if (selectedTask) {
|
|
// Create link using Obsidian's generateMarkdownLink (respects user's link format settings)
|
|
const file = this.app.vault.getAbstractFileByPath(selectedTask.path);
|
|
if (file) {
|
|
const currentFile = this.app.workspace.getActiveFile();
|
|
const sourcePath = currentFile?.path || '';
|
|
const properLink = this.app.fileManager.generateMarkdownLink(
|
|
file as TFile,
|
|
sourcePath,
|
|
'',
|
|
selectedTask.title // Use task title as alias
|
|
);
|
|
|
|
// Insert at cursor position
|
|
const cursor = editor.getCursor();
|
|
editor.replaceRange(properLink, cursor);
|
|
|
|
// Move cursor to end of inserted text
|
|
const newCursor = {
|
|
line: cursor.line,
|
|
ch: cursor.ch + properLink.length
|
|
};
|
|
editor.setCursor(newCursor);
|
|
} else {
|
|
new Notice('Failed to create link - file not found');
|
|
}
|
|
}
|
|
});
|
|
|
|
modal.open();
|
|
} catch (error) {
|
|
console.error('Error inserting tasknote link:', error);
|
|
new Notice('Failed to insert tasknote link');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract selection information for command usage
|
|
*/
|
|
private extractSelectionInfoForCommand(editor: Editor, lineNumber: number): { taskLine: string; details: string; startLine: number; endLine: number; originalContent: string[] } {
|
|
const selection = editor.getSelection();
|
|
|
|
// If there's a selection, use it; otherwise just use the current line
|
|
if (selection && selection.trim()) {
|
|
const selectionRange = editor.listSelections()[0];
|
|
const startLine = Math.min(selectionRange.anchor.line, selectionRange.head.line);
|
|
const endLine = Math.max(selectionRange.anchor.line, selectionRange.head.line);
|
|
|
|
// Extract all lines in the selection
|
|
const selectedLines: string[] = [];
|
|
for (let i = startLine; i <= endLine; i++) {
|
|
selectedLines.push(editor.getLine(i));
|
|
}
|
|
|
|
// First line should be the task, rest become details
|
|
const taskLine = selectedLines[0];
|
|
const detailLines = selectedLines.slice(1);
|
|
// Join without trimming to preserve indentation, but remove trailing whitespace only
|
|
const details = detailLines.join('\n').trimEnd();
|
|
|
|
return {
|
|
taskLine,
|
|
details,
|
|
startLine,
|
|
endLine,
|
|
originalContent: selectedLines
|
|
};
|
|
} else {
|
|
// No selection, just use the current line
|
|
const taskLine = editor.getLine(lineNumber);
|
|
return {
|
|
taskLine,
|
|
details: '',
|
|
startLine: lineNumber,
|
|
endLine: lineNumber,
|
|
originalContent: [taskLine]
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open Quick Actions for the currently active TaskNote
|
|
*/
|
|
async openQuickActionsForCurrentTask(): Promise<void> {
|
|
try {
|
|
// Get currently active file
|
|
const activeFile = this.app.workspace.getActiveFile();
|
|
if (!activeFile) {
|
|
new Notice('No file is currently open');
|
|
return;
|
|
}
|
|
|
|
// Check if it's a TaskNote
|
|
const taskInfo = await this.cacheManager.getTaskInfo(activeFile.path);
|
|
if (!taskInfo) {
|
|
new Notice('Current file is not a TaskNote');
|
|
return;
|
|
}
|
|
|
|
// Open TaskActionPaletteModal with detected task
|
|
const { TaskActionPaletteModal } = await import('./modals/TaskActionPaletteModal');
|
|
const modal = new TaskActionPaletteModal(this.app, taskInfo, this, this.selectedDate);
|
|
modal.open();
|
|
|
|
} catch (error) {
|
|
console.error('Error opening quick actions:', error);
|
|
new Notice('Failed to open quick actions');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if cache-related settings have changed since last save
|
|
*/
|
|
private haveCacheSettingsChanged(): boolean {
|
|
if (!this.previousCacheSettings) {
|
|
return true; // First time, assume changed
|
|
}
|
|
|
|
const current = {
|
|
taskTag: this.settings.taskTag,
|
|
excludedFolders: this.settings.excludedFolders,
|
|
disableNoteIndexing: this.settings.disableNoteIndexing,
|
|
storeTitleInFilename: this.settings.storeTitleInFilename,
|
|
fieldMapping: this.settings.fieldMapping
|
|
};
|
|
|
|
return (
|
|
current.taskTag !== this.previousCacheSettings.taskTag ||
|
|
current.excludedFolders !== this.previousCacheSettings.excludedFolders ||
|
|
current.disableNoteIndexing !== this.previousCacheSettings.disableNoteIndexing ||
|
|
current.storeTitleInFilename !== this.previousCacheSettings.storeTitleInFilename ||
|
|
JSON.stringify(current.fieldMapping) !== JSON.stringify(this.previousCacheSettings.fieldMapping)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Update tracking of cache-related settings
|
|
*/
|
|
private updatePreviousCacheSettings(): void {
|
|
this.previousCacheSettings = {
|
|
taskTag: this.settings.taskTag,
|
|
excludedFolders: this.settings.excludedFolders,
|
|
disableNoteIndexing: this.settings.disableNoteIndexing,
|
|
storeTitleInFilename: this.settings.storeTitleInFilename,
|
|
fieldMapping: JSON.parse(JSON.stringify(this.settings.fieldMapping)) // Deep copy
|
|
};
|
|
}
|
|
|
|
}
|