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
This commit is contained in:
Quorafind 2025-11-14 14:09:43 +08:00
parent 4a075ae35d
commit 37606e0bcf
7 changed files with 555 additions and 157 deletions

View file

@ -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<string, ViewMode[]> = {
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<string, string> = {
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;
}
/**

View file

@ -315,7 +315,8 @@ export class ContentComponent extends Component {
} else {
this.taskGroups = groupTasksBy(
this.filteredTasks,
this.groupByDimension
this.groupByDimension,
this.plugin.settings.taskStatusMarks
);
}
}

View file

@ -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, string>, string[]] {
return this.extractMetadataAndTagsInternal(content);
}
@ -57,7 +57,7 @@ export class MarkdownTaskParser {
static createWithStatusMapping(
config: TaskParserConfig,
statusMapping: Record<string, string>,
timeParsingService?: TimeParsingService,
timeParsingService?: TimeParsingService
): MarkdownTaskParser {
const newConfig = { ...config, statusMapping };
return new MarkdownTaskParser(newConfig, timeParsingService);
@ -71,7 +71,7 @@ export class MarkdownTaskParser {
filePath = "",
fileMetadata?: Record<string, any>,
projectConfigData?: Record<string, any>,
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<string, any>,
projectConfigData?: Record<string, any>,
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, string>, string[]] {
const metadata: Record<string, string> = {};
const tags: string[] = [];
@ -513,7 +513,7 @@ export class MarkdownTaskParser {
*/
private extractTimeComponents(
taskContent: string,
existingMetadata: Record<string, string>,
existingMetadata: Record<string, string>
): 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, string>, string[]] {
const metadata: Record<string, string> = {};
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<string, string>,
metadata: Record<string, string>
): number | undefined {
if (!metadata.priority) return undefined;
@ -1401,14 +1401,14 @@ export class MarkdownTaskParser {
private extractLegacyDate(
metadata: Record<string, string>,
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<string, string>,
isSubtask: boolean = false,
isSubtask: boolean = false
): Record<string, string> {
// 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<TaskParserConfig>,
timeParsingService?: TimeParsingService,
timeParsingService?: TimeParsingService
) {
// Default configuration
const defaultConfig: TaskParserConfig = {

View file

@ -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<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;
@ -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<FileSourceTaskMetadata> = {
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<FileSourceTaskMetadata> {
): Partial<FileSourceTaskMetadata> & { 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<string, string> = {
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 };
}
/**

View file

@ -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
}

View file

@ -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;
}

View file

@ -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<string, string> = {
// 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, string>
): 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<string, string>
): 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<string, string>
): TaskGroup[] {
const groupMap = new Map<string, Task[]>();
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,