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
38 changes: 38 additions & 0 deletions docs/docs/Choices/CaptureChoice.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ format_ value after applying format syntax:

- An empty value, or `/`, opens a whole-vault picker for Markdown files.
- A value starting with `#` opens a picker for Markdown files with that tag.
- A value starting with `property:` opens a picker for Markdown files filtered by
a frontmatter field (see [Capturing to a property](#capturing-to-a-property)).
- A value ending in `/` opens a folder picker.
- A value ending in a supported file extension, such as `.md` or `.canvas`,
targets that file path directly.
Expand Down Expand Up @@ -62,6 +64,42 @@ Similarly, you can type a **tag name** in the _Capture To_ field, and QuickAdd w

If you have a tag called `#people`, and you type `#people` in the _Capture To_ field, QuickAdd will ask you which file to capture to, assuming the file has the `#people` tag.

### Capturing to a property

You can pre-filter the picker by an arbitrary **frontmatter property** by typing
`property:<field>=<value>` in the _Capture To_ field. QuickAdd then asks you which
note to capture to, limited to notes whose frontmatter matches.

For example, if your notes have a `type` field (`draft`, `index`, `log`, …), typing
`property:type=draft` opens a picker containing only the notes whose `type` is
`draft`.

- `property:type=draft` — notes whose `type` equals `draft`.
- `property:type` — notes that **have** a `type` field, regardless of value
(presence mode).
- The value is matched case-insensitively, trimmed. For a list-valued property
(`type: [draft, idea]`), the note matches if **any** entry equals the value.
- The value supports [format syntax](/FormatSyntax.md), e.g.
`property:status={{VALUE}}` resolves the value when the capture runs.

You can also combine a property with the shared file filters using `|`, the same
grammar as the [`{{FIELD}}`](/FormatSyntax.md) token (folder / tag / exclude-\*):

- `property:type=draft|folder:Notes` — only drafts inside `Notes/`.
- `property:type=draft|exclude-folder:Archive` — drafts not in `Archive/`.
- `property:type=draft|exclude-tag:done` — drafts not tagged `#done`.

Notes:

- This matches **YAML frontmatter** only, not inline Dataview `field:: value` fields.
- The field name is matched case-insensitively (so `property:type` matches a `Type:` field).
- Value matching is always case-insensitive; only the `folder` / `tag` /
`exclude-folder` / `exclude-tag` / `exclude-file` pipe filters are applied.
- Because `|` starts a filter, a property value cannot itself contain `|`.
- As with the tag picker, typing a new note name (with **Create file if it
doesn't exist** enabled) creates that note — it will not automatically receive
the property.

## Capture Options

The Capture builder is grouped into sections: **Location**, **Position**, **Linking**, **Content**, and **Behavior**.
Expand Down
68 changes: 68 additions & 0 deletions src/engine/CaptureChoiceEngine.selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,74 @@ describe("CaptureChoiceEngine capture target resolution", () => {
expect(result).toEqual({ kind: "file", path: "journals" });
});

it("resolves a property:field=value target", () => {
const app = createApp();
const engine = new CaptureChoiceEngine(
app,
{ settings: { useSelectionAsCaptureValue: false } } as any,
createChoice({ captureTo: "property:type=draft" }),
createExecutor(),
);

expect(
(engine as any).resolveCaptureTarget("property:type=draft"),
).toEqual({ kind: "property", field: "type", value: "draft", filter: {} });
});

it("keeps a .md-bearing property value as a property target (no misroute)", () => {
const app = createApp();
const engine = new CaptureChoiceEngine(
app,
{ settings: { useSelectionAsCaptureValue: false } } as any,
createChoice({ captureTo: "property:type=draft.md" }),
createExecutor(),
);

// The property branch must precede the .md/extension/folder checks so a
// value that happens to contain ".md" is matched literally, not as a file.
expect(
(engine as any).resolveCaptureTarget("property:type=draft.md"),
).toEqual({
kind: "property",
field: "type",
value: "draft.md",
filter: {},
});
});

it("parses pipe filters on a property target", () => {
const app = createApp();
const engine = new CaptureChoiceEngine(
app,
{ settings: { useSelectionAsCaptureValue: false } } as any,
createChoice({ captureTo: "property:type=draft|folder:Notes" }),
createExecutor(),
);

expect(
(engine as any).resolveCaptureTarget("property:type=draft|folder:Notes"),
).toEqual({
kind: "property",
field: "type",
value: "draft",
filter: { folder: "Notes" },
});
});

it("throws on a property target with no field name", () => {
const app = createApp();
const engine = new CaptureChoiceEngine(
app,
{ settings: { useSelectionAsCaptureValue: false } } as any,
createChoice({ captureTo: "property:" }),
createExecutor(),
);

expect(() => (engine as any).resolveCaptureTarget("property:")).toThrow(
ChoiceAbortError,
);
});

it("rejects explicit .base capture target paths", () => {
const app = createApp();
const engine = new CaptureChoiceEngine(
Expand Down
121 changes: 104 additions & 17 deletions src/engine/CaptureChoiceEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
appendToCurrentLine,
getMarkdownFilesInFolder,
getMarkdownFilesWithTag,
getMarkdownFilesWithProperty,
insertFileLinkToActiveView,
insertOnNewLineAbove,
insertOnNewLineBelow,
Expand All @@ -43,6 +44,8 @@ import {
waitForTemplaterTriggerOnCreateToComplete,
} from "../utilityObsidian";
import { isCancellationError, reportError } from "../utils/errorUtils";
import { parsePropertyTarget } from "../utils/propertyTarget";
import type { FieldFilter } from "../utils/FieldSuggestionParser";
import { normalizeFileOpening } from "../utils/fileOpeningDefaults";
import { InputPromptDraftStore } from "../utils/InputPromptDraftStore";
import { basenameWithoutMdOrCanvas, parentFolderPath } from "../utils/pathUtils";
Expand Down Expand Up @@ -653,6 +656,12 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
return this.selectFileInFolder("", true);
case "tag":
return this.selectFileWithTag(resolution.tag);
case "property":
return this.selectFileWithProperty(
resolution.field,
resolution.value,
resolution.filter,
);
case "folder":
return this.selectFileInFolder(resolution.folder, false);
case "file":
Expand All @@ -665,14 +674,16 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
):
| { kind: "vault" }
| { kind: "tag"; tag: string }
| { kind: "property"; field: string; value?: string; filter: FieldFilter }
| { kind: "folder"; folder: string }
| { kind: "file"; path: string } {
// Resolution order:
// 1) empty => vault picker
// 2) #tag => tag picker
// 3) trailing "/" => folder picker (explicit)
// 4) known file extension => file
// 5) ambiguous => folder if it exists and no same-name file exists; else file
// 3) property:<field>[=<value>] => frontmatter-property picker
// 4) trailing "/" => folder picker (explicit)
// 5) known file extension => file
// 6) ambiguous => folder if it exists and no same-name file exists; else file
const normalizedCaptureTo = this.stripLeadingSlash(
formattedCaptureTo.trim(),
);
Expand All @@ -688,6 +699,24 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
};
}

// `property:<field>[=<value>]` pre-filters by a frontmatter field (issue #466).
// Checked before the `.base`/extension/folder branches so a property value
// containing `.md`/`/` (or a trailing `/`) can never misroute to a file/folder.
const propertyTarget = parsePropertyTarget(normalizedCaptureTo);
if (propertyTarget) {
if (!propertyTarget.field) {
throw new ChoiceAbortError(
"Property capture target needs a field name, e.g. property:type=draft",
);
}
return {
kind: "property",
field: propertyTarget.field,
value: propertyTarget.value,
filter: propertyTarget.filter,
};
}

if (BASE_FILE_EXTENSION_REGEX.test(normalizedCaptureTo)) {
throw new ChoiceAbortError(
`Capture to '.base' files is not supported (${normalizedCaptureTo}). Use a Template choice instead.`,
Expand Down Expand Up @@ -797,35 +826,93 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
const tagWithHash = tag.startsWith("#") ? tag : `#${tag}`;
const filesWithTag = getMarkdownFilesWithTag(this.app, tagWithHash);

invariant(filesWithTag.length > 0, `No files with tag ${tag}.`);
return this.selectFileFromSet(filesWithTag, `No files with tag ${tag}.`);
}

private async selectFileWithProperty(
field: string,
value: string | undefined,
filter: FieldFilter,
): Promise<string> {
const filesWithProperty = getMarkdownFilesWithProperty(
this.app,
field,
value,
filter,
);

const propertyLabel = value !== undefined ? `${field}=${value}` : field;
return this.selectFileFromSet(
filesWithProperty,
`No notes with property ${propertyLabel}.`,
);
}

/**
* Whether a typed picker value already resolves to an existing note — by exact
* path (root or a typed sub-path, with/without a .md/.canvas extension) OR by a
* bare basename matching a note in ANY folder. `vaultBasenames` is the set of
* existing note basenames (lowercased), built once per picker so this is O(1)
* per keystroke. Suppresses the "Create new note" affordance for any name that
* already exists, so a vault-wide picker never mislabels an existing note as
* creatable, captures into it, or spawns a duplicate-basename note.
*/
private captureTargetAlreadyExists(
value: string,
vaultBasenames: Set<string>,
): boolean {
const raw = value.trim();
if (!raw) return false;
const base = raw.replace(/\.(md|canvas)$/i, "");
const pathCandidates = [raw, `${base}.md`, `${base}.canvas`];
if (
pathCandidates.some(
(path) => !!this.app.vault.getAbstractFileByPath(path),
)
) {
return true;
}
return vaultBasenames.has(base.toLowerCase());
}

// Quick-Switcher-style ordering; show note names (not raw paths) for tag scope.
/**
* Shared picker for the "anywhere in the vault" capture scopes (tag, property):
* the matched notes can live in any folder, so the picker shows full paths. The
* "Create new note" affordance is suppressed for any name that already exists
* in the vault (by path or basename, in any folder), so typing an existing —
* possibly non-matching — note never mislabels as "create", never silently
* captures into that file, and never spawns a duplicate-basename note.
*/
private async selectFileFromSet(
files: TFile[],
notFoundMessage: string,
): Promise<string> {
invariant(files.length > 0, notFoundMessage);

// Quick-Switcher-style ordering; show note names (not raw paths).
const filePaths = orderFilesForPicker(
filesWithTag,
files,
buildPickerOrderingDeps(this.app),
).map((f) => f.path);
const allowCreate = this.choice.createFileIfItDoesntExist?.enabled ?? false;
// Tagged notes can live anywhere, so existence is matched by basename/path
// across the tagged set rather than by re-prefixing a folder.
const existingTagged = new Set<string>();
for (const f of filesWithTag) {
existingTagged.add(f.path);
existingTagged.add(f.basename);
}
// Build once (not per keystroke): existing note basenames across the vault.
const vaultBasenames = new Set(
this.app.vault
.getMarkdownFiles()
.map((f) => f.basename.toLowerCase()),
);
let targetFilePath: string;
try {
targetFilePath = await InputSuggester.Suggest(
this.app,
filePaths,
filePaths,
{
renderItem: (path, el) =>
renderNotePathSuggestion(el, path),
renderItem: (path, el) => renderNotePathSuggestion(el, path),
allowCustomValue: allowCreate,
customValueLabel: (value) => `Create new note: ${value}`,
valueExists: (value) =>
existingTagged.has(value) ||
existingTagged.has(value.replace(/\.md$/i, "")),
this.captureTargetAlreadyExists(value, vaultBasenames),
},
);
} catch (error) {
Expand Down
29 changes: 25 additions & 4 deletions src/gui/ChoiceBuilder/components/CaptureTargetSetting.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getAllFolderPathsInVault } from "../../../utilityObsidian";
import { sortFolderPathsByTree } from "../../../utils/folder-sorting";
import { FormatSyntaxSuggester } from "../../suggesters/formatSyntaxSuggester";
import { isCanvasTargetPath, normalizeVaultPath } from "../canvasNodes";
import { isPropertyTarget, parsePropertyTarget } from "../../../utils/propertyTarget";
import SettingItem from "../../components/SettingItem.svelte";
import Toggle from "../../components/Toggle.svelte";
import ValidatedInput from "./ValidatedInput.svelte";
Expand Down Expand Up @@ -50,7 +51,23 @@ const suggesters = [
new FormatSyntaxSuggester(app, el, plugin),
];

const isCanvasTarget = $derived(isCanvasTargetPath(choice.captureTo));
// A `property:` target filters notes by a frontmatter field — it is NOT a path,
// so showing the file-name format preview would render a misleading fake path.
const isProperty = $derived(isPropertyTarget(choice.captureTo ?? ""));
// Exclude property targets from canvas detection so a contrived value like
// `property:type=foo.canvas` never offers the (meaningless) canvas-node picker.
const isCanvasTarget = $derived(
!isProperty && isCanvasTargetPath(choice.captureTo),
);
const propertyHint = $derived.by(() => {
const parsed = parsePropertyTarget(choice.captureTo ?? "");
if (!parsed || !parsed.field) {
return "Add a field name, e.g. property:type=draft";
}
return parsed.value !== undefined
? `Filters notes whose frontmatter ${parsed.field} = ${parsed.value}`
: `Filters notes that have the frontmatter field ${parsed.field}`;
});

function onCaptureToActiveFileChange(value: boolean) {
// Read the prior state BEFORE mutating (one-way toggle, not bind).
Expand Down Expand Up @@ -88,7 +105,7 @@ function onCaptureToChange(value: string) {

<SettingItem
name="Capture to"
desc="Vault-relative path. Supports format syntax (use trailing '/' for folders)."
desc="Vault-relative path, #tag, or property:field=value. Supports format syntax (use trailing '/' for folders)."
/>

<SettingItem name="Capture to active file">
Expand All @@ -103,9 +120,13 @@ function onCaptureToChange(value: string) {
{#if !choice.captureToActiveFile}
<SettingItem
name="File path / format"
desc={"Choose a file, folder, or format syntax (e.g., {{DATE}})"}
desc={"Choose a file, folder, #tag, property:field=value, or format syntax (e.g., {{DATE}})"}
/>
<FormatPreviewField value={choice.captureTo} formatterKind="fileName" {app} {plugin} />
{#if isProperty}
<div class="qa-field-hint qa-field-hint--neutral">{propertyHint}</div>
{:else}
<FormatPreviewField value={choice.captureTo} formatterKind="fileName" {app} {plugin} />
{/if}
<ValidatedInput
value={choice.captureTo}
placeholder="File name format"
Expand Down
Loading