diff --git a/src/main.ts b/src/main.ts index 3dd66d84..6feaf1a2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -537,7 +537,7 @@ export default class TaskNotesPlugin extends Plugin { // Initialize notification service await this.notificationService.initialize(); - // Ensure MinimalNativeCache project indexes are warmed up + // Warm up TaskManager indexes for better performance await this.warmupProjectIndexes(); // Initialize and start auto-archive service @@ -702,7 +702,7 @@ export default class TaskNotesPlugin extends Plugin { } /** - * Warmup project indexes in MinimalNativeCache for better performance + * Warm up TaskManager indexes for better performance */ private async warmupProjectIndexes(): Promise { try { diff --git a/src/services/FilterService.ts b/src/services/FilterService.ts index b4b7b391..247efe13 100644 --- a/src/services/FilterService.ts +++ b/src/services/FilterService.ts @@ -2743,7 +2743,6 @@ export class FilterService extends EventEmitter { /** * Extract project names from a task project value, handling [[link]] format - * This mirrors the logic from MinimalNativeCache.extractProjectNamesFromValue */ private extractProjectNamesFromTaskValue(projectValue: string, sourcePath: string): string[] { if (!projectValue || projectValue.trim() === "" || projectValue === '""') { diff --git a/src/utils/MinimalNativeCache.ts b/src/utils/MinimalNativeCache.ts deleted file mode 100644 index 7cfacf04..00000000 --- a/src/utils/MinimalNativeCache.ts +++ /dev/null @@ -1,1910 +0,0 @@ -/* eslint-disable no-console */ -import { TFile, App, Events, EventRef, parseLinktext } from "obsidian"; -import { TaskInfo, NoteInfo, TaskDependency } from "../types"; -import { FieldMapper } from "../services/FieldMapper"; -import { FilterUtils } from "./FilterUtils"; -import { - getTodayString, - isBeforeDateSafe, - getDatePart, - parseDateToUTC, - formatDateForStorage, -} from "./dateUtils"; -import { filterEmptyProjects, calculateTotalTimeSpent } from "./helpers"; -import { normalizeDependencyList, resolveDependencyEntry } from "./dependencyUtils"; -import { TaskNotesSettings } from "../types/settings"; - -/** - * Ultra-minimal cache manager that leverages Obsidian's native metadata cache - * to the maximum extent possible. Only maintains essential indexes for performance. - * - * Design Philosophy: - * - Native-first: Always use app.metadataCache as primary data source - * - Minimal indexing: Only index performance-critical queries - * - Compute on-demand: Replace complex indexes with smart filtering - * - Event-driven: React to native metadata changes - */ -export class MinimalNativeCache extends Events { - private app: App; - private settings: TaskNotesSettings; - private taskTag: string; - private excludedFolders: string[]; - private fieldMapper?: FieldMapper; - private disableNoteIndexing: boolean; - private storeTitleInFilename: boolean; - - // Only essential indexes - everything else computed on-demand - private tasksByDate: Map> = new Map(); // YYYY-MM-DD -> task paths - private tasksByStatus: Map> = new Map(); // status -> task paths - private timeEstimatesByPath: Map = new Map(); // path -> timeEstimate - private overdueTasks: Set = new Set(); // overdue task paths - private projectReferences: Map> = new Map(); // project path -> Set - private dependencySources: Map> = new Map(); // task path -> blocking task paths - private dependencyTargets: Map> = new Map(); // task path -> tasks blocked by this task - - // Initialization state - private initialized = false; - private indexesBuilt = false; - - // Event listeners for cleanup - private eventListeners: EventRef[] = []; - - // Debouncing for file changes to prevent excessive updates during typing - private debouncedHandlers: Map = new Map(); - private readonly DEBOUNCE_DELAY = 300; // 300ms delay after user stops typing - private cleanupIntervalId: number | null = null; - private readonly CLEANUP_INTERVAL = 60000; // Clean up orphaned handlers every 60 seconds - - // Cache of last known task info for comparison to detect actual changes - private lastKnownTaskInfo: Map = new Map(); - - // Cache of last known frontmatter to detect frontmatter-specific changes - private lastKnownFrontmatter: Map = new Map(); - - constructor(app: App, settings: TaskNotesSettings, fieldMapper?: FieldMapper) { - super(); - this.app = app; - this.settings = settings; - this.taskTag = settings.taskTag; - this.excludedFolders = settings.excludedFolders - ? settings.excludedFolders - .split(",") - .map((folder) => folder.trim()) - .filter((folder) => folder.length > 0) - : []; - this.fieldMapper = fieldMapper; - this.disableNoteIndexing = settings.disableNoteIndexing; - this.storeTitleInFilename = settings.storeTitleInFilename; - } - - /** - * Initialize by setting up native event listeners - * Indexes built lazily for optimal startup performance - */ - initialize(): void { - if (this.initialized) { - return; - } - - this.setupNativeEventListeners(); - this.startPeriodicCleanup(); - this.initialized = true; - this.trigger("cache-initialized", { message: "Minimal native cache ready" }); - } - - /** - * Get the Obsidian app instance - * Needed for external services to access app APIs - */ - getApp(): App { - return this.app; - } - - /** - * Check if a file is a task based on current settings - * Public to allow ProjectSubtasksService to validate task files (issue #953) - */ - isTaskFile(frontmatter: any): boolean { - if (!frontmatter) return false; - - if (this.settings.taskIdentificationMethod === "property") { - const propName = this.settings.taskPropertyName; - const propValue = this.settings.taskPropertyValue; - if (!propName || !propValue) return false; // Not configured - - const frontmatterValue = frontmatter[propName]; - if (frontmatterValue === undefined) return false; - - // Handle both single and multi-value properties - if (Array.isArray(frontmatterValue)) { - return frontmatterValue.some((val: any) => - this.comparePropertyValues(val, propValue) - ); - } - return this.comparePropertyValues(frontmatterValue, propValue); - } else { - // Fallback to legacy tag-based method with hierarchical support - // Use exact matching (no substring fallback) for task identification - if (!Array.isArray(frontmatter.tags)) return false; - return frontmatter.tags.some((tag: string) => - typeof tag === 'string' && FilterUtils.matchesHierarchicalTagExact(tag, this.taskTag) - ); - } - } - - /** - * Compare frontmatter property values with settings value, with boolean coercion support. - */ - private comparePropertyValues(frontmatterValue: any, settingValue: string): boolean { - // Handle boolean frontmatter values compared to string settings (e.g., true vs "true") - if (typeof frontmatterValue === "boolean" && typeof settingValue === "string") { - const lower = settingValue.toLowerCase(); - if (lower === "true" || lower === "false") { - return frontmatterValue === (lower === "true"); - } - } - - // Fallback to strict equality for other types (strings, numbers, etc.) - return frontmatterValue === settingValue; - } - - /** - * Set up native event listeners for real-time updates - */ - private setupNativeEventListeners(): void { - // Store event listeners for proper cleanup - this.eventListeners.push( - this.app.metadataCache.on("changed", (file, data, cache) => { - if ( - file instanceof TFile && - file.extension === "md" && - this.isValidFile(file.path) - ) { - this.handleFileChangedDebounced(file, cache); - } - }) - ); - - this.eventListeners.push( - this.app.metadataCache.on("deleted", (file, prevCache) => { - if (file instanceof TFile && file.extension === "md") { - this.handleFileDeleted(file.path); - } - }) - ); - - this.eventListeners.push( - this.app.vault.on("rename", (file, oldPath) => { - if (file instanceof TFile && file.extension === "md") { - this.handleFileRenamed(file, oldPath); - } - }) - ); - } - - /** - * Start periodic cleanup of any orphaned debounce handlers - * This prevents memory leaks from accumulating over long sessions - */ - private startPeriodicCleanup(): void { - if (this.cleanupIntervalId !== null) { - return; // Already started - } - - this.cleanupIntervalId = window.setInterval(() => { - // This cleanup is primarily defensive - the fixes in handleFileChangedDebounced - // should prevent orphaned handlers, but this provides an additional safeguard - const now = Date.now(); - let cleanedCount = 0; - - this.debouncedHandlers.forEach((timeout, path) => { - // Note: We can't directly check if a timeout is still pending in JavaScript, - // but the refactored handleFileChangedDebounced should now properly clean - // these up. This interval is kept as a defensive measure. - }); - - // Log cleanup activity for debugging if needed (only when handlers exist) - if (this.debouncedHandlers.size > 0) { - console.debug( - `MinimalNativeCache: Periodic cleanup check - ${this.debouncedHandlers.size} pending handlers` - ); - } - }, this.CLEANUP_INTERVAL) as unknown as number; - } - - /** - * Ensure essential indexes are built (lazy loading) - */ - private ensureIndexesBuilt(): void { - if (this.indexesBuilt) return; - - this.indexesBuilt = true; - window.setTimeout(() => this.buildEssentialIndexes(), 1); - } - - /** - * Build only essential indexes in background - */ - private async buildEssentialIndexes(): Promise { - try { - const markdownFiles = this.app.vault - .getMarkdownFiles() - .filter((file) => this.isValidFile(file.path)); - - // Process in small batches to avoid blocking - const batchSize = 50; - for (let i = 0; i < markdownFiles.length; i += batchSize) { - const batch = markdownFiles.slice(i, i + batchSize); - - for (const file of batch) { - try { - const metadata = this.app.metadataCache.getFileCache(file); - if (metadata?.frontmatter && this.isTaskFile(metadata.frontmatter)) { - await this.indexTaskFile(file, metadata.frontmatter); - } - } catch (error) { - console.error(`Error indexing file ${file.path}:`, error); - } - } - - // Yield control between batches - await new Promise((resolve) => window.setTimeout(resolve, 1)); - } - - this.trigger("indexes-built", { - tasksByDate: this.tasksByDate.size, - tasksByStatus: this.tasksByStatus.size, - overdueTasks: this.overdueTasks.size, - }); - } catch (error) { - console.error("Error building essential indexes:", error); - } - } - - /** - * Index a task file (minimal - only essential indexes) - */ - private async indexTaskFile(file: TFile, frontmatter: any): Promise { - if (!this.fieldMapper) return; - - try { - const taskInfo = this.extractTaskInfoFromNative(file.path, frontmatter); - if (!taskInfo) return; - - // Update only essential indexes - this.updateDateIndex(file.path, taskInfo); - this.updateStatusIndex(file.path, taskInfo.status); - this.updateTimeEstimateIndex(file.path, taskInfo); - this.updateOverdueIndex(file.path, taskInfo); - this.updateProjectReferencesIndex(file.path, taskInfo.projects); - } catch (error) { - console.error(`Error indexing task ${file.path}:`, error); - } - } - - // ======================================== - // PUBLIC API - NATIVE-FIRST APPROACH - // ======================================== - - /** - * Get task info directly from native metadata cache - */ - async getTaskInfo(path: string): Promise { - const file = this.app.vault.getAbstractFileByPath(path); - if (!(file instanceof TFile)) return null; - - const metadata = this.app.metadataCache.getFileCache(file); - if (!metadata?.frontmatter) return null; - - // Validate that the file is actually a task based on identification settings - if (!this.isTaskFile(metadata.frontmatter)) return null; - - return this.extractTaskInfoFromNative(path, metadata.frontmatter); - } - - /** - * Get all tasks by scanning native metadata cache with lazy evaluation - */ - async getAllTasks(): Promise { - return this.getFilteredTasks(); - } - - /** - * Get filtered tasks with optional predicate for efficient querying - */ - async getFilteredTasks( - predicate?: (file: TFile, frontmatter: any) => boolean - ): Promise { - const markdownFiles = this.app.vault - .getMarkdownFiles() - .filter((file) => this.isValidFile(file.path)); - - const tasks: TaskInfo[] = []; - - // Process in batches to avoid blocking the main thread - const batchSize = 100; - for (let i = 0; i < markdownFiles.length; i += batchSize) { - const batch = markdownFiles.slice(i, i + batchSize); - - for (const file of batch) { - const metadata = this.app.metadataCache.getFileCache(file); - if (metadata?.frontmatter && this.isTaskFile(metadata.frontmatter)) { - // Apply optional filter early - if (predicate && !predicate(file, metadata.frontmatter)) { - continue; - } - - const taskInfo = this.extractTaskInfoFromNative( - file.path, - metadata.frontmatter - ); - if (taskInfo) tasks.push(taskInfo); - } - } - - // Yield control between batches for better UI responsiveness - if (i + batchSize < markdownFiles.length) { - await new Promise((resolve) => setTimeout(resolve, 1)); - } - } - - return tasks; - } - - /** - * Stream tasks with callback for memory-efficient processing - */ - async streamTasks(callback: (task: TaskInfo) => boolean | Promise): Promise { - const markdownFiles = this.app.vault - .getMarkdownFiles() - .filter((file) => this.isValidFile(file.path)); - - const batchSize = 50; // Smaller batch for streaming - for (let i = 0; i < markdownFiles.length; i += batchSize) { - const batch = markdownFiles.slice(i, i + batchSize); - - for (const file of batch) { - const metadata = this.app.metadataCache.getFileCache(file); - if (metadata?.frontmatter && this.isTaskFile(metadata.frontmatter)) { - const taskInfo = this.extractTaskInfoFromNative( - file.path, - metadata.frontmatter - ); - if (taskInfo) { - const shouldContinue = await callback(taskInfo); - if (!shouldContinue) { - return; // Early exit if callback returns false - } - } - } - } - - // Yield control between batches - if (i + batchSize < markdownFiles.length) { - await new Promise((resolve) => setTimeout(resolve, 1)); - } - } - } - - /** - * Get tasks for specific date (uses essential index) - */ - getTasksForDate(date: string): string[] { - this.ensureIndexesBuilt(); - const taskPaths = this.tasksByDate.get(date) || new Set(); - return Array.from(taskPaths); - } - - /** - * Get tasks that reference a specific project file (O(1) lookup) - */ - getTasksReferencingProject(projectPath: string): string[] { - this.ensureIndexesBuilt(); - const referencingTasks = this.projectReferences.get(projectPath) || new Set(); - return Array.from(referencingTasks); - } - - /** - * Check if a file is used as a project (O(1) lookup) - */ - isFileUsedAsProject(filePath: string): boolean { - this.ensureIndexesBuilt(); - const referencingTasks = this.projectReferences.get(filePath); - return referencingTasks ? referencingTasks.size > 0 : false; - } - - getBlockingTaskPaths(taskPath: string): string[] { - const blockers = this.dependencySources.get(taskPath); - return blockers ? Array.from(blockers) : []; - } - - getBlockedTaskPaths(taskPath: string): string[] { - const blocked = this.dependencyTargets.get(taskPath); - return blocked ? Array.from(blocked) : []; - } - - isTaskBlocked(taskPath: string): boolean { - const blockers = this.dependencySources.get(taskPath); - if (!blockers || blockers.size === 0) { - return false; - } - return this.calculateIsBlocked(Array.from(blockers)); - } - - /** - * Get task paths by status (uses essential index) - */ - getTaskPathsByStatus(status: string): string[] { - this.ensureIndexesBuilt(); - const taskPaths = this.tasksByStatus.get(status) || new Set(); - return Array.from(taskPaths); - } - - /** - * Get overdue task paths (uses essential index) - */ - getOverdueTaskPaths(): Set { - this.ensureIndexesBuilt(); - return new Set(this.overdueTasks); - } - - /** - * Get all time estimates by path (uses essential index) - */ - getAllTimeEstimates(): Map { - this.ensureIndexesBuilt(); - return this.timeEstimatesByPath; - } - - /** - * Get calendar data by computing on-demand - */ - async getCalendarData(year: number, month: number): Promise { - const taskData = new Map(); - const noteData = new Map(); - const dailyNotesSet = new Set(); - - // Get all markdown files for note counting - const markdownFiles = this.app.vault - .getMarkdownFiles() - .filter((file) => this.isValidFile(file.path)); - - // Use Obsidian's daily notes interface for daily notes detection - try { - const { getAllDailyNotes } = require("obsidian-daily-notes-interface"); - const allDailyNotes = getAllDailyNotes(); - - for (const [dateStr] of Object.entries(allDailyNotes)) { - dailyNotesSet.add(dateStr); - } - } catch (e) { - // Daily notes interface not available, fallback to filename pattern matching - } - - // Process all files to extract date information for notes - for (const file of markdownFiles) { - const metadata = this.app.metadataCache.getFileCache(file); - if (!metadata?.frontmatter) continue; - - const frontmatter = metadata.frontmatter; - const isTask = this.isTaskFile(frontmatter); - - if (!isTask && !this.disableNoteIndexing) { - // This is a note - extract date information - let noteDate: string | null = null; - - // Try to extract date from frontmatter using field mapper - if (this.fieldMapper) { - const dateCreatedField = this.fieldMapper.toUserField("dateCreated"); - if (frontmatter[dateCreatedField]) { - const dateValue = frontmatter[dateCreatedField]; - try { - const parsed = new Date(dateValue); - if (!isNaN(parsed.getTime())) { - noteDate = parsed.toISOString().split("T")[0]; - } - } catch (e) { - // Ignore invalid dates - } - } - } - - // Check if it's a daily note by filename pattern (fallback) - if (!noteDate) { - const fileName = file.basename; - // Common daily note patterns: YYYY-MM-DD, YYYY-MM-DD HH-mm-ss, etc. - const dateMatch = fileName.match(/(\d{4}-\d{2}-\d{2})/); - if (dateMatch) { - noteDate = dateMatch[1]; - dailyNotesSet.add(noteDate); - } - } - - if (noteDate) { - // Increment note count for this date - const currentCount = noteData.get(noteDate) || 0; - noteData.set(noteDate, currentCount + 1); - } - } - } - - // Build task data for the requested month - const daysInMonth = new Date(year, month + 1, 0).getDate(); - - for (let day = 1; day <= daysInMonth; day++) { - const dateKey = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; - - // Get tasks for this date - const taskPaths = this.getTasksForDate(dateKey); - if (taskPaths.length > 0) { - const tasks = await Promise.all(taskPaths.map((path) => this.getTaskInfo(path))); - const validTasks = tasks.filter((task) => task !== null) as TaskInfo[]; - - if (validTasks.length > 0) { - // Create task summary info for calendar coloring - const taskSummary = { - count: validTasks.length, - hasDue: validTasks.some((task) => task.due && !task.scheduled), - hasScheduled: validTasks.some((task) => task.scheduled), - hasCompleted: validTasks.some( - (task) => task.status === "completed" || task.status === "done" - ), - hasArchived: validTasks.some((task) => task.archived), - tasks: validTasks, - }; - taskData.set(dateKey, taskSummary); - } - } - } - - return { tasks: taskData, notes: noteData, dailyNotes: dailyNotesSet }; - } - - /** - * Get all task paths by scanning native cache - */ - getAllTaskPaths(): Set { - const taskPaths = new Set(); - const markdownFiles = this.app.vault - .getMarkdownFiles() - .filter((file) => this.isValidFile(file.path)); - - for (const file of markdownFiles) { - const metadata = this.app.metadataCache.getFileCache(file); - if (metadata?.frontmatter && this.isTaskFile(metadata.frontmatter)) { - taskPaths.add(file.path); - } - } - - return taskPaths; - } - - /** - * Get all statuses by computing on-demand from active tasks - */ - getAllStatuses(): string[] { - this.ensureIndexesBuilt(); - - // If indexes aren't built yet, try to get statuses synchronously - if (this.tasksByStatus.size === 0 && this.indexesBuilt) { - console.debug( - "MinimalNativeCache: Indexes marked as built but tasksByStatus is empty, computing statuses synchronously" - ); - const statuses = new Set(); - const markdownFiles = this.app.vault - .getMarkdownFiles() - .filter((file) => this.isValidFile(file.path)); - - for (const file of markdownFiles) { - const metadata = this.app.metadataCache.getFileCache(file); - if (metadata?.frontmatter && this.isTaskFile(metadata.frontmatter)) { - const taskInfo = this.extractTaskInfoFromNative( - file.path, - metadata.frontmatter - ); - if (taskInfo?.status) { - statuses.add(taskInfo.status); - } - } - } - - return Array.from(statuses).sort(); - } - - return Array.from(this.tasksByStatus.keys()).sort(); - } - - /** - * Get all priorities by computing on-demand - */ - getAllPriorities(): string[] { - const priorities = new Set(); - const markdownFiles = this.app.vault - .getMarkdownFiles() - .filter((file) => this.isValidFile(file.path)); - - for (const file of markdownFiles) { - const metadata = this.app.metadataCache.getFileCache(file); - if (metadata?.frontmatter && this.isTaskFile(metadata.frontmatter)) { - const taskInfo = this.extractTaskInfoFromNative(file.path, metadata.frontmatter); - if (taskInfo?.priority) { - priorities.add(taskInfo.priority); - } - } - } - - return Array.from(priorities).sort(); - } - - /** - * Get all tags using native MetadataCache.getTags() for optimal performance - * This leverages Obsidian's pre-computed tag index rather than scanning all files - */ - getAllTags(): string[] { - try { - // Use native MetadataCache.getTags() which returns Record - // where keys are tag names and values are usage counts - const nativeTags = this.app.metadataCache.getTags(); - - if (nativeTags && typeof nativeTags === "object") { - // Extract tag names and remove # prefix if present - const allTags = Object.keys(nativeTags) - .map((tag) => (tag.startsWith("#") ? tag.slice(1) : tag)) - .filter((tag) => tag.length > 0); // Remove empty tags - - return Array.from(new Set(allTags)).sort(); // Remove duplicates and sort - } - } catch (error) { - console.warn( - "MinimalNativeCache: Failed to use native getTags(), falling back to manual scan:", - error - ); - } - - // Fallback to original implementation if native method fails - const tags = new Set(); - const markdownFiles = this.app.vault - .getMarkdownFiles() - .filter((file) => this.isValidFile(file.path)); - - for (const file of markdownFiles) { - const metadata = this.app.metadataCache.getFileCache(file); - if (metadata?.frontmatter && this.isTaskFile(metadata.frontmatter)) { - const taskInfo = this.extractTaskInfoFromNative(file.path, metadata.frontmatter); - if (taskInfo?.tags && Array.isArray(taskInfo.tags)) { - taskInfo.tags.forEach((tag) => tags.add(tag)); - } - } - } - - return Array.from(tags).sort(); - } - - /** - * Get all contexts by computing on-demand - */ - getAllContexts(): string[] { - const contexts = new Set(); - const markdownFiles = this.app.vault - .getMarkdownFiles() - .filter((file) => this.isValidFile(file.path)); - - for (const file of markdownFiles) { - const metadata = this.app.metadataCache.getFileCache(file); - if (metadata?.frontmatter && this.isTaskFile(metadata.frontmatter)) { - const taskInfo = this.extractTaskInfoFromNative(file.path, metadata.frontmatter); - if (taskInfo?.contexts && Array.isArray(taskInfo.contexts)) { - taskInfo.contexts.forEach((context) => contexts.add(context)); - } - } - } - - return Array.from(contexts).sort(); - } - - /** - * Get all projects using native MetadataCache.resolvedLinks for optimal performance - * This leverages Obsidian's pre-computed link index rather than scanning all task files - */ - getAllProjects(): string[] { - try { - const projects = new Set(); - const resolvedLinks = this.app.metadataCache.resolvedLinks; - - if (resolvedLinks && typeof resolvedLinks === "object") { - // Iterate through all source files and their resolved links - for (const [sourcePath, targets] of Object.entries(resolvedLinks)) { - if (!targets || !this.isValidFile(sourcePath)) continue; - - // Check if source file is a task with project references - const metadata = this.app.metadataCache.getCache(sourcePath); - if (!metadata?.frontmatter || !this.isTaskFile(metadata.frontmatter)) continue; - - // Check if this task has projects field with wikilinks - const projectsFieldName = - this.fieldMapper?.toUserField("projects") || "projects"; - const projectsField = metadata.frontmatter[projectsFieldName]; - if (!Array.isArray(projectsField)) continue; - - // Check if projects field contains wikilinks (not just plain text) - const hasWikilinks = projectsField.some( - (p: any) => typeof p === "string" && p.startsWith("[[") && p.endsWith("]]") - ); - - if (hasWikilinks) { - // Add all linked files from this task as potential projects - for (const targetPath of Object.keys(targets)) { - if (targets[targetPath] > 0) { - const targetFile = this.app.vault.getAbstractFileByPath(targetPath); - if (targetFile instanceof TFile) { - projects.add(targetFile.basename); - } - } - } - } - } - - if (projects.size > 0) { - return Array.from(projects).sort(); - } - } - } catch (error) { - console.warn( - "MinimalNativeCache: Failed to use native resolvedLinks for projects, falling back to manual scan:", - error - ); - } - - // Fallback to original implementation if native method fails or returns no results - const projects = new Set(); - const markdownFiles = this.app.vault - .getMarkdownFiles() - .filter((file) => this.isValidFile(file.path)); - - for (const file of markdownFiles) { - const metadata = this.app.metadataCache.getFileCache(file); - if (metadata?.frontmatter && this.isTaskFile(metadata.frontmatter)) { - const taskInfo = this.extractTaskInfoFromNative(file.path, metadata.frontmatter); - if (taskInfo && taskInfo.projects) { - const filteredProjects = filterEmptyProjects(taskInfo.projects); - if (filteredProjects.length > 0) { - filteredProjects.forEach((project) => { - const extractedProjects = this.extractProjectNamesFromValue( - project, - file.path - ); - extractedProjects.forEach((projectName) => { - // Trim and validate project names to avoid empty entries - const cleanName = projectName.trim(); - if (cleanName.length > 0) { - projects.add(cleanName); - } - }); - }); - } - } - } - } - - return Array.from(projects).sort(); - } - - /** - * Get detailed project information including file paths and existence status - * Returns an object with project metadata for more advanced use cases - */ - getAllProjectsWithDetails(): Array<{ - name: string; - isLinkedNote: boolean; - filePath?: string; - exists: boolean; - usageCount: number; - }> { - const projectMap = new Map< - string, - { - name: string; - isLinkedNote: boolean; - filePath?: string; - exists: boolean; - usageCount: number; - } - >(); - - const markdownFiles = this.app.vault - .getMarkdownFiles() - .filter((file) => this.isValidFile(file.path)); - - for (const file of markdownFiles) { - const metadata = this.app.metadataCache.getFileCache(file); - if (metadata?.frontmatter && this.isTaskFile(metadata.frontmatter)) { - const taskInfo = this.extractTaskInfoFromNative(file.path, metadata.frontmatter); - const filteredProjects = filterEmptyProjects(taskInfo?.projects || []); - if (filteredProjects.length > 0) { - filteredProjects.forEach((project) => { - const projectDetails = this.extractProjectDetailsFromValue( - project, - file.path - ); - projectDetails.forEach((detail) => { - const existing = projectMap.get(detail.name); - if (existing) { - existing.usageCount++; - } else { - projectMap.set(detail.name, { ...detail, usageCount: 1 }); - } - }); - }); - } - } - } - - return Array.from(projectMap.values()).sort((a, b) => a.name.localeCompare(b.name)); - } - - /** - * Get all project notes that exist as files in the vault - * This returns only projects that are linked notes and actually exist as files - */ - getAllProjectFiles(): Array<{ - name: string; - path: string; - usageCount: number; - }> { - const projectDetails = this.getAllProjectsWithDetails(); - return projectDetails - .filter((project) => project.isLinkedNote && project.exists && project.filePath) - .map((project) => ({ - name: project.name, - path: project.filePath!, - usageCount: project.usageCount, - })); - } - - /** - * Extract project names from a project field value - * Handles both [[Note Name]] links and plain strings - * Returns an array of project names that should be included in the project list - */ - private extractProjectNamesFromValue(projectValue: string, sourcePath: string): string[] { - if (!projectValue) return []; - - const projects: string[] = []; - - // Check if this is a [[link]] format - if (projectValue.startsWith("[[") && projectValue.endsWith("]]")) { - const linkContent = projectValue.slice(2, -2); - const parsed = parseLinktext(linkContent); - const linkPath = parsed.path; - - // Handle both relative and absolute link paths - let resolvedFile; - - // First try to resolve as-is (handles absolute paths and relative paths from vault root) - resolvedFile = this.app.metadataCache.getFirstLinkpathDest(linkPath, ""); - - // If not found, try resolving relative to the source file's directory - if (!resolvedFile) { - const sourceDir = sourcePath.substring(0, sourcePath.lastIndexOf("/")); - resolvedFile = this.app.metadataCache.getFirstLinkpathDest(linkPath, sourceDir); - } - - if (resolvedFile) { - // Return the actual note name (basename without extension) - projects.push(resolvedFile.basename); - } else { - // If the linked file doesn't exist, still include the link text - // This handles cases where the project note hasn't been created yet - // but the link exists in the project field - const noteName = linkPath.split("/").pop()?.replace(/\.md$/, "") || linkPath; - projects.push(noteName); - } - } else { - // This is a plain string project name - // Only include it if it's not empty and not just whitespace - const trimmed = projectValue.trim(); - if (trimmed) { - projects.push(trimmed); - } - } - - return projects; - } - - /** - * Extract detailed project information from a project field value - * Returns project metadata including file paths and existence status - */ - private extractProjectDetailsFromValue( - projectValue: string, - sourcePath: string - ): Array<{ - name: string; - isLinkedNote: boolean; - filePath?: string; - exists: boolean; - }> { - if (!projectValue) return []; - - const projects: Array<{ - name: string; - isLinkedNote: boolean; - filePath?: string; - exists: boolean; - }> = []; - - // Check if this is a [[link]] format - if (projectValue.startsWith("[[") && projectValue.endsWith("]]")) { - const linkContent = projectValue.slice(2, -2); - const parsed = parseLinktext(linkContent); - const linkPath = parsed.path; - - // Handle both relative and absolute link paths - let resolvedFile; - - // First try to resolve as-is (handles absolute paths and relative paths from vault root) - resolvedFile = this.app.metadataCache.getFirstLinkpathDest(linkPath, ""); - - // If not found, try resolving relative to the source file's directory - if (!resolvedFile) { - const sourceDir = sourcePath.substring(0, sourcePath.lastIndexOf("/")); - resolvedFile = this.app.metadataCache.getFirstLinkpathDest(linkPath, sourceDir); - } - - if (resolvedFile) { - // Return the actual note name and file path - projects.push({ - name: resolvedFile.basename, - isLinkedNote: true, - filePath: resolvedFile.path, - exists: true, - }); - } else { - // If the linked file doesn't exist, still include the link info - const noteName = linkPath.split("/").pop()?.replace(/\.md$/, "") || linkPath; - projects.push({ - name: noteName, - isLinkedNote: true, - filePath: undefined, - exists: false, - }); - } - } else { - // This is a plain string project name - const trimmed = projectValue.trim(); - if (trimmed) { - projects.push({ - name: trimmed, - isLinkedNote: false, - filePath: undefined, - exists: false, - }); - } - } - - return projects; - } - - // ======================================== - // BACKWARD COMPATIBILITY METHODS - // ======================================== - - /** - * Sync version of getTaskInfo for editor extensions - */ - getCachedTaskInfoSync(path: string): TaskInfo | null { - const file = this.app.vault.getAbstractFileByPath(path); - if (!(file instanceof TFile)) return null; - - const metadata = this.app.metadataCache.getFileCache(file); - if (!metadata?.frontmatter) return null; - - // Check if the note has the task tag - if (!this.isTaskFile(metadata.frontmatter)) return null; - - return this.extractTaskInfoFromNative(path, metadata.frontmatter); - } - - /** - * Alias methods for backward compatibility - */ - async getCachedTaskInfo(path: string): Promise { - return this.getTaskInfo(path); - } - - async getTaskByPath(path: string): Promise { - return this.getTaskInfo(path); - } - - async getNotesForDate(date: Date): Promise { - // Check if note indexing is disabled - if (this.disableNoteIndexing) { - return []; - } - - const targetDateStr = getDatePart(date.toISOString()); // YYYY-MM-DD format - const notes: NoteInfo[] = []; - - // Get all markdown files excluding task files and excluded folders - const markdownFiles = this.app.vault - .getMarkdownFiles() - .filter((file) => this.isValidFile(file.path)); - - for (const file of markdownFiles) { - const metadata = this.app.metadataCache.getFileCache(file); - if (!metadata?.frontmatter) continue; - - const frontmatter = metadata.frontmatter; - const isTask = this.isTaskFile(frontmatter); - - // Skip task files - we only want notes - if (isTask) continue; - - let noteDate: string | null = null; - - // Try to extract date from frontmatter using field mapper - if (this.fieldMapper) { - const dateCreatedField = this.fieldMapper.toUserField("dateCreated"); - if (frontmatter[dateCreatedField]) { - const dateValue = frontmatter[dateCreatedField]; - - // Pre-validate the date value to avoid console warnings - if (typeof dateValue === "string" && dateValue.trim()) { - const trimmed = dateValue.trim(); - - // Skip invalid time-only formats like "T00:00" that would cause console warnings - if (trimmed.startsWith("T") && /^T\d{2}:\d{2}(:\d{2})?/.test(trimmed)) { - console.debug("Skipping invalid time-only date in note frontmatter:", { - path: file.path, - dateValue: trimmed, - }); - // Continue to filename parsing - } else { - try { - const parsed = parseDateToUTC(dateValue); - noteDate = formatDateForStorage(parsed); - } catch (e) { - // Ignore invalid dates or parsing errors - console.debug("Failed to parse date from note frontmatter:", { - path: file.path, - dateValue, - error: e instanceof Error ? e.message : String(e), - }); - } - } - } - } - } - - // If not found in frontmatter, try to extract from filename using parseDate - if (!noteDate) { - const fileName = file.basename; - const dateMatch = fileName.match(/(\d{4}-\d{2}-\d{2})/); - if (dateMatch) { - try { - const parsed = parseDateToUTC(dateMatch[1]); - noteDate = formatDateForStorage(parsed); - } catch (e) { - // Ignore invalid dates or parsing errors - } - } - } - - // If we found a date and it matches our target date, include this note - if (noteDate === targetDateStr) { - // Extract tags from frontmatter or metadata - let tags: string[] = []; - if (frontmatter.tags) { - if (Array.isArray(frontmatter.tags)) { - tags = frontmatter.tags.filter((tag) => typeof tag === "string"); - } else if (typeof frontmatter.tags === "string") { - tags = [frontmatter.tags]; - } - } - - // Also include tags from Obsidian's tag parsing - if (metadata.tags) { - const obsidianTags = metadata.tags.map((tag) => tag.tag.replace("#", "")); - tags = [...new Set([...tags, ...obsidianTags])]; - } - - const noteInfo: NoteInfo = { - title: file.basename, - tags: tags, - path: file.path, - createdDate: noteDate, - lastModified: file.stat.mtime, - }; - - notes.push(noteInfo); - } - } - - return notes; - } - - async getTaskInfoForDate(date: Date): Promise { - const dateStr = date.toISOString().split("T")[0]; - const taskPaths = this.getTasksForDate(dateStr); - const tasks = await Promise.all(taskPaths.map((path) => this.getTaskInfo(path))); - return tasks.filter((task) => task !== null) as TaskInfo[]; - } - - getTaskPathsByDate(dateStr: string): Set { - return new Set(this.getTasksForDate(dateStr)); - } - - getTaskPathsByPriority(priority: string): string[] { - // Compute on-demand - no longer indexed - const taskPaths: string[] = []; - const markdownFiles = this.app.vault - .getMarkdownFiles() - .filter((file) => this.isValidFile(file.path)); - - for (const file of markdownFiles) { - const metadata = this.app.metadataCache.getFileCache(file); - if (metadata?.frontmatter && this.isTaskFile(metadata.frontmatter)) { - const taskInfo = this.extractTaskInfoFromNative(file.path, metadata.frontmatter); - if (taskInfo?.priority === priority) { - taskPaths.push(file.path); - } - } - } - - return taskPaths; - } - - async rebuildDailyNotesCache(year: number, month: number): Promise { - // No-op - use Obsidian's daily notes interface - } - - // ======================================== - // ESSENTIAL INDEX MANAGEMENT - // ======================================== - - private updateDateIndex(path: string, taskInfo: TaskInfo): void { - // Remove from existing date indexes - for (const taskSet of this.tasksByDate.values()) { - taskSet.delete(path); - } - - // Add to due date index - if (taskInfo.due) { - const dueDateKey = getDatePart(taskInfo.due); - if (!this.tasksByDate.has(dueDateKey)) { - this.tasksByDate.set(dueDateKey, new Set()); - } - this.tasksByDate.get(dueDateKey)!.add(path); - } - - // Add to scheduled date index - if (taskInfo.scheduled) { - const scheduledDateKey = getDatePart(taskInfo.scheduled); - if (!this.tasksByDate.has(scheduledDateKey)) { - this.tasksByDate.set(scheduledDateKey, new Set()); - } - this.tasksByDate.get(scheduledDateKey)!.add(path); - } - } - - private updateStatusIndex(path: string, status: string): void { - // Remove from all status indexes - for (const statusSet of this.tasksByStatus.values()) { - statusSet.delete(path); - } - - // Add to new status index - if (!this.tasksByStatus.has(status)) { - this.tasksByStatus.set(status, new Set()); - } - this.tasksByStatus.get(status)!.add(path); - } - - private updateOverdueIndex(path: string, taskInfo: TaskInfo): void { - this.overdueTasks.delete(path); - - // Check if task is overdue - if (!taskInfo.recurrence) { - const today = getTodayString(); - - if (taskInfo.due && isBeforeDateSafe(taskInfo.due, today)) { - this.overdueTasks.add(path); - } else if ( - !taskInfo.due && - taskInfo.scheduled && - isBeforeDateSafe(taskInfo.scheduled, today) - ) { - this.overdueTasks.add(path); - } - } - } - - private updateProjectReferencesIndex( - taskPath: string, - projects: string[] | null | undefined - ): void { - // Remove this task from all existing project references - for (const referencingTasks of this.projectReferences.values()) { - referencingTasks.delete(taskPath); - } - - // Add to new project references (wikilinks only) - if (projects && projects.length > 0) { - for (const projectRef of projects) { - if (!projectRef || typeof projectRef !== "string") continue; - - // Only handle wikilink format [[Note Name]] - plain text doesn't create project relationships - if (projectRef.startsWith("[[") && projectRef.endsWith("]]")) { - const linkedNoteName = projectRef.slice(2, -2).trim(); - const resolvedFile = this.app.metadataCache.getFirstLinkpathDest( - linkedNoteName, - "" - ); - if (resolvedFile) { - const resolvedPath = resolvedFile.path; - if (!this.projectReferences.has(resolvedPath)) { - this.projectReferences.set(resolvedPath, new Set()); - } - this.projectReferences.get(resolvedPath)!.add(taskPath); - } - } - } - } - } - - private updateTimeEstimateIndex(path: string, taskInfo: TaskInfo): void { - if (taskInfo.timeEstimate !== undefined && taskInfo.timeEstimate > 0) { - this.timeEstimatesByPath.set(path, taskInfo.timeEstimate); - } else { - // Remove from index if timeEstimate is not set or is zero - this.timeEstimatesByPath.delete(path); - } - } - - private updateDependencyIndexes( - taskPath: string, - blockedByEntries: TaskDependency[] - ): { resolvedBlockers: string[]; blocking: string[] } { - const previousBlockers = this.dependencySources.get(taskPath); - if (previousBlockers) { - for (const blockerPath of previousBlockers) { - const blockedSet = this.dependencyTargets.get(blockerPath); - if (blockedSet) { - blockedSet.delete(taskPath); - if (blockedSet.size === 0) { - this.dependencyTargets.delete(blockerPath); - } - } - } - } - this.dependencySources.delete(taskPath); - - const resolvedBlockers: string[] = []; - const uniqueBlockers = new Set(); - - for (const entry of blockedByEntries) { - const resolved = resolveDependencyEntry(this.app, taskPath, entry); - if (!resolved) { - continue; - } - if (resolved.path === taskPath) { - continue; // Skip self-references - } - if (!uniqueBlockers.has(resolved.path)) { - uniqueBlockers.add(resolved.path); - resolvedBlockers.push(resolved.path); - } - } - - if (uniqueBlockers.size > 0) { - this.dependencySources.set(taskPath, uniqueBlockers); - for (const blockerPath of uniqueBlockers) { - if (!this.dependencyTargets.has(blockerPath)) { - this.dependencyTargets.set(blockerPath, new Set()); - } - this.dependencyTargets.get(blockerPath)!.add(taskPath); - } - } - - const blockingSet = this.dependencyTargets.get(taskPath); - const blocking = blockingSet ? Array.from(blockingSet) : []; - - return { resolvedBlockers, blocking }; - } - - private calculateIsBlocked(resolvedBlockers: string[]): boolean { - if (resolvedBlockers.length === 0) { - return false; - } - - for (const blockerPath of resolvedBlockers) { - const status = this.getStatusForTaskPath(blockerPath); - if (!status) { - return true; // Treat unresolved status as blocking - } - if (!this.isStatusCompleted(status)) { - return true; - } - } - - return false; - } - - private getStatusForTaskPath(path: string): string | undefined { - const cached = this.lastKnownTaskInfo.get(path); - if (cached) { - return cached.status; - } - - const file = this.app.vault.getAbstractFileByPath(path); - if (!(file instanceof TFile)) { - return undefined; - } - - const metadata = this.app.metadataCache.getFileCache(file); - if (!metadata?.frontmatter || !this.fieldMapper || !this.isTaskFile(metadata.frontmatter)) { - return undefined; - } - - try { - const mapped = this.fieldMapper.mapFromFrontmatter( - metadata.frontmatter, - path, - this.storeTitleInFilename - ); - return mapped.status; - } catch (error) { - console.error(`Failed to resolve status for dependency task ${path}:`, error); - return undefined; - } - } - - private isStatusCompleted(statusValue: string | undefined): boolean { - if (!statusValue) { - return false; - } - - const statuses = this.settings.customStatuses || []; - const match = statuses.find((status) => status.value === statusValue); - if (match) { - return match.isCompleted; - } - - const lower = statusValue.toLowerCase(); - return lower === "done" || lower === "completed" || lower === "complete"; - } - // ======================================== - // EVENT HANDLERS - // ======================================== - - /** - * Debounced version of handleFileChanged to prevent excessive updates during typing - */ - private async handleFileChangedDebounced(file: TFile, cache: any): Promise { - // Clear any existing timeout for this file FIRST to prevent orphaned timeouts - const existingTimeout = this.debouncedHandlers.get(file.path); - if (existingTimeout) { - clearTimeout(existingTimeout); - this.debouncedHandlers.delete(file.path); - } - - // Check for metadata availability - wait briefly if not immediately available - // This prevents race conditions where we might misclassify tasks during metadata updates - let metadata = this.app.metadataCache.getFileCache(file); - if (!metadata?.frontmatter) { - // Metadata not immediately available - wait briefly for it to be ready - // Use a shorter wait than the full waitForFreshData since we're in the hot path - await this.waitForFreshData(file, 3); // Only wait up to 3 attempts (~200ms max) - metadata = this.app.metadataCache.getFileCache(file); - } - - // Early exit: Only process files that are currently task files - if (!metadata?.frontmatter || !this.isTaskFile(metadata.frontmatter)) { - // Clean up state caches if file is no longer a task - this.lastKnownFrontmatter.delete(file.path); - this.lastKnownTaskInfo.delete(file.path); - return; - } - - // Check if frontmatter actually changed by comparing raw frontmatter objects - const currentFrontmatter = metadata.frontmatter; - const lastKnownFrontmatter = this.lastKnownFrontmatter.get(file.path); - - if (this.frontmatterEquals(currentFrontmatter, lastKnownFrontmatter)) { - // Frontmatter hasn't changed - this was likely just a content change or mtime update - return; - } - - // Store the current frontmatter for future comparisons - this.lastKnownFrontmatter.set(file.path, this.deepClone(currentFrontmatter)); - - // Extract task info for view update comparison - const currentTaskInfo = this.extractTaskInfoFromNative(file.path, currentFrontmatter); - const lastKnownTaskInfo = this.getLastKnownTaskInfo(file.path); - - // Even if frontmatter changed, check if it affects task rendering - if (this.taskInfoEquals(currentTaskInfo, lastKnownTaskInfo)) { - // Update our cached task info but don't trigger view updates - this.setLastKnownTaskInfo(file.path, currentTaskInfo); - return; - } - - // Store the current task info for future comparisons - this.setLastKnownTaskInfo(file.path, currentTaskInfo); - - // Set up new debounced handler - const timeout = setTimeout(() => { - this.handleFileChanged(file, cache); - this.debouncedHandlers.delete(file.path); - }, this.DEBOUNCE_DELAY) as unknown as number; - - this.debouncedHandlers.set(file.path, timeout); - } - - private async handleFileChanged(file: TFile, cache: any): Promise { - if (!this.initialized) return; - - // Wait for fresh data to be available before making any decisions - await this.waitForFreshData(file); - - const metadata = this.app.metadataCache.getFileCache(file); - const isTask = metadata?.frontmatter && this.isTaskFile(metadata.frontmatter); - - // Always clear from indexes first - will be re-added if still a task - this.clearFileFromIndexes(file.path); - - if (isTask) { - // Re-index the task with fresh metadata - await this.indexTaskFile(file, metadata.frontmatter); - } else { - // File is no longer a task - clean up cached data (issue #953) - this.lastKnownFrontmatter.delete(file.path); - this.lastKnownTaskInfo.delete(file.path); - } - - this.trigger("file-updated", { path: file.path, file }); - } - - private handleFileDeleted(path: string): void { - if (!this.initialized) return; - - this.clearFileFromIndexes(path); - this.lastKnownTaskInfo.delete(path); // Clean up cached task info - this.lastKnownFrontmatter.delete(path); // Clean up cached frontmatter - this.trigger("file-deleted", { path }); - } - - private async handleFileRenamed(file: TFile, oldPath: string): Promise { - if (!this.initialized) return; - - this.clearFileFromIndexes(oldPath); - this.lastKnownTaskInfo.delete(oldPath); // Clean up old path cached task info - this.lastKnownFrontmatter.delete(oldPath); // Clean up old path cached frontmatter - - // Wait for fresh data to be available before proceeding - await this.waitForFreshData(file); - - const metadata = this.app.metadataCache.getFileCache(file); - if (metadata?.frontmatter && this.isTaskFile(metadata.frontmatter)) { - await this.indexTaskFile(file, metadata.frontmatter); - // Store the task info and frontmatter for the new path - const taskInfo = this.extractTaskInfoFromNative(file.path, metadata.frontmatter); - this.setLastKnownTaskInfo(file.path, taskInfo); - this.lastKnownFrontmatter.set(file.path, this.deepClone(metadata.frontmatter)); - } - - this.trigger("file-renamed", { oldPath, newPath: file.path, file }); - } - - /** - * Wait for fresh data to be available in Obsidian's metadata cache - * This ensures we don't emit events before the data is actually updated - */ - private async waitForFreshData(file: TFile, maxAttempts = 10): Promise { - const startTime = Date.now(); - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - try { - // Try to get file stats and metadata - const fileStat = await this.app.vault.adapter.stat(file.path); - if (!fileStat) break; - - const metadata = this.app.metadataCache.getFileCache(file); - - // If metadata exists and we can extract task info, data is ready - if (metadata?.frontmatter) { - const taskInfo = this.extractTaskInfoFromNative( - file.path, - metadata.frontmatter - ); - if (taskInfo || !this.isTaskFile(metadata.frontmatter)) { - // Data is available (either valid task info or confirmed non-task) - return; - } - } - - // Data not ready yet, wait with exponential backoff - const delay = Math.min(50 * Math.pow(1.5, attempt), 200); - await new Promise((resolve) => setTimeout(resolve, delay)); - } catch (error) { - // If we can't check file stats, just wait briefly and continue - await new Promise((resolve) => setTimeout(resolve, 50)); - } - } - - // Log if we timed out (for debugging slow systems) - only log very slow waits - const elapsed = Date.now() - startTime; - if (elapsed > 3000) { - console.warn( - `MinimalNativeCache: Waited ${elapsed}ms for fresh data on ${file.path} - consider investigating file system performance` - ); - } - } - - /** - * Wait for fresh task data with specific expected changes - * This can be called from TaskService to verify specific updates are reflected - */ - async waitForFreshTaskData( - file: TFile, - expectedChanges?: Partial, - maxAttempts = 10 - ): Promise { - const startTime = Date.now(); - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - try { - const metadata = this.app.metadataCache.getFileCache(file); - if (!metadata?.frontmatter) { - const delay = Math.min(50 * Math.pow(1.5, attempt), 200); - await new Promise((resolve) => setTimeout(resolve, delay)); - continue; - } - - // Try to extract task info - const taskInfo = this.extractTaskInfoFromNative(file.path, metadata.frontmatter); - if (taskInfo) { - // If we have expected changes, verify they're present - if (expectedChanges) { - let dataIsUpdated = true; - for (const [key, expectedValue] of Object.entries(expectedChanges)) { - const actualValue = taskInfo[key as keyof TaskInfo]; - if (actualValue !== expectedValue) { - dataIsUpdated = false; - break; - } - } - if (dataIsUpdated) { - return; // Data matches expectations - } - } else { - return; // Data is available - } - } - - // Data not ready yet, wait with exponential backoff - const delay = Math.min(50 * Math.pow(1.5, attempt), 200); - await new Promise((resolve) => setTimeout(resolve, delay)); - } catch (error) { - await new Promise((resolve) => setTimeout(resolve, 50)); - } - } - - // Log if we timed out (for debugging) - only log very slow waits - const elapsed = Date.now() - startTime; - if (elapsed > 3000) { - console.warn( - `MinimalNativeCache: Waited ${elapsed}ms for fresh task data on ${file.path}`, - expectedChanges - ); - } - } - - // ======================================== - // UTILITY METHODS - // ======================================== - - /** - * Get the last known task info for a file path - */ - private getLastKnownTaskInfo(path: string): TaskInfo | null { - return this.lastKnownTaskInfo.get(path) || null; - } - - /** - * Store the last known task info for a file path - */ - private setLastKnownTaskInfo(path: string, taskInfo: TaskInfo | null): void { - this.lastKnownTaskInfo.set(path, taskInfo); - } - - /** - * Compare two TaskInfo objects to see if they're functionally equivalent - * Only compares fields that would affect view rendering - */ - private taskInfoEquals(current: TaskInfo | null, previous: TaskInfo | null): boolean { - if (current === null && previous === null) return true; - if (current === null || previous === null) return false; - - // Compare scalar fields - const scalarFieldsEqual = - current.title === previous.title && - current.status === previous.status && - current.priority === previous.priority && - current.due === previous.due && - current.scheduled === previous.scheduled && - current.archived === previous.archived && - current.completedDate === previous.completedDate && - current.recurrence === previous.recurrence && - current.timeEstimate === previous.timeEstimate; - - if (!scalarFieldsEqual) return false; - - // Compare array fields more efficiently - return ( - this.arraysEqual(current.tags, previous.tags) && - this.arraysEqual(current.contexts, previous.contexts) && - this.arraysEqual(current.projects, previous.projects) && - this.objectsEqual(current.timeEntries, previous.timeEntries) && - this.objectsEqual(current.reminders, previous.reminders) && - this.objectsEqual(current.complete_instances, previous.complete_instances) - ); - } - - /** - * Compare two arrays for equality (order-independent for string arrays) - */ - private arraysEqual(a: any[] | undefined, b: any[] | undefined): boolean { - if (a === b) return true; - if (!a || !b) return a === b; - if (a.length !== b.length) return false; - - // For string arrays, sort and compare - if (a.length === 0) return true; - if (typeof a[0] === "string") { - const sortedA = [...a].sort(); - const sortedB = [...b].sort(); - return sortedA.every((val, i) => val === sortedB[i]); - } - - // For object arrays, fall back to JSON comparison (less common case) - return JSON.stringify(a) === JSON.stringify(b); - } - - /** - * Compare two objects for deep equality - */ - private objectsEqual(a: any, b: any): boolean { - if (a === b) return true; - if (!a || !b) return a === b; - return JSON.stringify(a) === JSON.stringify(b); - } - - /** - * Compare two frontmatter objects for equality - */ - private frontmatterEquals(current: any, previous: any): boolean { - if (current === previous) return true; - if (!current || !previous) return current === previous; - - // Use JSON comparison for frontmatter since it's typically small - return JSON.stringify(current) === JSON.stringify(previous); - } - - /** - * Deep clone an object (for caching frontmatter) - */ - private deepClone(obj: any): any { - if (obj === null || typeof obj !== "object") return obj; - return JSON.parse(JSON.stringify(obj)); - } - - private extractTaskInfoFromNative(path: string, frontmatter: any): TaskInfo | null { - if (!this.fieldMapper) return null; - - // Validate that the file is actually a task based on identification settings - // This ensures we return null when a file stops being a task - if (!this.isTaskFile(frontmatter)) return null; - - try { - const mappedTask = this.fieldMapper.mapFromFrontmatter( - frontmatter, - path, - this.storeTitleInFilename - ); - - const blockedByEntries = normalizeDependencyList(mappedTask.blockedBy) ?? []; - const { resolvedBlockers, blocking } = this.updateDependencyIndexes( - path, - blockedByEntries - ); - const isBlocked = this.calculateIsBlocked(resolvedBlockers); - const isBlocking = blocking.length > 0; - - // Calculate total tracked time from time entries - const totalTrackedTime = mappedTask.timeEntries - ? calculateTotalTimeSpent(mappedTask.timeEntries) - : 0; - - return { - id: path, // Add id field for API consistency - title: mappedTask.title || "Untitled task", - status: mappedTask.status || "open", - priority: mappedTask.priority || "normal", - due: mappedTask.due, - scheduled: mappedTask.scheduled, - path, - archived: mappedTask.archived || false, - tags: Array.isArray(mappedTask.tags) ? mappedTask.tags : [], - contexts: Array.isArray(mappedTask.contexts) ? mappedTask.contexts : [], - projects: Array.isArray(mappedTask.projects) ? mappedTask.projects : [], - recurrence: mappedTask.recurrence, - complete_instances: mappedTask.complete_instances, - completedDate: mappedTask.completedDate, - timeEstimate: mappedTask.timeEstimate, - timeEntries: mappedTask.timeEntries, - totalTrackedTime: totalTrackedTime, - dateCreated: mappedTask.dateCreated, - dateModified: mappedTask.dateModified, - reminders: mappedTask.reminders, - blockedBy: blockedByEntries.length > 0 ? blockedByEntries : undefined, - blocking: blocking.length > 0 ? blocking : undefined, - isBlocked: blockedByEntries.length > 0 ? isBlocked : false, - isBlocking: isBlocking, - }; - } catch (error) { - console.error(`Error extracting task info from native metadata for ${path}:`, error); - return null; - } - } - - isValidFile(path: string): boolean { - return !this.excludedFolders.some((folder) => path.startsWith(folder)); - } - - private clearFileFromIndexes(path: string): void { - // Remove from date indexes - for (const taskSet of this.tasksByDate.values()) { - taskSet.delete(path); - } - - // Remove from status indexes - for (const statusSet of this.tasksByStatus.values()) { - statusSet.delete(path); - } - - // Remove from time estimate index - this.timeEstimatesByPath.delete(path); - - // Remove from overdue tasks - this.overdueTasks.delete(path); - - // Remove from project references indexes - for (const referencingTasks of this.projectReferences.values()) { - referencingTasks.delete(path); - } - - // Remove from dependency indexes - this.dependencySources.delete(path); - this.dependencyTargets.delete(path); - for (const blockerSet of this.dependencySources.values()) { - blockerSet.delete(path); - } - for (const blockedSet of this.dependencyTargets.values()) { - blockedSet.delete(path); - } - } - - private clearAllIndexes(): void { - this.tasksByDate.clear(); - this.tasksByStatus.clear(); - this.timeEstimatesByPath.clear(); - this.overdueTasks.clear(); - this.projectReferences.clear(); - this.dependencySources.clear(); - this.dependencyTargets.clear(); - } - - // ======================================== - // CACHE MANAGEMENT API - // ======================================== - - updateConfig( - taskTag: string, - excludedFolders: string, - fieldMapper?: FieldMapper, - disableNoteIndexing = false, - storeTitleInFilename = false - ): void { - this.taskTag = taskTag; - this.excludedFolders = excludedFolders - ? excludedFolders - .split(",") - .map((folder) => folder.trim()) - .filter((folder) => folder.length > 0) - : []; - this.fieldMapper = fieldMapper; - this.disableNoteIndexing = disableNoteIndexing; - this.storeTitleInFilename = storeTitleInFilename; - - if (this.initialized) { - this.clearAllIndexes(); - this.indexesBuilt = false; - } - } - - clearCacheEntry(path: string): void { - const file = this.app.vault.getAbstractFileByPath(path); - if (file instanceof TFile) { - const metadata = this.app.metadataCache.getFileCache(file); - if (metadata?.frontmatter && this.isTaskFile(metadata.frontmatter)) { - this.indexTaskFile(file, metadata.frontmatter); - } - } - } - - async clearAllCaches(): Promise { - this.clearAllIndexes(); - this.indexesBuilt = false; - } - - updateTaskInfoInCache(path: string, taskInfo: TaskInfo): void { - // Native metadata cache handles this automatically - // Just trigger our minimal index update - if (this.indexesBuilt) { - this.clearFileFromIndexes(path); - this.updateDateIndex(path, taskInfo); - this.updateStatusIndex(path, taskInfo.status); - this.updateOverdueIndex(path, taskInfo); - this.updateProjectReferencesIndex(path, taskInfo.projects); - this.updateDependencyIndexes(path, taskInfo.blockedBy || []); - } - } - - subscribe(eventName: string, callback: (data: any) => void): () => void { - this.on(eventName, callback); - return () => this.off(eventName, callback); - } - - isInitialized(): boolean { - return this.initialized; - } - - getStats() { - return { - indexSizes: { - tasksByDate: this.tasksByDate.size, - tasksByStatus: this.tasksByStatus.size, - timeEstimatesByPath: this.timeEstimatesByPath.size, - overdueTasks: this.overdueTasks.size, - }, - memoryFootprint: "Minimal - only essential indexes", - }; - } - - destroy(): void { - // Clean up event listeners - this.eventListeners.forEach((listener) => { - this.app.metadataCache.offref(listener); - }); - this.eventListeners = []; - - // Clean up periodic cleanup interval - if (this.cleanupIntervalId !== null) { - clearInterval(this.cleanupIntervalId); - this.cleanupIntervalId = null; - } - - // Clean up any pending debounced handlers - this.debouncedHandlers.forEach((timeout) => clearTimeout(timeout)); - this.debouncedHandlers.clear(); - - // Clean up task info cache - this.lastKnownTaskInfo.clear(); - this.lastKnownFrontmatter.clear(); - - this.clearAllIndexes(); - this.initialized = false; - this.indexesBuilt = false; - } -} diff --git a/tests/helpers/mock-factories.ts b/tests/helpers/mock-factories.ts index 8a09f418..f0fadf95 100644 --- a/tests/helpers/mock-factories.ts +++ b/tests/helpers/mock-factories.ts @@ -389,7 +389,7 @@ export const PluginFactory = { }; const mockCache = { - // Core MinimalNativeCache methods + // Core TaskManager methods initialize: jest.fn(), getAllTasks: jest.fn().mockResolvedValue([]), getAllTaskPaths: jest.fn().mockReturnValue(new Set()), diff --git a/tests/unit/issues/issue-384-scheduled-grouping-bug.test.ts b/tests/unit/issues/issue-384-scheduled-grouping-bug.test.ts index 991f9ada..21b5d618 100644 --- a/tests/unit/issues/issue-384-scheduled-grouping-bug.test.ts +++ b/tests/unit/issues/issue-384-scheduled-grouping-bug.test.ts @@ -4,7 +4,7 @@ */ import { FilterService } from '../../../src/services/FilterService'; -import { MinimalNativeCache } from '../../../src/utils/MinimalNativeCache'; +import { TaskManager } from '../../../src/utils/TaskManager'; import { StatusManager } from '../../../src/services/StatusManager'; import { PriorityManager } from '../../../src/services/PriorityManager'; import { FilterQuery, TaskInfo } from '../../../src/types'; @@ -13,7 +13,7 @@ import * as dateFns from 'date-fns'; describe('Issue #384: Scheduled grouping misclassifies today as past', () => { let filterService: FilterService; - let mockCacheManager: jest.Mocked; + let mockCacheManager: jest.Mocked; let mockStatusManager: jest.Mocked; let mockPriorityManager: jest.Mocked; let startOfDaySpy: jest.SpyInstance; diff --git a/tests/unit/services/FilterService-fix-verification.test.ts b/tests/unit/services/FilterService-fix-verification.test.ts index c78d4b95..bed0fe27 100644 --- a/tests/unit/services/FilterService-fix-verification.test.ts +++ b/tests/unit/services/FilterService-fix-verification.test.ts @@ -3,7 +3,7 @@ */ import { FilterService } from '../../../src/services/FilterService'; -import { MinimalNativeCache } from '../../../src/utils/MinimalNativeCache'; +import { TaskManager } from '../../../src/utils/TaskManager'; import { StatusManager } from '../../../src/services/StatusManager'; import { PriorityManager } from '../../../src/services/PriorityManager'; import { TaskInfo, FilterQuery } from '../../../src/types'; @@ -27,7 +27,7 @@ describe('Date comparison logic', () => { describe('FilterService - Issue 153 Fix Verification', () => { let filterService: FilterService; - let mockCacheManager: jest.Mocked; + let mockCacheManager: jest.Mocked; let mockStatusManager: jest.Mocked; let mockPriorityManager: jest.Mocked; diff --git a/tests/unit/services/FilterService-issue-153-fixed.test.ts b/tests/unit/services/FilterService-issue-153-fixed.test.ts index 515c4571..6b436a0b 100644 --- a/tests/unit/services/FilterService-issue-153-fixed.test.ts +++ b/tests/unit/services/FilterService-issue-153-fixed.test.ts @@ -4,14 +4,14 @@ */ import { FilterService } from '../../../src/services/FilterService'; -import { MinimalNativeCache } from '../../../src/utils/MinimalNativeCache'; +import { TaskManager } from '../../../src/utils/TaskManager'; import { StatusManager } from '../../../src/services/StatusManager'; import { PriorityManager } from '../../../src/services/PriorityManager'; import { TaskInfo, FilterQuery } from '../../../src/types'; describe('FilterService - Issue 153 Fixed', () => { let filterService: FilterService; - let mockCacheManager: jest.Mocked; + let mockCacheManager: jest.Mocked; let mockStatusManager: jest.Mocked; let mockPriorityManager: jest.Mocked; diff --git a/tests/unit/services/filterService.groupByUserField.more.test.ts b/tests/unit/services/filterService.groupByUserField.more.test.ts index 8615e680..d60b13f5 100644 --- a/tests/unit/services/filterService.groupByUserField.more.test.ts +++ b/tests/unit/services/filterService.groupByUserField.more.test.ts @@ -1,5 +1,5 @@ import { FilterService } from '../../../src/services/FilterService'; -import { MinimalNativeCache } from '../../../src/utils/MinimalNativeCache'; +import { TaskManager } from '../../../src/utils/TaskManager'; import { StatusManager } from '../../../src/services/StatusManager'; import { PriorityManager } from '../../../src/services/PriorityManager'; import { MockObsidian, App } from '../../__mocks__/obsidian'; @@ -10,11 +10,11 @@ function makeApp(): App { return MockObsidian.createMockApp(); } function makeCache(app: App, settingsOverride: Partial = {}) { const mapper = new FieldMapper(DEFAULT_FIELD_MAPPING); const settings = { ...DEFAULT_SETTINGS, ...settingsOverride } as any; - const cache = new MinimalNativeCache(app as any, settings, mapper); + const cache = new TaskManager(app as any, settings, mapper); cache.initialize(); return cache; } -function makeFilterService(cache: MinimalNativeCache, plugin: any) { +function makeFilterService(cache: TaskManager, plugin: any) { const status = new StatusManager([]); const priority = new PriorityManager([]); return new FilterService(cache, status, priority, plugin); diff --git a/tests/unit/services/filterService.groupByUserField.test.ts b/tests/unit/services/filterService.groupByUserField.test.ts index 089e0a2a..2a934046 100644 --- a/tests/unit/services/filterService.groupByUserField.test.ts +++ b/tests/unit/services/filterService.groupByUserField.test.ts @@ -1,5 +1,5 @@ import { FilterService } from '../../../src/services/FilterService'; -import { MinimalNativeCache } from '../../../src/utils/MinimalNativeCache'; +import { TaskManager } from '../../../src/utils/TaskManager'; import { StatusManager } from '../../../src/services/StatusManager'; import { PriorityManager } from '../../../src/services/PriorityManager'; import { MockObsidian, App } from '../../__mocks__/obsidian'; @@ -14,12 +14,12 @@ function makeApp(): App { function makeCache(app: App, settingsOverride: Partial = {}) { const mapper = new FieldMapper(DEFAULT_FIELD_MAPPING); const settings = { ...DEFAULT_SETTINGS, ...settingsOverride } as any; - const cache = new MinimalNativeCache(app as any, settings, mapper); + const cache = new TaskManager(app as any, settings, mapper); cache.initialize(); return cache; } -function makeFilterService(cache: MinimalNativeCache, plugin: any) { +function makeFilterService(cache: TaskManager, plugin: any) { const status = new StatusManager([]); const priority = new PriorityManager([]); return new FilterService(cache, status, priority, plugin); diff --git a/tests/unit/services/filterService.sortByUserField.test.ts b/tests/unit/services/filterService.sortByUserField.test.ts index e86dabe5..fc2d815f 100644 --- a/tests/unit/services/filterService.sortByUserField.test.ts +++ b/tests/unit/services/filterService.sortByUserField.test.ts @@ -1,5 +1,5 @@ import { FilterService } from '../../../src/services/FilterService'; -import { MinimalNativeCache } from '../../../src/utils/MinimalNativeCache'; +import { TaskManager } from '../../../src/utils/TaskManager'; import { StatusManager } from '../../../src/services/StatusManager'; import { PriorityManager } from '../../../src/services/PriorityManager'; import { MockObsidian, App } from '../../__mocks__/obsidian'; @@ -10,11 +10,11 @@ function makeApp(): App { return MockObsidian.createMockApp(); } function makeCache(app: App) { const mapper = new FieldMapper(DEFAULT_FIELD_MAPPING); const settings = { ...DEFAULT_SETTINGS, taskIdentificationMethod: 'property', taskPropertyName: 'isTask', taskPropertyValue: 'true' } as any; - const cache = new MinimalNativeCache(app as any, settings, mapper); + const cache = new TaskManager(app as any, settings, mapper); cache.initialize(); return cache; } -function makeFilterService(cache: MinimalNativeCache, plugin: any) { +function makeFilterService(cache: TaskManager, plugin: any) { const status = new StatusManager([]); const priority = new PriorityManager([]); return new FilterService(cache, status, priority, plugin); diff --git a/tests/unit/services/filterService.userListNormalization.test.ts b/tests/unit/services/filterService.userListNormalization.test.ts index 9817ceca..90ae6779 100644 --- a/tests/unit/services/filterService.userListNormalization.test.ts +++ b/tests/unit/services/filterService.userListNormalization.test.ts @@ -1,5 +1,5 @@ import { FilterService } from '../../../src/services/FilterService'; -import { MinimalNativeCache } from '../../../src/utils/MinimalNativeCache'; +import { TaskManager } from '../../../src/utils/TaskManager'; import { StatusManager } from '../../../src/services/StatusManager'; import { PriorityManager } from '../../../src/services/PriorityManager'; import { MockObsidian } from '../../__mocks__/obsidian'; @@ -14,7 +14,7 @@ function createPluginWithUserFields(userFields: any[]) { function makeFilterService(plugin: any) { const app = MockObsidian.createMockApp(); - const cache = new MinimalNativeCache(app as any, DEFAULT_SETTINGS); + const cache = new TaskManager(app as any, DEFAULT_SETTINGS); const status = new StatusManager([]); const priority = new PriorityManager([]); return new FilterService(cache, status, priority, plugin); diff --git a/tests/unit/utils/MinimalNativeCache.boolean-property.test.ts b/tests/unit/utils/MinimalNativeCache.boolean-property.test.ts deleted file mode 100644 index 0c8a6ca3..00000000 --- a/tests/unit/utils/MinimalNativeCache.boolean-property.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * MinimalNativeCache boolean property identification tests - */ - -import { MinimalNativeCache } from '../../../src/utils/MinimalNativeCache'; -import { MockObsidian } from '../../__mocks__/obsidian'; - -describe('MinimalNativeCache - boolean property identification', () => { - let app: any; - - beforeEach(() => { - MockObsidian.reset(); - app = MockObsidian.createMockApp(); - }); - - function createFrontmatterContent(frontmatter: Record, body: string = '\n') { - const yaml = require('yaml').stringify(frontmatter); - return `---\n${yaml}---\n${body}`; - } - - it('recognizes notes when frontmatter boolean true matches setting value "true"', async () => { - // Arrange settings for property-based identification - const settings: any = { - taskIdentificationMethod: 'property', - taskPropertyName: 'isTask', - taskPropertyValue: 'true', - taskTag: 'task', - excludedFolders: '', - disableNoteIndexing: false, - storeTitleInFilename: false, - }; - - // Create a markdown file with boolean property set to true (unquoted) - const path = 'tasks/boolean-task.md'; - const content = createFrontmatterContent({ title: 'Boolean Task', isTask: true }); - MockObsidian.createTestFile(path, content); - - // Ensure metadata cache has the frontmatter (some tests bypass vault events) - app.metadataCache.setCache(path, { frontmatter: { title: 'Boolean Task', isTask: true } }); - - const cache = new MinimalNativeCache(app, settings); - cache.initialize(); - - // Act - const paths = cache.getAllTaskPaths(); - - // Assert - expect(paths.has(path)).toBe(true); - }); - - it('does not recognize notes when frontmatter boolean false and setting value is "true"', async () => { - const settings: any = { - taskIdentificationMethod: 'property', - taskPropertyName: 'isTask', - taskPropertyValue: 'true', - taskTag: 'task', - excludedFolders: '', - disableNoteIndexing: false, - storeTitleInFilename: false, - }; - - const path = 'tasks/not-a-task.md'; - const content = createFrontmatterContent({ title: 'Not a Task', isTask: false }); - MockObsidian.createTestFile(path, content); - - // Ensure metadata cache has the frontmatter - app.metadataCache.setCache(path, { frontmatter: { title: 'Not a Task', isTask: false } }); - - const cache = new MinimalNativeCache(app, settings); - cache.initialize(); - - const paths = cache.getAllTaskPaths(); - expect(paths.has(path)).toBe(false); - }); - - it('recognizes when property is an array containing boolean true', async () => { - const settings: any = { - taskIdentificationMethod: 'property', - taskPropertyName: 'isTask', - taskPropertyValue: 'true', - taskTag: 'task', - excludedFolders: '', - disableNoteIndexing: false, - storeTitleInFilename: false, - }; - - const path = 'tasks/array-task.md'; - const content = createFrontmatterContent({ title: 'Array Task', isTask: [false, true] }); - MockObsidian.createTestFile(path, content); - - // Ensure metadata cache has the frontmatter - app.metadataCache.setCache(path, { frontmatter: { title: 'Array Task', isTask: [false, true] } }); - - const cache = new MinimalNativeCache(app, settings); - cache.initialize(); - - // Act - const paths = cache.getAllTaskPaths(); - // Assert - expect(paths.has(path)).toBe(true); - }); - - it('does not recognize when frontmatter boolean true and setting value is "false"', async () => { - const settings: any = { - taskIdentificationMethod: 'property', - taskPropertyName: 'isTask', - taskPropertyValue: 'false', - taskTag: 'task', - excludedFolders: '', - disableNoteIndexing: false, - storeTitleInFilename: false, - }; - - const path = 'tasks/boolean-task-false-setting.md'; - const content = createFrontmatterContent({ title: 'Boolean Task', isTask: true }); - MockObsidian.createTestFile(path, content); - - app.metadataCache.setCache(path, { frontmatter: { title: 'Boolean Task', isTask: true } }); - - const cache = new MinimalNativeCache(app, settings); - cache.initialize(); - - const paths = cache.getAllTaskPaths(); - expect(paths.has(path)).toBe(false); - }); - - it('recognizes notes when frontmatter boolean false matches setting value "false"', async () => { - const settings: any = { - taskIdentificationMethod: 'property', - taskPropertyName: 'isTask', - taskPropertyValue: 'false', - taskTag: 'task', - excludedFolders: '', - disableNoteIndexing: false, - storeTitleInFilename: false, - }; - - const path = 'tasks/boolean-false-task.md'; - const content = createFrontmatterContent({ title: 'Boolean False Task', isTask: false }); - MockObsidian.createTestFile(path, content); - - app.metadataCache.setCache(path, { frontmatter: { title: 'Boolean False Task', isTask: false } }); - - const cache = new MinimalNativeCache(app, settings); - cache.initialize(); - - const paths = cache.getAllTaskPaths(); - expect(paths.has(path)).toBe(true); - }); -}); - diff --git a/tests/unit/utils/MinimalNativeCache.excluded-folders.test.ts b/tests/unit/utils/MinimalNativeCache.excluded-folders.test.ts deleted file mode 100644 index f4b118f7..00000000 --- a/tests/unit/utils/MinimalNativeCache.excluded-folders.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * MinimalNativeCache excluded folders handling tests - */ - -import { MinimalNativeCache } from '../../../src/utils/MinimalNativeCache'; -import { MockObsidian } from '../../__mocks__/obsidian'; - -describe('MinimalNativeCache - excluded folders handling', () => { - let app: any; - - beforeEach(() => { - MockObsidian.reset(); - app = MockObsidian.createMockApp(); - }); - - it('should handle excluded folders with trailing comma', async () => { - // Arrange settings with trailing comma in excludedFolders - const settings: any = { - taskIdentificationMethod: 'tag', - taskTag: 'task', - excludedFolders: 'archive,temp,', // Note the trailing comma - disableNoteIndexing: false, - storeTitleInFilename: false, - }; - - // Create the cache - const cache = new MinimalNativeCache(app, settings, null as any); - - // Test isValidFile method - expect(cache.isValidFile('regular/file.md')).toBe(true); - expect(cache.isValidFile('archive/file.md')).toBe(false); - expect(cache.isValidFile('temp/file.md')).toBe(false); - expect(cache.isValidFile('other/file.md')).toBe(true); - }); - - it('should handle excluded folders without trailing comma', async () => { - // Arrange settings without trailing comma - const settings: any = { - taskIdentificationMethod: 'tag', - taskTag: 'task', - excludedFolders: 'archive,temp', - disableNoteIndexing: false, - storeTitleInFilename: false, - }; - - // Create the cache - const cache = new MinimalNativeCache(app, settings, null as any); - - // Test isValidFile method - expect(cache.isValidFile('regular/file.md')).toBe(true); - expect(cache.isValidFile('archive/file.md')).toBe(false); - expect(cache.isValidFile('temp/file.md')).toBe(false); - expect(cache.isValidFile('other/file.md')).toBe(true); - }); - - it('should handle empty excluded folders', async () => { - // Arrange settings with empty excludedFolders - const settings: any = { - taskIdentificationMethod: 'tag', - taskTag: 'task', - excludedFolders: '', - disableNoteIndexing: false, - storeTitleInFilename: false, - }; - - // Create the cache - const cache = new MinimalNativeCache(app, settings, null as any); - - // Test isValidFile method - all files should be valid when no exclusions - expect(cache.isValidFile('regular/file.md')).toBe(true); - expect(cache.isValidFile('archive/file.md')).toBe(true); - expect(cache.isValidFile('temp/file.md')).toBe(true); - expect(cache.isValidFile('other/file.md')).toBe(true); - }); - - it('should handle excluded folders with multiple trailing commas', async () => { - // Arrange settings with multiple trailing commas - const settings: any = { - taskIdentificationMethod: 'tag', - taskTag: 'task', - excludedFolders: 'archive,temp,,', - disableNoteIndexing: false, - storeTitleInFilename: false, - }; - - // Create the cache - const cache = new MinimalNativeCache(app, settings, null as any); - - // Test isValidFile method - expect(cache.isValidFile('regular/file.md')).toBe(true); - expect(cache.isValidFile('archive/file.md')).toBe(false); - expect(cache.isValidFile('temp/file.md')).toBe(false); - expect(cache.isValidFile('other/file.md')).toBe(true); - }); - - it('should handle excluded folders with spaces and trailing comma', async () => { - // Arrange settings with spaces and trailing comma - const settings: any = { - taskIdentificationMethod: 'tag', - taskTag: 'task', - excludedFolders: ' archive , temp , ', - disableNoteIndexing: false, - storeTitleInFilename: false, - }; - - // Create the cache - const cache = new MinimalNativeCache(app, settings, null as any); - - // Test isValidFile method - expect(cache.isValidFile('regular/file.md')).toBe(true); - expect(cache.isValidFile('archive/file.md')).toBe(false); - expect(cache.isValidFile('temp/file.md')).toBe(false); - expect(cache.isValidFile('other/file.md')).toBe(true); - }); - - it('should handle updateConfig with trailing comma', async () => { - // Arrange settings - const settings: any = { - taskIdentificationMethod: 'tag', - taskTag: 'task', - excludedFolders: 'initial', - disableNoteIndexing: false, - storeTitleInFilename: false, - }; - - // Create the cache - const cache = new MinimalNativeCache(app, settings, null as any); - - // Update settings with trailing comma - cache.updateConfig( - 'task', - 'archive,temp,', // trailing comma - null as any, - false, - false - ); - - // Test isValidFile method after update - expect(cache.isValidFile('regular/file.md')).toBe(true); - expect(cache.isValidFile('archive/file.md')).toBe(false); - expect(cache.isValidFile('temp/file.md')).toBe(false); - expect(cache.isValidFile('other/file.md')).toBe(true); - }); -}); \ No newline at end of file diff --git a/tests/unit/utils/MinimalNativeCache.getTaskInfo.test.ts b/tests/unit/utils/MinimalNativeCache.getTaskInfo.test.ts deleted file mode 100644 index 0bf12752..00000000 --- a/tests/unit/utils/MinimalNativeCache.getTaskInfo.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -/** - * MinimalNativeCache.getTaskInfo() validation tests - * Tests for issue #953 - Subtasks incorrectly identified as TaskNotes - */ - -import { MinimalNativeCache } from '../../../src/utils/MinimalNativeCache'; -import { TaskNotesSettings } from '../../../src/types/settings'; -import { TFile } from 'obsidian'; - -// Mock FilterUtils -jest.mock('../../../src/utils/FilterUtils', () => ({ - FilterUtils: { - matchesHierarchicalTagExact: jest.fn((tag: string, taskTag: string) => { - return tag.toLowerCase() === taskTag.toLowerCase(); - }), - }, -})); - -describe('MinimalNativeCache.getTaskInfo() - Task Identification Validation', () => { - let cache: MinimalNativeCache; - let mockApp: any; - let mockFile: any; - let mockFieldMapper: any; - - beforeEach(() => { - // Create a mock file object that will pass instanceof TFile check - mockFile = Object.create(TFile.prototype); - Object.defineProperty(mockFile, 'path', { value: 'test/note.md', writable: true }); - Object.defineProperty(mockFile, 'basename', { value: 'note', writable: true }); - Object.defineProperty(mockFile, 'extension', { value: 'md', writable: true }); - - // Mock FieldMapper to return task info - mockFieldMapper = { - mapFromFrontmatter: jest.fn((frontmatter: any, path: string, storeTitleInFilename: boolean) => ({ - title: frontmatter.title || 'Untitled', - status: frontmatter.status || 'open', - priority: frontmatter.priority || 'normal', - due: frontmatter.due, - scheduled: frontmatter.scheduled, - tags: frontmatter.tags || [], - contexts: frontmatter.contexts || [], - projects: frontmatter.projects || frontmatter.Parent ? [frontmatter.Parent] : [], - blockedBy: [], - timeEntries: [], - })), - }; - - mockApp = { - vault: { - getAbstractFileByPath: jest.fn().mockReturnValue(mockFile), - on: jest.fn(), - getMarkdownFiles: jest.fn().mockReturnValue([]), - }, - metadataCache: { - getFileCache: jest.fn(), - getFirstLinkpathDest: jest.fn().mockReturnValue(null), // For dependency resolution - on: jest.fn(), - }, - }; - }); - - describe('Tag-based identification', () => { - beforeEach(() => { - const settings: TaskNotesSettings = { - taskTag: 'task', - taskIdentificationMethod: 'tag', - excludedFolders: '', - disableNoteIndexing: false, - storeTitleInFilename: false, - } as TaskNotesSettings; - - cache = new MinimalNativeCache(mockApp, settings, mockFieldMapper); - }); - - test('should return TaskInfo for file with task tag', async () => { - // Arrange - const frontmatter = { - tags: ['task', 'project'], - title: 'Valid Task', - status: 'open', - }; - mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter }); - - // Act - const result = await cache.getTaskInfo('test/note.md'); - - // Assert - expect(result).not.toBeNull(); - expect(result?.title).toBe('Valid Task'); - }); - - test('should return null for file WITHOUT task tag (issue #953)', async () => { - // Arrange - Note with Parent property but NO task tag - const frontmatter = { - tags: ['note', 'reference'], - title: 'Random Note', - Parent: '[[Existing_Task]]', // Has parent link but not a task - }; - mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter }); - - // Act - const result = await cache.getTaskInfo('test/note.md'); - - // Assert - Should return null because it lacks the task tag - expect(result).toBeNull(); - }); - - test('should return null for file with no tags', async () => { - // Arrange - const frontmatter = { - title: 'Note without tags', - Parent: '[[Some_Task]]', - }; - mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter }); - - // Act - const result = await cache.getTaskInfo('test/note.md'); - - // Assert - expect(result).toBeNull(); - }); - - test('should return null for file with empty tags array', async () => { - // Arrange - const frontmatter = { - tags: [], - title: 'Note with empty tags', - }; - mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter }); - - // Act - const result = await cache.getTaskInfo('test/note.md'); - - // Assert - expect(result).toBeNull(); - }); - }); - - describe('Property-based identification', () => { - beforeEach(() => { - const settings: TaskNotesSettings = { - taskTag: 'task', - taskIdentificationMethod: 'property', - taskPropertyName: 'isTask', - taskPropertyValue: 'true', - excludedFolders: '', - disableNoteIndexing: false, - storeTitleInFilename: false, - } as TaskNotesSettings; - - cache = new MinimalNativeCache(mockApp, settings, mockFieldMapper); - }); - - test('should return TaskInfo for file with matching property', async () => { - // Arrange - const frontmatter = { - isTask: true, - title: 'Valid Task', - status: 'open', - }; - mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter }); - - // Act - const result = await cache.getTaskInfo('test/note.md'); - - // Assert - expect(result).not.toBeNull(); - expect(result?.title).toBe('Valid Task'); - }); - - test('should return null for file WITHOUT matching property (issue #953)', async () => { - // Arrange - Note with Parent but no isTask property - const frontmatter = { - title: 'Random Note', - Parent: '[[Existing_Task]]', - // isTask property is missing - }; - mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter }); - - // Act - const result = await cache.getTaskInfo('test/note.md'); - - // Assert - Should return null because it lacks the required property - expect(result).toBeNull(); - }); - - test('should return null for file with property set to false', async () => { - // Arrange - const frontmatter = { - isTask: false, - title: 'Not a Task', - Parent: '[[Some_Task]]', - }; - mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter }); - - // Act - const result = await cache.getTaskInfo('test/note.md'); - - // Assert - expect(result).toBeNull(); - }); - - test('should handle string "true" in frontmatter', async () => { - // Arrange - const frontmatter = { - isTask: 'true', // String instead of boolean - title: 'Task with string property', - }; - mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter }); - - // Act - const result = await cache.getTaskInfo('test/note.md'); - - // Assert - expect(result).not.toBeNull(); - }); - }); - - describe('Edge cases', () => { - beforeEach(() => { - const settings: TaskNotesSettings = { - taskTag: 'task', - taskIdentificationMethod: 'tag', - excludedFolders: '', - disableNoteIndexing: false, - storeTitleInFilename: false, - } as TaskNotesSettings; - - cache = new MinimalNativeCache(mockApp, settings, mockFieldMapper); - }); - - test('should return null for non-existent file', async () => { - // Arrange - mockApp.vault.getAbstractFileByPath.mockReturnValue(null); - - // Act - const result = await cache.getTaskInfo('nonexistent.md'); - - // Assert - expect(result).toBeNull(); - }); - - test('should return null for file without frontmatter', async () => { - // Arrange - mockApp.metadataCache.getFileCache.mockReturnValue({}); - - // Act - const result = await cache.getTaskInfo('test/note.md'); - - // Assert - expect(result).toBeNull(); - }); - - test('should return null when metadata cache returns null', async () => { - // Arrange - mockApp.metadataCache.getFileCache.mockReturnValue(null); - - // Act - const result = await cache.getTaskInfo('test/note.md'); - - // Assert - expect(result).toBeNull(); - }); - }); - - describe('Consistency with getCachedTaskInfoSync', () => { - test('async and sync methods should return same result for valid task', async () => { - // Arrange - const settings: TaskNotesSettings = { - taskTag: 'task', - taskIdentificationMethod: 'tag', - excludedFolders: '', - disableNoteIndexing: false, - storeTitleInFilename: false, - } as TaskNotesSettings; - - cache = new MinimalNativeCache(mockApp, settings, mockFieldMapper); - - const frontmatter = { - tags: ['task'], - title: 'Test Task', - status: 'open', - }; - mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter }); - - // Act - const asyncResult = await cache.getTaskInfo('test/note.md'); - const syncResult = cache.getCachedTaskInfoSync('test/note.md'); - - // Assert - Both should return TaskInfo - expect(asyncResult).not.toBeNull(); - expect(syncResult).not.toBeNull(); - expect(asyncResult?.title).toBe(syncResult?.title); - }); - - test('async and sync methods should both return null for non-task', async () => { - // Arrange - const settings: TaskNotesSettings = { - taskTag: 'task', - taskIdentificationMethod: 'tag', - excludedFolders: '', - disableNoteIndexing: false, - storeTitleInFilename: false, - } as TaskNotesSettings; - - cache = new MinimalNativeCache(mockApp, settings, mockFieldMapper); - - const frontmatter = { - tags: ['note'], - title: 'Not a Task', - Parent: '[[Some_Task]]', - }; - mockApp.metadataCache.getFileCache.mockReturnValue({ frontmatter }); - - // Act - const asyncResult = await cache.getTaskInfo('test/note.md'); - const syncResult = cache.getCachedTaskInfoSync('test/note.md'); - - // Assert - Both should return null - expect(asyncResult).toBeNull(); - expect(syncResult).toBeNull(); - }); - }); -}); - diff --git a/tests/utils/MinimalNativeCache.isTaskFile.test.ts b/tests/utils/MinimalNativeCache.isTaskFile.test.ts deleted file mode 100644 index 28fcffb6..00000000 --- a/tests/utils/MinimalNativeCache.isTaskFile.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { MinimalNativeCache } from '../../src/utils/MinimalNativeCache'; -import { TaskNotesSettings } from '../../src/types/settings'; - -// Mock FilterUtils -jest.mock('../../src/utils/FilterUtils', () => ({ - FilterUtils: { - matchesHierarchicalTagExact: jest.fn((tag: string, taskTag: string) => { - return tag.toLowerCase() === taskTag.toLowerCase(); - }), - }, -})); - -describe('MinimalNativeCache - isTaskFile with non-string tags', () => { - let cache: MinimalNativeCache; - let mockApp: any; - let settings: TaskNotesSettings; - - beforeEach(() => { - mockApp = { - metadataCache: { - on: jest.fn(), - }, - vault: { - on: jest.fn(), - }, - }; - - settings = { - taskTag: 'task', - taskIdentificationMethod: 'tag', - excludedFolders: '', - disableNoteIndexing: false, - storeTitleInFilename: false, - } as TaskNotesSettings; - - cache = new MinimalNativeCache(mockApp, settings); - }); - - test('handles frontmatter with valid string tags', () => { - const frontmatter = { - tags: ['task', 'project', 'important'], - }; - - const result = (cache as any).isTaskFile(frontmatter); - expect(result).toBe(true); - }); - - test('handles frontmatter with number in tags array', () => { - const frontmatter = { - tags: ['task', 123, 'project'], - }; - - const result = (cache as any).isTaskFile(frontmatter); - expect(result).toBe(true); - }); - - test('handles frontmatter with boolean in tags array', () => { - const frontmatter = { - tags: [true, 'task', false], - }; - - const result = (cache as any).isTaskFile(frontmatter); - expect(result).toBe(true); - }); - - test('handles frontmatter with mixed non-string types', () => { - const frontmatter = { - tags: [123, true, 'task', null, undefined, 'project'], - }; - - const result = (cache as any).isTaskFile(frontmatter); - expect(result).toBe(true); - }); - - test('returns false when all tags are non-strings', () => { - const frontmatter = { - tags: [123, true, null, undefined, 456], - }; - - const result = (cache as any).isTaskFile(frontmatter); - expect(result).toBe(false); - }); - - test('handles frontmatter with object in tags array', () => { - const frontmatter = { - tags: [{ nested: 'value' }, 'task'], - }; - - const result = (cache as any).isTaskFile(frontmatter); - expect(result).toBe(true); - }); - - test('handles frontmatter with array in tags array', () => { - const frontmatter = { - tags: [['nested', 'array'], 'task'], - }; - - const result = (cache as any).isTaskFile(frontmatter); - expect(result).toBe(true); - }); - - test('handles empty tags array', () => { - const frontmatter = { - tags: [], - }; - - const result = (cache as any).isTaskFile(frontmatter); - expect(result).toBe(false); - }); - - test('handles nested tag hierarchy with non-strings', () => { - const frontmatter = { - tags: ['task', 123, 'other/tag'], - }; - - const result = (cache as any).isTaskFile(frontmatter); - expect(result).toBe(true); - }); -}); \ No newline at end of file