Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
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