Skip to content

Commit 00f404a

Browse files
committed
fix(template): preserve wikilink lists in frontmatter
1 parent b74bee3 commit 00f404a

7 files changed

Lines changed: 276 additions & 18 deletions

src/engine/CaptureChoiceEngine.template-property-types.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ describe("CaptureChoiceEngine template property types", () => {
147147
};
148148
});
149149

150-
it("post-processes capture frontmatter arrays into YAML lists", async () => {
150+
it("writes a YAML-safe placeholder before post-processing capture frontmatter arrays", async () => {
151151
const targetPath = "Journal/Test.md";
152152
const createdContent: Record<string, string> = {};
153153
let writtenContent = "";
@@ -270,7 +270,9 @@ describe("CaptureChoiceEngine template property types", () => {
270270

271271
await engine.run();
272272

273-
expect(writtenContent).toContain("tags: foo,bar");
273+
// The raw file write only needs to stay YAML-parseable; processFrontMatter
274+
// applies the final structured array value afterward.
275+
expect(writtenContent).toContain("tags: []");
274276
expect(processFrontMatter).toHaveBeenCalledTimes(1);
275277
expect(appliedFrontmatter?.tags).toEqual(["foo", "bar"]);
276278
});

src/formatters/formatter-template-property-types.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,15 @@ describe('Formatter template property type inference', () => {
129129
expect(vars.get('projects')).toEqual(['project1', 'project2']);
130130
});
131131

132+
it('uses a YAML-safe placeholder for collected arrays before post-processing', async () => {
133+
(formatter as any).variables.set('tags', ['[[John Doe]]', '[[Jane Doe]]']);
134+
const output = await formatter.testFormat('---\ntags: {{VALUE:tags}}\n---');
135+
const vars = formatter.getAndClearTemplatePropertyVars();
136+
137+
expect(output).toBe('---\ntags: []\n---');
138+
expect(vars.get('tags')).toEqual(['[[John Doe]]', '[[Jane Doe]]']);
139+
});
140+
132141
it('ignores wiki links with commas to avoid incorrect splitting', async () => {
133142
(formatter as any).variables.set('source', '[[test, a]]');
134143
await formatter.testFormat('---\nsource: {{VALUE:source}}\n---');

src/formatters/formatter.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { TemplatePropertyCollector } from "../utils/TemplatePropertyCollector";
2727
import { settingsStore } from "../settingsStore";
2828
import { normalizeDateInput } from "../utils/dateAliases";
2929
import { transformCase } from "../utils/caseTransform";
30+
import { getYamlPlaceholder } from "../utils/yamlValues";
3031
import {
3132
parseAnonymousValueOptions,
3233
parseValueToken,
@@ -386,7 +387,7 @@ export abstract class Formatter {
386387
: rawValue;
387388

388389
// Offer this variable to the property collector for YAML post-processing
389-
this.propertyCollector.maybeCollect({
390+
const structuredYamlValue = this.propertyCollector.maybeCollect({
390391
input: output,
391392
matchStart: match.index,
392393
matchEnd: match.index + match[0].length,
@@ -395,8 +396,11 @@ export abstract class Formatter {
395396
featureEnabled: propertyTypesEnabled,
396397
});
397398

398-
// Always use string replacement initially
399-
const rawReplacement = this.getVariableValue(effectiveKey);
399+
// Keep the interim frontmatter YAML-parseable until post-processing
400+
// writes the real structured value back through Obsidian.
401+
const rawReplacement =
402+
getYamlPlaceholder(structuredYamlValue) ??
403+
this.getVariableValue(effectiveKey);
400404
const replacement = transformCase(rawReplacement, caseStyle);
401405

402406
// Replace in output and adjust regex position

src/utils/TemplatePropertyCollector.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
parseStructuredPropertyValueFromString,
55
type ParseOptions,
66
} from "./templatePropertyStringParser";
7+
import { isStructuredYamlValue } from "./yamlValues";
78

89
const PATH_SEPARATOR = "\u0000";
910

@@ -26,13 +27,13 @@ export class TemplatePropertyCollector {
2627
* Collects a variable for YAML post-processing when it is a complete value for a YAML key
2728
* and the raw value is a structured type (object/array/number/boolean/null).
2829
*/
29-
public maybeCollect(args: CollectArgs): void {
30+
public maybeCollect(args: CollectArgs): unknown | undefined {
3031
const { input, matchStart, matchEnd, rawValue, fallbackKey, featureEnabled } = args;
31-
if (!featureEnabled) return;
32+
if (!featureEnabled) return undefined;
3233
const yamlRange = findYamlFrontMatterRange(input);
3334
const context = getYamlContextForMatch(input, matchStart, matchEnd, yamlRange);
3435

35-
if (!context.isInYaml) return;
36+
if (!context.isInYaml) return undefined;
3637

3738
const lineContent = input.slice(context.lineStart, context.lineEnd);
3839
const trimmedLine = lineContent.trim();
@@ -49,7 +50,7 @@ export class TemplatePropertyCollector {
4950
propertyPath = this.findListParentPath(input, context.lineStart, context.baseIndent ?? "");
5051
}
5152

52-
if (!propertyPath || propertyPath.length === 0) return;
53+
if (!propertyPath || propertyPath.length === 0) return undefined;
5354
const effectiveKey = propertyPath[propertyPath.length - 1];
5455

5556
let structuredValue = rawValue;
@@ -61,17 +62,11 @@ export class TemplatePropertyCollector {
6162
}
6263
}
6364

64-
const isStructured =
65-
typeof structuredValue !== "string" &&
66-
(Array.isArray(structuredValue) ||
67-
(typeof structuredValue === "object" && structuredValue !== null) ||
68-
typeof structuredValue === "number" ||
69-
typeof structuredValue === "boolean" ||
70-
structuredValue === null);
71-
if (!isStructured) return;
65+
if (!isStructuredYamlValue(structuredValue)) return undefined;
7266

7367
const mapKey = propertyPath.join(PATH_SEPARATOR);
7468
this.map.set(mapKey, structuredValue);
69+
return structuredValue;
7570
}
7671

7772
/** Returns a copy and clears the collector. */

src/utils/yamlValues.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, it } from "vitest";
2-
import { coerceYamlValue } from "./yamlValues";
2+
import {
3+
coerceYamlValue,
4+
getYamlPlaceholder,
5+
isStructuredYamlValue,
6+
} from "./yamlValues";
37

48
describe("coerceYamlValue", () => {
59
it("converts @date:ISO to Date", () => {
@@ -23,3 +27,33 @@ describe("coerceYamlValue", () => {
2327
expect(coerceYamlValue(arr)).toBe(arr);
2428
});
2529
});
30+
31+
describe("isStructuredYamlValue", () => {
32+
it("accepts structured YAML property values", () => {
33+
expect(isStructuredYamlValue(["a"])).toBe(true);
34+
expect(isStructuredYamlValue({ a: 1 })).toBe(true);
35+
expect(isStructuredYamlValue(42)).toBe(true);
36+
expect(isStructuredYamlValue(false)).toBe(true);
37+
expect(isStructuredYamlValue(null)).toBe(true);
38+
});
39+
40+
it("rejects plain string placeholders", () => {
41+
expect(isStructuredYamlValue("hello")).toBe(false);
42+
expect(isStructuredYamlValue(undefined)).toBe(false);
43+
});
44+
});
45+
46+
describe("getYamlPlaceholder", () => {
47+
it("returns YAML-safe placeholders for structured values", () => {
48+
expect(getYamlPlaceholder(["a"])).toBe("[]");
49+
expect(getYamlPlaceholder({ a: 1 })).toBe("{}");
50+
expect(getYamlPlaceholder(42)).toBe("42");
51+
expect(getYamlPlaceholder(true)).toBe("true");
52+
expect(getYamlPlaceholder(null)).toBe("null");
53+
});
54+
55+
it("returns undefined for non-structured values", () => {
56+
expect(getYamlPlaceholder("hello")).toBeUndefined();
57+
expect(getYamlPlaceholder(undefined)).toBeUndefined();
58+
});
59+
});

src/utils/yamlValues.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,29 @@ export function coerceYamlValue(v: unknown): unknown {
3737
// Return original value for non-@date: strings and invalid dates
3838
return v;
3939
}
40+
41+
/**
42+
* Returns whether a value should be written back through Obsidian's YAML
43+
* processor as a structured property type instead of plain string text.
44+
*/
45+
export function isStructuredYamlValue(v: unknown): boolean {
46+
return typeof v !== "string" && (
47+
Array.isArray(v) ||
48+
(typeof v === "object" && v !== null) ||
49+
typeof v === "number" ||
50+
typeof v === "boolean" ||
51+
v === null
52+
);
53+
}
54+
55+
/**
56+
* Produces a YAML-parseable placeholder for structured values so frontmatter
57+
* stays valid until processFrontMatter rewrites the final value.
58+
*/
59+
export function getYamlPlaceholder(v: unknown): string | undefined {
60+
if (!isStructuredYamlValue(v)) return undefined;
61+
if (Array.isArray(v)) return "[]";
62+
if (v === null) return "null";
63+
if (typeof v === "object") return "{}";
64+
return String(v);
65+
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
2+
import {
3+
acquireVaultRunLock,
4+
captureFailureArtifacts,
5+
clearVaultRunLockMarker,
6+
createObsidianClient,
7+
createSandboxApi,
8+
} from "obsidian-e2e";
9+
import type {
10+
ObsidianClient,
11+
PluginHandle,
12+
SandboxApi,
13+
VaultRunLock,
14+
} from "obsidian-e2e";
15+
16+
const VAULT = "dev";
17+
const PLUGIN_ID = "quickadd";
18+
const WAIT_OPTS = { timeoutMs: 10_000, intervalMs: 200 };
19+
20+
let obsidian: ObsidianClient;
21+
let sandbox: SandboxApi;
22+
let qa: PluginHandle;
23+
let lock: VaultRunLock | undefined;
24+
25+
type QuickAddData = {
26+
choices: Record<string, unknown>[];
27+
migrations: Record<string, boolean>;
28+
enableTemplatePropertyTypes?: boolean;
29+
};
30+
31+
function templateChoice(id: string, templatePath: string, format: string) {
32+
return {
33+
id,
34+
name: id,
35+
type: "Template",
36+
command: false,
37+
templatePath,
38+
fileNameFormat: { enabled: true, format },
39+
folder: {
40+
enabled: false,
41+
folders: [],
42+
chooseWhenCreatingNote: false,
43+
createInSameFolderAsActiveFile: false,
44+
chooseFromSubfolders: false,
45+
},
46+
appendLink: false,
47+
openFile: false,
48+
fileOpening: {
49+
location: "tab",
50+
direction: "vertical",
51+
mode: "source",
52+
focus: false,
53+
},
54+
};
55+
}
56+
57+
function clearTestChoices(data: QuickAddData) {
58+
data.choices = data.choices.filter(
59+
(choice) => !String(choice.id ?? "").startsWith("__qa-test-1140-"),
60+
);
61+
}
62+
63+
async function seedTemplate(path: string, content: string) {
64+
await sandbox.write(path, content, {
65+
waitForContent: true,
66+
waitOptions: WAIT_OPTS,
67+
});
68+
}
69+
70+
async function runChoice(name: string, vars: Record<string, unknown>) {
71+
await obsidian.exec("quickadd:run", {
72+
choice: name,
73+
vars: JSON.stringify(vars),
74+
});
75+
}
76+
77+
async function runChoiceAndWaitForContent(
78+
name: string,
79+
vars: Record<string, unknown>,
80+
file: string,
81+
expected: string,
82+
) {
83+
await runChoice(name, vars);
84+
return sandbox.waitForContent(
85+
file,
86+
(content) => content.includes(expected),
87+
WAIT_OPTS,
88+
);
89+
}
90+
91+
beforeAll(async () => {
92+
obsidian = createObsidianClient({ vault: VAULT });
93+
await obsidian.verify();
94+
95+
lock = await acquireVaultRunLock({
96+
vaultName: VAULT,
97+
vaultPath: await obsidian.vaultPath(),
98+
});
99+
await lock.publishMarker(obsidian);
100+
101+
qa = obsidian.plugin(PLUGIN_ID);
102+
sandbox = await createSandboxApi({
103+
obsidian,
104+
sandboxRoot: "__obsidian_e2e__",
105+
testName: "template-property-links",
106+
});
107+
}, 30_000);
108+
109+
afterAll(async () => {
110+
await qa.restoreData();
111+
await qa.reload();
112+
await sandbox.cleanup();
113+
await clearVaultRunLockMarker(obsidian).catch(() => {});
114+
await lock?.release();
115+
}, 15_000);
116+
117+
beforeEach((ctx) => {
118+
ctx.onTestFailed(async () => {
119+
await captureFailureArtifacts(
120+
{ id: ctx.task.id, name: ctx.task.name },
121+
obsidian,
122+
{ plugin: qa, captureOnFailure: true },
123+
);
124+
});
125+
});
126+
127+
describe("issue 1140: list properties with links", () => {
128+
beforeAll(async () => {
129+
const root = sandbox.root;
130+
const templatePath = sandbox.path("issue-1140-template.md");
131+
132+
await seedTemplate(
133+
"issue-1140-template.md",
134+
[
135+
"---",
136+
"authors: {{VALUE:authors}}",
137+
"---",
138+
"",
139+
].join("\n"),
140+
);
141+
142+
await qa.data<QuickAddData>().patch((data) => {
143+
clearTestChoices(data);
144+
data.enableTemplatePropertyTypes = true;
145+
data.choices.push(
146+
templateChoice(
147+
"__qa-test-1140-single-link",
148+
templatePath,
149+
`${root}/qa-1140-single-link`,
150+
),
151+
templateChoice(
152+
"__qa-test-1140-multi-link",
153+
templatePath,
154+
`${root}/qa-1140-multi-link`,
155+
),
156+
);
157+
});
158+
159+
await qa.reload({ waitUntilReady: true });
160+
}, 15_000);
161+
162+
it("formats a single wikilink list item as a YAML list", async () => {
163+
const content = await runChoiceAndWaitForContent(
164+
"__qa-test-1140-single-link",
165+
{ authors: ["[[John Doe]]"] },
166+
"qa-1140-single-link.md",
167+
' - "[[John Doe]]"',
168+
);
169+
170+
expect(content).toContain("authors:");
171+
expect(content).toContain(' - "[[John Doe]]"');
172+
expect(content).not.toContain("authors: [[John Doe]]");
173+
});
174+
175+
it("formats multiple wikilinks as separate YAML list items", async () => {
176+
const content = await runChoiceAndWaitForContent(
177+
"__qa-test-1140-multi-link",
178+
{ authors: ["[[John Doe]]", "[[Jane Doe]]"] },
179+
"qa-1140-multi-link.md",
180+
' - "[[Jane Doe]]"',
181+
);
182+
183+
expect(content).toContain("authors:");
184+
expect(content).toContain(' - "[[John Doe]]"');
185+
expect(content).toContain(' - "[[Jane Doe]]"');
186+
expect(content).not.toContain("authors: [[John Doe]],[[Jane Doe]]");
187+
});
188+
});

0 commit comments

Comments
 (0)