From 078635732c21bf2e297a24fbe19d38fa9103ac12 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 23 Sep 2026 13:05:21 +0200 Subject: [PATCH 1/2] feat: pick attachments with {{FILE:|type:...}} Adds a |type: option to FILE pickers so they can list images, audio, video, PDFs, or any file instead of Markdown notes. Values are groups or bare extensions, combined with commas or repeated. --- docs/src/content/docs/docs/FormatSyntax.md | 26 ++++++- src/formatters/helpers/vaultPrompts.ts | 10 +-- src/formatters/previewFormatter.ts | 10 +-- src/gui/suggesters/formatTokenRegistry.ts | 1 + .../RequirementCollector.file.test.ts | 33 ++++++++- src/preflight/RequirementCollector.ts | 8 +-- src/utils/fileSyntax.test.ts | 22 ++++++ src/utils/fileSyntax.ts | 71 ++++++++++++++++--- src/utils/vaultQueries.ts | 16 +++++ 9 files changed, 163 insertions(+), 34 deletions(-) diff --git a/docs/src/content/docs/docs/FormatSyntax.md b/docs/src/content/docs/docs/FormatSyntax.md index 40c5c8d9..6f10bfde 100644 --- a/docs/src/content/docs/docs/FormatSyntax.md +++ b/docs/src/content/docs/docs/FormatSyntax.md @@ -970,6 +970,7 @@ Options: - `|optional` - allow skipping the pick (becomes nothing). - `|custom` - also allow typing a value that isn't in the folder. - `|multi` - pick several files. In frontmatter property positions, including a whole-token [property capture](/docs/Choices/CaptureChoice/#property), QuickAdd writes a YAML list. In note bodies, file names, and other text positions it writes comma-separated text. Combine with `|link` or `|path` to write links or paths for every pick. +- `|type:image` - pick images instead of notes. See [Pick attachments](#file-type). - `|label:Pick a person` - set the picker's placeholder text. - `|name:` - share one pick between placeholders. FILE placeholders are cached by their full definition: placeholders that differ (folder, filters, mode, or `|label:`) prompt independently, while identical ones reuse one pick. To pick **two different** people, give the placeholders different labels (`{{FILE:People|label:Author}}` and `{{FILE:People|label:Reviewer}}`). To reuse **the same** pick - say, a name in one place and a link in another - give them the same `|name:`. Placeholders sharing an id should target the same folder and filters; the shared pick is required if *any* occurrence omits `|optional`. - Filters reuse the FIELD syntax: `|tag:`, `|exclude-folder:`, `|exclude-tag:`, `|exclude-file:` (each repeatable). @@ -979,7 +980,7 @@ Good to know: - The folder is the first part of the placeholder. A `|folder:` option is FIELD syntax and is ignored here. - The folder matches **recursively** (subfolders included). Point at a leaf folder (like `{{FILE:fields/people}}`) to scope tightly. - Repeated `|tag:` filters are AND filters. Exclusions remove any matching file. -- Markdown files only. +- Markdown notes only, unless you add `|type:`. - `|link` and `|path` insert characters that aren't valid in file names; in the **file name** field, use the default mode. - In a one-page input form, single and multi FILE pickers appear inline. Search matches the friendly title, file name, and full path. Selected files remain exact path-backed values internally, so commas in file names or labels are safe. @@ -988,6 +989,29 @@ FILE multi-selects support `|format:yaml`, `|format:markdown`, `|path`, so `{{FILE:People|multi|link|format:yaml}}` writes a native YAML list of links without relying on the capture context. +#### Pick attachments: `|type:` {#file-type} + +Add `|type:` to pick images, PDFs, and other attachments instead of notes. Put +`!` in front of a link to embed the file: + +```markdown +!{{FILE:Attachments|type:image|link}} +``` + +| Type | Picks | +| --- | --- | +| `image` | avif, bmp, gif, jpeg, jpg, png, svg, webp | +| `audio` | 3gp, flac, m4a, mp3, ogg, wav, webm | +| `video` | mkv, mov, mp4, ogv, webm | +| `pdf` | pdf | +| `note` | md | +| `any` | Every file | + +Any other value is a file extension, so `|type:canvas` picks canvases. Combine +types with commas: `|type:image,pdf`. The default mode inserts the file name +with its extension (`photo.png`). `|tag:` only matches notes, since attachments +have no tags. + ## Insert other content ### Your clipboard: `{{CLIPBOARD}}` {#clipboard} diff --git a/src/formatters/helpers/vaultPrompts.ts b/src/formatters/helpers/vaultPrompts.ts index a6922269..26e000ee 100644 --- a/src/formatters/helpers/vaultPrompts.ts +++ b/src/formatters/helpers/vaultPrompts.ts @@ -5,7 +5,6 @@ import InputSuggester from "../../gui/InputSuggester/inputSuggester"; import MultiSuggester from "../../gui/MultiSuggester/multiSuggester"; import GenericSuggester from "../../gui/GenericSuggester/genericSuggester"; import { FieldSuggestionParser } from "../../utils/FieldSuggestionParser"; -import { FieldSuggestionFileFilter } from "../../utils/FieldSuggestionFileFilter"; import { collectFieldValuesProcessedDetailed } from "../../utils/FieldValueCollector"; import { FieldValueProcessor } from "../../utils/FieldValueProcessor"; import { resolveActiveNoteFieldDefault } from "../../utils/activeNoteFieldDefault"; @@ -13,6 +12,7 @@ import { buildFileDisplayLabels, FILE_CUSTOM_PREFIX, FILE_PICK_PREFIX, type Pars import { UserCancelError } from "../../errors/UserCancelError"; import { isCancellationError } from "../../utils/errorUtils"; import { log } from "../../logger/logManager"; +import { getFileTokenFiles } from "../../utils/vaultQueries"; interface VaultPromptContext { app: App; @@ -194,11 +194,7 @@ export async function suggestForFile({ app, executor, getSourcePath }: VaultProm // is driving; the vault-side file filtering below still runs unchanged. const provider = executor?.promptProvider; try { - const files = FieldSuggestionFileFilter.filterFiles( - app.vault.getMarkdownFiles(), - parsed.filter, - (file) => app.metadataCache.getFileCache(file), - ); + const files = getFileTokenFiles(app, parsed); const placeholder = parsed.label ?? `Select a file from ${parsed.folderPath}`; @@ -207,7 +203,7 @@ export async function suggestForFile({ app, executor, getSourcePath }: VaultProm // dead-ends, mirroring suggestForField. A typed value is stored as custom // (never resolved to a real file); an empty/skip stays "". if (files.length === 0) { - const description = `No markdown files found in "${parsed.folderPath}". Type a value or leave empty.`; + const description = `No matching files found in "${parsed.folderPath}". Type a value or leave empty.`; const typed = provider ? await provider.inputPrompt(placeholder, description) : await GenericInputPrompt.Prompt( diff --git a/src/formatters/previewFormatter.ts b/src/formatters/previewFormatter.ts index e55c7aa1..6759311e 100644 --- a/src/formatters/previewFormatter.ts +++ b/src/formatters/previewFormatter.ts @@ -1,7 +1,7 @@ import { Formatter } from "./formatter"; import { PreviewDiagnostics } from "./previewDiagnostics"; import { getCurrentFileLinkPreview, getCurrentFileNamePreview, getCurrentFolderPathPreview } from "./helpers/previewHelpers"; -import { FieldSuggestionFileFilter } from "../utils/FieldSuggestionFileFilter"; +import { getFileTokenFiles } from "../utils/vaultQueries"; import { FILE_CUSTOM_PREFIX, FILE_PICK_PREFIX, type ParsedFileToken } from "../utils/fileSyntax"; /** Shared inert resolvers. Concrete previews retain their own pass order. */ @@ -45,13 +45,7 @@ export abstract class PreviewFormatter extends Formatter { protected suggestForFile(parsed: ParsedFileToken): string { // Preview: show a representative real file, else a placeholder. Never prompt. - const files = this.app - ? FieldSuggestionFileFilter.filterFiles( - this.app.vault.getMarkdownFiles(), - parsed.filter, - (file) => this.app!.metadataCache.getFileCache(file), - ) - : []; + const files = this.app ? getFileTokenFiles(this.app, parsed) : []; if (files.length > 0) return `${FILE_PICK_PREFIX}${files[0].path}`; return `${FILE_CUSTOM_PREFIX}${parsed.folderPath || "file"}`; } diff --git a/src/gui/suggesters/formatTokenRegistry.ts b/src/gui/suggesters/formatTokenRegistry.ts index 53520dc7..9da54ba5 100644 --- a/src/gui/suggesters/formatTokenRegistry.ts +++ b/src/gui/suggesters/formatTokenRegistry.ts @@ -237,6 +237,7 @@ export const FORMAT_TOKEN_ENTRIES: readonly FormatTokenEntry[] = [ rows.push( token("{{FILE:|link}}", "Same, but inserts a link to the note"), token("{{FILE:|path}}", "Same, but inserts its full path"), + token("{{FILE:|type:image|link}}", "Same, but links an image instead of a note"), token("{{FILE:|optional}}", "Same, but skippable"), ); } diff --git a/src/preflight/RequirementCollector.file.test.ts b/src/preflight/RequirementCollector.file.test.ts index 702ab6e4..a39f5ddf 100644 --- a/src/preflight/RequirementCollector.file.test.ts +++ b/src/preflight/RequirementCollector.file.test.ts @@ -7,11 +7,12 @@ import { function makeFile(path: string) { const segment = path.split("/").pop() ?? path; - const basename = segment.replace(/\.md$/, ""); + const basename = segment.replace(/\.[^.]+$/, ""); + const extension = segment.slice(basename.length + 1); const parentPath = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "/"; - return { path, name: segment, basename, parent: { path: parentPath } }; + return { path, name: segment, basename, extension, parent: { path: parentPath } }; } const makeApp = ( @@ -23,7 +24,9 @@ const makeApp = ( vault: { getAbstractFileByPath: () => null, cachedRead: async () => "", - getMarkdownFiles: () => paths.map(makeFile), + getFiles: () => paths.map(makeFile), + getMarkdownFiles: () => + paths.filter((path) => path.endsWith(".md")).map(makeFile), }, metadataCache: { getFileCache: (file: { path: string }) => @@ -57,6 +60,30 @@ describe("RequirementCollector — {{FILE:...}}", () => { expect(req?.displayOptions).toEqual(["Alpha", "Beta"]); }); + it("offers attachments of the |type: instead of notes, labelled with their extension", async () => { + const app = makeApp([ + "Attachments/photo.png", + "Attachments/Photo.JPG", + "Attachments/scan.pdf", + "Attachments/notes.md", + "Elsewhere/logo.png", + ]); + const rc = new RequirementCollector(app, makePlugin()); + await rc.scanString("!{{FILE:Attachments|type:image|link}} {{FILE:Attachments}}"); + + const images = rc.requirements.get( + parseFileToken("Attachments|type:image|link")!.variableKey, + ); + expect(images?.options).toEqual([ + `${FILE_PICK_PREFIX}Attachments/photo.png`, + `${FILE_PICK_PREFIX}Attachments/Photo.JPG`, + ]); + expect(images?.displayOptions).toEqual(["photo.png", "Photo.JPG"]); + + const notes = rc.requirements.get(parseFileToken("Attachments")!.variableKey); + expect(notes?.options).toEqual([`${FILE_PICK_PREFIX}Attachments/notes.md`]); + }); + it("uses title metadata for FILE option display labels", async () => { const app = makeApp(["People/01HX.md"], { "People/01HX.md": { frontmatter: { title: "Ada Lovelace" } }, diff --git a/src/preflight/RequirementCollector.ts b/src/preflight/RequirementCollector.ts index 5f83150a..2f2f474a 100644 --- a/src/preflight/RequirementCollector.ts +++ b/src/preflight/RequirementCollector.ts @@ -24,7 +24,6 @@ import { unwrapQuotedValue, } from "src/utils/valueSyntax"; import { parseVDateOptions } from "src/utils/vdateSyntax"; -import { FieldSuggestionFileFilter } from "src/utils/FieldSuggestionFileFilter"; import { FieldSuggestionParser } from "src/utils/FieldSuggestionParser"; import { resolveActiveNoteFieldDefault } from "src/utils/activeNoteFieldDefault"; import { @@ -33,6 +32,7 @@ import { type ParsedFileToken, parseFileToken, } from "src/utils/fileSyntax"; +import { getFileTokenFiles } from "src/utils/vaultQueries"; export type { FieldType, FieldRequirement, FieldGroup } from "./fieldRequirements"; import type { FieldType, FieldRequirement } from "./fieldRequirements"; @@ -578,11 +578,7 @@ export class RequirementCollector extends Formatter { // Options are the folder's files encoded as `@file:` (display = // basenames) so the chosen value round-trips to the runtime formatter, // which decodes it back to the file. - const files = FieldSuggestionFileFilter.filterFiles( - this.app.vault.getMarkdownFiles(), - parsed.filter, - (file) => this.app.metadataCache.getFileCache(file), - ); + const files = getFileTokenFiles(this.app, parsed); const options = files.map((file) => `${FILE_PICK_PREFIX}${file.path}`); const displayOptions = buildFileDisplayLabels( files, diff --git a/src/utils/fileSyntax.test.ts b/src/utils/fileSyntax.test.ts index b83b9523..ef98d972 100644 --- a/src/utils/fileSyntax.test.ts +++ b/src/utils/fileSyntax.test.ts @@ -86,6 +86,22 @@ describe("parseFileToken", () => { expect(parsed?.filter.excludeFiles).toEqual(["tmp.md"]); }); + it("lists Markdown notes only unless |type: is given", () => { + expect(parseFileToken("People")?.extensions).toBeUndefined(); + expect(parseFileToken("People|type:any")?.extensions).toBe("any"); + expect(parseFileToken("People|type:any|type:image")?.extensions).toBe("any"); + }); + + it("expands |type: categories and takes other values as extensions", () => { + const extensions = parseFileToken( + "Attachments|type:image,PDF|type:.canvas", + )?.extensions; + expect(extensions).toBeInstanceOf(Set); + expect([...(extensions as Set)].sort()).toEqual([ + "avif", "bmp", "canvas", "gif", "jpeg", "jpg", "pdf", "png", "svg", "webp", + ]); + }); + describe("variableKey identity", () => { it("differs by mode (independent prompts by default)", () => { const name = parseFileToken("People")!.variableKey; @@ -137,6 +153,12 @@ describe("parseFileToken", () => { expect(pub).not.toBe(priv); }); + it("does NOT share |name: across different file types", () => { + const notes = parseFileToken("Inbox|name:ref")!.variableKey; + const images = parseFileToken("Inbox|type:image|name:ref")!.variableKey; + expect(notes).not.toBe(images); + }); + it("does NOT collapse a comma-bearing filter value with two separate values", () => { // `exclude-folder:a,b` is a single folder literally named "a,b" (the // grammar only splits on `|`), whereas two `exclude-folder:` parts are diff --git a/src/utils/fileSyntax.ts b/src/utils/fileSyntax.ts index 78dec925..8945f3fa 100644 --- a/src/utils/fileSyntax.ts +++ b/src/utils/fileSyntax.ts @@ -42,12 +42,41 @@ export type ParsedFileToken = { multiSelect: boolean; /** Explicit output shape for a multi-select; auto preserves legacy behavior. */ multiFormat: MultiValueFormat; + /** Extensions `|type:` lists; undefined means Markdown notes only. */ + extensions?: ReadonlySet | "any"; /** Variables-map key. Full token identity by default; `|name:` shares it. */ variableKey: string; }; const MODE_FLAGS = new Set(["name", "link", "path"]); +// Obsidian's accepted file formats, grouped for `|type:`. Any other value is +// taken as a bare extension, so `|type:canvas` picks canvases. +const FILE_TYPE_EXTENSIONS = new Map([ + ["note", ["md"]], + ["image", ["avif", "bmp", "gif", "jpeg", "jpg", "png", "svg", "webp"]], + ["audio", ["3gp", "flac", "m4a", "mp3", "ogg", "wav", "webm"]], + ["video", ["mkv", "mov", "mp4", "ogv", "webm"]], + ["pdf", ["pdf"]], +]); + +function addFileTypes( + extensions: Set | "any" | undefined, + value: 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; +} + /** Strip leading/trailing slashes, matching FieldSuggestionFileFilter. */ function normalizeFolder(path: string): string { return path.replace(/^\/+|\/+$/g, ""); @@ -73,8 +102,16 @@ function encodeSignatureList( * files the picker lists (folder + tag/exclude-* filters). Two tokens that share * this list can meaningfully share a pick; two that don't, can't. */ -function buildFileScopeSignature(folderPath: string, filter: FieldFilter): string { +function buildFileScopeSignature( + folderPath: string, + filter: FieldFilter, + extensions: ParsedFileToken["extensions"], +): string { const parts = [`folder=${normalizeFolder(folderPath)}`]; + if (extensions) + parts.push( + `types=${extensions === "any" ? "any" : encodeSignatureList([...extensions])}`, + ); if (filter.folders?.length) parts.push(`folders=${encodeSignatureList(filter.folders, normalizeFolder)}`); if (filter.tags?.length) @@ -104,11 +141,20 @@ function buildFileSignature(parsed: { allowCustomInput: boolean; multiSelect: boolean; filter: FieldFilter; + extensions: ParsedFileToken["extensions"]; }): string { - const { folderPath, mode, label, optional, allowCustomInput, multiSelect, filter } = - parsed; + const { + folderPath, + mode, + label, + optional, + allowCustomInput, + multiSelect, + filter, + extensions, + } = parsed; const parts = [ - buildFileScopeSignature(folderPath, filter), + buildFileScopeSignature(folderPath, filter, extensions), `mode=${mode}`, ]; if (label) parts.push(`label=${label}`); @@ -123,7 +169,7 @@ function buildFileSignature(parsed: { * * The first pipe-part is the (required) folder path. FILE-specific options are * peeled off here — the bare mode flags `name`/`link`/`path`, `optional`, - * `custom`, and the key:value `label:` / `name:` — then the remainder is handed + * `custom`, and the key:value `label:` / `name:` / `type:` — then the remainder is handed * to {@link FieldSuggestionParser.parse} so the folder/tag/exclude-* filter * grammar can never diverge from {{FIELD}}. The folder (first segment) is the * picker SCOPE, so it is applied as `filter.folder` regardless of any `|folder:`. @@ -165,15 +211,18 @@ export function parseFileToken( afterMode.push(part); } - // Peel key:value options that are FILE-specific (label/name); everything else - // (tag/exclude-*) is parsed by FieldSuggestionParser below. + // Peel key:value options that are FILE-specific (label/name/type); everything + // else (tag/exclude-*) is parsed by FieldSuggestionParser below. let label: string | undefined; let aliasName: string | undefined; + let extensions: Set | "any" | undefined; 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); } // Delegate filter parsing to the shared FIELD parser (it skips unknown keys @@ -199,7 +248,7 @@ export function parseFileToken( // must not silently reuse each other's pick. Mode/label/optional/custom are // intentionally NOT in the alias key, so one pick renders across modes. const variableKey = aliasName - ? `${FILE_VARIABLE_PREFIX}name=${aliasName}|${buildFileScopeSignature(folderPath, filter)}${multiSelect ? "|multi" : ""}` + ? `${FILE_VARIABLE_PREFIX}name=${aliasName}|${buildFileScopeSignature(folderPath, filter, extensions)}${multiSelect ? "|multi" : ""}` : `${FILE_VARIABLE_PREFIX}${buildFileSignature({ folderPath, mode, @@ -208,6 +257,7 @@ export function parseFileToken( allowCustomInput, multiSelect, filter, + extensions, })}`; return { @@ -221,6 +271,7 @@ export function parseFileToken( filter, multiSelect, multiFormat, + extensions, variableKey, }; } @@ -293,8 +344,10 @@ function parentLabel(file: TFile): string { return "vault root"; } +/** What name mode inserts: attachments keep their extension (`photo.png`). */ function basenameFor(file: TFile): string { - if (file.basename) return file.basename; + if (file.basename && (!file.extension || file.extension === "md")) + return file.basename; return fileBasenameFromPath(file.path); } diff --git a/src/utils/vaultQueries.ts b/src/utils/vaultQueries.ts index 653ba479..6c73681b 100644 --- a/src/utils/vaultQueries.ts +++ b/src/utils/vaultQueries.ts @@ -2,6 +2,7 @@ import type { App, CachedMetadata, TFile } from "obsidian"; import { TFolder } from "obsidian"; import { FieldSuggestionFileFilter } from "./FieldSuggestionFileFilter"; import type { FieldFilter } from "./FieldSuggestionParser"; +import type { ParsedFileToken } from "./fileSyntax"; import { normalizeFrontmatterTagValues, normalizeTag, @@ -164,3 +165,18 @@ export function getMarkdownFilesWithProperty( return files; } + +/** The files a `{{FILE:...}}` picker offers: its `|type:` files, then its filters. */ +export function getFileTokenFiles(app: App, parsed: ParsedFileToken): TFile[] { + const { extensions } = parsed; + const files = !extensions + ? app.vault.getMarkdownFiles() + : extensions === "any" + ? app.vault.getFiles() + : app.vault + .getFiles() + .filter((file) => extensions.has(file.extension.toLowerCase())); + return FieldSuggestionFileFilter.filterFiles(files, parsed.filter, (file) => + app.metadataCache.getFileCache(file), + ); +} From 38b4d1075bf00c4a9868061e87d2fa3fb1d8584e Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 23 Sep 2026 13:24:36 +0200 Subject: [PATCH 2/2] fix: keep canvas and base extensions in FILE name mode (#1789) A |type:canvas or |type:base pick rendered without its extension, so it didn't match the file Obsidian links to. Name mode and picker labels now only drop .md. --- src/formatters/formatter.ts | 6 +++--- src/formatters/helpers/fileTokenRendering.test.ts | 9 +++++++++ src/formatters/helpers/fileTokenRendering.ts | 4 ++-- src/preflight/RequirementCollector.file.test.ts | 11 +++++++++++ src/utils/fileSyntax.ts | 11 ++++++++--- 5 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/formatters/formatter.ts b/src/formatters/formatter.ts index 8707bd81..2d16d41e 100644 --- a/src/formatters/formatter.ts +++ b/src/formatters/formatter.ts @@ -7,7 +7,7 @@ import { TFile } from "obsidian"; import { LINK_TO_CURRENT_FILE_REGEX, LINK_TO_CURRENT_SECTION_REGEX, FILE_REGEX, MACRO_REGEX, MATH_VALUE_REGEX, TEMPLATE_REGEX, FIELD_VAR_REGEX_WITH_FILTERS, FIELD_VARIABLE_PREFIX, SELECTED_REGEX, CLIPBOARD_REGEX, RANDOM_REGEX, PROPERTY_REGEX } from "../constants"; import { decodeFileValue, - fileBasenameFromPath, + fileLinkNameFromPath, type ParsedFileToken, parseFileToken, } from "../utils/fileSyntax"; @@ -339,10 +339,10 @@ export abstract class Formatter extends ValueFormatter { this.app?.fileManager.generateMarkdownLink( file, this.getLinkSourcePath() ?? "", - ) ?? `[[${fileBasenameFromPath(path)}]]` + ) ?? `[[${fileLinkNameFromPath(path)}]]` ); } - return `[[${fileBasenameFromPath(path)}]]`; + return `[[${fileLinkNameFromPath(path)}]]`; } /** diff --git a/src/formatters/helpers/fileTokenRendering.test.ts b/src/formatters/helpers/fileTokenRendering.test.ts index 92adda20..1ee1ac62 100644 --- a/src/formatters/helpers/fileTokenRendering.test.ts +++ b/src/formatters/helpers/fileTokenRendering.test.ts @@ -13,6 +13,15 @@ describe("renderStoredFileValue", () => { ); }); + it("keeps a picked attachment's extension in name mode so it stays linkable", () => { + expect(renderStoredFileValue("@file:Boards/Plan.canvas", "name", link)).toBe( + "Plan.canvas", + ); + expect(renderStoredFileValue("@file:Attachments/photo.png", "name", link)).toBe( + "photo.png", + ); + }); + it("renders the full path in path mode", () => { expect(renderStoredFileValue("@file:Notes/Idea.md", "path", link)).toBe( "Notes/Idea.md", diff --git a/src/formatters/helpers/fileTokenRendering.ts b/src/formatters/helpers/fileTokenRendering.ts index 12b6a215..072bde25 100644 --- a/src/formatters/helpers/fileTokenRendering.ts +++ b/src/formatters/helpers/fileTokenRendering.ts @@ -1,6 +1,6 @@ import { decodeFileValue, - fileBasenameFromPath, + fileLinkNameFromPath, type FileMode, } from "../../utils/fileSyntax"; @@ -41,7 +41,7 @@ function renderSingleFileValue( case "file": return mode === "path" ? decoded.path - : fileBasenameFromPath(decoded.path); + : fileLinkNameFromPath(decoded.path); case "custom": case "raw": // Literal, user-provided text (a |custom type-in, a one-page typed diff --git a/src/preflight/RequirementCollector.file.test.ts b/src/preflight/RequirementCollector.file.test.ts index a39f5ddf..6d0754b0 100644 --- a/src/preflight/RequirementCollector.file.test.ts +++ b/src/preflight/RequirementCollector.file.test.ts @@ -84,6 +84,17 @@ describe("RequirementCollector — {{FILE:...}}", () => { expect(notes?.options).toEqual([`${FILE_PICK_PREFIX}Attachments/notes.md`]); }); + 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()); + await rc.scanString("{{FILE:Boards|type:canvas}}"); + + const req = rc.requirements.get( + parseFileToken("Boards|type:canvas")!.variableKey, + ); + expect(req?.displayOptions).toEqual(["Plan.canvas"]); + }); + it("uses title metadata for FILE option display labels", async () => { const app = makeApp(["People/01HX.md"], { "People/01HX.md": { frontmatter: { title: "Ada Lovelace" } }, diff --git a/src/utils/fileSyntax.ts b/src/utils/fileSyntax.ts index 8945f3fa..418dc1f9 100644 --- a/src/utils/fileSyntax.ts +++ b/src/utils/fileSyntax.ts @@ -318,6 +318,12 @@ export function fileBasenameFromPath(value: string): string { return segment.replace(/\.(md|canvas|base)$/i, ""); } +/** File name as Obsidian links it: notes drop `.md`, other files keep their extension. */ +export function fileLinkNameFromPath(value: string): string { + const segment = value.split("/").pop() ?? value; + return segment.replace(/\.md$/i, ""); +} + function scalarTitleValue(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() @@ -346,9 +352,8 @@ function parentLabel(file: TFile): string { /** What name mode inserts: attachments keep their extension (`photo.png`). */ function basenameFor(file: TFile): string { - if (file.basename && (!file.extension || file.extension === "md")) - return file.basename; - return fileBasenameFromPath(file.path); + if (file.extension && file.extension !== "md") return file.name; + return file.basename || fileLinkNameFromPath(file.path); } export interface FileDisplayInfo {