Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified bun.lockb
Binary file not shown.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"jsdom": "^26.1.0",
"obsidian": "1.11.4",
"obsidian-dataview": "^0.5.68",
"obsidian-e2e": "0.4.0",
"obsidian-e2e": "0.6.0",
"semantic-release": "^24.2.6",
"svelte": "^4.2.19",
"svelte-check": "^3.8.6",
Expand Down
3 changes: 3 additions & 0 deletions src/engine/CaptureChoiceEngine.selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ vi.mock("../formatters/captureChoiceFormatter", () => ({
}
setDestinationFile() {}
setDestinationSourcePath() {}
async withTemplatePropertyCollection<T>(work: () => Promise<T>) {
return await work();
}
async formatContentOnly(content: string) {
return content;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ describe("CaptureChoiceEngine template property types", () => {
};
});

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

await engine.run();

expect(writtenContent).toContain("tags: foo,bar");
// The raw file write only needs to stay YAML-parseable; processFrontMatter
// applies the final structured array value afterward.
expect(writtenContent).toContain("tags: []");
expect(processFrontMatter).toHaveBeenCalledTimes(1);
expect(appliedFrontmatter?.tags).toEqual(["foo", "bar"]);
});
Expand Down
35 changes: 22 additions & 13 deletions src/engine/CaptureChoiceEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,18 +628,22 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
this.formatter.setDestinationFile(file);

// First format pass...
const formatted = await this.formatter.formatContentOnly(content);
const formatted = await this.formatter.withTemplatePropertyCollection(
() => this.formatter.formatContentOnly(content),
);
this.mergeCapturePropertyVars(this.formatter.getAndClearTemplatePropertyVars());

const fileContent: string = await this.app.vault.read(file);
// Second format pass, with the file content... User input (long running) should have been captured during first pass
// So this pass is to insert the formatted capture value into the file content, depending on the user's settings
const formattedFileContent: string =
await this.formatter.formatContentWithFile(
formatted,
this.choice,
fileContent,
file,
await this.formatter.withTemplatePropertyCollection(() =>
this.formatter.formatContentWithFile(
formatted,
this.choice,
fileContent,
file,
),
);
this.mergeCapturePropertyVars(this.formatter.getAndClearTemplatePropertyVars());

Expand Down Expand Up @@ -685,7 +689,9 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
// This mirrors the logic used when the target file already exists and prevents the timing issue
// where templater would run before the {{value}} placeholder is substituted (Issue #809).
const formattedCaptureContent: string =
await this.formatter.formatContentOnly(captureContent);
await this.formatter.withTemplatePropertyCollection(() =>
this.formatter.formatContentOnly(captureContent),
);
this.mergeCapturePropertyVars(this.formatter.getAndClearTemplatePropertyVars());

let fileContent = "";
Expand Down Expand Up @@ -743,12 +749,15 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
// after the initial Templater run on newly created files.
const updatedFileContent: string = await this.app.vault.read(file);
// Second formatting pass: embed the already-resolved capture content into the newly created file
const newFileContent: string = await this.formatter.formatContentWithFile(
formattedCaptureContent,
this.choice,
updatedFileContent,
file,
);
const newFileContent: string =
await this.formatter.withTemplatePropertyCollection(() =>
this.formatter.formatContentWithFile(
formattedCaptureContent,
this.choice,
updatedFileContent,
file,
),
);
this.mergeCapturePropertyVars(this.formatter.getAndClearTemplatePropertyVars());

return { file, newFileContent, captureContent: formattedCaptureContent };
Expand Down
4 changes: 2 additions & 2 deletions src/engine/SingleTemplateEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ export class SingleTemplateEngine extends TemplateEngine {
log.logError(`Template ${this.templatePath} not found.`);
}

templateContent = await this.formatter.formatFileContent(
templateContent
templateContent = await this.formatter.withTemplatePropertyCollection(
() => this.formatter.formatFileContent(templateContent),
);

return templateContent;
Expand Down
3 changes: 3 additions & 0 deletions src/engine/TemplateChoiceEngine.notice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ vi.mock("../formatters/completeFormatter", () => {
async formatFileContent(...args: unknown[]) {
return await formatFileContentMock(...args);
}
async withTemplatePropertyCollection<T>(work: () => Promise<T>) {
return await work();
}
getAndClearTemplatePropertyVars() {
return new Map<string, unknown>();
}
Expand Down
8 changes: 6 additions & 2 deletions src/engine/TemplateEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,9 @@ export abstract class TemplateEngine extends QuickAddEngine {
this.formatter.setTitle(fileBasename);

const formattedTemplateContent: string =
await this.formatter.formatFileContent(templateContent);
await this.formatter.withTemplatePropertyCollection(() =>
this.formatter.formatFileContent(templateContent),
);

// Get template variables before creating the file
const templateVars = this.formatter.getAndClearTemplatePropertyVars();
Expand Down Expand Up @@ -537,7 +539,9 @@ export abstract class TemplateEngine extends QuickAddEngine {
this.formatter.setTitle(fileBasename);

const formattedTemplateContent: string =
await this.formatter.formatFileContent(templateContent);
await this.formatter.withTemplatePropertyCollection(() =>
this.formatter.formatFileContent(templateContent),
);

// Get template variables before modifying the file
const templateVars = this.formatter.getAndClearTemplatePropertyVars();
Expand Down
3 changes: 3 additions & 0 deletions src/engine/templateEngine-title.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ vi.mock('../formatters/completeFormatter', () => {
return {
setTitle: vi.fn((t: string) => { title = t; }),
getTitle: () => title,
withTemplatePropertyCollection: vi.fn(
async (work: () => Promise<unknown>) => await work(),
),
formatFileContent: vi.fn(async (content: string) => {
// Simple mock that replaces {{title}} with the stored title
return content.replace(/{{title}}/gi, title);
Expand Down
59 changes: 53 additions & 6 deletions src/formatters/formatter-template-property-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ class TemplatePropertyTypesTestFormatter extends Formatter {
}

protected getVariableValue(variableName: string): string {
const value = this.variables.get(variableName);
return typeof value === 'string' ? value : '';
return (this.variables.get(variableName) as string) ?? '';
}

protected suggestForValue(
Expand Down Expand Up @@ -85,6 +84,14 @@ class TemplatePropertyTypesTestFormatter extends Formatter {
public async testFormat(input: string): Promise<string> {
return await this.format(input);
}

public async testFormatWithTemplatePropertyCollection(
input: string,
): Promise<string> {
return await this.withTemplatePropertyCollection(() =>
this.testFormat(input),
);
}
}

describe('Formatter template property type inference', () => {
Expand All @@ -110,28 +117,68 @@ describe('Formatter template property type inference', () => {

it('collects comma-separated values as YAML arrays', async () => {
(formatter as any).variables.set('tags', 'tag1, tag2, awesomeproject');
await formatter.testFormat('---\ntags: {{VALUE:tags}}\n---');
await formatter.testFormatWithTemplatePropertyCollection(
'---\ntags: {{VALUE:tags}}\n---',
);
const vars = formatter.getAndClearTemplatePropertyVars();
expect(vars.get('tags')).toEqual(['tag1', 'tag2', 'awesomeproject']);
});

it('does not collect comma text for scalar properties', async () => {
(formatter as any).variables.set('description', 'Hello, world');
await formatter.testFormat('---\ndescription: {{VALUE:description}}\n---');
await formatter.testFormatWithTemplatePropertyCollection(
'---\ndescription: {{VALUE:description}}\n---',
);
const vars = formatter.getAndClearTemplatePropertyVars();
expect(vars.has('description')).toBe(false);
});

it('collects bullet list values as YAML arrays', async () => {
(formatter as any).variables.set('projects', '- project1\n- project2');
await formatter.testFormat('---\nprojects: {{VALUE:projects}}\n---');
await formatter.testFormatWithTemplatePropertyCollection(
'---\nprojects: {{VALUE:projects}}\n---',
);
const vars = formatter.getAndClearTemplatePropertyVars();
expect(vars.get('projects')).toEqual(['project1', 'project2']);
});

it('preserves raw structured replacements outside template property collection', async () => {
(formatter as any).variables.set('tags', ['[[John Doe]]', '[[Jane Doe]]']);
const output = await formatter.testFormat('---\ntags: {{VALUE:tags}}\n---');
const vars = formatter.getAndClearTemplatePropertyVars();

expect(output).toBe('---\ntags: [[John Doe]],[[Jane Doe]]\n---');
expect(vars.size).toBe(0);
});

it('uses a YAML-safe placeholder for collected arrays before post-processing', async () => {
(formatter as any).variables.set('tags', ['[[John Doe]]', '[[Jane Doe]]']);
const output = await formatter.testFormatWithTemplatePropertyCollection(
'---\ntags: {{VALUE:tags}}\n---',
);
const vars = formatter.getAndClearTemplatePropertyVars();

expect(output).toBe('---\ntags: []\n---');
expect(vars.get('tags')).toEqual(['[[John Doe]]', '[[Jane Doe]]']);
});

it('does not apply case transforms to YAML placeholders', async () => {
(formatter as any).variables.set('done', null);
const output =
await formatter.testFormatWithTemplatePropertyCollection(
'---\ndone: {{VALUE:done|case:upper}}\n---',
);
const vars = formatter.getAndClearTemplatePropertyVars();

expect(output).toBe('---\ndone: null\n---');
expect(vars.get('done')).toBeNull();
});

it('ignores wiki links with commas to avoid incorrect splitting', async () => {
(formatter as any).variables.set('source', '[[test, a]]');
await formatter.testFormat('---\nsource: {{VALUE:source}}\n---');
await formatter.testFormatWithTemplatePropertyCollection(
'---\nsource: {{VALUE:source}}\n---',
);
const vars = formatter.getAndClearTemplatePropertyVars();
expect(vars.has('source')).toBe(false);
});
Expand Down
33 changes: 28 additions & 5 deletions src/formatters/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { TemplatePropertyCollector } from "../utils/TemplatePropertyCollector";
import { settingsStore } from "../settingsStore";
import { normalizeDateInput } from "../utils/dateAliases";
import { transformCase } from "../utils/caseTransform";
import { getYamlPlaceholder } from "../utils/yamlValues";
import {
parseAnonymousValueOptions,
parseValueToken,
Expand Down Expand Up @@ -58,6 +59,7 @@ export abstract class Formatter {

// Tracks variables collected for YAML property post-processing
private readonly propertyCollector: TemplatePropertyCollector;
private templatePropertyCollectionDepth = 0;

protected constructor(protected readonly app?: App) {
this.propertyCollector = new TemplatePropertyCollector(app);
Expand Down Expand Up @@ -302,6 +304,23 @@ export abstract class Formatter {
return this.propertyCollector.drain();
}

/**
* Runs a formatting operation in a scope where structured YAML values should
* be collected and replaced with temporary placeholders for later
* `processFrontMatter()` post-processing.
*/
public async withTemplatePropertyCollection<T>(
work: () => Promise<T>,
): Promise<T> {
this.templatePropertyCollectionDepth += 1;

try {
return await work();
} finally {
this.templatePropertyCollectionDepth -= 1;
}
}

protected abstract getCurrentFileLink(): string | null;
protected abstract getCurrentFileName(): string | null;

Expand Down Expand Up @@ -386,18 +405,22 @@ export abstract class Formatter {
: rawValue;

// Offer this variable to the property collector for YAML post-processing
this.propertyCollector.maybeCollect({
const structuredYamlValue = this.propertyCollector.maybeCollect({
input: output,
matchStart: match.index,
matchEnd: match.index + match[0].length,
rawValue: rawValueForCollector,
fallbackKey: variableName,
featureEnabled: propertyTypesEnabled,
featureEnabled:
propertyTypesEnabled &&
this.templatePropertyCollectionDepth > 0,
});

// Always use string replacement initially
const rawReplacement = this.getVariableValue(effectiveKey);
const replacement = transformCase(rawReplacement, caseStyle);
// Keep the interim frontmatter YAML-parseable until post-processing
// writes the real structured value back through Obsidian.
const placeholder = getYamlPlaceholder(structuredYamlValue);
const replacement = placeholder ??
transformCase(this.getVariableValue(effectiveKey), caseStyle);

// Replace in output and adjust regex position
output = output.slice(0, match.index) + replacement + output.slice(match.index + match[0].length);
Expand Down
19 changes: 7 additions & 12 deletions src/utils/TemplatePropertyCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
parseStructuredPropertyValueFromString,
type ParseOptions,
} from "./templatePropertyStringParser";
import { isStructuredYamlValue } from "./yamlValues";

const PATH_SEPARATOR = "\u0000";

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

if (!context.isInYaml) return;
if (!context.isInYaml) return undefined;

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

if (!propertyPath || propertyPath.length === 0) return;
if (!propertyPath || propertyPath.length === 0) return undefined;
const effectiveKey = propertyPath[propertyPath.length - 1];

let structuredValue = rawValue;
Expand All @@ -61,17 +62,11 @@ export class TemplatePropertyCollector {
}
}

const isStructured =
typeof structuredValue !== "string" &&
(Array.isArray(structuredValue) ||
(typeof structuredValue === "object" && structuredValue !== null) ||
typeof structuredValue === "number" ||
typeof structuredValue === "boolean" ||
structuredValue === null);
if (!isStructured) return;
if (!isStructuredYamlValue(structuredValue)) return undefined;

const mapKey = propertyPath.join(PATH_SEPARATOR);
this.map.set(mapKey, structuredValue);
return structuredValue;
}

/** Returns a copy and clears the collector. */
Expand Down
Loading
Loading