feat(filesource): enhance FileSource task handling and WriteAPI support

- Add FileSource task update capability in WriteAPI
- Improve file type detection and metadata mapping
- Add comprehensive FileSource tests for basic operations
- Update Repository to handle FileSource task operations
- Enhance FileSource with filter manager integration
This commit is contained in:
Quorafind 2025-08-23 11:25:59 +08:00
parent c7db2b5c65
commit 4c5f560c2b
6 changed files with 387 additions and 227 deletions

View file

@ -66,17 +66,14 @@ describe('FileSource', () => {
fileTaskProperties: {
contentSource: 'filename',
stripExtension: true,
defaultStatus: ' '
defaultStatus: ' ',
preferFrontmatterTitle: true
},
performance: {
enableWorkerProcessing: false,
enableCaching: true,
cacheTTL: 300000
},
advanced: {
excludePatterns: ['**/.obsidian/**'],
maxFileSize: 1048576
}
};
fileSource = new FileSource(mockApp, config);

View file

@ -74,7 +74,8 @@ describe('FileSourceConfig', () => {
fileTaskProperties: {
contentSource: 'title',
stripExtension: false,
defaultStatus: 'x'
defaultStatus: 'x',
preferFrontmatterTitle: true
}
};
@ -241,7 +242,8 @@ describe('FileSourceConfig', () => {
fileTaskProperties: {
contentSource: 'custom',
stripExtension: true,
defaultStatus: ' '
defaultStatus: ' ',
preferFrontmatterTitle: true
// Missing customContentField
}
};
@ -262,31 +264,6 @@ describe('FileSourceConfig', () => {
const errors = config.validateConfig(invalidConfig);
expect(errors).toContain('Cache TTL must be a positive number');
});
it('should validate maximum file size', () => {
const invalidConfig: Partial<FileSourceConfiguration> = {
advanced: {
excludePatterns: [],
maxFileSize: 100 // Too small
}
};
const errors = config.validateConfig(invalidConfig);
expect(errors).toContain('Maximum file size must be at least 1KB');
});
it('should validate custom recognition function syntax', () => {
const invalidConfig: Partial<FileSourceConfiguration> = {
advanced: {
excludePatterns: [],
maxFileSize: 1024,
customRecognitionFunction: 'invalid javascript syntax {'
}
};
const errors = config.validateConfig(invalidConfig);
expect(errors).toContain('Custom recognition function has invalid syntax');
});
});
describe('configuration presets', () => {

View file

@ -1,6 +1,6 @@
/**
* WriteAPI - Handles all write operations in the Dataflow architecture
*
*
* This API provides methods for creating, updating, and deleting tasks
* by directly modifying vault files. Changes trigger ObsidianSource events
* which automatically update the index through the Orchestrator.
@ -75,7 +75,7 @@ export interface BatchCreateSubtasksArgs {
export class WriteAPI {
private canvasTaskUpdater: CanvasTaskUpdater;
constructor(
private app: App,
private vault: Vault,
@ -150,7 +150,7 @@ export class WriteAPI {
}
lines[task.line] = taskLine;
// Notify about write operation
emit(this.app, Events.WRITE_OPERATION_START, { path: file.path, taskId: args.taskId });
await this.vault.modify(file, lines.join("\n"));
@ -184,6 +184,12 @@ export class WriteAPI {
return this.updateCanvasTask(args);
}
// Handle FileSource (file-level) tasks differently
const isFileSourceTask = (originalTask as any)?.metadata?.source === "file-source" || originalTask.id.startsWith("file-source:");
if (isFileSourceTask) {
return this.updateFileSourceTask(originalTask, args.updates, args.taskId);
}
const file = this.vault.getAbstractFileByPath(originalTask.filePath) as TFile;
if (!file) {
return { success: false, error: "File not found" };
@ -236,26 +242,26 @@ export class WriteAPI {
}
lines[originalTask.line] = taskLine;
// Notify about write operation
emit(this.app, Events.WRITE_OPERATION_START, { path: file.path, taskId: args.taskId });
await this.vault.modify(file, lines.join("\n"));
// Create the updated task object with the new content
const updatedTaskObj: Task = {
...originalTask,
...args.updates,
originalMarkdown: taskLine.replace(/^\s*[-*+]\s*\[[^\]]*\]\s*/, ""), // Remove checkbox prefix
};
// Emit task updated event for direct update in dataflow
emit(this.app, Events.TASK_UPDATED, { task: updatedTaskObj });
// Trigger task-completed event if task was just completed
if (args.updates.completed === true && !originalTask.completed) {
this.app.workspace.trigger("task-genius:task-completed", updatedTaskObj);
}
// Still emit write operation complete for compatibility
emit(this.app, Events.WRITE_OPERATION_COMPLETE, { path: file.path, taskId: args.taskId });
@ -266,6 +272,132 @@ export class WriteAPI {
}
}
/**
* Update a FileSource (file-level) task. This updates frontmatter title, H1, or filename
* depending on settings, instead of trying to edit a markdown checkbox line.
*/
private async updateFileSourceTask(
originalTask: Task,
updates: Partial<Task>,
taskId: string
): Promise<{ success: boolean; task?: Task; error?: string }> {
const file = this.vault.getAbstractFileByPath(originalTask.filePath) as TFile;
if (!file) {
return { success: false, error: "File not found" };
}
let newFilePath = originalTask.filePath;
const cfg = this.plugin.settings?.fileSource?.fileTaskProperties;
const contentSource: "filename" | "title" | "h1" | "custom" = cfg?.contentSource ?? "filename";
const preferFrontmatterTitle = cfg?.preferFrontmatterTitle ?? true;
const customContentField = (this.plugin.settings?.fileSource?.fileTaskProperties as any)?.customContentField as string | undefined;
// Handle content/title change
if (updates.content && updates.content !== originalTask.content) {
try {
// Announce start of a write operation
emit(this.app, Events.WRITE_OPERATION_START, { path: file.path, taskId });
switch (contentSource) {
case "title": {
if (preferFrontmatterTitle) {
await this.app.fileManager.processFrontMatter(file, (fm) => {
(fm as any).title = updates.content;
});
} else {
newFilePath = await this.renameFile(file, updates.content!);
}
break;
}
case "h1": {
await this.updateH1Heading(file, updates.content!);
break;
}
case "custom": {
if (customContentField) {
await this.app.fileManager.processFrontMatter(file, (fm) => {
(fm as any)[customContentField] = updates.content;
});
} else {
newFilePath = await this.renameFile(file, updates.content!);
}
break;
}
case "filename":
default: {
newFilePath = await this.renameFile(file, updates.content!);
break;
}
}
// Announce completion of write operation
emit(this.app, Events.WRITE_OPERATION_COMPLETE, { path: newFilePath, taskId });
} catch (error) {
console.error("WriteAPI: Error updating file-source task content:", error);
return { success: false, error: String(error) };
}
}
// Build the updated task object
const updatedTaskObj: Task = {
...originalTask,
...updates,
filePath: newFilePath,
// Keep id in sync with FileSource convention when path changes
id: (originalTask.id.startsWith("file-source:") && newFilePath !== originalTask.filePath)
? `file-source:${newFilePath}`
: originalTask.id,
originalMarkdown: `[${updates.content ?? originalTask.content}](${newFilePath})`,
};
// Emit file-task update so repository updates fileTasks map directly
emit(this.app, Events.FILE_TASK_UPDATED, { task: updatedTaskObj });
return { success: true, task: updatedTaskObj };
}
private async updateH1Heading(file: TFile, newHeading: string): Promise<void> {
const content = await this.vault.read(file);
const lines = content.split("\n");
// Find first H1 after optional frontmatter
let h1Index = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith("# ")) { h1Index = i; break; }
}
if (h1Index >= 0) {
lines[h1Index] = `# ${newHeading}`;
} else {
let insertIndex = 0;
if (content.startsWith("---")) {
const fmEnd = content.indexOf("\n---\n", 3);
if (fmEnd >= 0) {
const fmLines = content.substring(0, fmEnd + 5).split("\n").length - 1;
insertIndex = fmLines;
}
}
lines.splice(insertIndex, 0, `# ${newHeading}`, "");
}
await this.vault.modify(file, lines.join("\n"));
}
private async renameFile(file: TFile, newTitle: string): Promise<string> {
const currentPath = file.path;
const lastSlash = currentPath.lastIndexOf("/");
const directory = lastSlash > 0 ? currentPath.substring(0, lastSlash) : "";
const extension = currentPath.substring(currentPath.lastIndexOf("."));
const sanitized = this.sanitizeFileName(newTitle);
const newPath = directory ? `${directory}/${sanitized}${extension}` : `${sanitized}${extension}`;
if (newPath !== currentPath) {
await this.vault.rename(file, newPath);
}
return newPath;
}
private sanitizeFileName(name: string): string {
return name.replace(/[<>:"/\\|?*]/g, "_");
}
/**
* Create a new task
*/
@ -320,7 +452,7 @@ export class WriteAPI {
const newContent = args.parent
? this.insertSubtask(content, args.parent, taskContent)
: (content ? content + "\n" : "") + taskContent;
// Notify about write operation
emit(this.app, Events.WRITE_OPERATION_START, { path: file.path });
await this.vault.modify(file, newContent);
@ -359,7 +491,7 @@ export class WriteAPI {
if (task.line >= 0 && task.line < lines.length) {
lines.splice(task.line, 1);
// Notify about write operation
emit(this.app, Events.WRITE_OPERATION_START, { path: file.path, taskId: args.taskId });
await this.vault.modify(file, lines.join("\n"));
@ -500,7 +632,7 @@ export class WriteAPI {
try {
// Try using Daily Notes plugin if available
let dailyNoteFile: TFile | null = null;
if (appHasDailyNotesPluginLoaded()) {
const date = moment().set("hour", 12);
const existing = getDailyNote(date, getAllDailyNotes());
@ -685,7 +817,7 @@ export class WriteAPI {
metadata.push(`[tags:: ${args.tags.join(", ")}]`);
} else {
// Ensure tags don't already have # prefix before adding one
metadata.push(...args.tags.map(tag =>
metadata.push(...args.tags.map(tag =>
tag.startsWith("#") ? tag : `#${tag}`
));
}
@ -884,12 +1016,12 @@ export class WriteAPI {
if (result.success) {
// Emit task updated event for dataflow
emit(this.app, Events.TASK_UPDATED, { task: updatedTask });
// Trigger task-completed event if task was just completed
if (args.updates.completed === true && !originalTask.completed) {
this.app.workspace.trigger("task-genius:task-completed", updatedTask);
}
return { success: true, task: updatedTask };
} else {
return { success: false, error: result.error };

View file

@ -15,7 +15,7 @@ export class Repository {
private sourceSeq: number = 0; // Track source sequence to differentiate events
private icsEvents: Task[] = []; // Store ICS events separately
private fileTasks = new Map<string, Task>(); // Store file-level tasks
// Persistence queue management
private persistQueue = new Set<string>();
private persistTimer: NodeJS.Timeout | null = null;
@ -34,27 +34,34 @@ export class Repository {
this.storage = new Storage(app.appId || "obsidian-task-genius", "1.0.0");
}
/** Allow orchestrator to pass a central FileFilterManager down to indexer */
public setFileFilterManager(filterManager: any) {
// TaskIndexer has setFileFilterManager API
(this.indexer as any).setFileFilterManager?.(filterManager);
}
/**
* Initialize the repository (load persisted data if available)
*/
async initialize(): Promise<void> {
console.log("[Repository] Initializing repository...");
try {
// Try to load consolidated index from storage
console.log("[Repository] Attempting to load consolidated index from storage...");
const consolidated = await this.storage.loadConsolidated();
if (consolidated && consolidated.data) {
// Restore the index from persisted data
const snapshotTaskCount = consolidated.data?.tasks ?
const snapshotTaskCount = consolidated.data?.tasks ?
(consolidated.data.tasks instanceof Map ? consolidated.data.tasks.size : Object.keys(consolidated.data.tasks).length) : 0;
console.log(`[Repository] Found persisted snapshot with ${snapshotTaskCount} tasks, restoring...`);
await this.indexer.restoreFromSnapshot(consolidated.data);
const taskCount = await this.indexer.getTotalTaskCount();
console.log(`[Repository] Index restored successfully with ${taskCount} tasks`);
// Emit cache ready event
emit(this.app, Events.CACHE_READY, {
initial: true,
@ -64,7 +71,7 @@ export class Repository {
} else {
console.log("[Repository] No persisted data found, starting with empty index");
}
// Load ICS events from storage
console.log("[Repository] Loading ICS events from storage...");
this.icsEvents = await this.storage.loadIcsEvents();
@ -85,20 +92,20 @@ export class Repository {
async updateFile(filePath: string, tasks: Task[], sourceSeq?: number): Promise<void> {
// Check if tasks have actually changed
const existingAugmented = await this.storage.loadAugmented(filePath);
const hasChanges = !existingAugmented ||
const hasChanges = !existingAugmented ||
JSON.stringify(tasks) !== JSON.stringify(existingAugmented.data);
// Always update the in-memory index for consistency
await this.indexer.updateIndexWithTasks(filePath, tasks);
// Always store augmented tasks to cache
await this.storage.storeAugmented(filePath, tasks);
// Schedule persist operation for single file updates
if (hasChanges) {
this.schedulePersist(filePath);
}
// Only emit update event if there are actual changes
if (hasChanges) {
this.lastSequence = Seq.next();
@ -129,12 +136,12 @@ export class Repository {
for (const [filePath, tasks] of updates) {
// Check if tasks have actually changed
const existingAugmented = await this.storage.loadAugmented(filePath);
const hasChanges = !existingAugmented ||
const hasChanges = !existingAugmented ||
JSON.stringify(tasks) !== JSON.stringify(existingAugmented.data);
await this.indexer.updateIndexWithTasks(filePath, tasks);
await this.storage.storeAugmented(filePath, tasks);
if (hasChanges) {
changedFiles.push(filePath);
totalChanged += tasks.length;
@ -170,10 +177,10 @@ export class Repository {
*/
async removeFile(filePath: string): Promise<void> {
await this.indexer.removeTasksFromFile(filePath);
// Clear storage for this file
await this.storage.clearFile(filePath);
// Emit update event
this.lastSequence = Seq.next();
emit(this.app, Events.TASK_CACHE_UPDATED, {
@ -192,13 +199,13 @@ export class Repository {
*/
async updateIcsEvents(events: Task[], sourceSeq?: number): Promise<void> {
console.log(`[Repository] Updating ${events.length} ICS events`);
// Store the new ICS events
this.icsEvents = events;
// Store ICS events to persistence
await this.storage.storeIcsEvents(events);
// Emit update event to notify views
this.lastSequence = Seq.next();
emit(this.app, Events.TASK_CACHE_UPDATED, {
@ -238,12 +245,12 @@ export class Repository {
async byProject(project: string): Promise<Task[]> {
const taskIds = await this.indexer.getTaskIdsByProject(project);
const fileTasks = await this.getTasksByIds(taskIds);
// Also filter ICS events by project if they have one
const icsProjectTasks = this.icsEvents.filter(task =>
const icsProjectTasks = this.icsEvents.filter(task =>
task.metadata?.project === project
);
return [...fileTasks, ...icsProjectTasks];
}
@ -254,15 +261,15 @@ export class Repository {
const taskIdSets = await Promise.all(
tags.map(tag => this.indexer.getTaskIdsByTag(tag))
);
// Find intersection of all tag sets
if (taskIdSets.length === 0) return [];
let intersection = new Set(taskIdSets[0]);
for (let i = 1; i < taskIdSets.length; i++) {
intersection = new Set([...intersection].filter(id => taskIdSets[i].has(id)));
}
return this.getTasksByIds(intersection);
}
@ -277,31 +284,31 @@ export class Repository {
/**
* Get tasks by date range
*/
async byDateRange(opts: {
from?: number;
to?: number;
field?: "due" | "start" | "scheduled"
async byDateRange(opts: {
from?: number;
to?: number;
field?: "due" | "start" | "scheduled"
}): Promise<Task[]> {
const field = opts.field || "due";
const cache = await this.indexer.getCache();
const dateIndex = field === "due" ? cache.dueDate :
field === "start" ? cache.startDate :
cache.scheduledDate;
const taskIds = new Set<string>();
for (const [dateStr, ids] of dateIndex) {
const date = new Date(dateStr).getTime();
if (opts.from && date < opts.from) continue;
if (opts.to && date > opts.to) continue;
for (const id of ids) {
taskIds.add(id);
}
}
return this.getTasksByIds(taskIds);
}
@ -330,22 +337,22 @@ export class Repository {
byStatus: Map<boolean, number>;
}> {
const cache = await this.indexer.getCache();
const byProject = new Map<string, number>();
for (const [project, ids] of cache.projects) {
byProject.set(project, ids.size);
}
const byTag = new Map<string, number>();
for (const [tag, ids] of cache.tags) {
byTag.set(tag, ids.size);
}
const byStatus = new Map<boolean, number>();
for (const [status, ids] of cache.completed) {
byStatus.set(status, ids.size);
}
return {
total: cache.tasks.size,
byProject,
@ -367,12 +374,12 @@ export class Repository {
*/
private schedulePersist(source: string): void {
this.persistQueue.add(source);
// Check if we should persist immediately
const shouldPersistNow =
const shouldPersistNow =
this.persistQueue.size >= this.MAX_QUEUE_SIZE ||
(Date.now() - this.lastPersistTime) > this.MAX_PERSIST_INTERVAL;
if (shouldPersistNow) {
this.executePersist();
} else {
@ -394,7 +401,7 @@ export class Repository {
clearTimeout(this.persistTimer);
this.persistTimer = null;
}
if (this.persistQueue.size > 0) {
const queueSize = this.persistQueue.size;
console.log(`[Repository] Persisting after ${queueSize} changes`);
@ -439,14 +446,14 @@ export class Repository {
private async getTasksByIds(taskIds: Set<string> | string[]): Promise<Task[]> {
const tasks: Task[] = [];
const ids = Array.isArray(taskIds) ? taskIds : Array.from(taskIds);
for (const id of ids) {
const task = await this.indexer.getTaskById(id);
if (task) {
tasks.push(task);
}
}
return tasks;
}
@ -464,13 +471,13 @@ export class Repository {
async updateFileTask(task: Task): Promise<void> {
const filePath = task.filePath;
if (!filePath) return;
// Store the file task
this.fileTasks.set(filePath, task);
// Schedule persist for file tasks
this.schedulePersist(`file-task:${filePath}`);
// Emit update event
this.lastSequence = Seq.next();
emit(this.app, Events.TASK_CACHE_UPDATED, {
@ -491,10 +498,10 @@ export class Repository {
async getTaskById(taskId: string): Promise<Task | undefined> {
// Get all tasks from the repository
const allTasks = await this.all();
// Find the task by ID
const task = allTasks.find(t => t.id === taskId);
return task;
}
@ -505,35 +512,35 @@ export class Repository {
async updateSingleTask(updatedTask: Task): Promise<void> {
const filePath = updatedTask.filePath;
if (!filePath) return;
console.log(`[Repository] Updating single task: ${updatedTask.id} in ${filePath}`);
// Load existing augmented tasks for the file
const existingAugmented = await this.storage.loadAugmented(filePath);
if (!existingAugmented) {
console.warn(`[Repository] No existing tasks found for ${filePath}, cannot update single task`);
return;
}
// Find and replace the task in the array
const tasks = existingAugmented.data;
const taskIndex = tasks.findIndex(t => t.id === updatedTask.id);
if (taskIndex === -1) {
console.warn(`[Repository] Task ${updatedTask.id} not found in ${filePath}`);
return;
}
// Update the task
tasks[taskIndex] = updatedTask;
// Update the index and storage
await this.indexer.updateIndexWithTasks(filePath, tasks);
await this.storage.storeAugmented(filePath, tasks);
// Schedule persist operation
this.schedulePersist(filePath);
// Emit update event
this.lastSequence = Seq.next();
emit(this.app, Events.TASK_CACHE_UPDATED, {
@ -546,7 +553,7 @@ export class Repository {
seq: this.lastSequence,
sourceSeq: undefined
});
console.log(`[Repository] Single task ${updatedTask.id} updated successfully`);
}
}

View file

@ -1,16 +1,16 @@
/**
* FileSource - Main implementation for FileSource feature
*
*
* This source integrates files as tasks into the dataflow architecture.
* It follows the same patterns as ObsidianSource and IcsSource.
*/
import type { App, TFile, EventRef, CachedMetadata } from "obsidian";
import type { Task } from "../../types/task";
import type {
FileSourceConfiguration,
FileSourceTaskMetadata,
FileSourceStats,
import type {
FileSourceConfiguration,
FileSourceTaskMetadata,
FileSourceStats,
UpdateDecision,
FileTaskCache,
RecognitionStrategy,
@ -20,9 +20,11 @@ import type {
import { Events, emit, Seq, on } from "../events/Events";
import { FileSourceConfig } from "./FileSourceConfig";
import { FileFilterManager } from "../../managers/file-filter-manager";
/**
* FileSource - Independent event source for file-based tasks
*
*
* Subscribes to file events and transforms qualifying files into tasks
* following the established dataflow patterns.
*/
@ -30,17 +32,17 @@ export class FileSource {
private config: FileSourceConfig;
private isInitialized = false;
private lastUpdateSeq = 0;
// Event references for cleanup
private eventRefs: EventRef[] = [];
// Cache for tracking file task state
private fileTaskCache = new Map<string, FileTaskCache>();
// Debouncing for rapid changes
private pendingUpdates = new Map<string, NodeJS.Timeout>();
private readonly DEBOUNCE_DELAY = 300; // ms
// Statistics tracking
private stats: FileSourceStats = {
initialized: false,
@ -57,7 +59,8 @@ export class FileSource {
constructor(
private app: App,
initialConfig?: Partial<FileSourceConfiguration>
initialConfig?: Partial<FileSourceConfiguration>,
private fileFilterManager?: FileFilterManager
) {
this.config = new FileSourceConfig(initialConfig);
}
@ -70,21 +73,23 @@ export class FileSource {
if (!this.config.isEnabled()) return;
console.log("[FileSource] Initializing FileSource...");
// Subscribe to configuration changes
this.config.onChange((newConfig) => {
this.handleConfigChange(newConfig);
});
// Subscribe to file events
this.subscribeToFileEvents();
// Perform initial scan of existing files
this.performInitialScan();
// Delay initial scan to ensure vault is fully loaded
setTimeout(() => {
this.performInitialScan();
}, 1000); // 1 second delay
this.isInitialized = true;
this.stats.initialized = true;
console.log(`[FileSource] Initialized with strategies: ${this.config.getEnabledStrategies().join(', ')}`);
}
@ -136,7 +141,7 @@ export class FileSource {
// Set new debounced timeout
const timeout = setTimeout(async () => {
this.pendingUpdates.delete(filePath);
try {
await this.processFileUpdate(filePath, reason);
} catch (error) {
@ -172,6 +177,10 @@ export class FileSource {
return;
}
// Apply centralized file filter manager for file-scope rules
if (this.fileFilterManager && !this.fileFilterManager.shouldIncludePath(filePath, "file")) {
return;
}
const shouldBeTask = await this.shouldCreateFileTask(filePath);
const existingCache = this.fileTaskCache.get(filePath);
const wasTask = existingCache?.fileTaskExists ?? false;
@ -201,7 +210,7 @@ export class FileSource {
try {
const fileContent = await this.app.vault.cachedRead(file);
const fileCache = this.app.metadataCache.getFileCache(file);
return this.evaluateRecognitionStrategies(filePath, fileContent, fileCache);
} catch (error) {
console.error(`[FileSource] Error reading file ${filePath}:`, error);
@ -213,8 +222,8 @@ export class FileSource {
* Evaluate all enabled recognition strategies
*/
private evaluateRecognitionStrategies(
filePath: string,
fileContent: string,
filePath: string,
fileContent: string,
fileCache: CachedMetadata | null
): boolean {
const config = this.config.getConfig();
@ -265,7 +274,7 @@ export class FileSource {
const { taskFields, requireAllFields } = config;
const frontmatter = fileCache.frontmatter;
const matchingFields = taskFields.filter((field: string) =>
const matchingFields = taskFields.filter((field: string) =>
frontmatter.hasOwnProperty(field) && frontmatter[field] !== undefined
);
@ -331,14 +340,14 @@ export class FileSource {
if (!config.enabled || !config.taskPaths || config.taskPaths.length === 0) {
return false;
}
// Normalize path (use forward slashes)
const normalizedPath = filePath.replace(/\\/g, '/');
// Check each configured path pattern
for (const pattern of config.taskPaths) {
const normalizedPattern = pattern.replace(/\\/g, '/');
switch (config.matchMode) {
case 'prefix':
if (normalizedPath.startsWith(normalizedPattern)) {
@ -346,7 +355,7 @@ export class FileSource {
return true;
}
break;
case 'regex':
try {
const regex = new RegExp(normalizedPattern);
@ -358,7 +367,7 @@ export class FileSource {
console.warn(`[FileSource] Invalid regex pattern: ${pattern}`, e);
}
break;
case 'glob':
if (this.matchGlobPattern(normalizedPath, normalizedPattern)) {
console.log(`[FileSource] Path matches glob pattern: ${pattern} for ${filePath}`);
@ -367,7 +376,7 @@ export class FileSource {
break;
}
}
return false;
}
@ -383,14 +392,14 @@ export class FileSource {
.replace(/\*/g, '[^/]*') // * matches any chars except /
.replace(/§§§/g, '.*') // ** matches any chars
.replace(/\?/g, '[^/]'); // ? matches single char
// If pattern ends with /, match all files in that directory
if (pattern.endsWith('/')) {
regexPattern = `^${regexPattern}.*`;
} else {
regexPattern = `^${regexPattern}$`;
}
try {
const regex = new RegExp(regexPattern);
return regex.test(path);
@ -410,19 +419,19 @@ export class FileSource {
try {
const fileContent = await this.app.vault.cachedRead(file);
const fileCache = this.app.metadataCache.getFileCache(file);
const fileTask = await this.buildFileTask(filePath, fileContent, fileCache);
if (!fileTask) return null;
// Update cache
this.updateFileTaskCache(filePath, fileTask);
// Update statistics
this.updateStatistics(fileTask.metadata.recognitionStrategy, 1);
// Emit file task event
this.emitFileTaskUpdate('created', fileTask);
return fileTask;
} catch (error) {
console.error(`[FileSource] Error creating file task for ${filePath}:`, error);
@ -448,20 +457,20 @@ export class FileSource {
// Remove from cache
this.fileTaskCache.delete(filePath);
// Update statistics
this.stats.trackedFileCount = Math.max(0, this.stats.trackedFileCount - 1);
// Emit removal event
const seq = Seq.next();
this.lastUpdateSeq = seq;
emit(this.app, Events.FILE_TASK_REMOVED, {
filePath,
timestamp: Date.now(),
seq
});
console.log(`[FileSource] Removed file task: ${filePath}`);
}
@ -524,25 +533,25 @@ export class FileSource {
fileCache: CachedMetadata | null
): { name: RecognitionStrategy; criteria: string } | null {
const config = this.config.getConfig();
if (config.recognitionStrategies.metadata.enabled &&
if (config.recognitionStrategies.metadata.enabled &&
this.matchesMetadataStrategy(filePath, fileContent, fileCache, config.recognitionStrategies.metadata)) {
return { name: "metadata", criteria: "frontmatter" };
}
if (config.recognitionStrategies.tags.enabled &&
if (config.recognitionStrategies.tags.enabled &&
this.matchesTagStrategy(filePath, fileContent, fileCache, config.recognitionStrategies.tags)) {
return { name: "tag", criteria: "file-tags" };
}
// Check path strategy
if (config.recognitionStrategies.paths.enabled &&
if (config.recognitionStrategies.paths.enabled &&
this.matchesPathStrategy(filePath, fileContent, fileCache, config.recognitionStrategies.paths)) {
return { name: "path", criteria: config.recognitionStrategies.paths.taskPaths.join(", ") };
}
// TODO: Add template strategy in Phase 2
return null;
}
@ -556,27 +565,28 @@ export class FileSource {
): string {
const config = this.config.getConfig().fileTaskProperties;
const fileName = filePath.split('/').pop() || filePath;
const fileNameWithoutExt = fileName.replace(/\.[^/.]+$/, "");
switch (config.contentSource) {
case 'filename':
return config.stripExtension ?
fileName.replace(/\.[^/.]+$/, "") : fileName;
return config.stripExtension ? fileNameWithoutExt : fileName;
case 'title':
return fileCache?.frontmatter?.title || fileName;
// Always prefer frontmatter title if available, fallback to filename without extension
return fileCache?.frontmatter?.title || fileNameWithoutExt;
case 'h1':
const h1 = fileCache?.headings?.find(h => h.level === 1);
return h1?.heading || fileName;
return h1?.heading || fileNameWithoutExt;
case 'custom':
if (config.customContentField && fileCache?.frontmatter) {
return fileCache.frontmatter[config.customContentField] || fileName;
return fileCache.frontmatter[config.customContentField] || fileNameWithoutExt;
}
return fileName;
return fileNameWithoutExt;
default:
return fileName;
return config.stripExtension ? fileNameWithoutExt : fileName;
}
}
@ -591,12 +601,12 @@ export class FileSource {
): Partial<FileSourceTaskMetadata> {
const config = this.config.getConfig();
const frontmatter = fileCache?.frontmatter || {};
// Get status from frontmatter and map it to symbol if needed
let status = frontmatter.status || config.fileTaskProperties.defaultStatus;
// Apply status mapping if enabled
if (config.statusMapping.enabled && frontmatter.status) {
if (config.statusMapping && config.statusMapping.enabled && frontmatter.status) {
// Try to map the metadata value to a symbol
const mappedStatus = this.config.mapMetadataToSymbol(frontmatter.status);
if (mappedStatus !== frontmatter.status) {
@ -605,7 +615,7 @@ export class FileSource {
console.log(`[FileSource] Mapped status '${frontmatter.status}' to '${mappedStatus}' for ${filePath}`);
}
}
// Extract standard task metadata
const metadata: Partial<FileSourceTaskMetadata> = {
dueDate: this.parseDate(frontmatter.dueDate || frontmatter.due),
@ -619,7 +629,7 @@ export class FileSource {
status: status,
children: []
};
return metadata;
}
@ -629,34 +639,34 @@ export class FileSource {
*/
private mapSymbolToFileMetadata(symbol: string): string {
const config = this.config.getConfig();
if (!config.statusMapping.enabled) {
return symbol;
}
// Map symbol back to preferred metadata value
return this.config.mapSymbolToMetadata(symbol);
}
/**
* Parse date from various formats
*/
private parseDate(dateValue: any): number | undefined {
if (!dateValue) return undefined;
if (typeof dateValue === 'number') {
return dateValue;
}
if (typeof dateValue === 'string') {
const parsed = Date.parse(dateValue);
return isNaN(parsed) ? undefined : parsed;
}
if (dateValue instanceof Date) {
return dateValue.getTime();
}
return undefined;
}
@ -665,7 +675,7 @@ export class FileSource {
*/
private updateFileTaskCache(filePath: string, task: Task<FileSourceTaskMetadata>): void {
const frontmatterHash = this.generateFrontmatterHash(filePath);
this.fileTaskCache.set(filePath, {
fileTaskExists: true,
frontmatterHash,
@ -680,10 +690,10 @@ export class FileSource {
private generateFrontmatterHash(filePath: string): string {
const file = this.app.vault.getAbstractFileByPath(filePath) as TFile;
if (!file) return '';
const fileCache = this.app.metadataCache.getFileCache(file);
if (!fileCache?.frontmatter) return '';
// Simple hash of frontmatter JSON
const frontmatterStr = JSON.stringify(fileCache.frontmatter, Object.keys(fileCache.frontmatter).sort());
return this.simpleHash(frontmatterStr);
@ -724,19 +734,19 @@ export class FileSource {
* Emit file task update event
*/
private emitFileTaskUpdate(
action: 'created' | 'updated' | 'removed',
action: 'created' | 'updated' | 'removed',
task: Task<FileSourceTaskMetadata>
): void {
const seq = Seq.next();
this.lastUpdateSeq = seq;
emit(this.app, Events.FILE_TASK_UPDATED, {
action,
task,
timestamp: Date.now(),
seq
});
console.log(`[FileSource] File task ${action}: ${task.filePath}`);
}
@ -744,26 +754,21 @@ export class FileSource {
* Check if file is relevant for processing
*/
private isRelevantFile(filePath: string): boolean {
const config = this.config.getConfig().advanced;
// Check exclude patterns
for (const pattern of config.excludePatterns) {
// Simple pattern matching (could be enhanced with glob patterns in Phase 2)
if (filePath.includes(pattern.replace('**/', '').replace('/**', ''))) {
return false;
}
// Use centralized FileFilterManager for 'file' scope filtering if available
if (this.fileFilterManager && !this.fileFilterManager.shouldIncludePath(filePath, "file")) {
return false;
}
// Only process markdown files for now
// Only process markdown files for now (additional file type support can be added later)
if (!filePath.endsWith('.md')) {
return false;
}
// Skip system/hidden files
if (filePath.startsWith('.') || filePath.includes('/.')) {
return false;
}
return true;
}
@ -772,18 +777,24 @@ export class FileSource {
*/
private async performInitialScan(): Promise<void> {
console.log("[FileSource] Performing initial scan...");
const mdFiles = this.app.vault.getMarkdownFiles();
console.log(`[FileSource] Found ${mdFiles.length} markdown files to check`);
let scannedCount = 0;
let taskCount = 0;
let relevantCount = 0;
for (const file of mdFiles) {
if (this.isRelevantFile(file.path)) {
relevantCount++;
try {
const shouldBeTask = await this.shouldCreateFileTask(file.path);
if (shouldBeTask) {
await this.createFileTask(file.path);
taskCount++;
const task = await this.createFileTask(file.path);
if (task) {
taskCount++;
}
}
scannedCount++;
} catch (error) {
@ -791,8 +802,22 @@ export class FileSource {
}
}
}
console.log(`[FileSource] Initial scan complete: ${scannedCount} files scanned, ${taskCount} file tasks created`);
console.log(`[FileSource] Initial scan complete: ${mdFiles.length} total files, ${relevantCount} relevant, ${scannedCount} scanned, ${taskCount} file tasks created`);
if (taskCount === 0 && relevantCount > 0) {
console.log(`[FileSource] No file tasks created. Check if your files match the configured recognition strategies:`);
const config = this.config.getConfig();
if (config.recognitionStrategies.metadata.enabled) {
console.log(`[FileSource] - Metadata strategy: requires frontmatter with fields: ${config.recognitionStrategies.metadata.taskFields.join(', ')}`);
}
if (config.recognitionStrategies.tags.enabled) {
console.log(`[FileSource] - Tag strategy: requires tags: ${config.recognitionStrategies.tags.taskTags.join(', ')}`);
}
if (config.recognitionStrategies.paths.enabled) {
console.log(`[FileSource] - Path strategy: requires files in paths: ${config.recognitionStrategies.paths.taskPaths.join(', ')}`);
}
}
}
/**
@ -804,13 +829,13 @@ export class FileSource {
this.destroy();
return;
}
if (newConfig.enabled && !this.isInitialized) {
// FileSource is being enabled
this.initialize();
return;
}
// Configuration changed while active - might need to rescan
// This is a Phase 2 enhancement
console.log("[FileSource] Configuration updated");
@ -838,19 +863,26 @@ export class FileSource {
this.config.updateConfig(config);
}
/**
* Alias for updateConfiguration to match expected interface
*/
updateConfig(config: Partial<FileSourceConfiguration>): void {
this.updateConfiguration(config);
}
/**
* Force refresh of all file tasks
*/
async refresh(): Promise<void> {
if (!this.isInitialized || !this.config.isEnabled()) return;
console.log("[FileSource] Manual refresh triggered");
// Clear cache and re-scan
this.fileTaskCache.clear();
this.stats.trackedFileCount = 0;
this.stats.recognitionBreakdown = { metadata: 0, tag: 0, template: 0, path: 0 };
await this.performInitialScan();
}
@ -859,24 +891,24 @@ export class FileSource {
*/
destroy(): void {
if (!this.isInitialized) return;
console.log("[FileSource] Destroying FileSource...");
// Clear all debouncing timeouts
for (const timeout of this.pendingUpdates.values()) {
clearTimeout(timeout);
}
this.pendingUpdates.clear();
// Clear event listeners
for (const ref of this.eventRefs) {
this.app.vault.offref(ref);
}
this.eventRefs = [];
// Clear cache
this.fileTaskCache.clear();
// Reset statistics
this.stats = {
initialized: false,
@ -885,7 +917,7 @@ export class FileSource {
lastUpdate: 0,
lastUpdateSeq: 0
};
// Emit cleanup event
emit(this.app, Events.FILE_TASK_REMOVED, {
filePath: null, // Indicates all file tasks removed
@ -893,9 +925,35 @@ export class FileSource {
seq: Seq.next(),
destroyed: true
});
this.isInitialized = false;
console.log("[FileSource] Cleanup complete");
}
/**
* Cleanup resources and stop listening to events
*/
cleanup(): void {
// Unsubscribe from all events
this.eventRefs.forEach(ref => {
if (this.app.workspace && typeof this.app.workspace.offref === 'function') {
this.app.workspace.offref(ref);
}
});
this.eventRefs = [];
// Clear pending updates
this.pendingUpdates.forEach(timeout => clearTimeout(timeout));
this.pendingUpdates.clear();
// Clear cache
this.fileTaskCache.clear();
// Reset state
this.isInitialized = false;
this.stats.initialized = false;
console.log("[FileSource] Cleaned up and stopped");
}
}

View file

@ -94,6 +94,8 @@ export interface FileTaskPropertiesConfig {
defaultStatus: string;
/** Default priority for new file tasks */
defaultPriority?: number;
/** Prefer frontmatter title over file renaming when updating task content */
preferFrontmatterTitle: boolean;
}
/** Relationship configuration */
@ -116,16 +118,6 @@ export interface PerformanceConfig {
cacheTTL: number;
}
/** Advanced configuration */
export interface AdvancedConfig {
/** Custom recognition function */
customRecognitionFunction?: string;
/** Exclude patterns */
excludePatterns: string[];
/** Maximum file size to process (bytes) */
maxFileSize: number;
}
/**
* Configuration for mapping between file metadata status values and task status symbols
*/
@ -165,9 +157,6 @@ export interface FileSourceConfiguration {
/** Performance configuration */
performance: PerformanceConfig;
/** Advanced configuration */
advanced: AdvancedConfig;
/** Status mapping configuration */
statusMapping: StatusMappingConfig;
}