Skip to content

feat: bulk edit metadata across folders and selected notes - #124

Merged
chhoumann merged 4 commits into
masterfrom
chhoumann/64-bulk-ops
Jun 27, 2026
Merged

feat: bulk edit metadata across folders and selected notes#124
chhoumann merged 4 commits into
masterfrom
chhoumann/64-bulk-ops

Conversation

@chhoumann

Copy link
Copy Markdown
Owner

Summary

Adds a coherent bulk metadata edit capability: add or update a single YAML
frontmatter property across many notes at once, safely and idempotently.

Reachable two ways:

  • Folder context menu -> "Bulk edit metadata in this folder (and subfolders)" (replaces the old blind per-file writer).
  • File-explorer multi-selection -> "Bulk edit metadata in selected notes" (new files-menu entry - the literal ask of Add feature to add a property to multiple notes. #64).

The flow: prompt for a property name and value, then - only when some notes
already define the property - choose a conflict policy once:

Policy Behavior
Skip (default) Add only where missing; never touch existing values (no duplicates).
Merge Add the value into a list, without duplicating; every note ends up list-shaped.
Overwrite Replace existing values. Gated behind a blast-radius confirmation.

A single summary Notice reports the outcome (added / merged / overwritten / skipped / unchanged / failed). Per-note failures are isolated and logged; one
bad note never aborts the batch.

Closes #64. Closes #20.

Why these issues, and what about #32?

Design

  • Idempotent by construction. Each note's decision is a pure function
    (src/bulk/bulkMetadata.ts): re-running any policy is a no-op (proven by unit
    tests and live re-run E2E). Merge dedupes by stable serialization and every
    policy converges.
  • Atomic, safe writes. Each note's read -> decide -> write happens inside a
    single app.fileManager.processFrontMatter callback - the same frontmatter
    primitive the controller write path is built on - not hand-rolled YAML text
    edits. Decisions use the note's live frontmatter, never the lazy metadata
    cache.
  • Parity with single-note adds. Bulk adds honor EditMode multi-value
    wrapping, so YAML shape is consistent whether you add one note or many.
  • Cancel-safe UI. An owned BulkOptionModal resolves null on Escape; text
    prompts catch the prompt's cancel rejection and abort cleanly.

Adversarial review

Design and implementation each went through opposing-model adversarial review
(refute-first, multiple lenses). Findings folded in: cancel-safe modal; atomic
read-decide-write against live frontmatter; EditMode parity; recursive menu
reachability (items now appear when markdown lives only in subfolders);
overwrite confirmation framed against the authoritative selection size (never a
cache count that could understate the blast radius); empty value aborts; failures
reported once via console.warn (not per-note error Notices). Verified live that
processFrontMatter performs no write on a no-op, so skip/unchanged re-runs
don't churn files or fire automators.

Collision boundary

Touches only src/main.ts, src/Modals/LinkMenu.ts, new files under
src/bulk/, src/Types/obsidian-augmentations.d.ts, and styles.css. It edits
none of: metaController.ts, parser.ts, Settings/, the suggester modals, or
MetaEditApi.ts.

Validation

Obsidian 1.12.7, isolated worktree E2E vault. Branch rebased on current
master (integrates #120 / #122).

  • pnpm run lint -> 0 errors (only pre-existing warnings; 2 adjacent ones fixed).
  • pnpm run build -> clean.
  • pnpm run test -> 96 unit tests pass (22 new for the decision engine).
  • pnpm run test:e2e -> 16 live tests pass (11 pre-existing + 5 new), each asserting zero runtime errors.
  • Live multi-note proof: seeded a nested folder with mixed states (no
    frontmatter, scalar conflict, array, empty, subfolder note, non-md file) and
    verified collection (recursion + md filter + dedup), every policy, idempotent
    re-runs, EditMode wrapping, recursive menu reachability, the multi-select menu
    wiring, the full interactive flow (prompts -> option modal -> summary), and
    cancel-safety (Escape aborts with no writes, no hang). Final frontmatter
    verified clean (correct fences, list formatting, merged [x, y, z] with no
    duplicates).

Known limitations / deliberate choices

  • Not undoable. Bulk edits write programmatically and aren't reversible with
    Ctrl+Z (same as the previous folder command); that's why Overwrite is confirmed.
  • Merge dedupes by string form, so 5 and "5" are treated as the same
    value (prevents visually-identical duplicates). Pre-existing duplicates inside
    a list are left as-is rather than silently rewritten.
  • Conflict count in the policy prompt is read from Obsidian's metadata cache
    (the same source as the existing getFilesWithProperty); the apply uses live
    frontmatter and the summary reports the true counts. The destructive overwrite
    confirmation is framed against the authoritative selection size, so it can
    never understate the blast radius.
  • Write serialization. Bulk writes use processFrontMatter (atomic,
    key-scoped) sequentially; they are not cross-serialized with the controller's
    private per-file queue, which lives in a file outside this change's boundary.
    Risk is low because writes are sequential and merge into existing frontmatter
    by key.
  • Large folders are processed sequentially (correct; no mid-run progress beyond
    the final summary).

Pure, Obsidian-free logic deciding what to write for one note given a
conflict policy (skip/overwrite/merge), with stable-serialization
equality so merge dedupes across types and every policy converges to a
no-op on re-run. Covered by 22 unit tests.
Replace the blind per-file folder writer with a policy-aware bulk flow,
reachable from the folder context menu and a new file-explorer
multi-selection (files-menu) item.

- Each note's read/decide/write runs atomically inside
  app.fileManager.processFrontMatter (the controller's frontmatter
  primitive), so decisions use live frontmatter, not the lazy cache.
- Conflict policy (skip/merge/overwrite) is chosen once; destructive
  overwrite is gated behind a blast-radius confirmation (closes the
  spirit of #20). A cancel-safe option modal resolves null on Escape.
- Adds honor EditMode multi-value wrapping for parity with single-note
  adds; merge keeps every note list-shaped and dedupes idempotently.
- One summary Notice; per-note failures are isolated and logged.

Closes #64, #20.
Live-vault coverage for folder add + idempotent re-run, merge into
lists without duplication, idempotent overwrite, multi-selection
dedup, and the files-menu item wiring - each asserting zero runtime
errors.
- Folder/multi-select menu items now appear when markdown lives only in
  subfolders, matching the recursive collector (was a direct-child-only
  gate). Regression-tested live.
- Frame the overwrite confirmation against the authoritative selection
  size instead of a cache-derived conflict count, so the stated blast
  radius can never understate what the live apply overwrites.
- Abort the flow on an empty value rather than writing a blank property
  across the batch.
- Report per-note failures once via console.warn instead of
  log.logWarning (which routes to logError, spamming Notices and
  registering as runtime errors).
@chhoumann
chhoumann merged commit c8d91e2 into master Jun 27, 2026
8 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1aebe430c1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// across the whole batch, matching the prior folder command's guard.
if (!rawValue) return;

const conflicts = this.countExisting(files, key);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detect conflicts from live frontmatter

When the selected notes were just created or externally modified and Obsidian's metadata cache has not caught up, countExisting() can return 0 even though applyToFile() later sees the key via processFrontMatter; in that case the interactive flow never offers Merge or Overwrite and silently applies the default skip policy, leaving those existing values unchanged. The conflict scan should use the same live frontmatter source as the write path, or otherwise wait for/cache-validate metadata before deciding whether to show the policy modal.

Useful? React with 👍 / 👎.

github-actions Bot pushed a commit that referenced this pull request Jul 1, 2026
# [1.9.0](1.8.4...1.9.0) (2026-07-01)

### Bug Fixes

* address review findings from PRs [#119](https://github.com/chhoumann/MetaEdit/issues/119)-[#128](https://github.com/chhoumann/MetaEdit/issues/128) ([adf0b57](adf0b57))
* **api:** preserve reserved keys when cloning values ([#162](#162)) ([cff6076](cff6076))
* **automators:** count only [x]/[X] tasks complete; null-guard Kanban lane display ([#145](#145)) ([21da53a](21da53a))
* **bulk:** reject __proto__/constructor as a bulk property key ([#148](#148)) ([f03eacc](f03eacc))
* **bulk:** serialize bulk frontmatter writes through the controller write queue ([#147](#147)) ([3eb8236](3eb8236))
* **controller:** guard __proto__/constructor in all frontmatter writes ([#159](#159)) ([9d99fae](9d99fae))
* **controller:** make inline-field writes fence-aware ([5f8e713](5f8e713))
* **core,api:** safe command no-op, block-list deletion, presence-based property lookups ([#143](#143)) ([1702d08](1702d08))
* edit YAML lists (incl. tags) as native lists instead of collapsing them to a string ([#94](#94)) ([#128](#128)) ([79f6ee0](79f6ee0)), closes [#51](#51) [#36](#36)
* harden frontmatter metadata writes ([add2e90](add2e90))
* Kanban helper syncs only the card's leading link, not trailing date/reference links ([#126](#126)) ([52aec1e](52aec1e))
* **kanban:** don't write to an ambiguous same-named note ([#158](#158)) ([7f9655b](7f9655b))
* **kanban:** single notice for a card missing the board property ([9b1c88a](9b1c88a))
* parse inline fields behind list/quote markers and brackets ([#122](#122)) ([a62ef21](a62ef21)), closes [#119](#119) [#18](#18) [#121](#121) [#78](#78) [#84](#84)
* **parser:** preserve [[wikilinks]] in multi-value inline edits ([c331adb](c331adb))
* **settings:** serialize Auto Property choice persistence to prevent lost updates ([#154](#154)) ([2066b3e](2066b3e))
* **settings:** settings tab no longer clobbers concurrently-added Auto Property choices ([5759534](5759534))
* **settings:** validate auto-properties before queueing and make rollbacks compare-and-restore ([#156](#156)) ([8cbacdc](8cbacdc)), closes [#154](#154)
* stop inline-field updates appending a stray bracket ([#127](#127)) ([d2617f5](d2617f5)), closes [#67](#67) [#121](#121)
* **suggester:** center row action icons flush-right ([#166](#166)) ([c00795e](c00795e))
* **suggester:** use native icons + tooltips for row actions ([70903cc](70903cc))
* **suggester:** well-formed duplicate Notice, robust modal close, cleaner name suggestions ([#144](#144)) ([915dd6b](915dd6b)), closes [#143](#143)
* **tags:** correct and clarify tag editing ([#142](#142)) ([b837899](b837899)), closes [#49](#49)
* tolerate malformed YAML frontmatter when parsing note metadata ([#132](#132)) ([b835f97](b835f97)), closes [#130](#130)

### Features

* adopt modern Obsidian APIs and raise baseline ([5131664](5131664))
* **api:** append Dataview field instances without replacing ([42c78a8](42c78a8)), closes [#91](#91)
* **api:** expand public integration surface ([#120](#120)) ([bbf0a7a](bbf0a7a))
* assist property-value entry with autocomplete and a native date picker ([#125](#125)) ([cd86235](cd86235)), closes [#61](#61) [#74](#74) [#61](#61) [#61](#61)
* **auto-properties:** description, multi-select, and learn-as-you-go values ([#123](#123)) ([4b24296](4b24296)), closes [#59](#59) [#40](#40) [#59](#59) [#40](#40) [#59](#59) [#40](#40) [#43](#43) [#30](#30)
* **auto-properties:** paste a list to split into choices ([#139](#139)) ([0245f7a](0245f7a))
* bulk edit metadata across folders and selected notes ([#124](#124)) ([c8d91e2](c8d91e2)), closes [#64](#64) [#20](#20)
* **create:** fluid, type-aware native YAML property creation ([#170](#170)) ([59b4c5d](59b4c5d))
* **editor:** edit properties with Obsidian's native widgets; raise minAppVersion to 1.12.7 ([#168](#168)) ([73b37e0](73b37e0))
* hide file tags from the Edit Meta menu ([#46](#46), [#90](#90)) ([#131](#131)) ([2c07937](2c07937)), closes [#tag](https://github.com/chhoumann/MetaEdit/issues/tag) [#tags](https://github.com/chhoumann/MetaEdit/issues/tags)
* support nested YAML path editing ([e9fce98](e9fce98))
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 1.9.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add feature to add a property to multiple notes. don't duplicate properties inside folders

1 participant