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
20 changes: 20 additions & 0 deletions src/preflight/RequirementCollector.file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
13 changes: 7 additions & 6 deletions src/preflight/RequirementCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const autoLabel = qualifiers.length
? `File from ${parsed.folderPath} (${qualifiers.join(", ")})`
: `File from ${parsed.folderPath}`;
this.requirements.set(key, {
id: key,
label: parsed.label ?? autoLabel,
Expand Down
37 changes: 21 additions & 16 deletions src/utils/fileSyntax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> | "any";
/** Variables-map key. Full token identity by default; `|name:` shares it. */
Expand All @@ -60,21 +62,21 @@ const FILE_TYPE_EXTENSIONS = new Map<string, readonly string[]>([
["pdf", ["pdf"]],
]);

function addFileTypes(
extensions: Set<string> | "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<string> | "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. */
Expand Down Expand Up @@ -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<string> | "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
Expand Down Expand Up @@ -271,6 +275,7 @@ export function parseFileToken(
filter,
multiSelect,
multiFormat,
types,
extensions,
variableKey,
};
Expand Down
86 changes: 86 additions & 0 deletions tests/e2e/file-type-label.test.ts
Original file line number Diff line number Diff line change
@@ -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:<folder>|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>('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<boolean>(
`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<QuickAddData>().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<string[]>(
'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`);
});
});
Loading