mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
fix(write): preserve unknown dataview fields when updating task metadata
- Refactored metadata detection to only match recognized dataview keys - Fixed content preservation when inserting start/cancelled dates - Added strict trailing metadata pattern matching to avoid false positives - Preserved user-defined fields like [projt::new] during metadata updates - Added comprehensive tests for dataview and emoji format preservation
This commit is contained in:
parent
a787cee793
commit
01613e5d7d
2 changed files with 366 additions and 74 deletions
|
|
@ -96,3 +96,117 @@ describe("WriteAPI.updateTask preserves links in content when regenerating metad
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WriteAPI.insert start date preserves unknown DV fields in content", () => {
|
||||
it("keeps [projt::new] when inserting start (dataview format)", async () => {
|
||||
let fileContent = "- [ ] 123123 [projt::new]";
|
||||
const filePath = "StartDV.md";
|
||||
const fakeVault: any = {
|
||||
getAbstractFileByPath: (path: string) => ({ path }),
|
||||
read: async () => fileContent,
|
||||
modify: async (_f: any, s: string) => (fileContent = s),
|
||||
};
|
||||
const app = new App();
|
||||
const metadataCache = new MetadataCache();
|
||||
(app as any).workspace = {
|
||||
...(app as any).workspace,
|
||||
trigger: jest.fn(),
|
||||
on: jest.fn(() => ({ unload: () => {} })),
|
||||
};
|
||||
const plugin: any = {
|
||||
settings: {
|
||||
preferMetadataFormat: "dataview",
|
||||
projectTagPrefix: { tasks: "project", dataview: "project" },
|
||||
contextTagPrefix: { tasks: "@", dataview: "context" },
|
||||
taskStatuses: { completed: "x" },
|
||||
autoDateManager: {
|
||||
manageStartDate: true,
|
||||
manageCancelledDate: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const getTaskById = async (id: string): Promise<Task | null> =>
|
||||
id === "2"
|
||||
? ({
|
||||
id: "2",
|
||||
content: "123123 [projt::new]",
|
||||
filePath,
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: fileContent,
|
||||
metadata: {} as any,
|
||||
} as Task)
|
||||
: null;
|
||||
const writeAPI = new WriteAPI(
|
||||
app as any,
|
||||
fakeVault,
|
||||
metadataCache as any,
|
||||
plugin,
|
||||
getTaskById
|
||||
);
|
||||
const res = await writeAPI.updateTask({
|
||||
taskId: "2",
|
||||
updates: { status: ">" },
|
||||
});
|
||||
expect(res.success).toBe(true);
|
||||
expect(fileContent).toContain("- [>] 123123 [projt::new]");
|
||||
expect(fileContent).toMatch(/\[start::\s*\d{4}-\d{2}-\d{2}\]/);
|
||||
});
|
||||
|
||||
it("keeps [projt::new] when inserting start (emoji format)", async () => {
|
||||
let fileContent = "- [ ] 123123 [projt::new]";
|
||||
const filePath = "StartEmoji.md";
|
||||
const fakeVault: any = {
|
||||
getAbstractFileByPath: (path: string) => ({ path }),
|
||||
read: async () => fileContent,
|
||||
modify: async (_f: any, s: string) => (fileContent = s),
|
||||
};
|
||||
const app = new App();
|
||||
const metadataCache = new MetadataCache();
|
||||
(app as any).workspace = {
|
||||
...(app as any).workspace,
|
||||
trigger: jest.fn(),
|
||||
on: jest.fn(() => ({ unload: () => {} })),
|
||||
};
|
||||
const plugin: any = {
|
||||
settings: {
|
||||
preferMetadataFormat: "tasks",
|
||||
projectTagPrefix: { tasks: "project", dataview: "project" },
|
||||
contextTagPrefix: { tasks: "@", dataview: "context" },
|
||||
taskStatuses: { completed: "x" },
|
||||
autoDateManager: {
|
||||
manageStartDate: true,
|
||||
manageCancelledDate: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const getTaskById = async (id: string): Promise<Task | null> =>
|
||||
id === "3"
|
||||
? ({
|
||||
id: "3",
|
||||
content: "123123 [projt::new]",
|
||||
filePath,
|
||||
line: 0,
|
||||
completed: false,
|
||||
status: " ",
|
||||
originalMarkdown: fileContent,
|
||||
metadata: {} as any,
|
||||
} as Task)
|
||||
: null;
|
||||
const writeAPI = new WriteAPI(
|
||||
app as any,
|
||||
fakeVault,
|
||||
metadataCache as any,
|
||||
plugin,
|
||||
getTaskById
|
||||
);
|
||||
const res = await writeAPI.updateTask({
|
||||
taskId: "3",
|
||||
updates: { status: ">" },
|
||||
});
|
||||
expect(res.success).toBe(true);
|
||||
expect(fileContent).toContain("- [>] 123123 [projt::new]");
|
||||
expect(fileContent).toMatch(/🛫\s*\d{4}-\d{2}-\d{2}/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -439,12 +439,52 @@ export class WriteAPI {
|
|||
);
|
||||
if (prefixMatch) {
|
||||
const prefix = prefixMatch[1];
|
||||
// Find where metadata starts (look for emoji markers or dataview fields)
|
||||
const metadataMatch = taskLine.match(
|
||||
/([\s]+(🔺|⏫|🔼|🔽|⏬|🛫|⏳|📅|✅|🔁|\[[\w]+::|#|@|\+).*)?$/
|
||||
);
|
||||
const metadata = metadataMatch ? metadataMatch[0] : "";
|
||||
taskLine = `${prefix}${args.updates.content}${metadata}`;
|
||||
// Preserve trailing metadata (strict: trailing-only, recognized keys; links/code sanitized)
|
||||
const afterPrefix = taskLine.substring(prefix.length);
|
||||
const sanitized2 = afterPrefix
|
||||
.replace(/\[\[[^\]]*\]\]/g, (m) => "x".repeat(m.length))
|
||||
.replace(/\[[^\]]*\]\([^\)]*\)/g, (m) =>
|
||||
"x".repeat(m.length)
|
||||
)
|
||||
.replace(/`[^`]*`/g, (m) => "x".repeat(m.length));
|
||||
const esc2 = (s: string) =>
|
||||
s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const projectKey2 =
|
||||
this.plugin.settings.projectTagPrefix?.dataview ||
|
||||
"project";
|
||||
const contextKey2 =
|
||||
this.plugin.settings.contextTagPrefix?.dataview ||
|
||||
"context";
|
||||
const dvKeysGroup2 = [
|
||||
"tags",
|
||||
esc2(projectKey2),
|
||||
esc2(contextKey2),
|
||||
"priority",
|
||||
"repeat",
|
||||
"start",
|
||||
"scheduled",
|
||||
"due",
|
||||
"completion",
|
||||
"cancelled",
|
||||
"onCompletion",
|
||||
"dependsOn",
|
||||
"id",
|
||||
].join("|");
|
||||
const baseEmoji2 = "(🔺|⏫|🔼|🔽|⏬|🛫|⏳|📅|✅|🔁)";
|
||||
const dvFieldToken2 = `\\[(?:${dvKeysGroup2})\\s*::[^\\]]*\\]`;
|
||||
const tagToken2 = "#[A-Za-z][\\w/-]*";
|
||||
const atToken2 = "@[A-Za-z][\\w/-]*";
|
||||
const plusToken2 = "\\+[A-Za-z][\\w/-]*";
|
||||
const emojiSeg2 = `(?:${baseEmoji2}[^\\n]*)`;
|
||||
const token2 = `(?:${emojiSeg2}|${dvFieldToken2}|${tagToken2}|${atToken2}|${plusToken2})`;
|
||||
const trailing2 = new RegExp(`(?:\\s+${token2})+$`);
|
||||
const tm2 = sanitized2.match(trailing2);
|
||||
const trailingMeta = tm2
|
||||
? afterPrefix.slice(
|
||||
afterPrefix.length - (tm2[0]?.length || 0)
|
||||
)
|
||||
: "";
|
||||
taskLine = `${prefix}${args.updates.content}${trailingMeta}`;
|
||||
}
|
||||
} else if (args.updates.content === "") {
|
||||
// Log warning if attempting to clear content
|
||||
|
|
@ -481,68 +521,133 @@ export class WriteAPI {
|
|||
taskLine = `${taskLine} ${completionMeta}`;
|
||||
}
|
||||
} else {
|
||||
// Remove existing metadata and regenerate from merged values
|
||||
// First, extract the checkbox prefix
|
||||
const checkboxMatch = taskLine.match(
|
||||
/^(\s*[-*+]\s*\[[^\]]*\]\s*)/
|
||||
// Only regenerate trailing metadata when updates include managed keys.
|
||||
const managedKeys = new Set([
|
||||
"tags",
|
||||
"project",
|
||||
"context",
|
||||
"priority",
|
||||
"repeat",
|
||||
"startDate",
|
||||
"dueDate",
|
||||
"scheduledDate",
|
||||
"recurrence",
|
||||
"completedDate",
|
||||
"onCompletion",
|
||||
"dependsOn",
|
||||
"id",
|
||||
]);
|
||||
const hasManagedUpdate = mdKeys.some((k) =>
|
||||
managedKeys.has(k)
|
||||
);
|
||||
if (checkboxMatch) {
|
||||
const checkboxPrefix = checkboxMatch[1];
|
||||
const afterCheckbox = taskLine.substring(
|
||||
checkboxPrefix.length
|
||||
if (!hasManagedUpdate) {
|
||||
// Ignore unknown metadata-only updates to avoid stripping user content like [projt::new]
|
||||
// and keep taskLine as-is.
|
||||
} else {
|
||||
// Remove existing metadata and regenerate from merged values
|
||||
// First, extract the checkbox prefix
|
||||
const checkboxMatch = taskLine.match(
|
||||
/^(\s*[-*+]\s*\[[^\]]*\]\s*)/
|
||||
);
|
||||
if (checkboxMatch) {
|
||||
const checkboxPrefix = checkboxMatch[1];
|
||||
const afterCheckbox = taskLine.substring(
|
||||
checkboxPrefix.length
|
||||
);
|
||||
|
||||
// Find where metadata starts (look for emoji markers or dataview fields)
|
||||
// Updated pattern to avoid matching wiki links [[...]] or markdown links [text](url)
|
||||
// To avoid false positives, sanitize out wiki links [[...]], markdown links [text](url), and inline code `...`
|
||||
const sanitized = afterCheckbox
|
||||
// Use non-whitespace placeholders to prevent \s+ from consuming across links/code
|
||||
.replace(/\[\[[^\]]*\]\]/g, (m) =>
|
||||
"x".repeat(m.length)
|
||||
)
|
||||
.replace(/\[[^\]]*\]\([^\)]*\)/g, (m) =>
|
||||
"x".repeat(m.length)
|
||||
)
|
||||
.replace(/`[^`]*`/g, (m) => "x".repeat(m.length));
|
||||
// Find where metadata starts (look for emoji markers or dataview fields)
|
||||
// Updated pattern to avoid matching wiki links [[...]] or markdown links [text](url)
|
||||
// To avoid false positives, sanitize out wiki links [[...]], markdown links [text](url), and inline code `...`
|
||||
const sanitized = afterCheckbox
|
||||
// Use non-whitespace placeholders to prevent \s+ from consuming across links/code
|
||||
.replace(/\[\[[^\]]*\]\]/g, (m) =>
|
||||
"x".repeat(m.length)
|
||||
)
|
||||
.replace(/\[[^\]]*\]\([^\)]*\)/g, (m) =>
|
||||
"x".repeat(m.length)
|
||||
)
|
||||
.replace(/`[^`]*`/g, (m) =>
|
||||
"x".repeat(m.length)
|
||||
);
|
||||
|
||||
const metadataMatch = sanitized.match(
|
||||
/([\s]+(🔺|⏫|🔼|🔽|⏬|🛫|⏳|📅|✅|🔁|\[(?!\[\])[^\[\]\r\n:]+::|#[A-Za-z][\w/-]*|@[A-Za-z][\w/-]*|\+[A-Za-z][\w/-]*).*)?$/
|
||||
);
|
||||
// Build strict trailing-metadata matcher using recognized Dataview keys
|
||||
const esc = (s: string) =>
|
||||
s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const projectKey =
|
||||
this.plugin.settings.projectTagPrefix
|
||||
?.dataview || "project";
|
||||
const contextKey =
|
||||
this.plugin.settings.contextTagPrefix
|
||||
?.dataview || "context";
|
||||
const dvKeysGroup = [
|
||||
"tags",
|
||||
esc(projectKey),
|
||||
esc(contextKey),
|
||||
"priority",
|
||||
"repeat",
|
||||
"start",
|
||||
"scheduled",
|
||||
"due",
|
||||
"completion",
|
||||
"cancelled",
|
||||
"onCompletion",
|
||||
"dependsOn",
|
||||
"id",
|
||||
].join("|");
|
||||
const baseEmoji = "(🔺|⏫|🔼|🔽|⏬|🛫|⏳|📅|✅|🔁)";
|
||||
const dvFieldToken = `\\[(?:${dvKeysGroup})\\s*::[^\\]]*\\]`;
|
||||
const tagToken = "#[A-Za-z][\\w/-]*";
|
||||
const atToken = "@[A-Za-z][\\w/-]*";
|
||||
const plusToken = "\\+[A-Za-z][\\w/-]*";
|
||||
const emojiSeg = `(?:${baseEmoji}[^\\n]*)`;
|
||||
const token = `(?:${emojiSeg}|${dvFieldToken}|${tagToken}|${atToken}|${plusToken})`;
|
||||
const trailing = new RegExp(`(?:\\s+${token})+$`);
|
||||
const tm = sanitized.match(trailing);
|
||||
|
||||
// Extract the task content (everything before metadata)
|
||||
const taskContent =
|
||||
metadataMatch && metadataMatch.index !== undefined
|
||||
// Extract the task content (everything before trailing metadata)
|
||||
const taskContent = tm
|
||||
? afterCheckbox
|
||||
.substring(0, metadataMatch.index)
|
||||
.substring(
|
||||
0,
|
||||
sanitized.length -
|
||||
(tm[0]?.length || 0)
|
||||
)
|
||||
.trim()
|
||||
: afterCheckbox.trim();
|
||||
|
||||
const mergedMd = {
|
||||
...originalTask.metadata,
|
||||
...args.updates.metadata,
|
||||
} as any;
|
||||
const completedFlag =
|
||||
args.updates.completed !== undefined
|
||||
? !!args.updates.completed
|
||||
: !!originalTask.completed;
|
||||
const newMetadata = this.generateMetadata({
|
||||
tags: mergedMd.tags,
|
||||
project: mergedMd.project,
|
||||
context: mergedMd.context,
|
||||
priority: mergedMd.priority,
|
||||
startDate: mergedMd.startDate,
|
||||
dueDate: mergedMd.dueDate,
|
||||
scheduledDate: mergedMd.scheduledDate,
|
||||
recurrence: mergedMd.recurrence,
|
||||
completed: completedFlag,
|
||||
completedDate: mergedMd.completedDate,
|
||||
onCompletion: mergedMd.onCompletion,
|
||||
dependsOn: mergedMd.dependsOn,
|
||||
id: mergedMd.id,
|
||||
});
|
||||
taskLine = `${checkboxPrefix}${taskContent}${
|
||||
newMetadata ? ` ${newMetadata}` : ""
|
||||
}`;
|
||||
console.log(
|
||||
"edit content",
|
||||
taskContent,
|
||||
afterCheckbox
|
||||
);
|
||||
|
||||
const mergedMd = {
|
||||
...originalTask.metadata,
|
||||
...args.updates.metadata,
|
||||
} as any;
|
||||
const completedFlag =
|
||||
args.updates.completed !== undefined
|
||||
? !!args.updates.completed
|
||||
: !!originalTask.completed;
|
||||
const newMetadata = this.generateMetadata({
|
||||
tags: mergedMd.tags,
|
||||
project: mergedMd.project,
|
||||
context: mergedMd.context,
|
||||
priority: mergedMd.priority,
|
||||
startDate: mergedMd.startDate,
|
||||
dueDate: mergedMd.dueDate,
|
||||
scheduledDate: mergedMd.scheduledDate,
|
||||
recurrence: mergedMd.recurrence,
|
||||
completed: completedFlag,
|
||||
completedDate: mergedMd.completedDate,
|
||||
onCompletion: mergedMd.onCompletion,
|
||||
dependsOn: mergedMd.dependsOn,
|
||||
id: mergedMd.id,
|
||||
});
|
||||
taskLine = `${checkboxPrefix}${taskContent}${
|
||||
newMetadata ? ` ${newMetadata}` : ""
|
||||
}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1069,15 +1174,62 @@ export class WriteAPI {
|
|||
);
|
||||
if (prefixMatch) {
|
||||
const prefix = prefixMatch[1];
|
||||
const metadataMatch = taskLine.match(
|
||||
/([\s]+(🔺|⏫|🔼|🔽|⏬|🛫|⏳|📅|✅|🔁|\[[\w]+::|#|@|\+).*)?$/
|
||||
// Preserve trailing metadata (strict trailing-only, recognized keys)
|
||||
const afterPrefix2 = taskLine.substring(
|
||||
prefix.length
|
||||
);
|
||||
const metadata = metadataMatch
|
||||
? metadataMatch[0]
|
||||
const sanitized3 = afterPrefix2
|
||||
.replace(/\[\[[^\]]*\]\]/g, (m) =>
|
||||
"x".repeat(m.length)
|
||||
)
|
||||
.replace(/\[[^\]]*\]\([^\)]*\)/g, (m) =>
|
||||
"x".repeat(m.length)
|
||||
)
|
||||
.replace(/`[^`]*`/g, (m) =>
|
||||
"x".repeat(m.length)
|
||||
);
|
||||
const esc3 = (s: string) =>
|
||||
s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const projectKey3 =
|
||||
this.plugin.settings.projectTagPrefix
|
||||
?.dataview || "project";
|
||||
const contextKey3 =
|
||||
this.plugin.settings.contextTagPrefix
|
||||
?.dataview || "context";
|
||||
const dvKeysGroup3 = [
|
||||
"tags",
|
||||
esc3(projectKey3),
|
||||
esc3(contextKey3),
|
||||
"priority",
|
||||
"repeat",
|
||||
"start",
|
||||
"scheduled",
|
||||
"due",
|
||||
"completion",
|
||||
"cancelled",
|
||||
"onCompletion",
|
||||
"dependsOn",
|
||||
"id",
|
||||
].join("|");
|
||||
const baseEmoji3 =
|
||||
"(🔺|⏫|🔼|🔽|⏬|🛫|⏳|📅|✅|🔁)";
|
||||
const dvFieldToken3 = `\\[(?:${dvKeysGroup3})\\s*::[^\\]]*\\]`;
|
||||
const tagToken3 = "#[A-Za-z][\\w/-]*";
|
||||
const atToken3 = "@[A-Za-z][\\w/-]*";
|
||||
const plusToken3 = "\\+[A-Za-z][\\w/-]*";
|
||||
const emojiSeg3 = `(?:${baseEmoji3}[^\\n]*)`;
|
||||
const token3 = `(?:${emojiSeg3}|${dvFieldToken3}|${tagToken3}|${atToken3}|${plusToken3})`;
|
||||
const trailing3 = new RegExp(`(?:\\s+${token3})+$`);
|
||||
const tm3 = sanitized3.match(trailing3);
|
||||
const trailingMeta2 = tm3
|
||||
? afterPrefix2.slice(
|
||||
afterPrefix2.length -
|
||||
(tm3[0]?.length || 0)
|
||||
)
|
||||
: "";
|
||||
lines[
|
||||
lineNum
|
||||
] = `${prefix}${newContent}${metadata}`;
|
||||
] = `${prefix}${newContent}${trailingMeta2}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2126,17 +2278,43 @@ export class WriteAPI {
|
|||
|
||||
// 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 metadataMatch = taskLine.match(metadataPattern);
|
||||
// Detect strict trailing metadata (recognized keys) on full line
|
||||
const sanitizedFull = taskLine
|
||||
.replace(/\[\[[^\]]*\]\]/g, (m) => "x".repeat(m.length))
|
||||
.replace(/\[[^\]]*\]\([^\)]*\)/g, (m) => "x".repeat(m.length))
|
||||
.replace(/`[^`]*`/g, (m) => "x".repeat(m.length));
|
||||
const escD = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const projectKeyD =
|
||||
this.plugin.settings.projectTagPrefix?.dataview || "project";
|
||||
const contextKeyD =
|
||||
this.plugin.settings.contextTagPrefix?.dataview || "context";
|
||||
const dvKeysGroupD = [
|
||||
"tags",
|
||||
escD(projectKeyD),
|
||||
escD(contextKeyD),
|
||||
"priority",
|
||||
"repeat",
|
||||
"start",
|
||||
"scheduled",
|
||||
"due",
|
||||
"completion",
|
||||
"cancelled",
|
||||
"onCompletion",
|
||||
"dependsOn",
|
||||
"id",
|
||||
].join("|");
|
||||
const baseEmojiD = "(🔺|⏫|🔼|🔽|⏬|🛫|⏳|📅|✅|🔁)";
|
||||
const dvFieldTokenD = `\\[(?:${dvKeysGroupD})\\s*::[^\\]]*\\]`;
|
||||
const tagTokenD = "#[A-Za-z][\\w/-]*";
|
||||
const atTokenD = "@[A-Za-z][\\w/-]*";
|
||||
const plusTokenD = "\\+[A-Za-z][\\w/-]*";
|
||||
const emojiSegD = `(?:${baseEmojiD}[^\\n]*)`;
|
||||
const tokenD = `(?:${emojiSegD}|${dvFieldTokenD}|${tagTokenD}|${atTokenD}|${plusTokenD})`;
|
||||
const trailingD = new RegExp(`(?:\\s+${tokenD})+$`);
|
||||
const tmD = sanitizedFull.match(trailingD);
|
||||
|
||||
if (
|
||||
metadataMatch &&
|
||||
metadataMatch.index !== undefined &&
|
||||
metadataMatch[0].trim()
|
||||
) {
|
||||
// Insert before existing metadata
|
||||
const insertPos = metadataMatch.index;
|
||||
if (tmD) {
|
||||
const insertPos = taskLine.length - (tmD[0]?.length || 0);
|
||||
return (
|
||||
taskLine.slice(0, insertPos) +
|
||||
" " +
|
||||
|
|
|
|||
Loading…
Reference in a new issue