taskgenius_taskgenius-plugin/src/dataflow/sources/FileSource.ts
Quorafind 37606e0bcf refactor: improve error handling and status mark display
- Add ErrorContext type for structured error information with view ID,
  component name, operation, and file path details
- Enhance FluentComponentManager error rendering with collapsible
  technical details and context display
- Add status mark to display name mapping with comprehensive defaults
  and user configuration support
- Improve FileSource status mapping with textual-to-symbol conversion
- Clean up code formatting and trailing commas in ConfigurableTaskParser
2025-11-14 14:09:43 +08:00

1473 lines
36 KiB
TypeScript

/**
* 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 TaskProgressBarPlugin from "@/index";
import type { Task } from "@/types/task";
import type {
FileSourceConfiguration,
FileSourceTaskMetadata,
FileSourceStats,
FileTaskCache,
RecognitionStrategy,
PathRecognitionConfig,
TemplateRecognitionConfig,
MetadataMappingConfig,
} from "@/types/file-source";
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.
*/
export class FileSource {
/**
* Default status mappings from textual values to task symbols
* Provides fallback mappings when user configuration doesn't specify a mapping
*/
private static readonly DEFAULT_STATUS_MAPPINGS: Record<string, string> = {
completed: "x",
done: "x",
finished: "x",
"in-progress": "/",
"in progress": "/",
doing: "/",
planned: "?",
todo: "?",
cancelled: "-",
canceled: "-",
"not-started": " ",
"not started": " ",
};
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,
trackedFileCount: 0,
recognitionBreakdown: {
metadata: 0,
tag: 0,
template: 0,
path: 0,
},
lastUpdate: 0,
lastUpdateSeq: 0,
};
constructor(
private app: App,
initialConfig?: Partial<FileSourceConfiguration>,
private fileFilterManager?: FileFilterManager,
private plugin?: TaskProgressBarPlugin
) {
this.config = new FileSourceConfig(initialConfig);
}
/**
* Initialize FileSource and start listening for events
*/
async initialize(): Promise<void> {
if (this.isInitialized) return;
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();
this.isInitialized = true;
this.stats.initialized = true;
try {
await this.performInitialScan();
} catch (error) {
console.error("[FileSource] Initial scan failed", error);
}
console.log(
`[FileSource] Initialized with strategies: ${this.config
.getEnabledStrategies()
.join(", ")}`
);
}
/**
* Subscribe to relevant file events
*/
private subscribeToFileEvents(): void {
// Subscribe to FILE_UPDATED events from ObsidianSource
this.eventRefs.push(
on(this.app, Events.FILE_UPDATED, (payload) => {
if (payload?.path) {
this.handleFileUpdate(payload.path, payload.reason);
}
})
);
// Subscribe to more granular events if they exist
// These would be added to Events.ts later in Phase 2
this.eventRefs.push(
on(
this.app,
"task-genius:file-metadata-changed" as any,
(payload) => {
if (payload?.path) {
this.handleFileMetadataChange(payload.path);
}
}
)
);
this.eventRefs.push(
on(
this.app,
"task-genius:file-content-changed" as any,
(payload) => {
if (payload?.path) {
this.handleFileContentChange(payload.path);
}
}
)
);
}
/**
* Handle file update events with debouncing
*/
private handleFileUpdate(filePath: string, reason: string): void {
if (!this.isInitialized || !this.config.isEnabled()) return;
const relevant = this.isRelevantFile(filePath);
if (!relevant) return;
// Clear existing timeout for this file
const existingTimeout = this.pendingUpdates.get(filePath);
if (existingTimeout) {
clearTimeout(existingTimeout);
}
// Set new debounced timeout
const timeout = setTimeout(async () => {
this.pendingUpdates.delete(filePath);
try {
await this.processFileUpdate(filePath, reason);
} catch (error) {
console.error(
`[FileSource] Error processing file update for ${filePath}:`,
error
);
}
}, this.DEBOUNCE_DELAY);
this.pendingUpdates.set(filePath, timeout);
}
/**
* Handle granular metadata changes (Phase 2 enhancement)
*/
private handleFileMetadataChange(filePath: string): void {
if (!this.shouldUpdateFileTask(filePath, "metadata")) return;
this.handleFileUpdate(filePath, "frontmatter");
}
/**
* Handle granular content changes (Phase 2 enhancement)
*/
private handleFileContentChange(filePath: string): void {
if (!this.shouldUpdateFileTask(filePath, "content")) return;
this.handleFileUpdate(filePath, "modify");
}
/**
* Process a file update and determine if it should be a file task
*/
private async processFileUpdate(
filePath: string,
reason: string
): Promise<void> {
if (reason === "delete") {
await this.removeFileTask(filePath);
return;
}
const shouldBeTask = await this.shouldCreateFileTask(filePath);
const existingCache = this.fileTaskCache.get(filePath);
const wasTask = existingCache?.fileTaskExists ?? false;
if (shouldBeTask && !wasTask) {
// File should become a task
await this.createFileTask(filePath);
} else if (shouldBeTask && wasTask) {
// File is already a task, check if it needs updating
await this.updateFileTask(filePath);
} else if (!shouldBeTask && wasTask) {
// File should no longer be a task
await this.removeFileTask(filePath);
}
// else: File is not and should not be a task, do nothing
}
/**
* Check if a file should be treated as a task
*/
async shouldCreateFileTask(filePath: string): Promise<boolean> {
// Fast reject non-relevant files before any IO
if (!this.isRelevantFile(filePath)) return false;
const file = this.app.vault.getAbstractFileByPath(filePath) as TFile;
if (!file) return false;
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
);
return false;
}
}
/**
* Evaluate all enabled recognition strategies
*/
private evaluateRecognitionStrategies(
filePath: string,
fileContent: string,
fileCache: CachedMetadata | null
): boolean {
const config = this.config.getConfig();
const { recognitionStrategies } = config;
// Check metadata strategy
if (recognitionStrategies.metadata.enabled) {
if (
this.matchesMetadataStrategy(
filePath,
fileContent,
fileCache,
recognitionStrategies.metadata
)
) {
return true;
}
}
// Check tag strategy
if (recognitionStrategies.tags.enabled) {
if (
this.matchesTagStrategy(
filePath,
fileContent,
fileCache,
recognitionStrategies.tags
)
) {
return true;
}
}
// Check template strategy (Phase 2)
if (recognitionStrategies.templates.enabled) {
if (
this.matchesTemplateStrategy(
filePath,
fileContent,
fileCache,
recognitionStrategies.templates
)
) {
return true;
}
}
// Check path strategy (Phase 2)
if (recognitionStrategies.paths.enabled) {
if (
this.matchesPathStrategy(
filePath,
fileContent,
fileCache,
recognitionStrategies.paths
)
) {
return true;
}
}
return false;
}
/**
* Check if file matches metadata strategy
*/
private matchesMetadataStrategy(
filePath: string,
fileContent: string,
fileCache: CachedMetadata | null,
config: any
): boolean {
if (!fileCache?.frontmatter) return false;
const { taskFields, requireAllFields } = config;
const frontmatter = fileCache.frontmatter;
const matchingFields = taskFields.filter(
(field: string) =>
frontmatter.hasOwnProperty(field) &&
frontmatter[field] !== undefined
);
if (requireAllFields) {
return matchingFields.length === taskFields.length;
} else {
return matchingFields.length > 0;
}
}
/**
* Check if file matches tag strategy
*/
private matchesTagStrategy(
filePath: string,
fileContent: string,
fileCache: CachedMetadata | null,
config: any
): boolean {
if (!fileCache?.tags) return false;
const { taskTags, matchMode } = config;
const fileTags = fileCache.tags.map((tag) => tag.tag);
return taskTags.some((taskTag: string) => {
return fileTags.some((fileTag) => {
switch (matchMode) {
case "exact":
return fileTag === taskTag;
case "prefix":
return fileTag.startsWith(taskTag);
case "contains":
return fileTag.includes(taskTag);
default:
return fileTag === taskTag;
}
});
});
}
/**
* Check if file matches template strategy
*/
private matchesTemplateStrategy(
filePath: string,
fileContent: string,
fileCache: CachedMetadata | null,
config: TemplateRecognitionConfig
): boolean {
if (
!config.enabled ||
!config.templatePaths ||
config.templatePaths.length === 0
) {
return false;
}
// Check if file matches any template path
return config.templatePaths.some((templatePath) => {
// Check direct path inclusion
if (filePath.includes(templatePath)) {
return true;
}
// Check frontmatter template references
if (config.checkTemplateMetadata && fileCache?.frontmatter) {
const frontmatter = fileCache.frontmatter;
return (
frontmatter.template === templatePath ||
frontmatter.templateFile === templatePath ||
frontmatter.templatePath === templatePath
);
}
return false;
});
}
/**
* Check if file matches path strategy
*/
private matchesPathStrategy(
filePath: string,
fileContent: string,
fileCache: CachedMetadata | null,
config: PathRecognitionConfig
): boolean {
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)) {
console.log(
`[FileSource] Path matches prefix pattern: ${pattern} for ${filePath}`
);
return true;
}
break;
case "regex":
try {
const regex = new RegExp(normalizedPattern);
if (regex.test(normalizedPath)) {
console.log(
`[FileSource] Path matches regex pattern: ${pattern} for ${filePath}`
);
return true;
}
} catch (e) {
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}`
);
return true;
}
break;
}
}
return false;
}
/**
* Match a path against a glob pattern
* Supports: * (any chars except /), ** (any chars), ? (single char)
*/
private matchGlobPattern(path: string, pattern: string): boolean {
// Convert glob pattern to regular expression
let regexPattern = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // Escape special chars
.replace(/\*\*/g, "§§§") // Temporary placeholder for **
.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);
} catch (e) {
console.warn(
`[FileSource] Failed to compile glob pattern: ${pattern}`,
e
);
return false;
}
}
/**
* Create a new file task
*/
async createFileTask(
filePath: string
): Promise<Task<FileSourceTaskMetadata> | null> {
const file = this.app.vault.getAbstractFileByPath(filePath) as TFile;
if (!file) return null;
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
);
return null;
}
}
/**
* Update an existing file task
*/
async updateFileTask(
filePath: string
): Promise<Task<FileSourceTaskMetadata> | null> {
// For Phase 1, just recreate the task
// Phase 2 will add smart update detection
return await this.createFileTask(filePath);
}
/**
* Remove a file task
*/
async removeFileTask(filePath: string): Promise<void> {
const existingCache = this.fileTaskCache.get(filePath);
if (!existingCache?.fileTaskExists) return;
// 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}`);
}
/**
* Build a file task from file data
*/
private async buildFileTask(
filePath: string,
fileContent: string,
fileCache: CachedMetadata | null
): Promise<Task<FileSourceTaskMetadata> | null> {
const config = this.config.getConfig();
const file = this.app.vault.getAbstractFileByPath(filePath) as TFile;
if (!file) return null;
// Determine which strategy matched
const strategy = this.getMatchingStrategy(
filePath,
fileContent,
fileCache
);
if (!strategy) return null;
// Generate task content based on configuration
const content = this.generateTaskContent(
filePath,
fileContent,
fileCache
);
const safeContent =
typeof content === "string"
? content
: filePath.split("/").pop() || filePath;
// Extract metadata from frontmatter
const metadataResult = this.extractTaskMetadata(
filePath,
fileContent,
fileCache,
strategy
);
// Separate rawStatus from metadata (rawStatus is not part of FileSourceTaskMetadata)
const { rawStatus, ...metadata } = metadataResult;
// Compute status symbol from raw frontmatter value (BaseTask layer)
const status = this.computeStatusSymbol(
rawStatus,
config.fileTaskProperties.defaultStatus
);
// Log status mapping if conversion occurred
if (rawStatus && status !== rawStatus) {
console.log(
`[FileSource] Mapped status '${rawStatus}' to '${status}' for ${filePath}`
);
}
// Create the file task
const fileTask: Task<FileSourceTaskMetadata> = {
id: `file-source:${filePath}`,
content: safeContent,
filePath,
line: 0, // File tasks are at line 0
completed: status === "x" || status === "X",
status: status,
originalMarkdown: `**${safeContent}**`,
metadata: {
...metadata,
source: "file-source",
recognitionStrategy: strategy.name,
recognitionCriteria: strategy.criteria,
fileTimestamps: {
created: file.stat.ctime,
modified: file.stat.mtime,
},
childTasks: [], // Will be populated in Phase 3
tags: metadata.tags || [],
children: [], // Required by StandardTaskMetadata
},
};
return fileTask;
}
/**
* Get the matching recognition strategy for a file
*/
private getMatchingStrategy(
filePath: string,
fileContent: string,
fileCache: CachedMetadata | null
): { name: RecognitionStrategy; criteria: string } | null {
const config = this.config.getConfig();
if (
config.recognitionStrategies.metadata.enabled &&
this.matchesMetadataStrategy(
filePath,
fileContent,
fileCache,
config.recognitionStrategies.metadata
)
) {
return { name: "metadata", criteria: "frontmatter" };
}
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 &&
this.matchesPathStrategy(
filePath,
fileContent,
fileCache,
config.recognitionStrategies.paths
)
) {
return {
name: "path",
criteria:
config.recognitionStrategies.paths.taskPaths.join(", "),
};
}
// Template-based recognition
const templateConfig = config.recognitionStrategies.templates;
if (templateConfig.enabled && templateConfig.templatePaths.length > 0) {
// Check if file matches any template path
const matchesTemplate = templateConfig.templatePaths.some(
(templatePath) => {
// Simple path matching - could be enhanced with more sophisticated matching
return (
filePath.includes(templatePath) ||
fileCache?.frontmatter?.template === templatePath ||
fileCache?.frontmatter?.templateFile === templatePath
);
}
);
if (matchesTemplate) {
return {
name: "template",
criteria: templateConfig.templatePaths.join(", "),
};
}
}
return null;
}
/**
* Generate task content based on configuration
*/
private generateTaskContent(
filePath: string,
fileContent: string,
fileCache: CachedMetadata | null
): string {
const config = this.config.getConfig().fileTaskProperties;
const fileName = filePath.split("/").pop() || filePath;
const fileNameWithoutExt = fileName.replace(/\.[^/.]+$/, "");
switch (config.contentSource) {
case "filename":
// If user prefers frontmatter title, show it over filename
if (
config.preferFrontmatterTitle &&
fileCache?.frontmatter?.title
) {
return fileCache.frontmatter.title as string;
}
return config.stripExtension ? fileNameWithoutExt : fileName;
case "title":
// Always prefer frontmatter title if available, fallback to filename without extension
return (
(fileCache?.frontmatter?.title as string) ||
fileNameWithoutExt
);
case "h1":
const h1 = fileCache?.headings?.find((h) => h.level === 1);
return (h1?.heading as string) || fileNameWithoutExt;
case "custom":
if (config.customContentField && fileCache?.frontmatter) {
const val =
fileCache.frontmatter[config.customContentField];
if (val) return val as string;
// If custom field not present, optionally prefer frontmatter title
if (
config.preferFrontmatterTitle &&
fileCache.frontmatter.title
) {
return fileCache.frontmatter.title as string;
}
return fileNameWithoutExt;
}
// No custom field specified: optionally prefer frontmatter title
if (
config.preferFrontmatterTitle &&
fileCache?.frontmatter?.title
) {
return fileCache.frontmatter.title as string;
}
return fileNameWithoutExt;
default:
// Default to respecting preferFrontmatterTitle when available
if (
config.preferFrontmatterTitle &&
fileCache?.frontmatter?.title
) {
return fileCache.frontmatter.title as string;
}
return config.stripExtension ? fileNameWithoutExt : fileName;
}
}
/**
* Compute task status symbol from raw metadata value
* Maps textual status values (e.g., "completed", "in-progress") to task status symbols (e.g., "x", "/")
*/
private computeStatusSymbol(
rawStatus: string | undefined,
defaultStatus: string
): string {
if (!rawStatus) return defaultStatus;
// Already a single-character mark
if (rawStatus.length === 1) return rawStatus;
const sm = this.config.getConfig().statusMapping;
const target = sm.caseSensitive
? rawStatus
: String(rawStatus).toLowerCase();
// Try configured metadata->symbol table first
for (const [k, sym] of Object.entries(sm.metadataToSymbol || {})) {
const key = sm.caseSensitive ? k : k.toLowerCase();
if (key === target) return sym;
}
// Fallback to common defaults to be robust
const norm = String(rawStatus).toLowerCase();
const defaultMapping = FileSource.DEFAULT_STATUS_MAPPINGS[norm];
if (defaultMapping !== undefined) return defaultMapping;
return defaultStatus;
}
/**
* Extract task metadata from file
* Returns metadata along with rawStatus for status symbol computation
*/
private extractTaskMetadata(
filePath: string,
fileContent: string,
fileCache: CachedMetadata | null,
strategy: { name: RecognitionStrategy; criteria: string }
): Partial<FileSourceTaskMetadata> & { rawStatus?: string } {
const config = this.config.getConfig();
const frontmatter = fileCache?.frontmatter || {};
const resolvedFrontmatter = this.applyMetadataMappings(
frontmatter,
config.metadataMappings
);
// Extract raw status value from frontmatter for later symbol mapping
const rawStatusVal = resolvedFrontmatter.status ?? "";
const rawStatus: string = typeof rawStatusVal === "string" ? rawStatusVal : String(rawStatusVal ?? "");
// TODO: Future enhancement - read frontmatter.repeat and map into FileSourceTaskMetadata (e.g., as recurrence)
// Collect all tags (from file cache and frontmatter)
const allTags = this.mergeTags(
fileCache?.tags?.map((tag) => tag.tag) || [],
resolvedFrontmatter.tags
);
// Extract project from tags as a fallback (only if not in frontmatter)
// Note: In FileSource mode, context and area come ONLY from frontmatter,
// not from tags like #context/xxx or #area/xxx
const projectTagExtraction = this.extractProjectFromTags(allTags);
const projectFromTags = projectTagExtraction.project;
// Priority order for project:
// 1. Direct frontmatter field (project: xxx) - highest priority
// 2. Metadata mapping (e.g., custom_project mapped to project via metadataMappings)
// 3. Tag extraction (#project/xxx) - lowest priority, only if frontmatter has nothing
const projectVal = resolvedFrontmatter.project ?? projectFromTags;
const projectValue: string | undefined =
projectVal !== undefined && projectVal !== null
? String(projectVal)
: undefined;
const shouldStripProjectTags =
!resolvedFrontmatter.project &&
Boolean(projectFromTags) &&
projectTagExtraction.matchedTags.length > 0;
const tagsForMetadata = shouldStripProjectTags
? allTags.filter(
(tag) => !projectTagExtraction.matchedTags.includes(tag)
)
: allTags;
// Extract standard task metadata
const metadata: Partial<FileSourceTaskMetadata> = {
dueDate: this.parseDate(
resolvedFrontmatter.dueDate ?? resolvedFrontmatter.due
),
startDate: this.parseDate(
resolvedFrontmatter.startDate ?? resolvedFrontmatter.start
),
scheduledDate: this.parseDate(
resolvedFrontmatter.scheduledDate ??
resolvedFrontmatter.scheduled
),
completedDate: this.parseDate(resolvedFrontmatter.completedDate),
createdDate: this.parseDate(resolvedFrontmatter.createdDate),
recurrence:
typeof resolvedFrontmatter.recurrence === "string"
? resolvedFrontmatter.recurrence
: (resolvedFrontmatter.recurrence != null
? String(resolvedFrontmatter.recurrence)
: undefined),
priority:
this.parsePriority(resolvedFrontmatter.priority) ??
config.fileTaskProperties.defaultPriority,
// Project: frontmatter (direct or mapped) > tags (#project/xxx)
project: projectValue,
// Context and area: ONLY from frontmatter (direct or mapped via metadataMappings)
context:
resolvedFrontmatter.context !== undefined &&
resolvedFrontmatter.context !== null
? String(resolvedFrontmatter.context)
: undefined,
area:
resolvedFrontmatter.area !== undefined &&
resolvedFrontmatter.area !== null
? String(resolvedFrontmatter.area)
: undefined,
tags: tagsForMetadata,
children: [],
};
// Return metadata with rawStatus for status computation in buildFileTask
return { ...metadata, rawStatus };
}
/**
* Extract project from tags
* Only extracts #project/xxx format tags as a fallback when frontmatter doesn't have project
* Note: context and area are NOT extracted from tags in FileSource mode,
* they should be defined directly in frontmatter (context: xxx, area: xxx)
*/
private extractProjectFromTags(tags: string[]): {
project?: string;
matchedTags: string[];
} {
// Get configurable project prefix from plugin settings, with fallback default
const configuredPrefix =
this.plugin?.settings?.projectTagPrefix?.["tasks"];
const projectPrefix =
typeof configuredPrefix === "string" && configuredPrefix.trim()
? configuredPrefix.trim()
: "project";
const matchedTags: string[] = [];
let projectValue: string | undefined;
for (const tag of tags) {
if (!tag || typeof tag !== "string") continue;
// Remove leading # if present
const tagWithoutHash = tag.startsWith("#") ? tag.substring(1) : tag;
// Check for project tags (case-insensitive prefix match)
if (
tagWithoutHash
.toLowerCase()
.startsWith(`${projectPrefix.toLowerCase()}/`)
) {
matchedTags.push(tag);
if (!projectValue) {
// Extract the value using the actual prefix length (preserving case in the value)
const slashIndex = tagWithoutHash.indexOf("/");
if (slashIndex !== -1) {
const value = tagWithoutHash.substring(slashIndex + 1);
if (value) {
projectValue = value;
}
}
}
}
}
if (projectValue) {
console.log(
`[FileSource] Extracted project from tag: ${projectValue}`
);
}
return { project: projectValue, matchedTags };
}
/**
* Apply configured metadata mappings to normalize frontmatter keys
*/
private applyMetadataMappings(
frontmatter: Record<string, any>,
mappings: MetadataMappingConfig[]
): Record<string, any> {
if (!Array.isArray(mappings) || mappings.length === 0) {
return { ...frontmatter };
}
const result = { ...frontmatter };
for (const mapping of mappings) {
if (!mapping?.enabled) continue;
const sourceKey = mapping.sourceKey;
const targetKey = mapping.targetKey;
if (!sourceKey || !targetKey) continue;
const value = frontmatter[sourceKey];
if (value !== undefined) {
result[targetKey] = value;
}
}
return result;
}
/**
* Merge vault tags with mapped frontmatter tag values
*/
private mergeTags(existing: string[], extra: unknown): string[] {
const tagSet = new Set(existing);
if (Array.isArray(extra)) {
for (const value of extra) {
if (typeof value !== "string") continue;
const normalized = this.normalizeTag(value);
if (normalized) tagSet.add(normalized);
}
} else if (typeof extra === "string") {
const segments = extra
.split(/[,\s]+/)
.map((segment) => this.normalizeTag(segment))
.filter((segment): segment is string => Boolean(segment));
for (const segment of segments) {
tagSet.add(segment);
}
}
return Array.from(tagSet);
}
private normalizeTag(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
return trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
}
private parsePriority(value: unknown): number | undefined {
if (value === undefined || value === null) {
return undefined;
}
if (typeof value === "number" && !Number.isNaN(value)) {
return value;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return undefined;
const parsed = Number(trimmed);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
return undefined;
}
/**
* 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;
}
/**
* Update file task cache
*/
private updateFileTaskCache(
filePath: string,
task: Task<FileSourceTaskMetadata>
): void {
const frontmatterHash = this.generateFrontmatterHash(filePath);
this.fileTaskCache.set(filePath, {
fileTaskExists: true,
frontmatterHash,
childTaskIds: new Set(task.metadata.childTasks || []),
lastUpdated: Date.now(),
});
}
/**
* Generate hash for frontmatter for change detection
*/
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);
}
/**
* Simple hash function
*/
private simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
return hash.toString(36);
}
/**
* Check if file needs updating (stub for Phase 2)
*/
private shouldUpdateFileTask(
filePath: string,
changeType: "metadata" | "content"
): boolean {
// Simple check for Phase 1 - always update if file is tracked
return this.fileTaskCache.has(filePath);
}
/**
* Update statistics
*/
private updateStatistics(
strategy: RecognitionStrategy,
delta: number
): void {
this.stats.recognitionBreakdown[strategy] += delta;
this.stats.trackedFileCount += delta;
this.stats.lastUpdate = Date.now();
this.stats.lastUpdateSeq = this.lastUpdateSeq;
}
/**
* Emit file task update event
*/
private emitFileTaskUpdate(
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}`);
}
/**
* Check if file is relevant for processing
*/
private isRelevantFile(filePath: string): boolean {
// Fast-path filters first
// 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;
}
// Apply centralized FileFilterManager for 'file' scope filtering if available
if (this.fileFilterManager) {
const include = this.fileFilterManager.shouldIncludePath(
filePath,
"file"
);
if (!include) return false;
}
return true;
}
/**
* Perform initial scan of existing files
*/
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) {
const task = await this.createFileTask(file.path);
if (task) {
taskCount++;
}
}
scannedCount++;
} catch (error) {
console.error(
`[FileSource] Error scanning ${file.path}:`,
error
);
}
}
}
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(
", "
)}`
);
}
}
}
/**
* Handle configuration changes
*/
private handleConfigChange(newConfig: FileSourceConfiguration): void {
if (!newConfig.enabled && this.isInitialized) {
// FileSource is being disabled
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");
}
/**
* Get current statistics
*/
getStats(): FileSourceStats {
return { ...this.stats };
}
/**
* Get all file tasks (stub for Phase 2)
*/
async getAllFileTasks(): Promise<Task<FileSourceTaskMetadata>[]> {
// This will be implemented properly in Phase 3 with Repository integration
return [];
}
/**
* Update configuration
*/
updateConfiguration(config: Partial<FileSourceConfiguration>): void {
this.config.updateConfig(config);
}
/**
* Sync FileSource status mapping from plugin TaskStatus settings
*/
public syncStatusMappingFromSettings(
taskStatuses: Record<string, string>
): void {
try {
this.config.syncWithTaskStatuses(taskStatuses);
} catch (e) {
console.warn(
"[FileSource] Failed to sync status mapping from settings",
e
);
}
}
/**
* 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;
// 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();
}
/**
* Cleanup and destroy 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,
trackedFileCount: 0,
recognitionBreakdown: { metadata: 0, tag: 0, template: 0, path: 0 },
lastUpdate: 0,
lastUpdateSeq: 0,
};
// Emit cleanup event
emit(this.app, Events.FILE_TASK_REMOVED, {
filePath: null, // Indicates all file tasks removed
timestamp: Date.now(),
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");
}
}