From 37606e0bcf8d4b7d5053e0fc4cf8aa7299bb52fd Mon Sep 17 00:00:00 2001 From: Quorafind Date: Fri, 14 Nov 2025 14:09:43 +0800 Subject: [PATCH] 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 --- .../fluent/managers/FluentComponentManager.ts | 232 ++++++++++++++++-- src/components/features/task/view/content.ts | 3 +- src/dataflow/core/ConfigurableTaskParser.ts | 124 +++++----- src/dataflow/sources/FileSource.ts | 144 +++++++---- src/pages/FluentTaskView.ts | 47 +++- src/types/fluent-types.ts | 18 ++ src/utils/grouping/taskGrouping.ts | 144 ++++++++++- 7 files changed, 555 insertions(+), 157 deletions(-) diff --git a/src/components/features/fluent/managers/FluentComponentManager.ts b/src/components/features/fluent/managers/FluentComponentManager.ts index b3a153da..1c1a76ca 100644 --- a/src/components/features/fluent/managers/FluentComponentManager.ts +++ b/src/components/features/fluent/managers/FluentComponentManager.ts @@ -22,6 +22,7 @@ import { QuickCaptureModal } from "@/components/features/quick-capture/modals/Qu import { t } from "@/translations/helper"; import { ViewMode, TopNavigation } from "../components/FluentTopNavigation"; import { TaskSelectionManager } from "@/components/features/task/selection/TaskSelectionManager"; +import { ErrorContext } from "@/types/fluent-types"; type ManagedViewComponent = { containerEl: HTMLElement; @@ -51,6 +52,18 @@ const VIEW_MODE_CONFIG: Record = { kanban: [], }; +// CSS class names for error state display +const ERROR_CSS_CLASSES = { + STATE: "tg-fluent-error-state", + ICON: "tg-fluent-error-icon", + TITLE: "tg-fluent-error-title", + CONTEXT: "tg-fluent-error-context", + CONTEXT_ITEM: "tg-fluent-error-context-item", + MESSAGE: "tg-fluent-error-message", + DETAILS: "tg-fluent-error-details", + STACK: "tg-fluent-error-stack", +}; + /** * FluentComponentManager - Manages view component lifecycle * @@ -873,39 +886,206 @@ export class FluentComponentManager extends Component { } /** - * Render error state + * Render error state with detailed context */ - renderErrorState(errorMessage: string, onRetry: () => void): void { - if (this.contentArea) { - this.contentArea - .querySelectorAll(".tg-fluent-error-state") - .forEach((el) => el.remove()); + renderErrorState(context: ErrorContext | string, onRetry: () => void): void { + if (!this.contentArea) return; - const errorEl = this.contentArea.createDiv({ - cls: "tg-fluent-error-state", - }); - const errorIcon = errorEl.createDiv({ - cls: "tg-fluent-error-icon", - }); - setIcon(errorIcon, "alert-triangle"); + // Parse context: support both new (ErrorContext) and old (string) formats + const errorContext = this.parseErrorContext(context); - errorEl.createDiv({ - cls: "tg-fluent-error-title", - text: t("Failed to load tasks"), - }); + // Remove existing error overlays + this.contentArea + .querySelectorAll(`.${ERROR_CSS_CLASSES.STATE}`) + .forEach((el) => el.remove()); - errorEl.createDiv({ - cls: "tg-fluent-error-message", - text: errorMessage || t("An unexpected error occurred"), - }); + // Create error container + const errorEl = this.contentArea.createDiv({ + cls: ERROR_CSS_CLASSES.STATE, + }); - const retryBtn = errorEl.createEl("button", { - cls: "tg-fluent-button tg-fluent-button-primary", - text: t("Retry"), - }); + // Build error UI components + this.createErrorIcon(errorEl); + this.createErrorTitle(errorEl); + this.createErrorContext(errorEl, errorContext); + this.createErrorMessage(errorEl, errorContext); + this.createTechnicalDetails(errorEl, errorContext); + this.createRetryButton(errorEl, onRetry); + } - retryBtn.addEventListener("click", onRetry); + /** + * Parse error context from various input formats + */ + private parseErrorContext(context: ErrorContext | string): ErrorContext { + if (typeof context === "string") { + // Backward compatibility: convert string to ErrorContext + return { + userMessage: context, + originalError: new Error(context), + }; } + return context; + } + + /** + * Create error icon element + */ + private createErrorIcon(errorEl: HTMLElement): void { + const errorIcon = errorEl.createDiv({ + cls: ERROR_CSS_CLASSES.ICON, + }); + setIcon(errorIcon, "alert-triangle"); + } + + /** + * Create error title element + */ + private createErrorTitle(errorEl: HTMLElement): void { + errorEl.createDiv({ + cls: ERROR_CSS_CLASSES.TITLE, + text: t("Failed to load tasks"), + }); + } + + /** + * Create error context information (view, component, operation) + */ + private createErrorContext( + errorEl: HTMLElement, + errorContext: ErrorContext + ): void { + if ( + !errorContext.viewId && + !errorContext.componentName && + !errorContext.operation + ) { + return; + } + + const contextEl = errorEl.createDiv({ + cls: ERROR_CSS_CLASSES.CONTEXT, + }); + + if (errorContext.viewId) { + const viewLabel = this.getViewLabel(errorContext.viewId); + contextEl.createDiv({ + cls: ERROR_CSS_CLASSES.CONTEXT_ITEM, + text: `${t("View")}: ${viewLabel} (${errorContext.viewId})`, + }); + } + + if (errorContext.componentName) { + contextEl.createDiv({ + cls: ERROR_CSS_CLASSES.CONTEXT_ITEM, + text: `${t("Component")}: ${errorContext.componentName}`, + }); + } + + if (errorContext.operation) { + contextEl.createDiv({ + cls: ERROR_CSS_CLASSES.CONTEXT_ITEM, + text: `${t("Operation")}: ${errorContext.operation}`, + }); + } + } + + /** + * Create user-friendly error message + */ + private createErrorMessage( + errorEl: HTMLElement, + errorContext: ErrorContext + ): void { + const userMessage = + errorContext.userMessage || + errorContext.originalError?.message || + t("An unexpected error occurred"); + + errorEl.createDiv({ + cls: ERROR_CSS_CLASSES.MESSAGE, + text: userMessage, + }); + } + + /** + * Create collapsible technical details section + */ + private createTechnicalDetails( + errorEl: HTMLElement, + errorContext: ErrorContext + ): void { + if (!errorContext.originalError) return; + + const detailsEl = errorEl.createEl("details", { + cls: ERROR_CSS_CLASSES.DETAILS, + }); + + detailsEl.createEl("summary", { + text: t("View technical details"), + }); + + const stackEl = detailsEl.createEl("pre", { + cls: ERROR_CSS_CLASSES.STACK, + }); + + // Build technical details + let technicalInfo = ""; + if (errorContext.filePath) { + technicalInfo += `File: ${errorContext.filePath}\n\n`; + } + if (errorContext.originalError.stack) { + technicalInfo += errorContext.originalError.stack; + } else { + technicalInfo += errorContext.originalError.message; + } + + stackEl.textContent = technicalInfo; + } + + /** + * Create retry button + */ + private createRetryButton( + errorEl: HTMLElement, + onRetry: () => void + ): void { + const retryBtn = errorEl.createEl("button", { + cls: "tg-fluent-button tg-fluent-button-primary", + text: t("Retry"), + }); + + retryBtn.addEventListener("click", onRetry); + } + + /** + * Get localized view label + */ + private getViewLabel(viewId: string): string { + const labels: Record = { + inbox: t("Inbox"), + today: t("Today"), + upcoming: t("Upcoming"), + flagged: t("Flagged"), + projects: t("Projects"), + tags: t("Tags"), + forecast: t("Forecast"), + review: t("Review"), + habit: t("Habit"), + calendar: t("Calendar"), + kanban: t("Kanban"), + gantt: t("Gantt"), + }; + return labels[viewId] || viewId; + } + + /** + * Get current visible component name + */ + public getCurrentComponentName(): string | undefined { + if (!this.currentVisibleComponent) { + return undefined; + } + return this.currentVisibleComponent.constructor?.name; } /** diff --git a/src/components/features/task/view/content.ts b/src/components/features/task/view/content.ts index 4fdc60b8..352c59a6 100644 --- a/src/components/features/task/view/content.ts +++ b/src/components/features/task/view/content.ts @@ -315,7 +315,8 @@ export class ContentComponent extends Component { } else { this.taskGroups = groupTasksBy( this.filteredTasks, - this.groupByDimension + this.groupByDimension, + this.plugin.settings.taskStatusMarks ); } } diff --git a/src/dataflow/core/ConfigurableTaskParser.ts b/src/dataflow/core/ConfigurableTaskParser.ts index 74bb409f..c11ca91a 100644 --- a/src/dataflow/core/ConfigurableTaskParser.ts +++ b/src/dataflow/core/ConfigurableTaskParser.ts @@ -36,7 +36,7 @@ export class MarkdownTaskParser { constructor( config: TaskParserConfig, - timeParsingService?: TimeParsingService, + timeParsingService?: TimeParsingService ) { this.config = config; // Extract custom date formats if available @@ -46,7 +46,7 @@ export class MarkdownTaskParser { // Public alias for extractMetadataAndTags public extractMetadataAndTags( - content: string, + content: string ): [string, Record, string[]] { return this.extractMetadataAndTagsInternal(content); } @@ -57,7 +57,7 @@ export class MarkdownTaskParser { static createWithStatusMapping( config: TaskParserConfig, statusMapping: Record, - timeParsingService?: TimeParsingService, + timeParsingService?: TimeParsingService ): MarkdownTaskParser { const newConfig = { ...config, statusMapping }; return new MarkdownTaskParser(newConfig, timeParsingService); @@ -71,7 +71,7 @@ export class MarkdownTaskParser { filePath = "", fileMetadata?: Record, projectConfigData?: Record, - tgProject?: TgProject, + tgProject?: TgProject ): EnhancedTask[] { this.reset(); this.fileMetadata = fileMetadata; @@ -90,7 +90,7 @@ export class MarkdownTaskParser { parseIteration++; if (parseIteration > this.config.maxParseIterations) { console.warn( - "Warning: Maximum parse iterations reached, stopping to prevent infinite loop", + "Warning: Maximum parse iterations reached, stopping to prevent infinite loop" ); break; } @@ -142,13 +142,13 @@ export class MarkdownTaskParser { const isSubtask = parentId !== undefined; const inheritedMetadata = this.inheritFileMetadata( metadata, - isSubtask, + isSubtask ); // Extract time components from task content using enhanced time parsing const enhancedMetadata = this.extractTimeComponents( taskContent, - inheritedMetadata, + inheritedMetadata ); // Process inherited tags and merge with task's own tags @@ -156,7 +156,7 @@ export class MarkdownTaskParser { if (inheritedMetadata.tags) { try { const inheritedTags = JSON.parse( - inheritedMetadata.tags, + inheritedMetadata.tags ); if (Array.isArray(inheritedTags)) { finalTags = this.mergeTags(tags, inheritedTags); @@ -179,8 +179,8 @@ export class MarkdownTaskParser { ? this.extractMultilineComment( lines, i + 1, - actualSpaces, - ) + actualSpaces + ) : [undefined, 0]; i += linesToSkip; @@ -216,23 +216,23 @@ export class MarkdownTaskParser { priority: extractedPriority, startDate: this.extractLegacyDate( enhancedMetadata, - "startDate", + "startDate" ), dueDate: this.extractLegacyDate( enhancedMetadata, - "dueDate", + "dueDate" ), scheduledDate: this.extractLegacyDate( enhancedMetadata, - "scheduledDate", + "scheduledDate" ), completedDate: this.extractLegacyDate( enhancedMetadata, - "completedDate", + "completedDate" ), createdDate: this.extractLegacyDate( enhancedMetadata, - "createdDate", + "createdDate" ), recurrence: enhancedMetadata.recurrence, project: enhancedMetadata.project, @@ -241,7 +241,7 @@ export class MarkdownTaskParser { if (parentId && this.tasks.length > 0) { const parentTask = this.tasks.find( - (t) => t.id === parentId, + (t) => t.id === parentId ); if (parentTask) { parentTask.childrenIds.push(taskId); @@ -267,14 +267,14 @@ export class MarkdownTaskParser { filePath: string = "", fileMetadata?: Record, projectConfigData?: Record, - tgProject?: TgProject, + tgProject?: TgProject ): Task[] { const enhancedTasks = this.parse( input, filePath, fileMetadata, projectConfigData, - tgProject, + tgProject ); return enhancedTasks.map((task) => this.convertToLegacyTask(task)); } @@ -299,7 +299,7 @@ export class MarkdownTaskParser { } private extractTaskLine( - line: string, + line: string ): [number, number, string, string] | null { // Preserve trailing spaces to allow parsing of empty-content tasks like "- [ ] " const trimmed = line.trimStart(); @@ -361,7 +361,7 @@ export class MarkdownTaskParser { } private extractMetadataAndTagsInternal( - content: string, + content: string ): [string, Record, string[]] { const metadata: Record = {}; const tags: string[] = []; @@ -513,7 +513,7 @@ export class MarkdownTaskParser { */ private extractTimeComponents( taskContent: string, - existingMetadata: Record, + existingMetadata: Record ): EnhancedStandardTaskMetadata { if (!this.timeParsingService) { // Return existing metadata as EnhancedStandardTaskMetadata without time components @@ -539,7 +539,7 @@ export class MarkdownTaskParser { // Swallow JSON.parse or format errors from time parsing; continue without time components console.warn( "[MarkdownTaskParser] timeParsingService.parseTimeComponents failed, continuing without time components:", - innerErr, + innerErr ); timeComponents = {}; errors = []; @@ -550,7 +550,7 @@ export class MarkdownTaskParser { if (warnings.length > 0) { console.warn( `[MarkdownTaskParser] Time parsing warnings for "${taskContent}":`, - warnings, + warnings ); } @@ -558,7 +558,7 @@ export class MarkdownTaskParser { if (errors.length > 0) { console.warn( `[MarkdownTaskParser] Time parsing errors for "${taskContent}":`, - errors, + errors ); } @@ -582,7 +582,7 @@ export class MarkdownTaskParser { scheduledDate: existingMetadata.scheduledDate, completedDate: existingMetadata.completedDate, }, - timeComponents, + timeComponents ); } @@ -590,7 +590,7 @@ export class MarkdownTaskParser { } catch (error) { console.error( `[MarkdownTaskParser] Failed to extract time components from "${taskContent}":`, - error, + error ); // Return existing metadata without time components on error return { @@ -611,7 +611,7 @@ export class MarkdownTaskParser { scheduledDate?: number | string; completedDate?: number | string; }, - timeComponents: EnhancedStandardTaskMetadata["timeComponents"], + timeComponents: EnhancedStandardTaskMetadata["timeComponents"] ): EnhancedStandardTaskMetadata["enhancedDates"] { if (!timeComponents) { return undefined; @@ -622,7 +622,7 @@ export class MarkdownTaskParser { // Helper function to combine date and time component const combineDateTime = ( dateValue: number | string | undefined, - timeComponent: TimeComponent | undefined, + timeComponent: TimeComponent | undefined ): Date | undefined => { if (!dateValue || !timeComponent) { return undefined; @@ -652,7 +652,7 @@ export class MarkdownTaskParser { date.getDate(), timeComponent.hour, timeComponent.minute, - timeComponent.second || 0, + timeComponent.second || 0 ); return combinedDate; @@ -662,7 +662,7 @@ export class MarkdownTaskParser { if (dates.startDate && timeComponents.startTime) { enhancedDates.startDateTime = combineDateTime( dates.startDate, - timeComponents.startTime, + timeComponents.startTime ); } @@ -670,7 +670,7 @@ export class MarkdownTaskParser { if (dates.dueDate && timeComponents.dueTime) { enhancedDates.dueDateTime = combineDateTime( dates.dueDate, - timeComponents.dueTime, + timeComponents.dueTime ); } @@ -678,7 +678,7 @@ export class MarkdownTaskParser { if (dates.scheduledDate && timeComponents.scheduledTime) { enhancedDates.scheduledDateTime = combineDateTime( dates.scheduledDate, - timeComponents.scheduledTime, + timeComponents.scheduledTime ); } @@ -686,7 +686,7 @@ export class MarkdownTaskParser { if (dates.startDate && timeComponents.endTime) { enhancedDates.endDateTime = combineDateTime( dates.startDate, - timeComponents.endTime, + timeComponents.endTime ); } @@ -699,7 +699,7 @@ export class MarkdownTaskParser { ) { enhancedDates.dueDateTime = combineDateTime( dates.dueDate, - timeComponents.scheduledTime, + timeComponents.scheduledTime ); } @@ -712,7 +712,7 @@ export class MarkdownTaskParser { ) { enhancedDates.scheduledDateTime = combineDateTime( dates.scheduledDate, - timeComponents.dueTime, + timeComponents.dueTime ); } @@ -722,7 +722,7 @@ export class MarkdownTaskParser { } private extractDataviewMetadata( - content: string, + content: string ): [string, string, string] | null { const start = content.indexOf("["); if (start === -1) return null; @@ -763,7 +763,7 @@ export class MarkdownTaskParser { // We need to reverse lookup: find prefix that maps to standard metadata keys const lowerKey = key.toLowerCase(); for (const [prefix, metadataType] of Object.entries( - this.config.specialTagPrefixes || {}, + this.config.specialTagPrefixes || {} )) { if (prefix.toLowerCase() === lowerKey) { key = metadataType; // Map to the target metadata field (project, context, area) @@ -784,7 +784,7 @@ export class MarkdownTaskParser { } private extractEmojiMetadata( - content: string, + content: string ): [string, string, string, string] | null { // Find the earliest emoji let earliestEmoji: { pos: number; emoji: string; key: string } | null = @@ -803,7 +803,7 @@ export class MarkdownTaskParser { const beforeEmoji = content.substring(0, earliestEmoji.pos); const afterEmoji = content.substring( - earliestEmoji.pos + earliestEmoji.emoji.length, + earliestEmoji.pos + earliestEmoji.emoji.length ); // Extract value after emoji @@ -817,7 +817,7 @@ export class MarkdownTaskParser { // Check if we encounter other emojis or special characters if ( Object.keys(this.config.emojiMapping).some((e) => - valuePart.substring(i).startsWith(e), + valuePart.substring(i).startsWith(e) ) || char === "[" ) { @@ -1126,7 +1126,7 @@ export class MarkdownTaskParser { } private extractTagsOnly( - content: string, + content: string ): [string, Record, string[]] { const metadata: Record = {}; const tags: string[] = []; @@ -1232,7 +1232,7 @@ export class MarkdownTaskParser { } private findParentAndLevel( - actualSpaces: number, + actualSpaces: number ): [string | undefined, number] { if (this.indentStack.length === 0 || actualSpaces === 0) { return [undefined, 0]; @@ -1255,7 +1255,7 @@ export class MarkdownTaskParser { private updateIndentStack( taskId: string, indentLevel: number, - actualSpaces: number, + actualSpaces: number ): void { let stackOperations = 0; @@ -1263,7 +1263,7 @@ export class MarkdownTaskParser { stackOperations++; if (stackOperations > this.config.maxStackOperations) { console.warn( - "Warning: Maximum stack operations reached, clearing stack", + "Warning: Maximum stack operations reached, clearing stack" ); this.indentStack = []; break; @@ -1280,7 +1280,7 @@ export class MarkdownTaskParser { if (this.indentStack.length >= this.config.maxStackSize) { this.indentStack.splice( 0, - this.indentStack.length - this.config.maxStackSize + 1, + this.indentStack.length - this.config.maxStackSize + 1 ); } @@ -1290,7 +1290,7 @@ export class MarkdownTaskParser { private getStatusFromMapping(rawStatus: string): string | undefined { // Find status name corresponding to raw character for (const [statusName, mappedChar] of Object.entries( - this.config.statusMapping, + this.config.statusMapping )) { if (mappedChar === rawStatus) { return statusName; @@ -1327,7 +1327,7 @@ export class MarkdownTaskParser { private extractMultilineComment( lines: string[], startIndex: number, - actualSpaces: number, + actualSpaces: number ): [string | undefined, number] { const commentLines: string[] = []; let i = startIndex; @@ -1359,7 +1359,7 @@ export class MarkdownTaskParser { // Legacy compatibility methods private extractLegacyPriority( - metadata: Record, + metadata: Record ): number | undefined { if (!metadata.priority) return undefined; @@ -1401,14 +1401,14 @@ export class MarkdownTaskParser { private extractLegacyDate( metadata: Record, - key: string, + key: string ): number | undefined { const dateStr = metadata[key]; if (!dateStr) return undefined; // Check cache first to avoid repeated date parsing const cacheKey = `${dateStr}_${(this.customDateFormats || []).join( - ",", + "," )}`; const cachedDate = MarkdownTaskParser.dateCache.get(cacheKey); if (cachedDate !== undefined) { @@ -1452,7 +1452,7 @@ export class MarkdownTaskParser { filePath: enhancedTask.filePath, line: enhancedTask.line, completed: enhancedTask.completed, - status: enhancedTask.rawStatus, + status: enhancedTask.rawStatus || " ", // Default to " " if no raw status originalMarkdown: enhancedTask.originalMarkdown, children: enhancedTask.children || [], metadata: { @@ -1494,8 +1494,8 @@ export class MarkdownTaskParser { heading: Array.isArray(enhancedTask.heading) ? enhancedTask.heading : enhancedTask.heading - ? [enhancedTask.heading] - : [], + ? [enhancedTask.heading] + : [], parent: enhancedTask.parentId, tgProject: enhancedTask.tgProject, }, @@ -1668,10 +1668,10 @@ export class MarkdownTaskParser { private mergeTags(baseTags: string[], inheritedTags: string[]): string[] { // Normalize all tags before merging const normalizedBaseTags = baseTags.map((tag) => - this.normalizeTag(tag), + this.normalizeTag(tag) ); const normalizedInheritedTags = inheritedTags.map((tag) => - this.normalizeTag(tag), + this.normalizeTag(tag) ); const merged = [...normalizedBaseTags]; @@ -1696,7 +1696,7 @@ export class MarkdownTaskParser { */ private inheritFileMetadata( taskMetadata: Record, - isSubtask: boolean = false, + isSubtask: boolean = false ): Record { // Helper function to convert priority values to numbers const convertPriorityValue = (value: any): string => { @@ -1827,7 +1827,7 @@ export class MarkdownTaskParser { this.fileMetadata[configuredProjectKey] !== undefined && this.fileMetadata[configuredProjectKey] !== null && String( - this.fileMetadata[configuredProjectKey], + this.fileMetadata[configuredProjectKey] ).trim() !== "" ) { if ( @@ -1836,7 +1836,7 @@ export class MarkdownTaskParser { inherited.project === "" ) { inherited.project = String( - this.fileMetadata[configuredProjectKey], + this.fileMetadata[configuredProjectKey] ).trim(); } } @@ -1851,7 +1851,7 @@ export class MarkdownTaskParser { // Merge extracted metadata from tags for (const [tagKey, tagValue] of Object.entries( - tagMetadata, + tagMetadata )) { if ( !nonInheritableFields.has(tagKey) && @@ -1880,7 +1880,7 @@ export class MarkdownTaskParser { ) { // Normalize tags before storing const normalizedTags = value.map((tag) => - this.normalizeTag(tag), + this.normalizeTag(tag) ); inherited["tags"] = JSON.stringify(normalizedTags); } @@ -1911,7 +1911,7 @@ export class MarkdownTaskParser { // LEGACY: Inherit from project configuration data if available if (this.projectConfigCache) { for (const [key, value] of Object.entries( - this.projectConfigCache, + this.projectConfigCache )) { // Only inherit if: // 1. The field is not in the non-inheritable list @@ -1947,7 +1947,7 @@ export class MarkdownTaskParser { export class ConfigurableTaskParser extends MarkdownTaskParser { constructor( config?: Partial, - timeParsingService?: TimeParsingService, + timeParsingService?: TimeParsingService ) { // Default configuration const defaultConfig: TaskParserConfig = { diff --git a/src/dataflow/sources/FileSource.ts b/src/dataflow/sources/FileSource.ts index d2a6cdd3..118e6d19 100644 --- a/src/dataflow/sources/FileSource.ts +++ b/src/dataflow/sources/FileSource.ts @@ -31,6 +31,25 @@ import { FileFilterManager } from "@/managers/file-filter-manager"; * 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 = { + 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; @@ -621,21 +640,37 @@ export class FileSource { : filePath.split("/").pop() || filePath; // Extract metadata from frontmatter - const metadata = this.extractTaskMetadata( + 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 = { id: `file-source:${filePath}`, content: safeContent, filePath, line: 0, // File tasks are at line 0 - completed: metadata.status === "x" || metadata.status === "X", - status: metadata.status || config.fileTaskProperties.defaultStatus, + completed: status === "x" || status === "X", + status: status, originalMarkdown: `**${safeContent}**`, metadata: { ...metadata, @@ -801,15 +836,47 @@ export class FileSource { } } + /** + * 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 { + ): Partial & { rawStatus?: string } { const config = this.config.getConfig(); const frontmatter = fileCache?.frontmatter || {}; const resolvedFrontmatter = this.applyMetadataMappings( @@ -817,46 +884,9 @@ export class FileSource { config.metadataMappings ); - // Derive status from frontmatter and eagerly map textual metadata to a symbol - const rawStatus = resolvedFrontmatter.status ?? ""; - const toSymbol = (val: string): string => { - if (!val) return config.fileTaskProperties.defaultStatus; - // Already a single-character mark - if (val.length === 1) return val; - const sm = this.config.getConfig().statusMapping; - const target = sm.caseSensitive ? val : String(val).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 defaults: Record = { - completed: "x", - done: "x", - finished: "x", - "in-progress": "/", - "in progress": "/", - doing: "/", - planned: "?", - todo: "?", - cancelled: "-", - canceled: "-", - "not-started": " ", - "not started": " ", - }; - const norm = String(val).toLowerCase(); - if (defaults[norm] !== undefined) return defaults[norm]; - return config.fileTaskProperties.defaultStatus; - }; - let status = rawStatus - ? toSymbol(rawStatus) - : config.fileTaskProperties.defaultStatus; - if (rawStatus && status !== rawStatus) { - console.log( - `[FileSource] Mapped status '${rawStatus}' to '${status}' for ${filePath}` - ); - } + // 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) @@ -876,7 +906,11 @@ export class FileSource { // 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 projectValue = resolvedFrontmatter.project ?? projectFromTags; + const projectVal = resolvedFrontmatter.project ?? projectFromTags; + const projectValue: string | undefined = + projectVal !== undefined && projectVal !== null + ? String(projectVal) + : undefined; const shouldStripProjectTags = !resolvedFrontmatter.project && @@ -905,21 +939,31 @@ export class FileSource { recurrence: typeof resolvedFrontmatter.recurrence === "string" ? resolvedFrontmatter.recurrence - : undefined, + : (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, - area: resolvedFrontmatter.area, + context: + resolvedFrontmatter.context !== undefined && + resolvedFrontmatter.context !== null + ? String(resolvedFrontmatter.context) + : undefined, + area: + resolvedFrontmatter.area !== undefined && + resolvedFrontmatter.area !== null + ? String(resolvedFrontmatter.area) + : undefined, tags: tagsForMetadata, - status: status, children: [], }; - return metadata; + // Return metadata with rawStatus for status computation in buildFileTask + return { ...metadata, rawStatus }; } /** diff --git a/src/pages/FluentTaskView.ts b/src/pages/FluentTaskView.ts index 09669096..bd77c7b0 100644 --- a/src/pages/FluentTaskView.ts +++ b/src/pages/FluentTaskView.ts @@ -10,7 +10,7 @@ import { TopNavigation, ViewMode, } from "@/components/features/fluent/components/FluentTopNavigation"; -import { FluentTaskViewState } from "@/types/fluent-types"; +import { FluentTaskViewState, ErrorContext } from "@/types/fluent-types"; import { onWorkspaceSwitched, onWorkspaceOverridesSaved, @@ -482,9 +482,16 @@ export class FluentTaskView extends ItemView { if (error) { this.loadError = error; this.isLoading = false; - this.componentManager?.renderErrorState(error, () => { - this.dataManager.loadTasks(); - }); + this.componentManager?.renderErrorState( + this.createErrorContext( + error, + t("Loading tasks"), + "src/components/features/fluent/managers/FluentComponentManager.ts" + ), + () => { + this.dataManager.loadTasks(); + } + ); } else { this.tasks = tasks; this.loadError = null; @@ -1182,9 +1189,16 @@ export class FluentTaskView extends ItemView { // Show error state if (this.loadError) { - this.componentManager.renderErrorState(this.loadError, () => { - this.dataManager.loadTasks(); - }); + this.componentManager.renderErrorState( + this.createErrorContext( + this.loadError, + t("Updating view"), + "src/pages/FluentTaskView.ts" + ), + () => { + this.dataManager.loadTasks(); + } + ); return; } @@ -1263,6 +1277,25 @@ export class FluentTaskView extends ItemView { /** * Set state (for debugging) */ + /** + * Create error context object for structured error display + */ + private createErrorContext( + error: string | Error, + operation: string, + filePath: string + ): ErrorContext { + return { + viewId: this.currentViewId, + componentName: this.componentManager?.getCurrentComponentName(), + operation: operation, + filePath: filePath, + userMessage: typeof error === "string" ? error : error?.message, + originalError: + error instanceof Error ? error : new Error(String(error)), + }; + } + async setState(state: any, result: any) { // Restore state if needed } diff --git a/src/types/fluent-types.ts b/src/types/fluent-types.ts index f34573a1..4095d520 100644 --- a/src/types/fluent-types.ts +++ b/src/types/fluent-types.ts @@ -19,3 +19,21 @@ export type FluentTaskNavigationItem = { action?: () => void; badge?: number; }; + +/** + * Error context for structured error display + */ +export interface ErrorContext { + /** View ID (inbox, today, projects, etc.) */ + viewId?: string; + /** Component name (ContentComponent, KanbanComponent, etc.) */ + componentName?: string; + /** Operation description (e.g., "Loading tasks", "Switching view") */ + operation?: string; + /** File path where error occurred */ + filePath?: string; + /** Original error object */ + originalError?: Error; + /** User-friendly error message */ + userMessage?: string; +} diff --git a/src/utils/grouping/taskGrouping.ts b/src/utils/grouping/taskGrouping.ts index c92c71ba..9b96a049 100644 --- a/src/utils/grouping/taskGrouping.ts +++ b/src/utils/grouping/taskGrouping.ts @@ -7,12 +7,132 @@ import { Task } from "@/types/task"; import { GroupByDimension, TaskGroup } from "@/types/groupBy"; import { t } from "@/translations/helper"; +/** + * Default status mark to display name mapping + * Based on common task management conventions and Tasks plugin compatibility + * Used as fallback when status mark is not found in user's taskStatusMarks configuration + */ +const DEFAULT_STATUS_NAMES: Record = { + // Standard statuses + " ": "Todo", // Incomplete/Not started + "x": "Done", // Completed (lowercase) + "X": "Done", // Completed (uppercase) + "-": "Cancelled", // Cancelled/Abandoned + + // Progress indicators + "/": "In Progress", // Half-done/In progress + ">": "Forwarded", // Deferred/Forwarded + "<": "Scheduled", // Scheduled for later + + // Priority/importance markers + "!": "Important", // Important/High priority + "*": "Star", // Starred/Favorite + + // Question/tentative + "?": "Question", // Uncertain/Question + + // Extended status marks (from various task management systems) + "i": "Info", // Information + "I": "Idea", // Idea + "S": "Started", // Started + "p": "Pro", // Pro/Professional + "c": "Choice", // Choice + "l": "Location", // Location-based + "b": "Bookmark", // Bookmarked + "f": "Fire", // Urgent/Fire + "k": "Key", // Key task + "w": "Win", // Won/Achieved + "u": "Up", // Ongoing/Up + "d": "Down", // Deprecated/Down + '"': "Quote", // Quoted/Referenced + "B": "Bug", // Bug/Issue + + // Numeric progress indicators (0-5 scale) + "0": "0/5", + "1": "1/5", + "2": "2/5", + "3": "3/5", + "4": "4/5", + "5": "5/5", + + // Other + "n": "Note", // Note +}; + +/** + * Format status key to readable text + * Handles camelCase, snake_case, and kebab-case + * @example formatStatusKey("IN_PROGRESS") => "In Progress" + * @example formatStatusKey("inProgress") => "In Progress" + */ +function formatStatusKey(key: string): string { + return key + .replace(/([a-z])([A-Z])/g, "$1 $2") // camelCase -> camel Case + .replace(/[-_]+/g, " ") // snake_case/kebab-case -> spaces + .split(" ") + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join(" "); +} + +/** + * Convert status mark to display name using task status marks mapping + * Performs reverse lookup in taskStatusMarks to find the configured status name + * Falls back to default status names, then formatting if no mapping is found + * + * Lookup priority: + * 1. User-configured taskStatusMarks (highest priority) + * 2. DEFAULT_STATUS_NAMES (common status marks) + * 3. formatStatusKey (generic formatting fallback) + * + * @param mark - Status mark from task (e.g., "x", ">", "-", " ") + * @param taskStatusMarks - Status marks mapping from plugin settings + * @returns Display name (e.g., "Completed", "In Progress", "Abandoned") + * + * @example + * // With taskStatusMarks = { "Completed": "x", "In Progress": ">", "Abandoned": "-" } + * getStatusDisplayName("x", taskStatusMarks) => "Completed" + * getStatusDisplayName(">", taskStatusMarks) => "In Progress" + * getStatusDisplayName("/", taskStatusMarks) => "In Progress" (from DEFAULT_STATUS_NAMES) + * getStatusDisplayName("unknown", taskStatusMarks) => "Unknown" (from formatStatusKey) + */ +function getStatusDisplayName( + mark: string, + taskStatusMarks?: Record +): string { + // Priority 1: User-configured taskStatusMarks + if (taskStatusMarks) { + for (const [statusName, statusMark] of Object.entries(taskStatusMarks)) { + // Match both exact and case-insensitive + if (statusMark === mark || + (statusMark && statusMark.toLowerCase() === mark.toLowerCase())) { + return statusName; + } + } + } + + // Priority 2: Check default status names mapping + if (mark in DEFAULT_STATUS_NAMES) { + return DEFAULT_STATUS_NAMES[mark]; + } + + // Priority 3: Fallback formatting for unknown marks + // Handle empty/whitespace marks + if (!mark || mark.trim().length === 0) { + return DEFAULT_STATUS_NAMES[" "] || "Todo"; + } + + // Use generic formatting for completely unknown marks + return formatStatusKey(mark); +} + /** * Main grouping function - dispatches to specific grouping strategies */ export function groupTasksBy( tasks: Task[], - dimension: GroupByDimension + dimension: GroupByDimension, + taskStatusMarks?: Record ): TaskGroup[] { switch (dimension) { case "none": @@ -28,7 +148,7 @@ export function groupTasksBy( case "tags": return groupTasksByTags(tasks); case "status": - return groupTasksByStatus(tasks); + return groupTasksByStatus(tasks, taskStatusMarks); default: return groupTasksNone(tasks); } @@ -539,8 +659,16 @@ export function groupTasksByTags(tasks: Task[]): TaskGroup[] { /** * Group tasks by status + * Uses taskStatusMarks mapping to display user-configured status names + * + * @param tasks - Array of tasks to group + * @param taskStatusMarks - Optional status marks mapping from plugin settings + * @returns Array of task groups organized by status */ -export function groupTasksByStatus(tasks: Task[]): TaskGroup[] { +export function groupTasksByStatus( + tasks: Task[], + taskStatusMarks?: Record +): TaskGroup[] { const groupMap = new Map(); tasks.forEach((task) => { @@ -573,14 +701,8 @@ export function groupTasksByStatus(tasks: Task[]): TaskGroup[] { }); sortedKeys.forEach((key, index) => { - // Format status for display - const displayStatus = key - .split("_") - .map( - (word) => - word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() - ) - .join(" "); + // Get display name using status marks mapping or fallback formatting + const displayStatus = getStatusDisplayName(key, taskStatusMarks); groups.push({ title: displayStatus,