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
This commit is contained in:
Quorafind 2025-09-16 15:29:56 +08:00
parent c75f207a2d
commit cf51bdb758
9 changed files with 620 additions and 238 deletions

View file

@ -2,6 +2,8 @@ module.exports = {
preset: "ts-jest",
testEnvironment: "jsdom",
testMatch: ["**/__tests__/**/*.test.ts"],
testPathIgnorePatterns: ["<rootDir>/.conductor/"],
modulePathIgnorePatterns: ["<rootDir>/.conductor/"],
moduleNameMapper: {
"^obsidian$": "<rootDir>/src/__mocks__/obsidian.ts",
"^moment$": "<rootDir>/src/__mocks__/moment.js",

View file

@ -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<T>(field: any /* StateField<T> | Facet<any, T> */): T | undefined {
return this._fields.get(field);
}

View file

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

View file

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

View file

@ -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, " ");

View file

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

View file

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

View file

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

View file

@ -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) {