From cf51bdb7580f4fdad1622acf53f5d1e1b60dced8 Mon Sep 17 00:00:00 2001 From: Quorafind Date: Tue, 16 Sep 2025 15:29:56 +0800 Subject: [PATCH] refactor: improve task parsing, date management, and tag handling - Enhanced task parser to preserve trailing spaces for empty-content tasks - Refactored WriteAPI for cleaner date insertion logic with proper formatting - Improved status cycler to prevent conflicts with normal typing - Fixed escaped hash tag handling to align with Obsidian behavior - Optimized tag extraction with recursive unprotected hash detection - Added CodeMirror state mock for better test coverage - Improved quadrant card component structure and rendering --- jest.config.js | 2 + src/__mocks__/codemirror-state.ts | 31 ++ src/__tests__/taskMarkCleanup.test.ts | 2 +- .../features/quadrant/quadrant-card.ts | 49 ++- .../ui/renderers/MarkdownRenderer.ts | 82 +++- src/dataflow/api/WriteAPI.ts | 358 ++++++++++++++---- src/dataflow/core/ConfigurableTaskParser.ts | 156 ++++---- .../date-time/date-manager.ts | 81 ++-- .../task-operations/status-cycler.ts | 97 ++--- 9 files changed, 620 insertions(+), 238 deletions(-) diff --git a/jest.config.js b/jest.config.js index 9fbe05b7..2701f1ec 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,6 +2,8 @@ module.exports = { preset: "ts-jest", testEnvironment: "jsdom", testMatch: ["**/__tests__/**/*.test.ts"], + testPathIgnorePatterns: ["/.conductor/"], + modulePathIgnorePatterns: ["/.conductor/"], moduleNameMapper: { "^obsidian$": "/src/__mocks__/obsidian.ts", "^moment$": "/src/__mocks__/moment.js", diff --git a/src/__mocks__/codemirror-state.ts b/src/__mocks__/codemirror-state.ts index 4e70d9eb..11bf9d69 100644 --- a/src/__mocks__/codemirror-state.ts +++ b/src/__mocks__/codemirror-state.ts @@ -186,6 +186,37 @@ export class EditorState { } } + update(spec: any = {}) { + const changesSpec = spec.changes || {}; + const from = changesSpec.from ?? 0; + const to = changesSpec.to ?? from; + const insert = + typeof changesSpec.insert === "string" + ? changesSpec.insert + : changesSpec.insert?.toString?.() ?? ""; + + const oldText = this.doc.toString(); + const newContent = oldText.slice(0, from) + insert + oldText.slice(to); + const newDoc = new Text(newContent); + + const changes = new Changes(); + (changes as any)._changes.push({ + fromA: from, + toA: to, + fromB: from, + toB: from + insert.length, + inserted: new Text(insert), + }); + + return new Transaction({ + startState: this, + newDoc, + changes, + selection: spec.selection, + annotations: spec.annotations, + }); + } + field(field: any /* StateField | Facet */): T | undefined { return this._fields.get(field); } diff --git a/src/__tests__/taskMarkCleanup.test.ts b/src/__tests__/taskMarkCleanup.test.ts index 352d7f8a..fe945555 100644 --- a/src/__tests__/taskMarkCleanup.test.ts +++ b/src/__tests__/taskMarkCleanup.test.ts @@ -77,7 +77,7 @@ describe("Task Mark Cleanup", () => { test("should handle complex example from user", () => { const input = "今天要过去吃饭 #123-123-123 ~ 📅 2025-07-18"; - const expected = "今天要过去吃饭 #123-123-123 2025-07-18"; + const expected = "今天要过去吃饭 2025-07-18"; // #123-123-123 is a normal tag and should be removed expect(clearAllMarks(input)).toBe(expected); }); }); diff --git a/src/components/features/quadrant/quadrant-card.ts b/src/components/features/quadrant/quadrant-card.ts index 4d2f1b15..2804d797 100644 --- a/src/components/features/quadrant/quadrant-card.ts +++ b/src/components/features/quadrant/quadrant-card.ts @@ -218,7 +218,8 @@ export class QuadrantCardComponent extends Component { numericPriority = this.task.metadata.priority; } - const sanitizedPriority = sanitizePriorityForClass(numericPriority); + const sanitizedPriority = + sanitizePriorityForClass(numericPriority); const classes = ["tg-quadrant-card-priority"]; if (sanitizedPriority) { classes.push(`priority-${sanitizedPriority}`); @@ -422,8 +423,50 @@ export class QuadrantCardComponent extends Component { } private extractTags(): string[] { - const tags = this.task.content.match(/#[\w-]+/g) || []; - return tags; + const content = this.task.content || ""; + const results: string[] = []; + let i = 0; + while (i < content.length) { + const hashIndex = content.indexOf("#", i); + if (hashIndex === -1) break; + // Count consecutive backslashes immediately before '#' + let bsCount = 0; + let j = hashIndex - 1; + while (j >= 0 && content[j] === "\\") { + bsCount++; + j--; + } + // If odd number of backslashes precede '#', it is escaped → skip + if (bsCount % 2 === 1) { + i = hashIndex + 1; + continue; + } + // Extract tag text: allow a-zA-Z0-9, '/', '-', '_', and non-ASCII (e.g., Chinese) + let k = hashIndex + 1; + while (k < content.length) { + const ch = content[k]; + const code = ch.charCodeAt(0); + const isAsciiAlnum = + (code >= 48 && code <= 57) || + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122); + const isAllowed = + isAsciiAlnum || + ch === "/" || + ch === "-" || + ch === "_" || + code > 127; + if (!isAllowed) break; + k++; + } + if (k > hashIndex + 1) { + results.push(content.substring(hashIndex, k)); + i = k; + } else { + i = hashIndex + 1; + } + } + return results; } private getPriorityClass(): string { diff --git a/src/components/ui/renderers/MarkdownRenderer.ts b/src/components/ui/renderers/MarkdownRenderer.ts index becd68b1..a7223238 100644 --- a/src/components/ui/renderers/MarkdownRenderer.ts +++ b/src/components/ui/renderers/MarkdownRenderer.ts @@ -6,6 +6,9 @@ import { } from "obsidian"; import { DEFAULT_SYMBOLS, TAG_REGEX } from "../../../common/default-symbol"; +// Use a non-global, start-anchored tag matcher to allow index checks +const TAG_HEAD = new RegExp("^" + TAG_REGEX.source); + /** * Remove tags while protecting content inside wiki links */ @@ -13,6 +16,17 @@ function removeTagsWithLinkProtection(text: string): string { let result = ""; let i = 0; + // Helper: check if '#' at index i is escaped by odd number of backslashes + function isEscapedHash(idx: number): boolean { + let bs = 0; + let j = idx - 1; + while (j >= 0 && text[j] === "\\") { + bs++; + j--; + } + return bs % 2 === 1; + } + while (i < text.length) { // Check if we're at the start of a wiki link if (i < text.length - 1 && text[i] === "[" && text[i + 1] === "[") { @@ -38,11 +52,23 @@ function removeTagsWithLinkProtection(text: string): string { result += text.substring(i, linkEnd); i = linkEnd; } else if (text[i] === "#") { + // Ignore escaped \# + if (isEscapedHash(i)) { + result += text[i]; + i++; + continue; + } // Check if this is a tag (not inside a link) - const tagMatch = text.substring(i).match(TAG_REGEX); - if (tagMatch && tagMatch.index === 0) { - // Skip the entire tag - i += tagMatch[0].length; + const headMatch = TAG_HEAD.exec(text.substring(i)); + if (headMatch) { + const full = headMatch[0]; + const body = full.slice(1); + // Preserve only pure numeric tokens like #123 (not a tag by spec) + if (/^\d+$/.test(body)) { + result += full; // keep as plain text + } + // Otherwise treat as tag and remove it + i += full.length; } else { // Not a tag, keep the character result += text[i]; @@ -240,10 +266,50 @@ export function clearAllMarks(markdown: string): string { tempMarkdown = tempMarkdown.replace(/\s*📁\s*/g, " "); // Remove any remaining simple tags but preserve special tags like #123-123-123 - tempMarkdown = tempMarkdown.replace( - /#(?![0-9-]+\b)[^\u2000-\u206F\u2E00-\u2E7F'!"#$%&()*+,.:;<=>?@^`{|}~\[\]\\\s]+/g, - "" - ); + // Also ignore escaped \# (do not treat as tag) + tempMarkdown = (function removeSimpleTagsIgnoringEscapes( + input: string + ): string { + let out = ""; + let i = 0; + function isEscapedHashAt(idx: number): boolean { + let bs = 0; + let j = idx - 1; + while (j >= 0 && input[j] === "\\") { + bs++; + j--; + } + return bs % 2 === 1; + } + while (i < input.length) { + if (input[i] === "#") { + if (isEscapedHashAt(i)) { + out += "#"; + i++; + continue; + } + const rest = input.substring(i); + const m = TAG_HEAD.exec(rest); + if (m) { + const full = m[0]; + const body = full.slice(1); + // Preserve only pure numeric tokens like #123; others are tags to remove + if (/^\d+$/.test(body)) { + out += full; + } + i += full.length; + continue; + } + // not a tag, keep '#' + out += "#"; + i++; + continue; + } + out += input[i]; + i++; + } + return out; + })(tempMarkdown); // Remove any remaining tilde symbols (~ symbol) that weren't handled by the special case tempMarkdown = tempMarkdown.replace(/\s+~\s+/g, " "); diff --git a/src/dataflow/api/WriteAPI.ts b/src/dataflow/api/WriteAPI.ts index 684bda3e..3d432066 100644 --- a/src/dataflow/api/WriteAPI.ts +++ b/src/dataflow/api/WriteAPI.ts @@ -158,48 +158,70 @@ export class WriteAPI { const previousMark = task.status || " "; const isCompleting = willComplete && !task.completed; const isAbandoning = markToWrite === "-" && previousMark !== "-"; - const isStarting = (markToWrite === ">" || markToWrite === "/") && + const isStarting = + (markToWrite === ">" || markToWrite === "/") && (previousMark === " " || previousMark === "?"); - + // Add completion date if completing and not already present if (isCompleting) { const hasCompletionMeta = /(\[completion::|✅)/.test(taskLine); if (!hasCompletionMeta) { const completionDate = moment().format("YYYY-MM-DD"); const useDataviewFormat = - this.plugin.settings.preferMetadataFormat === "dataview"; + this.plugin.settings.preferMetadataFormat === + "dataview"; const completionMeta = useDataviewFormat ? `[completion:: ${completionDate}]` : `✅ ${completionDate}`; - taskLine = this.insertDateAtCorrectPosition(taskLine, completionMeta, "completed"); + taskLine = this.insertDateAtCorrectPosition( + taskLine, + completionMeta, + "completed" + ); } } - + // Add cancelled date if abandoning - if (isAbandoning && this.plugin.settings.autoDateManager?.manageCancelledDate) { + if ( + isAbandoning && + this.plugin.settings.autoDateManager?.manageCancelledDate + ) { const hasCancelledMeta = /(\[cancelled::|❌)/.test(taskLine); if (!hasCancelledMeta) { const cancelledDate = moment().format("YYYY-MM-DD"); const useDataviewFormat = - this.plugin.settings.preferMetadataFormat === "dataview"; + this.plugin.settings.preferMetadataFormat === + "dataview"; const cancelledMeta = useDataviewFormat ? `[cancelled:: ${cancelledDate}]` : `❌ ${cancelledDate}`; - taskLine = this.insertDateAtCorrectPosition(taskLine, cancelledMeta, "cancelled"); + taskLine = this.insertDateAtCorrectPosition( + taskLine, + cancelledMeta, + "cancelled" + ); } } - + // Add start date if starting - if (isStarting && this.plugin.settings.autoDateManager?.manageStartDate) { + if ( + isStarting && + this.plugin.settings.autoDateManager?.manageStartDate + ) { const hasStartMeta = /(\[start::|🛫|🚀)/.test(taskLine); if (!hasStartMeta) { const startDate = moment().format("YYYY-MM-DD"); const useDataviewFormat = - this.plugin.settings.preferMetadataFormat === "dataview"; + this.plugin.settings.preferMetadataFormat === + "dataview"; const startMeta = useDataviewFormat ? `[start:: ${startDate}]` : `🛫 ${startDate}`; - taskLine = this.insertDateAtCorrectPosition(taskLine, startMeta, "start"); + taskLine = this.insertDateAtCorrectPosition( + taskLine, + startMeta, + "start" + ); } } @@ -306,7 +328,7 @@ export class WriteAPI { const updatedTask = { ...originalTask, ...args.updates }; let taskLine = lines[originalTask.line]; - + // Track previous status for date management const previousStatus = originalTask.status || " "; let newStatus = previousStatus; @@ -329,61 +351,88 @@ export class WriteAPI { `$1${statusMark}$2` ); } - + // Handle date writing based on status changes const configuredCompleted = ( this.plugin.settings.taskStatuses?.completed || "x" ).split("|")[0]; - const isCompleting = (newStatus === "x" || newStatus === configuredCompleted) && - (previousStatus !== "x" && previousStatus !== configuredCompleted); + const isCompleting = + (newStatus === "x" || newStatus === configuredCompleted) && + previousStatus !== "x" && + previousStatus !== configuredCompleted; const isAbandoning = newStatus === "-" && previousStatus !== "-"; - const isStarting = (newStatus === ">" || newStatus === "/") && + const isStarting = + (newStatus === ">" || newStatus === "/") && (previousStatus === " " || previousStatus === "?"); - + // Add completion date if completing if (isCompleting && !args.updates.metadata?.completedDate) { const hasCompletionMeta = /(\[completion::|✅)/.test(taskLine); if (!hasCompletionMeta) { const completionDate = moment().format("YYYY-MM-DD"); const useDataviewFormat = - this.plugin.settings.preferMetadataFormat === "dataview"; + this.plugin.settings.preferMetadataFormat === + "dataview"; const completionMeta = useDataviewFormat ? `[completion:: ${completionDate}]` : `✅ ${completionDate}`; - taskLine = this.insertDateAtCorrectPosition(taskLine, completionMeta, "completed"); + taskLine = this.insertDateAtCorrectPosition( + taskLine, + completionMeta, + "completed" + ); } } - + // Add cancelled date if abandoning - if (isAbandoning && this.plugin.settings.autoDateManager?.manageCancelledDate) { + if ( + isAbandoning && + this.plugin.settings.autoDateManager?.manageCancelledDate + ) { const hasCancelledMeta = /(\[cancelled::|❌)/.test(taskLine); if (!hasCancelledMeta) { const cancelledDate = moment().format("YYYY-MM-DD"); const useDataviewFormat = - this.plugin.settings.preferMetadataFormat === "dataview"; + this.plugin.settings.preferMetadataFormat === + "dataview"; const cancelledMeta = useDataviewFormat ? `[cancelled:: ${cancelledDate}]` : `❌ ${cancelledDate}`; - taskLine = this.insertDateAtCorrectPosition(taskLine, cancelledMeta, "cancelled"); + taskLine = this.insertDateAtCorrectPosition( + taskLine, + cancelledMeta, + "cancelled" + ); } } - + // Add start date if starting - if (isStarting && this.plugin.settings.autoDateManager?.manageStartDate) { + if ( + isStarting && + this.plugin.settings.autoDateManager?.manageStartDate + ) { const hasStartMeta = /(\[start::|🛫|🚀)/.test(taskLine); if (!hasStartMeta) { const startDate = moment().format("YYYY-MM-DD"); const useDataviewFormat = - this.plugin.settings.preferMetadataFormat === "dataview"; + this.plugin.settings.preferMetadataFormat === + "dataview"; const startMeta = useDataviewFormat ? `[start:: ${startDate}]` : `🛫 ${startDate}`; - taskLine = this.insertDateAtCorrectPosition(taskLine, startMeta, "start"); + taskLine = this.insertDateAtCorrectPosition( + taskLine, + startMeta, + "start" + ); } } // Update content if changed (but prevent clearing content) - if (args.updates.content !== undefined && args.updates.content !== "") { + if ( + args.updates.content !== undefined && + args.updates.content !== "" + ) { // Extract the task prefix and metadata const prefixMatch = taskLine.match( /^(\s*[-*+]\s*\[[^\]]*\]\s*)/ @@ -399,7 +448,10 @@ export class WriteAPI { } } else if (args.updates.content === "") { // Log warning if attempting to clear content - console.warn("[WriteAPI] Prevented clearing task content for task:", originalTask.id); + console.warn( + "[WriteAPI] Prevented clearing task content for task:", + originalTask.id + ); } // Update metadata if changed @@ -564,10 +616,14 @@ export class WriteAPI { let newFilePath = originalTask.filePath; // Handle content updates (i.e., renaming the file itself) - if (updates.content !== undefined && updates.content !== originalTask.content) { + if ( + updates.content !== undefined && + updates.content !== originalTask.content + ) { try { // Get effective content field settings - const settings = this.plugin.settings.fileSource?.fileTaskProperties || {}; + const settings = + this.plugin.settings.fileSource?.fileTaskProperties || {}; const displayMode = settings.contentSource || "filename"; const preferFrontmatterTitle = settings.preferFrontmatterTitle; const customContentField = (settings as any).customContentField; @@ -826,10 +882,16 @@ export class WriteAPI { let newContent = content; if (args.parent) { // Insert as subtask - newContent = this.insertSubtask(content, args.parent, taskContent); + newContent = this.insertSubtask( + content, + args.parent, + taskContent + ); } else { // Append to end of file - newContent = content ? `${content}\n${taskContent}` : taskContent; + newContent = content + ? `${content}\n${taskContent}` + : taskContent; } // Notify about write operation @@ -952,7 +1014,9 @@ export class WriteAPI { if (!fileUpdates.has(task.filePath)) { fileUpdates.set(task.filePath, new Map()); } - fileUpdates.get(task.filePath)!.set(task.line, updatedContent); + fileUpdates + .get(task.filePath)! + .set(task.line, updatedContent); updatedCount++; } } @@ -981,7 +1045,9 @@ export class WriteAPI { const metadata = metadataMatch ? metadataMatch[0] : ""; - lines[lineNum] = `${prefix}${newContent}${metadata}`; + lines[ + lineNum + ] = `${prefix}${newContent}${metadata}`; } } } @@ -1076,7 +1142,10 @@ export class WriteAPI { const lineIndentLevel = lineIndentMatch ? lineIndentMatch[0].length : 0; - if (lineIndentLevel <= parentIndentLevel && line.trim() !== "") { + if ( + lineIndentLevel <= parentIndentLevel && + line.trim() !== "" + ) { break; } insertLine++; @@ -1103,6 +1172,105 @@ export class WriteAPI { } } + /** + * Backward-compatible: batch update task status (wrapper) + */ + async batchUpdateTaskStatus(args: { + taskIds: string[]; + status?: string; + completed?: boolean; + }): Promise<{ + updated: string[]; + failed: Array<{ id: string; error: string }>; + }> { + const updated: string[] = []; + const failed: Array<{ id: string; error: string }> = []; + + for (const taskId of args.taskIds) { + const result = await this.updateTaskStatus({ + taskId, + status: args.status, + completed: args.completed, + }); + + if (result.success) { + updated.push(taskId); + } else { + failed.push({ + id: taskId, + error: result.error || "Unknown error", + }); + } + } + + return { updated, failed }; + } + + /** + * Backward-compatible: postpone tasks to a new date (wrapper) + */ + async postponeTasks(args: { taskIds: string[]; newDate: string }): Promise<{ + updated: string[]; + failed: Array<{ id: string; error: string }>; + }> { + const updated: string[] = []; + const failed: Array<{ id: string; error: string }> = []; + + const parseDateOrOffset = (input: string): number | null => { + const abs = Date.parse(input); + if (!isNaN(abs)) return abs; + const m = input.match(/^\+(\d+)([dwmy])$/i); + if (!m) return null; + const n = parseInt(m[1], 10); + const unit = m[2].toLowerCase(); + const base = new Date(); + switch (unit) { + case "d": + base.setDate(base.getDate() + n); + break; + case "w": + base.setDate(base.getDate() + n * 7); + break; + case "m": + base.setMonth(base.getMonth() + n); + break; + case "y": + base.setFullYear(base.getFullYear() + n); + break; + } + return base.getTime(); + }; + + const newDateMs = parseDateOrOffset(args.newDate); + if (newDateMs === null) { + return { + updated: [], + failed: args.taskIds.map((id) => ({ + id, + error: "Invalid date format", + })), + }; + } + + for (const taskId of args.taskIds) { + const result = await this.updateTask({ + taskId, + updates: { metadata: { dueDate: newDateMs } as any }, + }); + + if (result.success) { + updated.push(taskId); + } else { + failed.push({ + id: taskId, + error: result.error || "Unknown error", + }); + } + } + + return { updated, failed }; + } + /** * Get all descendant task IDs for a given task */ @@ -1285,6 +1453,27 @@ export class WriteAPI { } } + /** + * Backward-compatible: create a task in daily note (wrapper) + */ + async createTaskInDailyNote( + args: CreateTaskArgs & { heading?: string } + ): Promise<{ success: boolean; error?: string }> { + return this.addTaskToDailyNote({ + content: args.content, + parent: args.parent, + tags: args.tags, + project: args.project, + context: args.context, + priority: args.priority, + startDate: args.startDate, + dueDate: args.dueDate, + heading: (args as any).heading, + completed: !!args.completed, + completedDate: args.completedDate, + }); + } + /** * Add a project task to quick capture */ @@ -1330,17 +1519,16 @@ export class WriteAPI { } // Save to quick capture - await saveCapture( - this.app, - line, - { - targetHeading: args.heading, - targetFile: this.plugin.settings.quickCapture?.targetFile, - targetType: this.plugin.settings.quickCapture?.targetType || "fixed", - appendToFile: "append" - } - ); - const filePath = this.plugin.settings.quickCapture?.targetFile || "quick-capture.md"; // Use the target file + await saveCapture(this.app, line, { + targetHeading: args.heading, + targetFile: this.plugin.settings.quickCapture?.targetFile, + targetType: + this.plugin.settings.quickCapture?.targetType || "fixed", + appendToFile: "append", + }); + const filePath = + this.plugin.settings.quickCapture?.targetFile || + "quick-capture.md"; // Use the target file // Notify about write operation emit(this.app, Events.WRITE_OPERATION_START, { path: filePath }); @@ -1625,11 +1813,7 @@ export class WriteAPI { /** * Add interval to date based on unit */ - private addInterval( - base: Date, - interval: number, - unit: string - ): number { + private addInterval(base: Date, interval: number, unit: string): number { const n = interval; switch (unit) { case "d": @@ -1893,29 +2077,44 @@ export class WriteAPI { // Check for block reference at the end const blockRefPattern = /\s*(\^[a-zA-Z0-9-]+)$/; const blockRefMatch = taskLine.match(blockRefPattern); - + if (blockRefMatch && blockRefMatch.index !== undefined) { // Insert before block reference const insertPos = blockRefMatch.index; - return taskLine.slice(0, insertPos) + " " + dateMetadata + taskLine.slice(insertPos); + return ( + taskLine.slice(0, insertPos) + + " " + + dateMetadata + + taskLine.slice(insertPos) + ); } - + // For completion date, add at the very end if (dateType === "completed") { return taskLine + " " + dateMetadata; } - + // For cancelled and start dates, insert after task content but before other metadata // Find where metadata starts (tags, dates, etc) - const metadataPattern = /([\s]+(🔺|⏫|🔼|🔽|⏬|🛫|⏳|📅|✅|🔁|\[[\w]+::|#|@|\+).*)?$/; + const metadataPattern = + /([\s]+(🔺|⏫|🔼|🔽|⏬|🛫|⏳|📅|✅|🔁|\[[\w]+::|#|@|\+).*)?$/; const metadataMatch = taskLine.match(metadataPattern); - - if (metadataMatch && metadataMatch.index !== undefined && metadataMatch[0].trim()) { + + if ( + metadataMatch && + metadataMatch.index !== undefined && + metadataMatch[0].trim() + ) { // Insert before existing metadata const insertPos = metadataMatch.index; - return taskLine.slice(0, insertPos) + " " + dateMetadata + taskLine.slice(insertPos); + return ( + taskLine.slice(0, insertPos) + + " " + + dateMetadata + + taskLine.slice(insertPos) + ); } - + // No metadata found, add at the end return taskLine + " " + dateMetadata; } @@ -2092,10 +2291,10 @@ export class WriteAPI { if (unit.endsWith("s")) { unit = unit.substring(0, unit.length - 1); } - + // Start from base date let nextDate = new Date(baseDate); - + // Keep advancing the date until it's in the future while (nextDate.getTime() <= todayStart.getTime()) { switch (unit) { @@ -2103,19 +2302,25 @@ export class WriteAPI { nextDate.setDate(nextDate.getDate() + interval); break; case "week": - nextDate.setDate(nextDate.getDate() + interval * 7); + nextDate.setDate( + nextDate.getDate() + interval * 7 + ); break; case "month": // Save the original day of month for proper month rolling const originalDay = baseDate.getDate(); - nextDate.setMonth(nextDate.getMonth() + interval); + nextDate.setMonth( + nextDate.getMonth() + interval + ); // If day has changed (e.g., Jan 31 -> Feb 28), adjust back if (nextDate.getDate() !== originalDay) { nextDate.setDate(0); // Go to last day of previous month } break; case "year": - nextDate.setFullYear(nextDate.getFullYear() + interval); + nextDate.setFullYear( + nextDate.getFullYear() + interval + ); break; default: // Default to days if unit is not recognized @@ -2123,10 +2328,10 @@ export class WriteAPI { break; } } - + // Normalize to midnight nextDate.setHours(0, 0, 0, 0); - + // Convert to UTC noon timestamp for consistent storage const year = nextDate.getFullYear(); const month = nextDate.getMonth(); @@ -2140,9 +2345,9 @@ export class WriteAPI { if (simpleMatch) { const interval = parseInt(simpleMatch[1]); const unit = simpleMatch[2]; - + let nextDate = new Date(baseDate); - + // Keep advancing the date until it's in the future while (nextDate.getTime() <= todayStart.getTime()) { switch (unit) { @@ -2161,14 +2366,16 @@ export class WriteAPI { } break; case "y": - nextDate.setFullYear(nextDate.getFullYear() + interval); + nextDate.setFullYear( + nextDate.getFullYear() + interval + ); break; } } - + // Normalize to midnight nextDate.setHours(0, 0, 0, 0); - + // Convert to UTC noon timestamp const year = nextDate.getFullYear(); const month = nextDate.getMonth(); @@ -2183,7 +2390,6 @@ export class WriteAPI { const month = tomorrow.getMonth(); const day = tomorrow.getDate(); return Date.UTC(year, month, day, 12, 0, 0); - } catch (error) { console.error("Error calculating next due date:", error); // Return tomorrow as fallback @@ -2196,4 +2402,4 @@ export class WriteAPI { return Date.UTC(year, month, day, 12, 0, 0); } } -} \ No newline at end of file +} diff --git a/src/dataflow/core/ConfigurableTaskParser.ts b/src/dataflow/core/ConfigurableTaskParser.ts index 165e7f9b..d1f13576 100644 --- a/src/dataflow/core/ConfigurableTaskParser.ts +++ b/src/dataflow/core/ConfigurableTaskParser.ts @@ -301,7 +301,8 @@ export class MarkdownTaskParser { private extractTaskLine( line: string ): [number, number, string, string] | null { - const trimmed = line.trim(); + // 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)) { @@ -772,8 +773,6 @@ export class MarkdownTaskParser { if (key && value) { // Debug: Log dataview metadata extraction for configured prefixes - - const before = content.substring(0, start); const after = content.substring(end + 1); @@ -944,85 +943,82 @@ export class MarkdownTaskParser { const detector = new ContextDetector(content); detector.detectAllProtectedRanges(); - const hashPos = detector.findNextUnprotectedHash(0); - if (hashPos === -1) return null; + const tryFrom = (startPos: number): [string, string, string] | null => { + const hashPos = detector.findNextUnprotectedHash(startPos); + if (hashPos === -1) return null; - // Enhanced word boundary check - const isWordStart = this.isValidTagStart(content, hashPos); - if (!isWordStart) { - // Try to find the next unprotected hash - const nextHashPos = detector.findNextUnprotectedHash(hashPos + 1); - if (nextHashPos === -1) return null; - - // Recursively check the remaining content - const remainingContent = content.substring(nextHashPos); - const recurseResult = this.extractTag(remainingContent); - if (recurseResult) { - const [tag, beforeTag, afterTag] = recurseResult; - return [ - tag, - content.substring(0, nextHashPos) + beforeTag, - afterTag, - ]; + // 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--; } - return null; - } - - 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); - - // Check if character is valid for tags: - // - 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 === "-" || - 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 (bsCount % 2 === 1) { + return tryFrom(hashPos + 1); } - } - 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]; - } + // Enhanced word boundary check + const isWordStart = this.isValidTagStart(content, hashPos); + if (!isWordStart) { + return tryFrom(hashPos + 1); + } - return null; + 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); } /** @@ -1763,9 +1759,9 @@ export class MarkdownTaskParser { // Early return if enhanced project features are disabled // Check if file metadata inheritance is enabled if (!this.config.fileMetadataInheritance?.enabled) { - // Parser should not perform file-level inheritance when disabled - // Leave any file/frontmatter merging to Augmentor when enabled - return {}; + // Inheritance disabled: preserve task-level metadata as-is + // (enhanced merging will be handled elsewhere when enabled) + return inherited; } // Check if frontmatter inheritance is enabled diff --git a/src/editor-extensions/date-time/date-manager.ts b/src/editor-extensions/date-time/date-manager.ts index 0362eb71..5c992680 100644 --- a/src/editor-extensions/date-time/date-manager.ts +++ b/src/editor-extensions/date-time/date-manager.ts @@ -71,7 +71,7 @@ function handleAutoDateManagerTransaction( return tr; } - const {doc, lineNumber, oldStatus, newStatus} = taskStatusChangeInfo; + const { doc, lineNumber, oldStatus, newStatus } = taskStatusChangeInfo; // Determine what date operations need to be performed const dateOperations = determineDateOperations( @@ -463,7 +463,9 @@ function applyDateOperations( let lineText = line.text; const changes = []; - console.log(`[AutoDateManager] applyDateOperations - Working with line: "${lineText}"`); + console.log( + `[AutoDateManager] applyDateOperations - Working with line: "${lineText}"` + ); for (const operation of operations) { if (operation.type === "add") { @@ -499,12 +501,18 @@ function applyDateOperations( const absolutePosition = line.from + insertPosition; - console.log(`[AutoDateManager] Inserting ${operation.dateType} date:`); + console.log( + `[AutoDateManager] Inserting ${operation.dateType} date:` + ); console.log(` - Insert position (relative): ${insertPosition}`); console.log(` - Line.from: ${line.from}`); console.log(` - Absolute position: ${absolutePosition}`); console.log(` - Date text: "${dateText}"`); - console.log(` - Text at insert point: "${lineText.substring(insertPosition)}"`); + console.log( + ` - Text at insert point: "${lineText.substring( + insertPosition + )}"` + ); changes.push({ from: absolutePosition, @@ -529,8 +537,8 @@ function applyDateOperations( operation.dateType === "completed" ? "completion" : operation.dateType === "cancelled" - ? "cancelled" - : "unknown"; + ? "cancelled" + : "unknown"; datePattern = new RegExp( `\\s*\\[${fieldName}::\\s*\\d{4}-\\d{2}-\\d{2}(?:\\s+\\d{2}:\\d{2}(?::\\d{2})?)?\\]`, "g" @@ -671,7 +679,8 @@ function findMetadataInsertPosition( // For cancelled date, we need special handling to insert after all metadata and start dates if (dateType === "cancelled") { - const useDataviewFormat = plugin.settings.preferMetadataFormat === "dataview"; + const useDataviewFormat = + plugin.settings.preferMetadataFormat === "dataview"; // Find the last occurrence of either: // 1. Start date marker (🛫 or [start::) @@ -700,11 +709,15 @@ function findMetadataInsertPosition( const commonStartEmojis = ["🚀", "🛫", "▶️", "⏰", "🏁"]; for (const emoji of commonStartEmojis) { const pattern = new RegExp( - `${escapeRegex(emoji)}\\s*\\d{4}-\\d{2}-\\d{2}(?:\\s+\\d{2}:\\d{2}(?::\\d{2})?)?` + `${escapeRegex( + emoji + )}\\s*\\d{4}-\\d{2}-\\d{2}(?:\\s+\\d{2}:\\d{2}(?::\\d{2})?)?` ); startDateMatch = lineText.match(pattern); if (startDateMatch) { - console.log(`[AutoDateManager] Found start date with emoji ${emoji}`); + console.log( + `[AutoDateManager] Found start date with emoji ${emoji}` + ); break; } } @@ -713,11 +726,15 @@ function findMetadataInsertPosition( if (startDateMatch && startDateMatch.index !== undefined) { position = startDateMatch.index + startDateMatch[0].length; startDateFound = true; - console.log(`[AutoDateManager] Found start date at index ${startDateMatch.index}, length ${startDateMatch[0].length}, new position: ${position}`); + console.log( + `[AutoDateManager] Found start date at index ${startDateMatch.index}, length ${startDateMatch[0].length}, new position: ${position}` + ); } } - console.log(`[AutoDateManager] Start date found: ${startDateFound}, position: ${position}`); + console.log( + `[AutoDateManager] Start date found: ${startDateFound}, position: ${position}` + ); if (!startDateFound) { // No start date found, find the end of all metadata @@ -728,7 +745,7 @@ function findMetadataInsertPosition( const metadataRegexes = [ /#[\w-]+/g, // Tags /\[[a-zA-Z]+::[^\]]*\]/g, // Dataview fields - /[📅🚀✅❌🛫▶️⏰🏁]\s*\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}(?::\d{2})?)?/g // Date markers (including all common start emojis) + /[📅🚀✅❌🛫▶️⏰🏁]\s*\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}(?::\d{2})?)?/g, // Date markers (including all common start emojis) ]; for (const regex of metadataRegexes) { @@ -747,7 +764,7 @@ function findMetadataInsertPosition( } // Ensure we have a space before the cancelled date - if (position > 0 && lineText[position - 1] !== ' ') { + if (position > 0 && lineText[position - 1] !== " ") { // No need to add space here, it will be added with the date } } else if (dateType === "completed") { @@ -759,7 +776,7 @@ function findMetadataInsertPosition( if (blockRef) { position = blockRef.index; // Remove trailing space if exists - if (position > 0 && lineText[position - 1] === ' ') { + if (position > 0 && lineText[position - 1] === " ") { position--; } } @@ -767,13 +784,13 @@ function findMetadataInsertPosition( // For start date, find the end of main content before metadata let contentEnd = position; let inBrackets = false; - const chars = lineText.slice(position).split(''); + const chars = lineText.slice(position).split(""); for (let i = 0; i < chars.length; i++) { const char = chars[i]; // Track if we're inside dataview brackets - if (char === '[' && !inBrackets) { + if (char === "[" && !inBrackets) { // Check if this is a dataview field like [due::...] const remainingText = lineText.slice(position + i); if (remainingText.match(/^\[[a-zA-Z]+::/)) { @@ -782,12 +799,22 @@ function findMetadataInsertPosition( } inBrackets = true; contentEnd = position + i + 1; - } else if (char === ']' && inBrackets) { + } else if (char === "]" && inBrackets) { inBrackets = false; contentEnd = position + i + 1; } else if (!inBrackets) { // Check for metadata markers when not inside brackets - if (char === '#' || char === '📅' || char === '🚀' || char === '✅' || char === '❌' || char === '🛫' || char === '▶️' || char === '⏰' || char === '🏁') { + if ( + char === "#" || + char === "📅" || + char === "🚀" || + char === "✅" || + char === "❌" || + char === "🛫" || + char === "▶️" || + char === "⏰" || + char === "🏁" + ) { // This is metadata, stop here break; } @@ -801,7 +828,7 @@ function findMetadataInsertPosition( position = contentEnd; // Trim any trailing whitespace from position - while (position > 0 && lineText[position - 1] === ' ') { + while (position > 0 && lineText[position - 1] === " ") { position--; } } @@ -810,12 +837,14 @@ function findMetadataInsertPosition( if (blockRef && position > blockRef.index) { position = blockRef.index; // Remove trailing space if it exists - if (position > 0 && lineText[position - 1] === ' ') { + if (position > 0 && lineText[position - 1] === " ") { position--; } } - console.log(`[AutoDateManager] Final insert position for ${dateType}: ${position}`); + console.log( + `[AutoDateManager] Final insert position for ${dateType}: ${position}` + ); return position; } @@ -835,7 +864,7 @@ function findCompletedDateInsertPosition( // Insert before the block reference ID // Remove trailing space if exists let position = blockRef.index; - if (position > 0 && lineText[position - 1] === ' ') { + if (position > 0 && lineText[position - 1] === " ") { position--; } return position; @@ -870,7 +899,7 @@ function detectBlockReference(text: string): { // - Can have optional whitespace before and after // - Block ID can contain letters, numbers, hyphens, and underscores // - Must be at the end of the line - const blockRefPattern = /\s*(\^[a-zA-Z0-9-]+)$/; + const blockRefPattern = /\s*(\^[A-Za-z0-9_-]+)\s*$/; const match = text.match(blockRefPattern); if (match && match.index !== undefined) { @@ -878,7 +907,7 @@ function detectBlockReference(text: string): { blockId: match[1], index: match.index, length: match[0].length, - fullMatch: match[0] + fullMatch: match[0], }; } @@ -898,10 +927,10 @@ function extractBlockReference(text: string): { if (blockRef) { const cleanedText = text.substring(0, blockRef.index).trimEnd(); - return {cleanedText, blockRef}; + return { cleanedText, blockRef }; } - return {cleanedText: text, blockRef: null}; + return { cleanedText: text, blockRef: null }; } /** diff --git a/src/editor-extensions/task-operations/status-cycler.ts b/src/editor-extensions/task-operations/status-cycler.ts index c8466cf3..26c54bde 100644 --- a/src/editor-extensions/task-operations/status-cycler.ts +++ b/src/editor-extensions/task-operations/status-cycler.ts @@ -66,34 +66,28 @@ function isValidTaskMarkerReplacement( return false; } - // Check if user actively selected text before replacement - const startSelection = tr.startState.selection.main; - const hasUserSelection = startSelection && !startSelection.empty; - - // If user had a selection that covers the replacement range, this is intentional replacement - if (hasUserSelection && startSelection && fromA >= startSelection.from && toA <= startSelection.to) { - console.log( - `User selection detected (${startSelection.from}-${startSelection.to}) covering replacement range (${fromA}-${toA}). Skipping automatic cycling as this is user-intended replacement.` - ); - return false; - } - // Get valid task status marks from plugin settings - const {marks} = getTaskStatusConfig(plugin); + const { marks } = getTaskStatusConfig(plugin); const validMarks = Object.values(marks); // Check if both the original and inserted characters are valid task status marks - const isOriginalValidMark = validMarks.includes(originalText) || originalText === ' '; - const isInsertedValidMark = validMarks.includes(insertedText) || insertedText === ' '; + const isOriginalValidMark = + validMarks.includes(originalText) || originalText === " "; + const isInsertedValidMark = + validMarks.includes(insertedText) || insertedText === " "; // If either character is not a valid task mark, this is likely manual input if (!isOriginalValidMark || !isInsertedValidMark) { return false; } - + // IMPORTANT: Prevent triggering when typing regular letters in an empty checkbox // If original is space and inserted is a letter (not a status mark), it's typing - if (originalText === ' ' && !validMarks.includes(insertedText) && insertedText !== ' ') { + if ( + originalText === " " && + !validMarks.includes(insertedText) && + insertedText !== " " + ) { // User is typing in an empty checkbox, not changing status return false; } @@ -181,9 +175,9 @@ export function findTaskStatusChanges( change.text === " " || (change.text === "" && (tr.startState.doc.sliceString( - change.fromA, - change.toA - ) === "\t" || + change.fromA, + change.toA + ) === "\t" || tr.startState.doc.sliceString( change.fromA, change.toA @@ -331,29 +325,40 @@ export function findTaskStatusChanges( // Check if our insertion point is at the mark position const markIndex = newLineText.indexOf("[") + 1; // Don't trigger when typing the "[" character itself, only when editing the status mark within brackets - // Also don't trigger when typing regular letters in empty checkbox + // Also don't trigger when typing regular letters in empty checkbox (unless it's a valid status mark like x, /, etc.) if ( pos === newLine.from + markIndex && insertedText !== "[" && - // Don't trigger for regular letters typed in empty checkbox (space status) - !(match[2] === ' ' && /[a-zA-Z]/.test(insertedText)) + !( + match[2] === " " && + /[a-zA-Z]/.test(insertedText) && + (plugin + ? !Object.values( + getTaskStatusConfig(plugin).marks + ).includes(insertedText) + : true) + ) ) { // Check if this is a replacement operation and validate if it's a valid task marker replacement if (fromA !== toA) { - const originalText = tr.startState.doc.sliceString(fromA, toA); + const originalText = tr.startState.doc.sliceString( + fromA, + toA + ); // Only perform validation if plugin is provided if (plugin) { - const isValidReplacement = isValidTaskMarkerReplacement( - tr, - fromA, - toA, - insertedText, - originalText, - pos, - newLineText, - plugin - ); + const isValidReplacement = + isValidTaskMarkerReplacement( + tr, + fromA, + toA, + insertedText, + originalText, + pos, + newLineText, + plugin + ); if (!isValidReplacement) { console.log( @@ -423,13 +428,13 @@ export function findTaskStatusChanges( wasCompleteTask: wasCompleteTask, tasksInfo: triggerByTasks ? { - isTaskChange: triggerByTasks, - originalFromA: fromA, - originalToA: toA, - originalFromB: fromB, - originalToB: toB, - originalInsertedText: insertedText, - } + isTaskChange: triggerByTasks, + originalFromA: fromA, + originalToA: toA, + originalFromB: fromB, + originalToB: toB, + originalInsertedText: insertedText, + } : null, }); } @@ -531,13 +536,17 @@ export function handleCycleCompleteStatusTransaction( } // Check if any task statuses were changed in this transaction - const taskStatusChanges = findTaskStatusChanges(tr, !!getTasksAPI(plugin), plugin); + const taskStatusChanges = findTaskStatusChanges( + tr, + !!getTasksAPI(plugin), + plugin + ); if (taskStatusChanges.length === 0) { return tr; } // Get the task cycle and marks from plugin settings - const {cycle, marks, excludeMarksFromCycle} = getTaskStatusConfig(plugin); + const { cycle, marks, excludeMarksFromCycle } = getTaskStatusConfig(plugin); const remainingCycle = cycle.filter( (state) => !excludeMarksFromCycle.includes(state) ); @@ -671,7 +680,7 @@ export function handleCycleCompleteStatusTransaction( // Process each task status change for (const taskStatusInfo of taskStatusChanges) { - const {position, currentMark, wasCompleteTask, tasksInfo} = + const { position, currentMark, wasCompleteTask, tasksInfo } = taskStatusInfo; if (tasksInfo?.isTaskChange) {