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
26 changes: 25 additions & 1 deletion docs/src/content/docs/docs/FormatSyntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>` - 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).
Expand All @@ -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.

Expand All @@ -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}
Expand Down
6 changes: 3 additions & 3 deletions src/formatters/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)}]]`;
}

/**
Expand Down
9 changes: 9 additions & 0 deletions src/formatters/helpers/fileTokenRendering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/formatters/helpers/fileTokenRendering.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
decodeFileValue,
fileBasenameFromPath,
fileLinkNameFromPath,
type FileMode,
} from "../../utils/fileSyntax";

Expand Down Expand Up @@ -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
Expand Down
10 changes: 3 additions & 7 deletions src/formatters/helpers/vaultPrompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ 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";
import { buildFileDisplayLabels, FILE_CUSTOM_PREFIX, FILE_PICK_PREFIX, type ParsedFileToken } from "../../utils/fileSyntax";
import { UserCancelError } from "../../errors/UserCancelError";
import { isCancellationError } from "../../utils/errorUtils";
import { log } from "../../logger/logManager";
import { getFileTokenFiles } from "../../utils/vaultQueries";

interface VaultPromptContext {
app: App;
Expand Down Expand Up @@ -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}`;
Expand All @@ -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(
Expand Down
10 changes: 2 additions & 8 deletions src/formatters/previewFormatter.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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"}`;
}
Expand Down
1 change: 1 addition & 0 deletions src/gui/suggesters/formatTokenRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ export const FORMAT_TOKEN_ENTRIES: readonly FormatTokenEntry[] = [
rows.push(
token("{{FILE:<folder>|link}}", "Same, but inserts a link to the note"),
token("{{FILE:<folder>|path}}", "Same, but inserts its full path"),
token("{{FILE:<folder>|type:image|link}}", "Same, but links an image instead of a note"),
token("{{FILE:<folder>|optional}}", "Same, but skippable"),
);
}
Expand Down
44 changes: 41 additions & 3 deletions src/preflight/RequirementCollector.file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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 }) =>
Expand Down Expand Up @@ -57,6 +60,41 @@ 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("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" } },
Expand Down
8 changes: 2 additions & 6 deletions src/preflight/RequirementCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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";
Expand Down Expand Up @@ -578,11 +578,7 @@ export class RequirementCollector extends Formatter {
// Options are the folder's files encoded as `@file:<path>` (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,
Expand Down
22 changes: 22 additions & 0 deletions src/utils/fileSyntax.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>)].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;
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading