From 1a4bccfeab4f8e96348df68597a9e2359ba1e4ea Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sat, 26 Sep 2026 17:45:39 +0200 Subject: [PATCH] fix(file): name the |type: in FILE picker default labels --- .../RequirementCollector.file.test.ts | 20 +++++ src/preflight/RequirementCollector.ts | 13 +-- src/utils/fileSyntax.ts | 37 ++++---- tests/e2e/file-type-label.test.ts | 86 +++++++++++++++++++ 4 files changed, 134 insertions(+), 22 deletions(-) create mode 100644 tests/e2e/file-type-label.test.ts diff --git a/src/preflight/RequirementCollector.file.test.ts b/src/preflight/RequirementCollector.file.test.ts index 6d0754b09..fdb7541ef 100644 --- a/src/preflight/RequirementCollector.file.test.ts +++ b/src/preflight/RequirementCollector.file.test.ts @@ -84,6 +84,26 @@ describe("RequirementCollector — {{FILE:...}}", () => { expect(notes?.options).toEqual([`${FILE_PICK_PREFIX}Attachments/notes.md`]); }); + it("names the |type: in the default label so same-folder pickers differ", async () => { + const app = makeApp(["Attachments/photo.png", "Attachments/scan.pdf"]); + const rc = new RequirementCollector(app, makePlugin()); + await rc.scanString( + "{{FILE:Attachments|type:image|link}} {{FILE:Attachments|type:pdf|link}} {{FILE:Attachments|type:image,.PDF}}", + ); + + const labelOf = (token: string) => + rc.requirements.get(parseFileToken(token)!.variableKey)?.label; + expect(labelOf("Attachments|type:image|link")).toBe( + "File from Attachments (image, link)", + ); + expect(labelOf("Attachments|type:pdf|link")).toBe( + "File from Attachments (pdf, link)", + ); + expect(labelOf("Attachments|type:image,.PDF")).toBe( + "File from Attachments (image, pdf)", + ); + }); + it("labels a |type: canvas pick with its extension", async () => { const app = makeApp(["Boards/Plan.canvas", "Boards/Plan.md"]); const rc = new RequirementCollector(app, makePlugin()); diff --git a/src/preflight/RequirementCollector.ts b/src/preflight/RequirementCollector.ts index bebd95277..d1b0bdd12 100644 --- a/src/preflight/RequirementCollector.ts +++ b/src/preflight/RequirementCollector.ts @@ -590,12 +590,13 @@ export class RequirementCollector extends Formatter { // previous empty-folder one-page behavior by permitting a literal value when // no real file options exist. const allowCustomInput = parsed.allowCustomInput || options.length === 0; - // Disambiguate same-scope tokens that differ only by mode (e.g. a basename - // and a link to the same folder) when no explicit |label. - const autoLabel = - parsed.mode === "name" - ? `File from ${parsed.folderPath}` - : `File from ${parsed.folderPath} (${parsed.mode})`; + // Disambiguate same-folder tokens that differ only by type or mode (e.g. an + // image and a PDF link from one folder) when no explicit |label. + const qualifiers = [...parsed.types]; + if (parsed.mode !== "name") qualifiers.push(parsed.mode); + const autoLabel = qualifiers.length + ? `File from ${parsed.folderPath} (${qualifiers.join(", ")})` + : `File from ${parsed.folderPath}`; this.requirements.set(key, { id: key, label: parsed.label ?? autoLabel, diff --git a/src/utils/fileSyntax.ts b/src/utils/fileSyntax.ts index 418dc1f91..c6378d334 100644 --- a/src/utils/fileSyntax.ts +++ b/src/utils/fileSyntax.ts @@ -42,6 +42,8 @@ export type ParsedFileToken = { multiSelect: boolean; /** Explicit output shape for a multi-select; auto preserves legacy behavior. */ multiFormat: MultiValueFormat; + /** Normalized `|type:` values as written, e.g. `["image", "pdf"]`. */ + types: readonly string[]; /** Extensions `|type:` lists; undefined means Markdown notes only. */ extensions?: ReadonlySet | "any"; /** Variables-map key. Full token identity by default; `|name:` shares it. */ @@ -60,21 +62,21 @@ const FILE_TYPE_EXTENSIONS = new Map([ ["pdf", ["pdf"]], ]); -function addFileTypes( - extensions: Set | "any" | undefined, - value: string, +function parseFileTypes(value: string): string[] { + return value + .split(",") + .map((part) => part.trim().toLowerCase().replace(/^\./, "")) + .filter(Boolean); +} + +function getFileTypeExtensions( + types: readonly string[], ): Set | "any" | undefined { - if (extensions === "any") return extensions; - for (const part of value.split(",")) { - const type = part.trim().toLowerCase().replace(/^\./, ""); - if (!type) continue; - if (type === "any") return "any"; - extensions ??= new Set(); - for (const extension of FILE_TYPE_EXTENSIONS.get(type) ?? [type]) { - extensions.add(extension); - } - } - return extensions; + if (types.length === 0) return undefined; + if (types.includes("any")) return "any"; + return new Set( + types.flatMap((type) => FILE_TYPE_EXTENSIONS.get(type) ?? [type]), + ); } /** Strip leading/trailing slashes, matching FieldSuggestionFileFilter. */ @@ -215,15 +217,17 @@ export function parseFileToken( // else (tag/exclude-*) is parsed by FieldSuggestionParser below. let label: string | undefined; let aliasName: string | undefined; - let extensions: Set | "any" | undefined; + const types: string[] = []; for (const part of afterMode) { const parsed = parsePipeKeyValue(part); if (!parsed) continue; if (parsed.key === "label" && parsed.value) label = parsed.value; else if (parsed.key === "name" && parsed.value) aliasName = parsed.value; else if (parsed.key === "type") - extensions = addFileTypes(extensions, parsed.value); + for (const type of parseFileTypes(parsed.value)) + if (!types.includes(type)) types.push(type); } + const extensions = getFileTypeExtensions(types); // Delegate filter parsing to the shared FIELD parser (it skips unknown keys // like label/name and bare flags), then force the scope folder. The first @@ -271,6 +275,7 @@ export function parseFileToken( filter, multiSelect, multiFormat, + types, extensions, variableKey, }; diff --git a/tests/e2e/file-type-label.test.ts b/tests/e2e/file-type-label.test.ts new file mode 100644 index 000000000..d5bc4472d --- /dev/null +++ b/tests/e2e/file-type-label.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type IChoice from "../../src/types/choices/IChoice"; +import { TemplateChoice } from "../../src/types/choices/TemplateChoice"; +import { createQuickAddE2EHarness, seedVaultFile } from "./e2eVault"; +import { POLL_OPTS, expectNoPrompt, pressKey, typeInto, waitForElement } from "./uiHelpers"; + +// Two `{{FILE:|type:...}}` pickers on one folder and mode get default +// labels that name their type, so a one-page form can tell them apart. +const getContext = createQuickAddE2EHarness("file-type-label"); + +type QuickAddData = { + choices: IChoice[]; + onePageInputEnabled: boolean; +}; + +async function closeOpenPrompts() { + const { obsidian } = getContext(); + for (let remaining = 10; remaining > 0; remaining--) { + if (!await obsidian.dev.evalJson('Boolean(document.querySelector(".modal-container, .prompt"))')) break; + await pressKey(obsidian, "Escape"); + } + await expectNoPrompt(obsidian); +} + +beforeEach(closeOpenPrompts); +afterEach(closeOpenPrompts); + +async function pick(selector: string, typed: string, expected: string) { + const { obsidian } = getContext(); + // A single picker preselects its first file and hides picked files from + // its suggestions, so clear it before searching. + await obsidian.dev.evalJson(`document.querySelector(${JSON.stringify(selector)}) + ?.closest(".qa-onepage-file-picker") + ?.querySelectorAll(".qa-onepage-file-picker__remove") + .forEach((button) => button.click())`); + await typeInto(obsidian, selector, typed); + // A suggestion reads as the file name followed by its path. + await expect.poll(() => obsidian.dev.evalJson( + `document.querySelector(".suggestion-container .suggestion-item")?.textContent?.startsWith(${JSON.stringify(expected)}) ?? false`, + ), POLL_OPTS).toBe(true); + await pressKey(obsidian, "Enter"); +} + +describe("FILE |type: default label", () => { + it("names each one-page attachment picker after its type", async () => { + const { obsidian, plugin, sandbox } = getContext(); + await seedVaultFile(obsidian, sandbox, "Attachments/photo.png", "png"); + await seedVaultFile(obsidian, sandbox, "Attachments/scan.pdf", "pdf"); + const folder = sandbox.path("Attachments"); + const template = new TemplateChoice("Attachment pair"); + template.command = true; + template.templatePath = await seedVaultFile( + obsidian, + sandbox, + "attachment-template.md", + `image: {{FILE:${folder}|type:image|link}}\npdf: {{FILE:${folder}|type:pdf|link}}\n`, + ); + template.fileNameFormat = { enabled: true, format: "attachment pair" }; + template.folder = { ...template.folder, enabled: true, folders: [sandbox.path("out")] }; + await plugin.data().patch((data) => { + data.onePageInputEnabled = true; + data.choices.push(template); + }); + await plugin.reload({ waitUntilReady: true }); + await obsidian.exec("command", { id: `quickadd:choice:${template.id}` }); + + await waitForElement(obsidian, ".onePageInputModal"); + expect(await obsidian.dev.evalJson( + 'Array.from(document.querySelectorAll(".onePageInputModal .setting-item-name")).map((name) => name.textContent)', + )).toEqual([ + `File from ${folder} (image, link)`, + `File from ${folder} (pdf, link)`, + ]); + + const field = (qualifiers: string) => + `.onePageInputModal input[aria-label=${JSON.stringify( + `Choose file for File from ${folder} (${qualifiers})`, + )}]`; + await pick(field("image, link"), "pho", "photo.png"); + await pick(field("pdf, link"), "sca", "scan.pdf"); + await pressKey(obsidian, "Enter", true); + await expectNoPrompt(obsidian); + await expect.poll(() => sandbox.read("out/attachment pair.md").catch(() => ""), POLL_OPTS) + .toBe(`image: [[${folder}/photo.png]]\npdf: [[${folder}/scan.pdf]]\n`); + }); +});