diff --git a/docs/docs/Choices/CaptureChoice.md b/docs/docs/Choices/CaptureChoice.md index 5d95066a..8bf42ba8 100644 --- a/docs/docs/Choices/CaptureChoice.md +++ b/docs/docs/Choices/CaptureChoice.md @@ -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. @@ -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:=` 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**. diff --git a/src/engine/CaptureChoiceEngine.selection.test.ts b/src/engine/CaptureChoiceEngine.selection.test.ts index bac9e80e..c0293018 100644 --- a/src/engine/CaptureChoiceEngine.selection.test.ts +++ b/src/engine/CaptureChoiceEngine.selection.test.ts @@ -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( diff --git a/src/engine/CaptureChoiceEngine.ts b/src/engine/CaptureChoiceEngine.ts index ccfc3261..172c66c2 100644 --- a/src/engine/CaptureChoiceEngine.ts +++ b/src/engine/CaptureChoiceEngine.ts @@ -30,6 +30,7 @@ import { appendToCurrentLine, getMarkdownFilesInFolder, getMarkdownFilesWithTag, + getMarkdownFilesWithProperty, insertFileLinkToActiveView, insertOnNewLineAbove, insertOnNewLineBelow, @@ -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"; @@ -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": @@ -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:[=] => 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(), ); @@ -688,6 +699,24 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { }; } + // `property:[=]` 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.`, @@ -797,21 +826,81 @@ 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 { + 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, + ): 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 { + 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(); - 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( @@ -819,13 +908,11 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { 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) { diff --git a/src/gui/ChoiceBuilder/components/CaptureTargetSetting.svelte b/src/gui/ChoiceBuilder/components/CaptureTargetSetting.svelte index 3141ef20..63c93cec 100644 --- a/src/gui/ChoiceBuilder/components/CaptureTargetSetting.svelte +++ b/src/gui/ChoiceBuilder/components/CaptureTargetSetting.svelte @@ -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"; @@ -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). @@ -88,7 +105,7 @@ function onCaptureToChange(value: string) { @@ -103,9 +120,13 @@ function onCaptureToChange(value: string) { {#if !choice.captureToActiveFile} - + {#if isProperty} +
{propertyHint}
+ {:else} + + {/if} ({ getMarkdownFilesInFolderMock: vi.fn(() => []), getMarkdownFilesWithTagMock: vi.fn(() => []), + getMarkdownFilesWithPropertyMock: vi.fn(() => []), getUserScriptMock: vi.fn(), getTemplateFileMock: vi.fn(() => null), isFolderMock: vi.fn(() => false), @@ -29,6 +32,7 @@ const { vi.mock("src/utilityObsidian", () => ({ getMarkdownFilesInFolder: getMarkdownFilesInFolderMock, getMarkdownFilesWithTag: getMarkdownFilesWithTagMock, + getMarkdownFilesWithProperty: getMarkdownFilesWithPropertyMock, getUserScript: getUserScriptMock, getTemplateFile: getTemplateFileMock, isFolder: isFolderMock, @@ -231,10 +235,12 @@ describe("collectChoiceRequirements - capture targets", () => { beforeEach(() => { getMarkdownFilesInFolderMock.mockReset(); getMarkdownFilesWithTagMock.mockReset(); + getMarkdownFilesWithPropertyMock.mockReset(); isFolderMock.mockReset(); logWarningMock.mockReset(); getMarkdownFilesInFolderMock.mockReturnValue([]); getMarkdownFilesWithTagMock.mockReturnValue([]); + getMarkdownFilesWithPropertyMock.mockReturnValue([]); }); it("normalizes capture folder paths ending in .md", async () => { @@ -267,7 +273,97 @@ describe("collectChoiceRequirements - capture targets", () => { expect( requirements.some( (requirement) => - requirement.id === "QA_INTERNAL_CAPTURE_TARGET_FILE_PATH", + requirement.id === QA_INTERNAL_CAPTURE_TARGET_FILE_PATH, + ), + ).toBe(false); + }); + + it("forces the capture target dropdown for a property:field=value target (issue #466)", async () => { + const requirements = await collectChoiceRequirements( + app, + plugin, + choiceExecutor, + createCaptureChoice("property:type=draft"), + ); + + expect(getMarkdownFilesWithPropertyMock).toHaveBeenCalledWith( + app, + "type", + "draft", + expect.any(Object), + ); + expect(getMarkdownFilesWithTagMock).not.toHaveBeenCalled(); + expect(getMarkdownFilesInFolderMock).not.toHaveBeenCalled(); + expect( + requirements.some( + (requirement) => + requirement.id === QA_INTERNAL_CAPTURE_TARGET_FILE_PATH, + ), + ).toBe(true); + }); + + it("treats a value-less property target as presence mode (undefined value)", async () => { + await collectChoiceRequirements( + app, + plugin, + choiceExecutor, + createCaptureChoice("property:type"), + ); + + expect(getMarkdownFilesWithPropertyMock).toHaveBeenCalledWith( + app, + "type", + undefined, + expect.any(Object), + ); + }); + + it("passes pipe filters through to the property query", async () => { + await collectChoiceRequirements( + app, + plugin, + choiceExecutor, + createCaptureChoice("property:type=draft|folder:Notes"), + ); + + expect(getMarkdownFilesWithPropertyMock).toHaveBeenCalledWith( + app, + "type", + "draft", + expect.objectContaining({ folder: "Notes" }), + ); + }); + + it("does not force the dropdown for a tokenized property value", async () => { + const requirements = await collectChoiceRequirements( + app, + plugin, + choiceExecutor, + createCaptureChoice("property:type={{VALUE}}"), + ); + + expect(getMarkdownFilesWithPropertyMock).not.toHaveBeenCalled(); + expect( + requirements.some( + (requirement) => + requirement.id === QA_INTERNAL_CAPTURE_TARGET_FILE_PATH, + ), + ).toBe(false); + }); + + it("does not force the dropdown for a property target missing a field name", async () => { + const requirements = await collectChoiceRequirements( + app, + plugin, + choiceExecutor, + createCaptureChoice("property:"), + ); + + expect(getMarkdownFilesWithPropertyMock).not.toHaveBeenCalled(); + expect( + requirements.some( + (requirement) => + requirement.id === QA_INTERNAL_CAPTURE_TARGET_FILE_PATH, ), ).toBe(false); }); diff --git a/src/preflight/collectChoiceRequirements.ts b/src/preflight/collectChoiceRequirements.ts index 32db0097..36577f3a 100644 --- a/src/preflight/collectChoiceRequirements.ts +++ b/src/preflight/collectChoiceRequirements.ts @@ -13,12 +13,14 @@ import type { IUserScript } from "src/types/macros/IUserScript"; import { getMarkdownFilesInFolder, getMarkdownFilesWithTag, + getMarkdownFilesWithProperty, getTemplateFile, getUserScript, isFolder, } from "src/utilityObsidian"; import { log } from "src/logger/logManager"; import { hasTemplatePathSyntax } from "src/utils/templatePathSyntax"; +import { parsePropertyTarget } from "src/utils/propertyTarget"; import { orderFilesForPicker } from "src/utils/fileOrdering"; import { buildPickerOrderingDeps } from "src/utils/pickerOrderingDeps"; import { resolveExistingVariableKey } from "src/utils/valueSyntax"; @@ -208,20 +210,39 @@ async function collectForCaptureChoice( const formattedTarget = choice.captureTo?.trim() ?? ""; const normalizedTarget = formattedTarget.replace(/^\/+/, ""); - const isTagTarget = normalizedTarget.startsWith("#"); + // A `property:` target whose field/value carries a format token can only be + // resolved at run time (mirrors the tokenized-file-path case), so skip the + // preflight dropdown for it rather than forcing a dead "no files" picker. + const propertyTarget = + !hasTemplatePathSyntax(normalizedTarget) + ? parsePropertyTarget(normalizedTarget) + : null; + const isPropertyTarget = !!propertyTarget && !!propertyTarget.field; + const isTagTarget = !isPropertyTarget && normalizedTarget.startsWith("#"); const trimmedPath = normalizedTarget.replace(/\/$|\.md$/g, ""); const isFolderTarget = - !isTagTarget && (normalizedTarget === "" || isFolder(app, trimmedPath)); - const looksLikeFolderBySuffix = normalizedTarget.endsWith("/"); + !isTagTarget && + !isPropertyTarget && + (normalizedTarget === "" || isFolder(app, trimmedPath)); + const looksLikeFolderBySuffix = + !isPropertyTarget && normalizedTarget.endsWith("/"); if ( !choice.captureToActiveFile && - (isTagTarget || + (isPropertyTarget || + isTagTarget || isFolderTarget || looksLikeFolderBySuffix) ) { let files: TFile[] = []; - if (isTagTarget) { + if (isPropertyTarget && propertyTarget) { + files = getMarkdownFilesWithProperty( + app, + propertyTarget.field, + propertyTarget.value, + propertyTarget.filter, + ); + } else if (isTagTarget) { files = getMarkdownFilesWithTag(app, normalizedTarget); } else { const folder = normalizedTarget.replace(/(?:\/|\.md)+$/g, ""); diff --git a/src/utilityObsidian.ts b/src/utilityObsidian.ts index 8a495c1f..70e4c322 100644 --- a/src/utilityObsidian.ts +++ b/src/utilityObsidian.ts @@ -65,6 +65,7 @@ export { isFolder, getMarkdownFilesInFolder, getMarkdownFilesWithTag, + getMarkdownFilesWithProperty, } from "./utils/vaultQueries"; export { findObsidianCommand, deleteObsidianCommand } from "./utils/obsidianCommands"; diff --git a/src/utils/propertyTarget.test.ts b/src/utils/propertyTarget.test.ts new file mode 100644 index 00000000..6e88276f --- /dev/null +++ b/src/utils/propertyTarget.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { isPropertyTarget, parsePropertyTarget } from "./propertyTarget"; + +describe("parsePropertyTarget", () => { + it("returns null for a non-property target", () => { + expect(parsePropertyTarget("Notes/Inbox.md")).toBeNull(); + expect(parsePropertyTarget("#people")).toBeNull(); + expect(parsePropertyTarget("")).toBeNull(); + expect(parsePropertyTarget("Projects/")).toBeNull(); + }); + + it("parses field=value equality", () => { + expect(parsePropertyTarget("property:type=draft")).toEqual({ + field: "type", + value: "draft", + filter: {}, + }); + }); + + it("parses a value-less target as presence mode (undefined value)", () => { + expect(parsePropertyTarget("property:type")).toEqual({ + field: "type", + value: undefined, + filter: {}, + }); + }); + + it("treats an empty value after '=' as presence mode", () => { + expect(parsePropertyTarget("property:type=")).toEqual({ + field: "type", + value: undefined, + filter: {}, + }); + }); + + it("matches the prefix case-insensitively", () => { + expect(parsePropertyTarget("PROPERTY:type=draft")?.field).toBe("type"); + expect(parsePropertyTarget("Property:Type=Draft")).toEqual({ + field: "Type", + value: "Draft", + filter: {}, + }); + }); + + it("trims whitespace around field and value", () => { + expect(parsePropertyTarget("property: type = draft ")).toEqual({ + field: "type", + value: "draft", + filter: {}, + }); + }); + + it("splits on the FIRST '=' so values may contain '='", () => { + expect(parsePropertyTarget("property:expr=a=b")).toEqual({ + field: "expr", + value: "a=b", + filter: {}, + }); + }); + + it("returns an empty field for a malformed target (caller aborts)", () => { + expect(parsePropertyTarget("property:")).toEqual({ + field: "", + value: undefined, + filter: {}, + }); + expect(parsePropertyTarget("property:=draft")).toEqual({ + field: "", + value: "draft", + filter: {}, + }); + }); + + it("reserves '|' for pipe filters and parses them via the shared FIELD grammar", () => { + expect(parsePropertyTarget("property:type=draft|folder:Notes")).toEqual({ + field: "type", + value: "draft", + filter: { folder: "Notes" }, + }); + const parsed = parsePropertyTarget( + "property:type=draft|exclude-folder:Archive|exclude-tag:done", + ); + expect(parsed?.field).toBe("type"); + expect(parsed?.value).toBe("draft"); + expect(parsed?.filter.excludeFolders).toEqual(["Archive"]); + expect(parsed?.filter.excludeTags).toEqual(["done"]); + }); + + it("does not let a '|' bleed into the value (reserved)", () => { + expect(parsePropertyTarget("property:type=a|b")?.value).toBe("a"); + }); +}); + +describe("isPropertyTarget", () => { + it("detects the prefix case-insensitively, ignoring surrounding whitespace", () => { + expect(isPropertyTarget("property:type=draft")).toBe(true); + expect(isPropertyTarget(" PROPERTY:type ")).toBe(true); + expect(isPropertyTarget("Notes/Inbox.md")).toBe(false); + expect(isPropertyTarget("#tag")).toBe(false); + }); +}); diff --git a/src/utils/propertyTarget.ts b/src/utils/propertyTarget.ts new file mode 100644 index 00000000..630a320b --- /dev/null +++ b/src/utils/propertyTarget.ts @@ -0,0 +1,68 @@ +import { FieldSuggestionParser, type FieldFilter } from "./FieldSuggestionParser"; + +/** + * A parsed "Capture to" frontmatter-property target (issue #466). Mirrors the + * `#tag` capture target, but pre-filters notes by a frontmatter field/value: + * + * property:type=draft → notes whose frontmatter `type` equals `draft` + * property:type → notes that HAVE a `type` field (presence mode) + * property:type=draft|folder:Notes|exclude-tag:archived + * → equality + the shared {{FILE}}/{{FIELD}} pipe filters + * + * The `property:` prefix is matched case-insensitively (like the `#` sigil) and + * survives the engine's `formatFileName` pass, so the value may even carry format + * tokens (`property:type={{VALUE}}`) that resolve before classification. + * + * `|` is RESERVED for the pipe-filter grammar (parsed by {@link FieldSuggestionParser}, + * exactly as `{{FILE:}}` does), so a literal `|` cannot appear in a property value. + */ +export interface PropertyTarget { + /** Frontmatter field name (case-insensitively matched at query time). May be "" when malformed. */ + field: string; + /** Target value; `undefined` means presence mode (match any value, incl. empty). */ + value?: string; + /** folder / tag / exclude-* filters from `|pipes`; empty object when none. */ + filter: FieldFilter; +} + +const PROPERTY_PREFIX = /^property:/i; + +/** Whether a (trimmed) "Capture to" value is a `property:` target. */ +export function isPropertyTarget(raw: string): boolean { + return PROPERTY_PREFIX.test(raw.trim()); +} + +/** + * Parse a `property:` capture target. Returns `null` when the value is not a + * property target at all (no `property:` prefix), so callers fall through to the + * tag/folder/file resolution. A present-but-malformed target (empty field name) + * returns `{ field: "" }` so the engine can abort with a clear message rather + * than silently creating a literal file. + * + * This is the SINGLE classifier used by both the capture engine and the one-page + * preflight, so the two paths can never disagree on what is a property target. + */ +export function parsePropertyTarget(raw: string): PropertyTarget | null { + const trimmed = raw.trim(); + if (!PROPERTY_PREFIX.test(trimmed)) return null; + + const interior = trimmed.replace(PROPERTY_PREFIX, ""); + + // Reuse the shared FIELD pipe grammar: the parser splits on `|`, treats the + // first part as the "field name" (here our `field=value` core) and parses the + // rest as folder/tag/exclude-* filters — identical to {{FILE:}}/{{FIELD:}}. + // Only folder / tag / exclude-folder / exclude-tag / exclude-file are honored + // by the property query (see getMarkdownFilesWithProperty); other FIELD options + // the parser accepts (default:/inline:/case-sensitive:) are inert here — value + // matching is always case-insensitive by design. + const { fieldName: core, filters } = FieldSuggestionParser.parse(interior); + + const eq = core.indexOf("="); + const field = (eq >= 0 ? core.slice(0, eq) : core).trim(); + // Split on the FIRST `=`, so values may contain further `=` (e.g. `a=b`). + const rawValue = eq >= 0 ? core.slice(eq + 1).trim() : ""; + // Empty value (`property:type` or `property:type=`) → presence mode. + const value = rawValue.length > 0 ? rawValue : undefined; + + return { field, value, filter: filters }; +} diff --git a/src/utils/vaultQueries.test.ts b/src/utils/vaultQueries.test.ts new file mode 100644 index 00000000..6f1c04df --- /dev/null +++ b/src/utils/vaultQueries.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import type { App, TFile } from "obsidian"; +import { + frontmatterValueMatches, + getMarkdownFilesWithProperty, +} from "./vaultQueries"; + +describe("frontmatterValueMatches", () => { + it("matches scalar strings case-insensitively and trimmed", () => { + expect(frontmatterValueMatches("draft", "draft")).toBe(true); + expect(frontmatterValueMatches("Draft", "draft")).toBe(true); + expect(frontmatterValueMatches(" draft ", "draft")).toBe(true); + expect(frontmatterValueMatches("draft", "note")).toBe(false); + }); + + it("coerces numbers and booleans (including 0 and false)", () => { + expect(frontmatterValueMatches(1, "1")).toBe(true); + expect(frontmatterValueMatches(0, "0")).toBe(true); + expect(frontmatterValueMatches(true, "true")).toBe(true); + expect(frontmatterValueMatches(false, "false")).toBe(true); + expect(frontmatterValueMatches(0, "")).toBe(false); + }); + + it("matches any element of an array", () => { + expect(frontmatterValueMatches(["draft", "idea"], "idea")).toBe(true); + expect(frontmatterValueMatches(["draft", "idea"], "DRAFT")).toBe(true); + expect(frontmatterValueMatches([1, 2, 3], "2")).toBe(true); + expect(frontmatterValueMatches(["draft", "idea"], "log")).toBe(false); + }); + + it("never matches null/undefined (avoids the String(null) === 'null' trap)", () => { + expect(frontmatterValueMatches(null, "draft")).toBe(false); + expect(frontmatterValueMatches(undefined, "draft")).toBe(false); + expect(frontmatterValueMatches(null, "null")).toBe(false); + }); + + it("never equality-matches a nested object", () => { + expect(frontmatterValueMatches({ a: 1 }, "[object Object]")).toBe(false); + expect(frontmatterValueMatches([{ a: 1 }], "[object Object]")).toBe(false); + }); +}); + +type FakeFile = { path: string; basename: string }; + +function makeApp( + files: Array<{ path: string; basename: string; frontmatter: Record | null }>, +): App { + const fileObjs: FakeFile[] = files.map((f) => ({ + path: f.path, + basename: f.basename, + })); + const cacheByPath = new Map( + files.map((f) => [f.path, f.frontmatter ? { frontmatter: f.frontmatter } : {}]), + ); + return { + vault: { + getMarkdownFiles: () => fileObjs as unknown as TFile[], + }, + metadataCache: { + getFileCache: (file: TFile) => + cacheByPath.get((file as unknown as FakeFile).path) ?? null, + }, + } as unknown as App; +} + +describe("getMarkdownFilesWithProperty", () => { + const app = makeApp([ + { path: "Draft A.md", basename: "Draft A", frontmatter: { type: "draft" } }, + { path: "Draft B.md", basename: "Draft B", frontmatter: { Type: "Draft" } }, + { path: "Note C.md", basename: "Note C", frontmatter: { type: "note" } }, + { path: "Arr D.md", basename: "Arr D", frontmatter: { type: ["draft", "idea"] } }, + { path: "Empty E.md", basename: "Empty E", frontmatter: { type: null } }, + { path: "Sub/Draft F.md", basename: "Draft F", frontmatter: { type: "draft" } }, + { path: "None G.md", basename: "None G", frontmatter: null }, + ]); + + it("matches by value, case-insensitively on key and value, including arrays", () => { + const paths = getMarkdownFilesWithProperty(app, "type", "draft").map( + (f) => f.path, + ); + expect(paths.sort()).toEqual( + ["Arr D.md", "Draft A.md", "Draft B.md", "Sub/Draft F.md"].sort(), + ); + }); + + it("presence mode matches any note that has the field (including null value)", () => { + const paths = getMarkdownFilesWithProperty(app, "type").map((f) => f.path); + expect(paths).toContain("Empty E.md"); + expect(paths).not.toContain("None G.md"); + expect(paths).toHaveLength(6); + }); + + it("returns nothing for an empty field name", () => { + expect(getMarkdownFilesWithProperty(app, " ")).toEqual([]); + }); + + it("applies a folder pipe filter (intersection with the property match)", () => { + const paths = getMarkdownFilesWithProperty(app, "type", "draft", { + folder: "Sub", + }).map((f) => f.path); + expect(paths).toEqual(["Sub/Draft F.md"]); + }); + + it("applies an exclude-folder pipe filter", () => { + const paths = getMarkdownFilesWithProperty(app, "type", "draft", { + excludeFolders: ["Sub"], + }) + .map((f) => f.path) + .sort(); + expect(paths).toEqual(["Arr D.md", "Draft A.md", "Draft B.md"].sort()); + }); +}); diff --git a/src/utils/vaultQueries.ts b/src/utils/vaultQueries.ts index 6d0be413..60c69ed2 100644 --- a/src/utils/vaultQueries.ts +++ b/src/utils/vaultQueries.ts @@ -1,5 +1,7 @@ import type { App, CachedMetadata, TFile } from "obsidian"; import { TFolder } from "obsidian"; +import { EnhancedFieldSuggestionFileFilter } from "./EnhancedFieldSuggestionFileFilter"; +import type { FieldFilter } from "./FieldSuggestionParser"; export function getAllFolderPathsInVault(app: App): string[] { return app.vault @@ -76,3 +78,86 @@ export function getMarkdownFilesWithTag(app: App, tag: string): TFile[] { return fileTags.includes(targetTag); }); } + +/** + * Whether a frontmatter value equals a (case-insensitive, trimmed) target string. + * Pure and Obsidian-free so it can be unit-tested directly. + * + * - `null`/`undefined` never equals a non-empty target — this also stops the + * `String(null) === "null"` coercion trap (an empty `type:` property would + * otherwise match `property:type=null`). + * - Arrays match if ANY element string-coerces to the target (e.g. + * `type: [draft, idea]` matches `draft`), mirroring multi-valued tags. + * - Scalars (string/number/boolean) match by `String(value)` — so `0`/`false` + * compare correctly (a naive `if (!value)` would drop them). + * - Nested objects (non-array) never equality-match a scalar target. + * + * Obsidian stores frontmatter dates as strings, so no Date handling is needed. + */ +export function frontmatterValueMatches(raw: unknown, target: string): boolean { + const normalizedTarget = target.trim().toLowerCase(); + if (raw === null || raw === undefined) return false; + if (Array.isArray(raw)) { + return raw.some( + (element) => + element !== null && + element !== undefined && + typeof element !== "object" && + String(element).trim().toLowerCase() === normalizedTarget, + ); + } + if (typeof raw === "object") return false; + return String(raw).trim().toLowerCase() === normalizedTarget; +} + +/** + * Markdown files whose frontmatter matches a property target (issue #466): + * - `value === undefined` → presence mode: the file HAS the field (any value, + * including an empty/`null` value — Obsidian stores an empty property as `null`). + * - otherwise → the field equals `value` (see {@link frontmatterValueMatches}). + * + * The field name is matched case-insensitively (Obsidian's metadata cache + * preserves the author's key case, e.g. `Type`, so a case-sensitive lookup would + * miss it). An optional {@link FieldFilter} (folder / tag / exclude-*) is applied + * via the same {@link EnhancedFieldSuggestionFileFilter} used by `{{FILE:}}`, so + * the pipe-filter grammar behaves identically across features. + */ +export function getMarkdownFilesWithProperty( + app: App, + field: string, + value?: string, + filter?: FieldFilter, +): TFile[] { + const targetField = field.trim().toLowerCase(); + if (!targetField) return []; + + let files = app.vault.getMarkdownFiles().filter((f: TFile) => { + const frontmatter = app.metadataCache.getFileCache(f)?.frontmatter; + if (!frontmatter) return false; + + const entry = Object.entries(frontmatter).find( + ([key]) => key.toLowerCase() === targetField, + ); + if (!entry) return false; + + if (value === undefined) return true; // presence mode + return frontmatterValueMatches(entry[1], value); + }); + + const hasFilter = + !!filter && + (!!filter.folder || + !!filter.tags?.length || + !!filter.excludeFolders?.length || + !!filter.excludeTags?.length || + !!filter.excludeFiles?.length); + if (hasFilter) { + files = EnhancedFieldSuggestionFileFilter.filterFiles( + files, + filter as FieldFilter, + (file) => app.metadataCache.getFileCache(file), + ); + } + + return files; +}