taskgenius_taskgenius-plugin/src/dataflow/core/ConfigurableTaskParser.ts
2025-10-31 09:34:04 -07:00

1997 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Configurable Markdown Task Parser
* Based on Rust implementation design with TypeScript adaptation
*/
import { Task, TgProject, EnhancedStandardTaskMetadata } from "@/types/task";
import {
TaskParserConfig,
EnhancedTask,
MetadataParseMode,
} from "../../types/TaskParserConfig";
import { parseLocalDate } from "@/utils/date/date-formatter";
import { TASK_REGEX } from "@/common/regex-define";
import { ContextDetector } from "@/parsers/context-detector";
import { TimeParsingService } from "@/services/time-parsing-service";
import { TimeComponent } from "@/types/time-parsing";
export class MarkdownTaskParser {
private config: TaskParserConfig;
private tasks: EnhancedTask[] = [];
private indentStack: Array<{
taskId: string;
indentLevel: number;
actualSpaces: number;
}> = [];
private currentHeading?: string;
private currentHeadingLevel?: number;
private fileMetadata?: Record<string, any>; // Store file frontmatter metadata
private projectConfigCache?: Record<string, any>; // Cache for project config files
private customDateFormats?: string[]; // Store custom date formats from settings
private timeParsingService?: TimeParsingService; // Enhanced time parsing service
// Date parsing cache to improve performance for large-scale parsing
private static dateCache = new Map<string, number | undefined>();
private static readonly MAX_CACHE_SIZE = 10000; // Limit cache size to prevent memory issues
constructor(
config: TaskParserConfig,
timeParsingService?: TimeParsingService,
) {
this.config = config;
// Extract custom date formats if available
this.customDateFormats = config.customDateFormats;
this.timeParsingService = timeParsingService;
}
// Public alias for extractMetadataAndTags
public extractMetadataAndTags(
content: string,
): [string, Record<string, string>, string[]] {
return this.extractMetadataAndTagsInternal(content);
}
/**
* Create parser with predefined status mapping
*/
static createWithStatusMapping(
config: TaskParserConfig,
statusMapping: Record<string, string>,
timeParsingService?: TimeParsingService,
): MarkdownTaskParser {
const newConfig = { ...config, statusMapping };
return new MarkdownTaskParser(newConfig, timeParsingService);
}
/**
* Parse markdown content and return enhanced tasks
*/
parse(
input: string,
filePath = "",
fileMetadata?: Record<string, any>,
projectConfigData?: Record<string, any>,
tgProject?: TgProject,
): EnhancedTask[] {
this.reset();
this.fileMetadata = fileMetadata;
// Store project config data if provided
if (projectConfigData) {
this.projectConfigCache = projectConfigData;
}
const lines = input.split(/\r?\n/);
let i = 0;
let parseIteration = 0;
let inCodeBlock = false;
while (i < lines.length) {
parseIteration++;
if (parseIteration > this.config.maxParseIterations) {
console.warn(
"Warning: Maximum parse iterations reached, stopping to prevent infinite loop",
);
break;
}
const line = lines[i];
// Check for code block fences
if (
line.trim().startsWith("```") ||
line.trim().startsWith("~~~")
) {
inCodeBlock = !inCodeBlock;
i++;
continue;
}
if (inCodeBlock) {
i++;
continue;
}
// Check if it's a heading line
if (this.config.parseHeadings) {
const headingResult = this.extractHeading(line);
if (headingResult) {
const [level, headingText] = headingResult;
this.currentHeading = headingText;
this.currentHeadingLevel = level;
i++;
continue;
}
}
const taskLineResult = this.extractTaskLine(line);
if (taskLineResult) {
const [actualSpaces, , content, listMarker] = taskLineResult;
const taskId = `${filePath}-L${i}`;
const [parentId, indentLevel] =
this.findParentAndLevel(actualSpaces);
const [taskContent, rawStatus] = this.parseTaskContent(content);
const completed = rawStatus.toLowerCase() === "x";
const status = this.getStatusFromMapping(rawStatus);
const [cleanedContent, metadata, tags] =
this.extractMetadataAndTagsInternal(taskContent);
// Inherit metadata from file frontmatter
// A task is a subtask if it has a parent
const isSubtask = parentId !== undefined;
const inheritedMetadata = this.inheritFileMetadata(
metadata,
isSubtask,
);
// Extract time components from task content using enhanced time parsing
const enhancedMetadata = this.extractTimeComponents(
taskContent,
inheritedMetadata,
);
// Process inherited tags and merge with task's own tags
let finalTags = tags;
if (inheritedMetadata.tags) {
try {
const inheritedTags = JSON.parse(
inheritedMetadata.tags,
);
if (Array.isArray(inheritedTags)) {
finalTags = this.mergeTags(tags, inheritedTags);
}
} catch (e) {
// If parsing fails, treat as a single tag
finalTags = this.mergeTags(tags, [
inheritedMetadata.tags,
]);
}
}
// Prefer up-to-date detection for current file; fall back to provided tgProject
const taskTgProject =
this.determineTgProject(filePath) || tgProject;
// Check for multiline comments
const [comment, linesToSkip] =
this.config.parseComments && i + 1 < lines.length
? this.extractMultilineComment(
lines,
i + 1,
actualSpaces,
)
: [undefined, 0];
i += linesToSkip;
// Debug: Log priority extraction for each task
const extractedPriority =
this.extractLegacyPriority(inheritedMetadata);
const enhancedTask: EnhancedTask = {
id: taskId,
content: cleanedContent,
status,
rawStatus,
completed,
indentLevel,
parentId,
childrenIds: [],
metadata: enhancedMetadata,
tags: finalTags,
comment,
lineNumber: i + 1,
actualIndent: actualSpaces,
heading: this.currentHeading,
headingLevel: this.currentHeadingLevel,
listMarker,
filePath,
originalMarkdown: line,
tgProject: taskTgProject,
// Legacy fields for backward compatibility
line: i,
children: [],
priority: extractedPriority,
startDate: this.extractLegacyDate(
enhancedMetadata,
"startDate",
),
dueDate: this.extractLegacyDate(
enhancedMetadata,
"dueDate",
),
scheduledDate: this.extractLegacyDate(
enhancedMetadata,
"scheduledDate",
),
completedDate: this.extractLegacyDate(
enhancedMetadata,
"completedDate",
),
createdDate: this.extractLegacyDate(
enhancedMetadata,
"createdDate",
),
recurrence: enhancedMetadata.recurrence,
project: enhancedMetadata.project,
context: enhancedMetadata.context,
};
if (parentId && this.tasks.length > 0) {
const parentTask = this.tasks.find(
(t) => t.id === parentId,
);
if (parentTask) {
parentTask.childrenIds.push(taskId);
parentTask.children.push(taskId); // Legacy field
}
}
this.updateIndentStack(taskId, indentLevel, actualSpaces);
this.tasks.push(enhancedTask);
}
i++;
}
return [...this.tasks];
}
/**
* Parse and return legacy Task format for compatibility
*/
parseLegacy(
input: string,
filePath: string = "",
fileMetadata?: Record<string, any>,
projectConfigData?: Record<string, any>,
tgProject?: TgProject,
): Task[] {
const enhancedTasks = this.parse(
input,
filePath,
fileMetadata,
projectConfigData,
tgProject,
);
return enhancedTasks.map((task) => this.convertToLegacyTask(task));
}
/**
* Parse a single task line
*/
parseTask(line: string, filePath: string = "", lineNum: number = 0): Task {
const enhancedTask = this.parse(line, filePath);
return this.convertToLegacyTask({
...enhancedTask[0],
line: lineNum,
id: `${filePath}-L${lineNum}`,
});
}
private reset(): void {
this.tasks = [];
this.indentStack = [];
this.currentHeading = undefined;
this.currentHeadingLevel = undefined;
}
private extractTaskLine(
line: string,
): [number, number, string, string] | null {
// Preserve trailing spaces to allow parsing of empty-content tasks like "- [ ] "
const trimmed = line.trimStart();
const actualSpaces = line.length - trimmed.length;
if (this.isTaskLine(trimmed)) {
const listMarker = this.extractListMarker(trimmed);
return [actualSpaces, actualSpaces, trimmed, listMarker];
}
return null;
}
private extractListMarker(trimmed: string): string {
// Check unordered list markers
for (const marker of ["-", "*", "+"]) {
if (trimmed.startsWith(marker)) {
return marker;
}
}
// Check ordered list markers
const chars = trimmed.split("");
let i = 0;
while (i < chars.length && /\d/.test(chars[i])) {
i++;
}
if (i > 0 && i < chars.length) {
if (chars[i] === "." || chars[i] === ")") {
return chars.slice(0, i + 1).join("");
}
}
// Fallback: return first character
return trimmed.charAt(0) || " ";
}
private isTaskLine(trimmed: string): boolean {
// Use existing TASK_REGEX from common/regex-define
return TASK_REGEX.test(trimmed);
}
private parseTaskContent(content: string): [string, string] {
const taskMatch = content.match(TASK_REGEX);
if (
taskMatch &&
taskMatch[4] !== undefined &&
taskMatch[5] !== undefined
) {
const status = taskMatch[4];
const taskContent = taskMatch[5].trim();
return [taskContent, status];
}
// Fallback - treat as unchecked task
return [content, " "];
}
private extractMetadataAndTagsInternal(
content: string,
): [string, Record<string, string>, string[]] {
const metadata: Record<string, string> = {};
const tags: string[] = [];
let cleanedContent = "";
let remaining = content;
let metadataIteration = 0;
while (metadataIteration < this.config.maxMetadataIterations) {
metadataIteration++;
let foundMatch = false;
// Check dataview format metadata [key::value]
if (
this.config.parseMetadata &&
(this.config.metadataParseMode ===
MetadataParseMode.DataviewOnly ||
this.config.metadataParseMode === MetadataParseMode.Both)
) {
const bracketMatch = this.extractDataviewMetadata(remaining);
if (bracketMatch) {
const [key, value, newRemaining] = bracketMatch;
metadata[key] = value;
// Debug: Log dataview metadata extraction, especially priority
if (
(process.env.NODE_ENV === "development" || true) &&
key === "priority"
) {
// Always log for debugging
console.log("[Parser] Dataview priority found:", {
key,
value,
remaining: remaining.substring(0, 50),
});
}
remaining = newRemaining;
foundMatch = true;
continue;
}
}
// Check emoji metadata
if (
!foundMatch &&
this.config.parseMetadata &&
(this.config.metadataParseMode ===
MetadataParseMode.EmojiOnly ||
this.config.metadataParseMode === MetadataParseMode.Both)
) {
const emojiMatch = this.extractEmojiMetadata(remaining);
if (emojiMatch) {
const [key, value, beforeContent, afterRemaining] =
emojiMatch;
// Process tags in the content before emoji
const [beforeCleaned, beforeMetadata, beforeTags] =
this.extractTagsOnly(beforeContent);
// Merge metadata and tags from before content
for (const tag of beforeTags) {
tags.push(tag);
}
for (const [k, v] of Object.entries(beforeMetadata)) {
metadata[k] = v;
}
metadata[key] = value;
cleanedContent += beforeCleaned;
remaining = afterRemaining;
foundMatch = true;
continue;
}
}
// Check context (@symbol)
if (!foundMatch && this.config.parseTags) {
const contextMatch = this.extractContext(remaining);
if (contextMatch) {
const [context, beforeContent, afterRemaining] =
contextMatch;
metadata.context = context;
cleanedContent += beforeContent;
remaining = afterRemaining;
foundMatch = true;
continue;
}
}
// Check tags and special tags
if (!foundMatch && this.config.parseTags) {
const tagMatch = this.extractTag(remaining);
if (tagMatch) {
const [tag, beforeContent, afterRemaining] = tagMatch;
// Check if it's a special tag format (prefix/value)
// Remove # prefix for checking special tags
const tagWithoutHash = tag.startsWith("#")
? tag.substring(1)
: tag;
const slashPos = tagWithoutHash.indexOf("/");
if (slashPos !== -1) {
const prefix = tagWithoutHash.substring(0, slashPos);
const value = tagWithoutHash.substring(slashPos + 1);
// Case-insensitive match for special tag prefixes, with debug
const metadataKey =
this.config.specialTagPrefixes[prefix] ??
this.config.specialTagPrefixes[
prefix.toLowerCase()
];
console.debug("[TG] Tag parse", {
tag,
prefix,
mappedKey: metadataKey,
keys: Object.keys(this.config.specialTagPrefixes),
});
if (
metadataKey &&
this.config.metadataParseMode !==
MetadataParseMode.None
) {
metadata[metadataKey] = value;
} else {
tags.push(tag);
}
} else {
tags.push(tag);
}
cleanedContent += beforeContent;
remaining = afterRemaining;
foundMatch = true;
continue;
}
}
if (!foundMatch) {
cleanedContent += remaining;
break;
}
}
return [cleanedContent.trim(), metadata, tags];
}
/**
* Extract time components from task content and merge with existing metadata
*/
private extractTimeComponents(
taskContent: string,
existingMetadata: Record<string, string>,
): EnhancedStandardTaskMetadata {
if (!this.timeParsingService) {
// Return existing metadata as EnhancedStandardTaskMetadata without time components
return {
...existingMetadata,
tags: this.safeParseTagsField(existingMetadata.tags),
children: [],
} as EnhancedStandardTaskMetadata;
}
try {
// Parse time components from task content
let timeComponents: Record<string, TimeComponent> = {};
let errors: any[] = [];
let warnings: any[] = [];
try {
const result =
this.timeParsingService.parseTimeComponents(taskContent);
timeComponents = result.timeComponents;
errors = result.errors || [];
warnings = result.warnings || [];
} catch (innerErr) {
// Swallow JSON.parse or format errors from time parsing; continue without time components
console.warn(
"[MarkdownTaskParser] timeParsingService.parseTimeComponents failed, continuing without time components:",
innerErr,
);
timeComponents = {};
errors = [];
warnings = [];
}
// Log warnings if any
if (warnings.length > 0) {
console.warn(
`[MarkdownTaskParser] Time parsing warnings for "${taskContent}":`,
warnings,
);
}
// Log errors if any (but don't fail)
if (errors.length > 0) {
console.warn(
`[MarkdownTaskParser] Time parsing errors for "${taskContent}":`,
errors,
);
}
// Create enhanced metadata
const enhancedMetadata: EnhancedStandardTaskMetadata = {
...existingMetadata,
tags: this.safeParseTagsField(existingMetadata.tags),
children: [],
} as EnhancedStandardTaskMetadata;
// Add time components if found
if (Object.keys(timeComponents).length > 0) {
enhancedMetadata.timeComponents = timeComponents;
// Create enhanced datetime objects by combining existing dates with time components
enhancedMetadata.enhancedDates =
this.combineTimestampsWithTimeComponents(
{
startDate: existingMetadata.startDate,
dueDate: existingMetadata.dueDate,
scheduledDate: existingMetadata.scheduledDate,
completedDate: existingMetadata.completedDate,
},
timeComponents,
);
}
return enhancedMetadata;
} catch (error) {
console.error(
`[MarkdownTaskParser] Failed to extract time components from "${taskContent}":`,
error,
);
// Return existing metadata without time components on error
return {
...existingMetadata,
tags: this.safeParseTagsField(existingMetadata.tags),
children: [],
} as EnhancedStandardTaskMetadata;
}
}
/**
* Combine date timestamps with time components to create enhanced datetime objects
*/
private combineTimestampsWithTimeComponents(
dates: {
startDate?: number | string;
dueDate?: number | string;
scheduledDate?: number | string;
completedDate?: number | string;
},
timeComponents: EnhancedStandardTaskMetadata["timeComponents"],
): EnhancedStandardTaskMetadata["enhancedDates"] {
if (!timeComponents) {
return undefined;
}
const enhancedDates: EnhancedStandardTaskMetadata["enhancedDates"] = {};
// Helper function to combine date and time component
const combineDateTime = (
dateValue: number | string | undefined,
timeComponent: TimeComponent | undefined,
): Date | undefined => {
if (!dateValue || !timeComponent) {
return undefined;
}
let date: Date;
if (typeof dateValue === "string") {
// Handle date strings like "2025-08-25"
if (/^\d{4}-\d{2}-\d{2}$/.test(dateValue)) {
const [year, month, day] = dateValue.split("-").map(Number);
date = new Date(year, month - 1, day); // month is 0-based
} else {
date = new Date(dateValue);
}
} else {
// Handle timestamp numbers
date = new Date(dateValue);
}
if (isNaN(date.getTime())) {
return undefined;
}
const combinedDate = new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
timeComponent.hour,
timeComponent.minute,
timeComponent.second || 0,
);
return combinedDate;
};
// Combine start date with start time
if (dates.startDate && timeComponents.startTime) {
enhancedDates.startDateTime = combineDateTime(
dates.startDate,
timeComponents.startTime,
);
}
// Combine due date with due time
if (dates.dueDate && timeComponents.dueTime) {
enhancedDates.dueDateTime = combineDateTime(
dates.dueDate,
timeComponents.dueTime,
);
}
// Combine scheduled date with scheduled time
if (dates.scheduledDate && timeComponents.scheduledTime) {
enhancedDates.scheduledDateTime = combineDateTime(
dates.scheduledDate,
timeComponents.scheduledTime,
);
}
// Handle end time - if we have start date and end time, create end datetime
if (dates.startDate && timeComponents.endTime) {
enhancedDates.endDateTime = combineDateTime(
dates.startDate,
timeComponents.endTime,
);
}
// If we have a due date but the time component is scheduledTime (common with "at" keyword),
// create dueDateTime using scheduledTime
if (
dates.dueDate &&
!timeComponents.dueTime &&
timeComponents.scheduledTime
) {
enhancedDates.dueDateTime = combineDateTime(
dates.dueDate,
timeComponents.scheduledTime,
);
}
// If we have a scheduled date but the time component is dueTime,
// create scheduledDateTime using dueTime
if (
dates.scheduledDate &&
!timeComponents.scheduledTime &&
timeComponents.dueTime
) {
enhancedDates.scheduledDateTime = combineDateTime(
dates.scheduledDate,
timeComponents.dueTime,
);
}
return Object.keys(enhancedDates).length > 0
? enhancedDates
: undefined;
}
private extractDataviewMetadata(
content: string,
): [string, string, string] | null {
const start = content.indexOf("[");
if (start === -1) return null;
const end = content.indexOf("]", start);
if (end === -1) return null;
const bracketContent = content.substring(start + 1, end);
if (!bracketContent.includes("::")) return null;
const parts = bracketContent.split("::", 2);
if (parts.length !== 2) return null;
let key = parts[0].trim();
const value = parts[1].trim();
// Map dataview keys to standard field names for consistency
const dataviewKeyMapping: Record<string, string> = {
due: "dueDate",
start: "startDate",
scheduled: "scheduledDate",
completion: "completedDate",
created: "createdDate",
cancelled: "cancelledDate",
id: "id",
dependsOn: "dependsOn",
onCompletion: "onCompletion",
repeat: "recurrence",
};
// Apply key mapping if it exists
const mappedKey = dataviewKeyMapping[key.toLowerCase()];
if (mappedKey) {
key = mappedKey;
} else {
// Check if the key matches any configured special tag prefixes
// specialTagPrefixes format: { "prefixName": "metadataKey" }
// 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 || {},
)) {
if (prefix.toLowerCase() === lowerKey) {
key = metadataType; // Map to the target metadata field (project, context, area)
break;
}
}
}
if (key && value) {
// Debug: Log dataview metadata extraction for configured prefixes
const before = content.substring(0, start);
const after = content.substring(end + 1);
return [key, value, before + after];
}
return null;
}
private extractEmojiMetadata(
content: string,
): [string, string, string, string] | null {
// Find the earliest emoji
let earliestEmoji: { pos: number; emoji: string; key: string } | null =
null;
for (const [emoji, key] of Object.entries(this.config.emojiMapping)) {
const pos = content.indexOf(emoji);
if (pos !== -1) {
if (!earliestEmoji || pos < earliestEmoji.pos) {
earliestEmoji = { pos, emoji, key };
}
}
}
if (!earliestEmoji) return null;
const beforeEmoji = content.substring(0, earliestEmoji.pos);
const afterEmoji = content.substring(
earliestEmoji.pos + earliestEmoji.emoji.length,
);
// Extract value after emoji
const valueStartMatch = afterEmoji.match(/^\s*/);
const valueStart = valueStartMatch ? valueStartMatch[0].length : 0;
const valuePart = afterEmoji.substring(valueStart);
let valueEnd = valuePart.length;
for (let i = 0; i < valuePart.length; i++) {
const char = valuePart[i];
// Check if we encounter other emojis or special characters
if (
Object.keys(this.config.emojiMapping).some((e) =>
valuePart.substring(i).startsWith(e),
) ||
char === "["
) {
valueEnd = i;
break;
}
// Check for file extensions followed by space or end of content
const fileExtensionEnd = this.findFileExtensionEnd(valuePart, i);
if (fileExtensionEnd > i) {
valueEnd = fileExtensionEnd;
break;
}
// Check for whitespace followed by # (tag) or @ (context), or direct #/@ without preceding space
if (
/\s/.test(char) &&
i + 1 < valuePart.length &&
(valuePart[i + 1] === "#" || valuePart[i + 1] === "@")
) {
valueEnd = i;
break;
}
// Also stop if we encounter # or @ directly (no whitespace)
if (char === "#" || char === "@") {
valueEnd = i;
break;
}
}
const value = valuePart.substring(0, valueEnd).trim();
// Handle special field processing
let metadataValue: string;
if (earliestEmoji.key === "dependsOn" && value) {
// For dependsOn, split by comma and join back as string for metadata storage
metadataValue = value
.split(",")
.map((id) => id.trim())
.filter((id) => id.length > 0)
.join(",");
} else if (earliestEmoji.key === "priority") {
// For priority emojis, use the emoji itself or the provided value
// This ensures we can distinguish between different priority levels
metadataValue = value || earliestEmoji.emoji;
} else {
// For other emojis, use provided value or default
metadataValue =
value || this.getDefaultEmojiValue(earliestEmoji.emoji);
}
// Sanitize date-like emoji values to avoid trailing context (e.g., "2025-08-15 @work")
if (
[
"dueDate",
"startDate",
"scheduledDate",
"completedDate",
"createdDate",
"cancelledDate",
].includes(earliestEmoji.key as string) &&
typeof metadataValue === "string"
) {
const m = metadataValue.match(/\d{4}-\d{2}-\d{2}/);
if (m) {
metadataValue = m[0];
}
}
const newPos =
earliestEmoji.pos +
earliestEmoji.emoji.length +
valueStart +
valueEnd;
const afterRemaining = content.substring(newPos);
return [earliestEmoji.key, metadataValue, beforeEmoji, afterRemaining];
}
/**
* Find the end position of a file extension pattern (e.g., .md, .canvas)
* followed by optional heading (#heading) and then space or end of content
*/
private findFileExtensionEnd(content: string, startPos: number): number {
const supportedExtensions = [".md", ".canvas", ".txt", ".pdf"];
for (const ext of supportedExtensions) {
if (content.substring(startPos).startsWith(ext)) {
let pos = startPos + ext.length;
// Check for optional heading (#heading)
if (pos < content.length && content[pos] === "#") {
// Find the end of the heading (next space or end of content)
while (pos < content.length && content[pos] !== " ") {
pos++;
}
}
// Check if we're at end of content or followed by space
if (pos >= content.length || content[pos] === " ") {
return pos;
}
}
}
return startPos; // No file extension pattern found
}
private getDefaultEmojiValue(emoji: string): string {
const defaultValues: Record<string, string> = {
"🔺": "highest",
"⏫": "high",
"🔼": "medium",
"🔽": "low",
"⏬️": "lowest",
"⏬": "lowest",
};
return defaultValues[emoji] || "true";
}
private extractTag(content: string): [string, string, string] | null {
// Use ContextDetector to find unprotected hash symbols
const detector = new ContextDetector(content);
detector.detectAllProtectedRanges();
const tryFrom = (startPos: number): [string, string, string] | null => {
const hashPos = detector.findNextUnprotectedHash(startPos);
if (hashPos === -1) return null;
// If an odd number of backslashes immediately precede '#', it's escaped → skip
let bsCount = 0;
let j = hashPos - 1;
while (j >= 0 && content[j] === "\\") {
bsCount++;
j--;
}
if (bsCount % 2 === 1) {
return tryFrom(hashPos + 1);
}
// Enhanced word boundary check
const isWordStart = this.isValidTagStart(content, hashPos);
if (!isWordStart) {
return tryFrom(hashPos + 1);
}
const afterHash = content.substring(hashPos + 1);
let tagEnd = 0;
// Find tag end, including '/' for special tags and Unicode characters
for (let i = 0; i < afterHash.length; i++) {
const char = afterHash[i];
const charCode = char.charCodeAt(0);
// Valid tag characters
if (
(charCode >= 48 && charCode <= 57) || // 0-9
(charCode >= 65 && charCode <= 90) || // A-Z
(charCode >= 97 && charCode <= 122) || // a-z
char === "/" ||
char === "-" ||
char === "_" ||
(charCode > 127 &&
char !== "" &&
char !== "。" &&
char !== "" &&
char !== "" &&
char !== "" &&
char !== "" &&
char !== "「" &&
char !== "」" &&
char !== "『" &&
char !== "』" &&
char !== "" &&
char !== "" &&
char !== "【" &&
char !== "】" &&
char !== '"' &&
char !== '"' &&
char !== "'" &&
char !== "'" &&
char !== " ")
) {
tagEnd = i + 1;
} else {
break;
}
}
if (tagEnd > 0) {
const fullTag = "#" + afterHash.substring(0, tagEnd); // Include # prefix
const before = content.substring(0, hashPos);
const after = content.substring(hashPos + 1 + tagEnd);
return [fullTag, before, after];
}
// Not a valid tag, continue searching
return tryFrom(hashPos + 1);
};
return tryFrom(0);
}
/**
* Enhanced word boundary check for tag start validation
*/
private isValidTagStart(content: string, hashPos: number): boolean {
// Check if it's at the beginning of content
if (hashPos === 0) return true;
const prevChar = content[hashPos - 1];
// Valid tag starts are preceded by:
// 1. Whitespace
// 2. Start of line
// 3. Punctuation that typically separates words
// 4. Opening brackets/parentheses
// Invalid tag starts are preceded by:
// 1. Alphanumeric characters (part of a word)
// 2. Other hash symbols (multiple hashes)
// 3. Special symbols that indicate non-tag context
const validPrecedingChars = /[\s\(\[\{<,;:!?\-\+\*\/\\\|=]/;
const invalidPrecedingChars = /[a-zA-Z0-9#@$%^&*]/;
if (validPrecedingChars.test(prevChar)) {
return true;
}
if (invalidPrecedingChars.test(prevChar)) {
return false;
}
// For other characters (Unicode, etc.), use the original logic
return !prevChar.match(/[a-zA-Z0-9#@$%^&*]/);
}
private extractContext(content: string): [string, string, string] | null {
const atPos = content.indexOf("@");
if (atPos === -1) return null;
// Check if it's a word start
const isWordStart =
atPos === 0 ||
content[atPos - 1].match(/\s/) ||
!content[atPos - 1].match(/[a-zA-Z0-9#@$%^&*]/);
if (!isWordStart) return null;
const afterAt = content.substring(atPos + 1);
let contextEnd = 0;
// Find context end, similar to tag parsing but for context
for (let i = 0; i < afterAt.length; i++) {
const char = afterAt[i];
const charCode = char.charCodeAt(0);
// Check if character is valid for context:
// - ASCII letters and numbers: a-z, A-Z, 0-9
// - Special characters: -, _
// - Unicode characters (including Chinese): > 127
// - Exclude common separators and punctuation
if (
(charCode >= 48 && charCode <= 57) || // 0-9
(charCode >= 65 && charCode <= 90) || // A-Z
(charCode >= 97 && charCode <= 122) || // a-z
char === "-" ||
char === "_" ||
(charCode > 127 &&
char !== "" &&
char !== "。" &&
char !== "" &&
char !== "" &&
char !== "" &&
char !== "" &&
char !== "「" &&
char !== "」" &&
char !== "『" &&
char !== "』" &&
char !== "" &&
char !== "" &&
char !== "【" &&
char !== "】" &&
char !== '"' &&
char !== '"' &&
char !== "'" &&
char !== "'" &&
char !== " ")
) {
contextEnd = i + 1;
} else {
break;
}
}
if (contextEnd > 0) {
const context = afterAt.substring(0, contextEnd);
const before = content.substring(0, atPos);
const after = content.substring(atPos + 1 + contextEnd);
return [context, before, after];
}
return null;
}
private extractTagsOnly(
content: string,
): [string, Record<string, string>, string[]] {
const metadata: Record<string, string> = {};
const tags: string[] = [];
let cleanedContent = "";
let remaining = content;
while (true) {
let foundMatch = false;
// Check dataview format metadata
if (
this.config.parseMetadata &&
(this.config.metadataParseMode ===
MetadataParseMode.DataviewOnly ||
this.config.metadataParseMode === MetadataParseMode.Both)
) {
const bracketMatch = this.extractDataviewMetadata(remaining);
if (bracketMatch) {
const [key, value, newRemaining] = bracketMatch;
metadata[key] = value;
remaining = newRemaining;
foundMatch = true;
continue;
}
}
// Check context (@symbol)
if (!foundMatch && this.config.parseTags) {
const contextMatch = this.extractContext(remaining);
if (contextMatch) {
const [context, beforeContent, afterRemaining] =
contextMatch;
// Recursively process the content before context
const [beforeCleaned, beforeMetadata, beforeTags] =
this.extractTagsOnly(beforeContent);
// Merge metadata and tags from before content
for (const tag of beforeTags) {
tags.push(tag);
}
for (const [k, v] of Object.entries(beforeMetadata)) {
metadata[k] = v;
}
metadata.context = context;
cleanedContent += beforeCleaned;
remaining = afterRemaining;
foundMatch = true;
continue;
}
}
// Check tags
if (!foundMatch && this.config.parseTags) {
const tagMatch = this.extractTag(remaining);
if (tagMatch) {
const [tag, beforeContent, afterRemaining] = tagMatch;
// Check special tag format
// Remove # prefix for checking special tags
const tagWithoutHash = tag.startsWith("#")
? tag.substring(1)
: tag;
const slashPos = tagWithoutHash.indexOf("/");
if (slashPos !== -1) {
const prefix = tagWithoutHash.substring(0, slashPos);
const value = tagWithoutHash.substring(slashPos + 1);
// Case-insensitive match for special tag prefixes
const metadataKey =
this.config.specialTagPrefixes[prefix] ??
this.config.specialTagPrefixes[
prefix.toLowerCase()
];
if (
metadataKey &&
this.config.metadataParseMode !==
MetadataParseMode.None
) {
metadata[metadataKey] = value;
} else {
tags.push(tag);
}
} else {
tags.push(tag);
}
cleanedContent += beforeContent;
remaining = afterRemaining;
foundMatch = true;
continue;
}
}
if (!foundMatch) {
cleanedContent += remaining;
break;
}
}
return [cleanedContent.trim(), metadata, tags];
}
private findParentAndLevel(
actualSpaces: number,
): [string | undefined, number] {
if (this.indentStack.length === 0 || actualSpaces === 0) {
return [undefined, 0];
}
for (let i = this.indentStack.length - 1; i >= 0; i--) {
const {
taskId,
indentLevel,
actualSpaces: spaces,
} = this.indentStack[i];
if (spaces < actualSpaces) {
return [taskId, indentLevel + 1];
}
}
return [undefined, 0];
}
private updateIndentStack(
taskId: string,
indentLevel: number,
actualSpaces: number,
): void {
let stackOperations = 0;
while (this.indentStack.length > 0) {
stackOperations++;
if (stackOperations > this.config.maxStackOperations) {
console.warn(
"Warning: Maximum stack operations reached, clearing stack",
);
this.indentStack = [];
break;
}
const lastItem = this.indentStack[this.indentStack.length - 1];
if (lastItem.actualSpaces >= actualSpaces) {
this.indentStack.pop();
} else {
break;
}
}
if (this.indentStack.length >= this.config.maxStackSize) {
this.indentStack.splice(
0,
this.indentStack.length - this.config.maxStackSize + 1,
);
}
this.indentStack.push({ taskId, indentLevel, actualSpaces });
}
private getStatusFromMapping(rawStatus: string): string | undefined {
// Find status name corresponding to raw character
for (const [statusName, mappedChar] of Object.entries(
this.config.statusMapping,
)) {
if (mappedChar === rawStatus) {
return statusName;
}
}
return undefined;
}
private extractHeading(line: string): [number, string] | null {
const trimmed = line.trim();
if (!trimmed.startsWith("#")) return null;
let level = 0;
for (const char of trimmed) {
if (char === "#") {
level++;
} else if (char.match(/\s/)) {
break;
} else {
return null; // Not a valid heading format
}
}
if (level > 0 && level <= 6) {
const headingText = trimmed.substring(level).trim();
if (headingText) {
return [level, headingText];
}
}
return null;
}
private extractMultilineComment(
lines: string[],
startIndex: number,
actualSpaces: number,
): [string | undefined, number] {
const commentLines: string[] = [];
let i = startIndex;
let linesConsumed = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trimStart();
const nextSpaces = line.length - trimmed.length;
// Only consider as comment if next line is not a task line and has deeper indentation
if (nextSpaces > actualSpaces && !this.isTaskLine(trimmed)) {
commentLines.push(trimmed);
linesConsumed++;
} else {
break;
}
i++;
}
if (commentLines.length === 0) {
return [undefined, 0];
} else {
const comment = commentLines.join("\n");
return [comment, linesConsumed];
}
}
// Legacy compatibility methods
private extractLegacyPriority(
metadata: Record<string, string>,
): number | undefined {
if (!metadata.priority) return undefined;
// Use the standard PRIORITY_MAP for consistent priority values
const priorityMap: Record<string, number> = {
highest: 5,
high: 4,
medium: 3,
low: 2,
lowest: 1,
urgent: 5, // Alias for highest
critical: 5, // Alias for highest
important: 4, // Alias for high
normal: 3, // Alias for medium
moderate: 3, // Alias for medium
minor: 2, // Alias for low
trivial: 1, // Alias for lowest
// Emoji priority mappings
"🔺": 5,
"⏫": 4,
"🔼": 3,
"🔽": 2,
"⏬️": 1,
"⏬": 1,
};
// First try to parse as number
const numericPriority = parseInt(metadata.priority, 10);
if (!isNaN(numericPriority)) {
return numericPriority;
}
// Then try to map string values (including emojis)
const mappedPriority =
priorityMap[metadata.priority.toLowerCase()] ||
priorityMap[metadata.priority];
return mappedPriority;
}
private extractLegacyDate(
metadata: Record<string, 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) {
return cachedDate;
}
// Parse date with custom formats and cache the result
const date = parseLocalDate(dateStr, this.customDateFormats);
// Implement cache size limit to prevent memory issues
if (
MarkdownTaskParser.dateCache.size >=
MarkdownTaskParser.MAX_CACHE_SIZE
) {
// Remove oldest entries (simple FIFO eviction)
const firstKey = MarkdownTaskParser.dateCache.keys().next().value;
if (firstKey) {
MarkdownTaskParser.dateCache.delete(firstKey);
}
}
MarkdownTaskParser.dateCache.set(cacheKey, date);
return date;
}
private convertToLegacyTask(enhancedTask: EnhancedTask): Task {
// Helper function to safely parse tags from metadata
const parseTagsFromMetadata = (tagsString: string): string[] => {
try {
const parsed = JSON.parse(tagsString);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
// If parsing fails, treat as a single tag
return [tagsString];
}
};
return {
id: enhancedTask.id,
content: enhancedTask.content,
filePath: enhancedTask.filePath,
line: enhancedTask.line,
completed: enhancedTask.completed,
status: enhancedTask.rawStatus,
originalMarkdown: enhancedTask.originalMarkdown,
children: enhancedTask.children || [],
metadata: {
tags:
enhancedTask.tags ||
(enhancedTask.metadata.tags
? parseTagsFromMetadata(enhancedTask.metadata.tags)
: []),
priority:
enhancedTask.priority || enhancedTask.metadata.priority,
startDate:
enhancedTask.startDate || enhancedTask.metadata.startDate,
dueDate: enhancedTask.dueDate || enhancedTask.metadata.dueDate,
scheduledDate:
enhancedTask.scheduledDate ||
enhancedTask.metadata.scheduledDate,
completedDate:
enhancedTask.completedDate ||
enhancedTask.metadata.completedDate,
createdDate:
enhancedTask.createdDate ||
enhancedTask.metadata.createdDate,
cancelledDate: enhancedTask.metadata.cancelledDate,
recurrence:
enhancedTask.recurrence || enhancedTask.metadata.recurrence,
project: enhancedTask.project || enhancedTask.metadata.project,
context: enhancedTask.context || enhancedTask.metadata.context,
area: enhancedTask.metadata.area,
id: enhancedTask.metadata.id,
dependsOn: enhancedTask.metadata.dependsOn
? enhancedTask.metadata.dependsOn
.split(",")
.map((id) => id.trim())
.filter((id) => id.length > 0)
: undefined,
onCompletion: enhancedTask.metadata.onCompletion,
// Legacy compatibility fields that should remain in metadata
children: enhancedTask.children,
heading: Array.isArray(enhancedTask.heading)
? enhancedTask.heading
: enhancedTask.heading
? [enhancedTask.heading]
: [],
parent: enhancedTask.parentId,
tgProject: enhancedTask.tgProject,
},
} as any;
}
/**
* Load project configuration for the given file path
*/
private loadProjectConfig(filePath: string): void {
if (!this.config.projectConfig) return;
// This is a simplified implementation for the worker environment
// In a real implementation, you would need to pass project config data
// from the main thread or implement file reading in the worker
this.projectConfigCache = {};
}
/**
* Determine tgProject for a task based on various sources
*/
private determineTgProject(filePath: string): TgProject | undefined {
if (!this.config.projectConfig?.enableEnhancedProject) {
return undefined;
}
const config = this.config.projectConfig;
// 1. Check path-based mappings
if (config.pathMappings && config.pathMappings.length > 0) {
for (const mapping of config.pathMappings) {
if (!mapping.enabled) continue;
// Simple path matching (in a real implementation, you'd use glob patterns)
if (filePath.includes(mapping.pathPattern)) {
return {
type: "path",
name: mapping.projectName,
source: mapping.pathPattern,
readonly: true,
};
}
}
}
// 2. Check file metadata - only if metadata detection is enabled
if (config.metadataConfig?.enabled && this.fileMetadata) {
const metadataKey = config.metadataConfig.metadataKey || "project";
const projectFromMetadata = this.fileMetadata[metadataKey];
if (
projectFromMetadata &&
typeof projectFromMetadata === "string"
) {
return {
type: "metadata",
name: projectFromMetadata,
source: metadataKey,
readonly: true,
};
}
}
// 3. Check project config file - only if config file detection is enabled
if (config.configFile?.enabled && this.projectConfigCache) {
const projectFromConfig = this.projectConfigCache.project;
if (projectFromConfig && typeof projectFromConfig === "string") {
return {
type: "config",
name: projectFromConfig,
source: config.configFile.fileName,
readonly: true,
};
}
}
return undefined;
}
/**
* Static method to clear the date cache when needed (e.g., for memory management)
*/
public static clearDateCache(): void {
MarkdownTaskParser.dateCache.clear();
}
/**
* Static method to get cache statistics
*/
public static getDateCacheStats(): { size: number; maxSize: number } {
return {
size: MarkdownTaskParser.dateCache.size,
maxSize: MarkdownTaskParser.MAX_CACHE_SIZE,
};
}
/**
* Parse tags array to extract special tag formats and convert them to metadata
* @param tags Array of tags to parse
* @returns Object containing extracted metadata from tags
*/
private parseTagsForMetadata(tags: string[]): Record<string, string> {
const metadata: Record<string, string> = {};
for (const tag of tags) {
// Remove # prefix if present
const tagWithoutHash = tag.startsWith("#") ? tag.substring(1) : tag;
const slashPos = tagWithoutHash.indexOf("/");
if (slashPos !== -1) {
const prefix = tagWithoutHash.substring(0, slashPos);
const value = tagWithoutHash.substring(slashPos + 1);
// Check if this is a special tag prefix that should be converted to metadata
const metadataKey =
this.config.specialTagPrefixes[prefix] ??
this.config.specialTagPrefixes[prefix.toLowerCase()];
if (
metadataKey &&
this.config.metadataParseMode !== MetadataParseMode.None
) {
metadata[metadataKey] = value;
}
}
}
return metadata;
}
/**
* Normalize a tag to ensure it has a # prefix
* @param tag The tag to normalize
* @returns Normalized tag with # prefix
*/
private normalizeTag(tag: string): string {
if (typeof tag !== "string") {
return tag as any;
}
const trimmed = tag.trim();
if (!trimmed || trimmed.startsWith("#")) {
return trimmed;
}
return `#${trimmed}`;
}
/**
* Safely parse tags field from metadata which might be a JSON string or a plain string
*/
private safeParseTagsField(tagsField: any): string[] {
if (!tagsField) return [];
if (Array.isArray(tagsField)) return tagsField;
if (typeof tagsField === "string") {
try {
const parsed = JSON.parse(tagsField);
return Array.isArray(parsed) ? parsed : [tagsField];
} catch (e) {
return [tagsField];
}
}
return [];
}
/**
* Merge tags from different sources, removing duplicates
* @param baseTags Base tags array (from task)
* @param inheritedTags Tags to inherit (from file metadata)
* @returns Merged tags array with duplicates removed
*/
private mergeTags(baseTags: string[], inheritedTags: string[]): string[] {
// Normalize all tags before merging
const normalizedBaseTags = baseTags.map((tag) =>
this.normalizeTag(tag),
);
const normalizedInheritedTags = inheritedTags.map((tag) =>
this.normalizeTag(tag),
);
const merged = [...normalizedBaseTags];
for (const tag of normalizedInheritedTags) {
if (!merged.includes(tag)) {
merged.push(tag);
}
}
return merged;
}
/**
* LEGACY (pre-dataflow): Inherit metadata from file frontmatter and project configuration
*
* In the new dataflow architecture, inheritance is handled exclusively by Augmentor.
* This method remains for backward compatibility and is effectively disabled when
* fileMetadataInheritance.enabled is false (returns {}). When enabled, Parser may still
* perform minimal, legacy-compatible merging, but authoritative merging should be done
* in Augmentor.merge().
*/
private inheritFileMetadata(
taskMetadata: Record<string, string>,
isSubtask: boolean = false,
): Record<string, string> {
// Helper function to convert priority values to numbers
const convertPriorityValue = (value: any): string => {
if (value === undefined || value === null) {
return String(value);
}
// If it's already a number, convert to string and return
if (typeof value === "number") {
return String(value);
}
// If it's a string, try to convert priority values to numbers, but return as string
// since the metadata record expects string values that will later be processed by extractLegacyPriority
const strValue = String(value);
const priorityMap: Record<string, number> = {
highest: 5,
high: 4,
medium: 3,
low: 2,
lowest: 1,
urgent: 5,
critical: 5,
important: 4,
normal: 3,
moderate: 3,
minor: 2,
trivial: 1,
// Emoji priority mappings
"🔺": 5,
"⏫": 4,
"🔼": 3,
"🔽": 2,
"⏬️": 1,
"⏬": 1,
};
// Try numeric conversion first
const numericValue = parseInt(strValue, 10);
if (!isNaN(numericValue)) {
return String(numericValue);
}
// Try priority mapping (including emojis)
const mappedPriority =
priorityMap[strValue.toLowerCase()] || priorityMap[strValue];
if (mappedPriority !== undefined) {
return String(mappedPriority);
}
// Return original value if no conversion applies
return strValue;
};
// Always convert priority values in task metadata, even if inheritance is disabled
const inherited = { ...taskMetadata };
if (inherited.priority !== undefined) {
inherited.priority = convertPriorityValue(inherited.priority);
}
// Early return if enhanced project features are disabled
// Check if file metadata inheritance is enabled
if (!this.config.fileMetadataInheritance?.enabled) {
// Inheritance disabled: preserve task-level metadata as-is
// (enhanced merging will be handled elsewhere when enabled)
return inherited;
}
// Check if frontmatter inheritance is enabled
if (!this.config.fileMetadataInheritance?.inheritFromFrontmatter) {
// Legacy behavior: return task-only metadata
return inherited;
}
// Check if subtask inheritance is allowed
if (
isSubtask &&
!this.config.fileMetadataInheritance
?.inheritFromFrontmatterForSubtasks
) {
// Legacy behavior: do not inherit for subtasks
return inherited;
}
// List of fields that should NOT be inherited (task-specific only)
const nonInheritableFields = new Set([
"id",
"content",
"status",
"rawStatus",
"completed",
"line",
"lineNumber",
"originalMarkdown",
"filePath",
"heading",
"headingLevel",
"parent",
"parentId",
"children",
"childrenIds",
"indentLevel",
"actualIndent",
"listMarker",
"tgProject",
"comment",
"metadata", // Prevent recursive metadata inheritance
]);
// LEGACY: Inherit from file metadata (frontmatter) if available
if (this.fileMetadata) {
// When enhanced project + metadata detection are enabled,
// do NOT inject frontmatter project into metadata.project here.
// Let tgProject be determined via determineTgProject, and later
// Augmentor will mirror tgProject.name into metadata.project if needed.
const enhancedOn =
!!this.config.projectConfig?.enableEnhancedProject;
const metadataDetectOn =
!!this.config.projectConfig?.metadataConfig?.enabled;
if (!(enhancedOn && metadataDetectOn)) {
// Map configured frontmatter project key to standard 'project'
try {
const configuredProjectKey =
this.config.projectConfig?.metadataConfig?.metadataKey;
if (
configuredProjectKey &&
this.fileMetadata[configuredProjectKey] !== undefined &&
this.fileMetadata[configuredProjectKey] !== null &&
String(
this.fileMetadata[configuredProjectKey],
).trim() !== ""
) {
if (
inherited.project === undefined ||
inherited.project === null ||
inherited.project === ""
) {
inherited.project = String(
this.fileMetadata[configuredProjectKey],
).trim();
}
}
} catch {}
}
for (const [key, value] of Object.entries(this.fileMetadata)) {
// Special handling for tags field
if (key === "tags" && Array.isArray(value)) {
// Parse tags to extract special tag formats (e.g., #project/myproject)
const tagMetadata = this.parseTagsForMetadata(value);
// Merge extracted metadata from tags
for (const [tagKey, tagValue] of Object.entries(
tagMetadata,
)) {
if (
!nonInheritableFields.has(tagKey) &&
(inherited[tagKey] === undefined ||
inherited[tagKey] === null ||
inherited[tagKey] === "") &&
tagValue !== undefined &&
tagValue !== null
) {
// Convert priority values to numbers before inheritance
if (tagKey === "priority") {
inherited[tagKey] =
convertPriorityValue(tagValue);
} else {
inherited[tagKey] = String(tagValue);
}
}
}
// Store the tags array itself as tags metadata
if (
!nonInheritableFields.has("tags") &&
(inherited["tags"] === undefined ||
inherited["tags"] === null ||
inherited["tags"] === "")
) {
// Normalize tags before storing
const normalizedTags = value.map((tag) =>
this.normalizeTag(tag),
);
inherited["tags"] = JSON.stringify(normalizedTags);
}
} else {
// Only inherit if:
// 1. The field is not in the non-inheritable list
// 2. The task doesn't already have a meaningful value for this field
// 3. The file metadata value is not undefined/null
if (
!nonInheritableFields.has(key) &&
(inherited[key] === undefined ||
inherited[key] === null ||
inherited[key] === "") &&
value !== undefined &&
value !== null
) {
// Convert priority values to numbers before inheritance
if (key === "priority") {
inherited[key] = convertPriorityValue(value);
} else {
inherited[key] = String(value);
}
}
}
}
}
// LEGACY: Inherit from project configuration data if available
if (this.projectConfigCache) {
for (const [key, value] of Object.entries(
this.projectConfigCache,
)) {
// Only inherit if:
// 1. The field is not in the non-inheritable list
// 2. The task doesn't already have a meaningful value for this field (task metadata takes precedence)
// 3. File metadata doesn't have this field (file metadata takes precedence over project config)
// 4. The value is not undefined/null
if (
!nonInheritableFields.has(key) &&
(inherited[key] === undefined ||
inherited[key] === null ||
inherited[key] === "") &&
!(
this.fileMetadata &&
this.fileMetadata[key] !== undefined
) &&
value !== undefined &&
value !== null
) {
// Convert priority values to numbers before inheritance
if (key === "priority") {
inherited[key] = convertPriorityValue(value);
} else {
inherited[key] = String(value);
}
}
}
}
return inherited;
}
}
export class ConfigurableTaskParser extends MarkdownTaskParser {
constructor(
config?: Partial<TaskParserConfig>,
timeParsingService?: TimeParsingService,
) {
// Default configuration
const defaultConfig: TaskParserConfig = {
parseMetadata: true,
parseTags: true,
parseComments: true,
parseHeadings: true,
maxIndentSize: 100,
maxParseIterations: 100,
maxMetadataIterations: 50,
maxTagLength: 50,
maxEmojiValueLength: 50,
maxStackOperations: 1000,
maxStackSize: 50,
statusMapping: {
TODO: " ",
IN_PROGRESS: "/",
DONE: "x",
CANCELLED: "-",
},
emojiMapping: {
"📅": "dueDate",
"🛫": "startDate",
"⏳": "scheduledDate",
"✅": "completedDate",
"": "createdDate",
"❌": "cancelledDate",
"🆔": "id",
"⛔": "dependsOn",
"🏁": "onCompletion",
"🔁": "repeat",
"🔺": "priority",
"⏫": "priority",
"🔼": "priority",
"🔽": "priority",
"⏬": "priority",
},
metadataParseMode: MetadataParseMode.Both,
specialTagPrefixes: {
project: "project",
"@": "context",
},
};
super({ ...defaultConfig, ...config }, timeParsingService);
}
}