Files
Marcos Paulo 7f432138df
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
docs: Obsidian plugin parity research, Bases spec and tasks, empty notescraft-bases crate
Inventory of the 29 core plugins (22 done, 2 partial, 5 missing), 80 community plugins with a verdict each, a ranked roadmap with 8 ready briefs, the Bases spec (D1-D13) and 20 tasks, ASCII wireframes of the Bases screens (PaperCraft was not running). crates/bases is registered at layer L2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:30:28 -03:00

28 KiB

Bases: database views over notes (.base files)

Part of specs/plugins-parity/. The owner asked on 2026-10-21 for Obsidian's Bases core plugin (Table, Cards, List) and the Map view (Obsidian ships it as the community plugin Maps). SPEC 6.1 lists "Bases/database views" and "Dataview-style queries" as deferred; this spec lifts both: a base is the one query surface, Dataview-style queries become a .base the user can click through.

Reference: the official help vault ~/Documents/vaults/obsidian-help-all/en/Bases/ (Bases syntax.md, Functions.md, Formulas.md, Views.md, Layouts/*.md). Where this spec and those pages differ, the pages win for the file format (we must open files Obsidian wrote and Obsidian must open ours).

Problem

A vault of 15 000 notes with properties (author, year, status, coordinates) has no way to see them as a table, sort them, filter them, or edit a value across many notes. The user must open each note, or write a query in a plugin we do not have. The properties view (views/properties_view) edits ONE note. The query embed (obsidian-basics task 19) shows search results, not columns.

Goal

A .base file opens as a tab with a toolbar (view menu, Sort, Group, Filter, Properties, Search, New). Views are Table, Cards, List and (with the Maps feature on) Map, over the notes the file's filters select. Cells show note properties, file properties and formulas; a cell of a note property is editable and writes the note's frontmatter. The same base can be embedded in a note. Everything is an engine command (SPEC 10), the evaluator is a pure crate, and a 15 000-note vault scrolls the table at frame rate.

Non-Goals

  • Kanban view (Obsidian 1.14+). type: kanban and unknown view types are kept in the file and shown as a placeholder card "view type not supported"; never dropped on save.
  • Writing formulas that mutate anything. The language is pure.
  • JS plugin function libraries (Obsidian: "in the future, plugins will add functions").
  • Bases over non-Markdown file properties beyond the file.* set (no PDF metadata).
  • Real-time refresh of file.backlinks on every keystroke (Obsidian does not either).
  • Vector map tiles / TileJSON styles in v1 (raster {z}/{x}/{y} only).
  • Importing Dataview dataview blocks. A converter is a later task of the roadmap.

Design decisions

D1 - Crate and layering

New crate crates/bases, package notescraft-bases, layer L2 (add to xtask/src/layers.rs and the root [workspace.dependencies]; this registration is owner/lead work, agents may not edit Cargo.toml). Depends on notescraft-markdown (L0) only, plus thiserror. No I/O, no clock (the caller passes now), no egui. It never sees notescraft-index types: the rows come through a trait

pub trait NoteSource {
    fn len(&self) -> usize;                 // number of candidate files
    fn row(&self, i: usize) -> Option<RowRef<'_>>;   // lazy: no copies
}
pub struct RowRef<'a> { /* path, ext, size, ctime, mtime, tags, links (as &[String]),
                          props: &'a [(String, Value)], backlinks: &'a dyn Fn() -> Vec<String> */ }

The engine implements NoteSource on a snapshot of notescraft-index (D2). L2 may depend on L1, so a later task may let bases use index types directly; the trait keeps the crate testable with a Vec<TestRow> and keeps the evaluator free of index internals.

D2 - What the index must learn: properties

NoteInfo (crates/index/src/model.rs) holds path, title, aliases, tags, headings, block ids and links. It does not hold frontmatter properties, so a base cannot filter on year > 2000 without reading 15 000 files. Add to the index, filled by update_note (the content is already in hand): props: Vec<(String, PropValue)> per note (typed through the existing notescraft_markdown::{PropValue, infer_type} and .obsidian/types.json), plus size, ctime, mtime (from the vault scan; insert_info path). Limits: 200 properties per note, 4 KiB per value string, list length 500; excess is dropped and flagged, never a panic. The on-disk index cache (crates/engine/src/vault/index_cache/) gains the new fields under a bumped version (an old cache is rebuilt, not migrated).

Nested maps in frontmatter stay Raw today (yaml::Value::Raw). Bases needs a.b access (note.coordinates, file.properties.icon). v1: one level, a Raw map value is parsed lazily by the evaluator when a formula touches .field.

D3 - The .base file format (YAML)

Exactly the schema of Bases syntax.md. Top-level keys, all optional, unknown keys preserved:

filters:            # string | { and|or|not: [string | filter-object] }
formulas:           # name: expression-string
properties:         # id: { displayName: str }      id = note.x | file.x | formula.x | bare x (=note.x)
summaries:          # name: expression-string (custom summaries; `values` is the column's list)
views:
  - type: table|cards|list|map|<other>
    name: str
    limit: n
    filters: ...                        # AND-ed with the global filters
    groupBy: { property: id, direction: ASC|DESC }
    groupOrder: [value|null, ...]
    order: [id, ...]                    # visible columns, in order
    sort: [{ property: id, direction: ASC|DESC }, ...]
    summaries: { id: Average|Min|Max|Sum|Range|Median|Stddev|Earliest|Latest|Checked|Unchecked|Empty|Filled|Unique|<custom> }
    columnSize: { id: px }              # table
    rowHeight: short|medium|tall|extra-tall          # table
    cardSize: n  image: id  imageFit: cover|contain  imageAspectRatio: n     # cards
    markers: bullets|numbers|none  indentProperties: bool  separator: ","    # list
    coordinates: id  markerIcon: id  markerColor: id  center: [lat,lng]  zoom: n  minZoom: n  maxZoom: n  height: n  tiles: url   # map

Rules:

  • Parse is lossless for keys we do not model (kept in an ordered extra map per object) and emits keys in the original order, so Obsidian's own additions survive a save. Comments in the YAML are not preserved (Obsidian drops them too); this is documented in the settings card.
  • A parse error opens the tab in a "raw YAML" fallback with the error line, never a crash, never an empty file written back.
  • YAML subset: block maps and lists, flow lists [a, b], plain / single- / double-quoted scalars, | and > block scalars, null, numbers, booleans, comments ignored. No anchors, no tags, no multi-document. The existing notescraft_markdown::yaml has NO nested maps (it keeps them Raw), so the crate carries its own small parser and emitter (yaml.rs, ~400 lines). Owner question Q1: approve a YAML crate instead.
  • Limits: file at most 1 MiB, depth 12, 100 views, 200 formulas, expression at most 4 KiB, expression AST depth 64 (iteration or a depth counter, never unbounded recursion).

D4 - Property ids and references

note.x / bare x -> the frontmatter property x. file.name|basename|path|folder|ext|size|ctime| mtime|tags|links|embeds|backlinks|properties|file. formula.x. this.* (D8). note["x y"] for keys with spaces. Resolution order for a bare identifier: note, then error unknown_property (null at filter time, not an error; a formula referencing a missing property is null). formula.a may use formula.b; a cycle is detected before evaluation and reported once per formula (formula_cycle), the cell shows the error chip, the rest of the table still renders.

D5 - The expression language

Tokens and grammar follow the docs: literals (strings in ' or ", numbers, true|false|null), arrays [a, b], objects {k: v} (needed for [latitude, longitude] and list()), member access a.b, a["b"], a[0], calls f(x) and methods x.f(y), lambdas implicit in filter|map|reduce|sort with the names value, index, acc bound (as in the docs). Precedence (high to low): member/call, unary ! -, * / %, + -, comparisons < <= > >=, == !=, &&, ||. if(c, a, b) is a function with lazy branches.

Types: Null, Bool, Number(f64), String, Date{ms, has_time}, Duration{ms}, List, Object, Link{target, display}, File{path}, Regex, Html, Image, Icon. Rules:

  • Date +/- duration string ("1M", "2 hours", "1d"; units y M w d h m s and long forms) -> Date; Date - Date -> Number (ms); duration('5h') * 2 -> Duration; scalar * Duration is a type error. Month arithmetic clamps to the last day (Jan 31 + 1M = Feb 28/29). bases (L2) cannot depend on notescraft-templates (also L2), so it carries its own civil-date helpers (days_from_civil, ~60 lines).
  • == between Link and File compares the resolved path; between Links the same file, else the link text; Strings compare by code point; mixed types are unequal, never an error.
  • Ordering: null < bool < number < string < date < list; within a type natural order; strings compare case-insensitively then by code point. Sort is stable.
  • Truthiness (isTruthy): null, false, 0, "" and empty list are falsy.
  • Errors never panic and never abort the table: an error value renders as a red chip with the message; in a filter an error counts as false and is counted in the status line ("3 errors").

D6 - The function library (all of Functions.md)

Group Functions
Global escapeHTML date duration file html if image icon link list max min now number today random
Any isTruthy isType toString
Date fields year month day hour minute second millisecond; date() format() time() relative() isEmpty()
String field length; contains containsAll containsAny endsWith isEmpty lower replace repeat reverse slice split startsWith title trim
Number abs ceil floor isEmpty round toFixed
List field length; contains containsAll containsAny filter flat isEmpty join map reduce reverse slice sort unique
Link asFile linksTo
File fields name basename path folder ext size properties tags links ctime mtime; asLink hasLink hasProperty hasTag inFolder
Object isEmpty keys values
Regexp matches (literal /re/flags); the engine regex is notescraft-regex (workspace crate), no regex dependency

Each function has a table-driven test: arity, a normal case, a null input (returns null or the documented default), a type error. file.hasTag("a") matches #a and #a/b (nested), as search does. file.inFolder("x") matches a folder and its descendants. random() takes its seed from the caller (Env::seed), never from the OS, so results are reproducible in tests. Default summaries (Average Min Max Sum Range Median Stddev Earliest Latest Checked Unchecked Empty Filled Unique) are implemented in Rust, not as formulas; custom summaries are formulas over values.

D7 - Evaluation and query

pub struct Query<'a> { base: &'a Base, view: usize, this: Option<RowRef<'a>>, now_ms: i64, seed: u64 }
pub struct QueryResult { columns: Vec<Column>, groups: Vec<Group>, total: usize, errors: usize }
pub struct Group { key: Value, rows: Vec<RowId>, summaries: Vec<Value> }   // RowId = index into the source

Pipeline: (1) candidates = every file the index lists (Obsidian: no from, a base selects from the whole vault, attachments included); (2) global filters AND view filters; (3) sort (multi-key, stable); (4) group (groupBy + groupOrder, null key = "no value"); (5) limit; (6) summaries. Column values are computed lazily: the result holds RowIds and QueryResult::cell(row, col) evaluates on demand (memoised per frame by the UI). Sorting evaluates only the sort keys, filters only what they reference (AST property-dependency analysis drives which props to touch). file.backlinks and file.properties are computed only when referenced; a backlinks reference marks the result slow and the UI shows a spinner row.

Caching: the engine keeps (base file hash, view, index generation) -> QueryResult and invalidates on the index generation counter; a typing note does not re-run a base that does not select it (dependency: the set of paths a result contains plus the set of properties it reads).

D8 - this

Open as a tab: this = the base file. Embedded in a note: this = the embedding note. In a sidebar panel (roadmap item, not v1): the active note. The caller passes this: Option<RowRef>.

D9 - Editing cells writes the note

base.row.set {path, property, value} writes ONE frontmatter key of ONE note through the existing vault/properties.rs::set (validates, typed by .obsidian/types.json, lands in the open buffer as one undoable edit or on disk through Session::write_note). Rules: only note.* (bare) properties are editable; file.* and formula.* cells are read-only except file.name (rename through note.rename, link-updating); multi-cell paste is a batch of single sets applied with batch.apply-style all-or-nothing preflight; a failed validation changes nothing and shows the error in the cell. Undo in a table = the buffer undo of each touched note (one undo step per cell edit). New in the toolbar runs note.create in the folder the filters imply (file.inFolder("x") -> x/), with the equality filters (type == "book") pre-filled as frontmatter.

D10 - Engine commands

Id Params Result
base.list none [{path, name, views}] every .base in the vault
base.read path the parsed base as JSON (filters, formulas, properties, summaries, views)
base.query path OR yaml (inline), view (name or index, default 0), this? path, offset?, limit? {columns, total, errors, groups:[{key, count, summaries}], rows:[{path, cells}]} for the slice; limit default 200, max 2000
base.properties path? every property key in the vault with its type and use count (feeds the Properties picker)
base.write path, yaml validated write through craft-atomic; refused when parse fails
base.view.add / base.view.duplicate / base.view.remove / base.view.set path, view, fields edits the file (lossless, D3)
base.row.set path (the note), property, value, base? {changed} (D9)
base.create path, from? (folder or tag to seed a filter) new .base with one Table view

Read ids (base.list read query properties) go to the READ list of ai/assistant_gate.rs; write ids to the WRITE list. The MCP server (crates/mcp) exposes base_query read-only.

D11 - UI

New tab kind TabKind::Base for the base extension (widgets/tabs/kind.rs), view in crates/ui-egui/src/views/base_view/ (folder; max 12 loose files each):

  • toolbar.rs: view menu (switch, add, duplicate, rename, delete, drag to reorder), Results, Sort, Group, Filter, Properties, Search, New. Popovers use the existing widgets popup patterns.
  • table.rs: virtualised (egui::ScrollArea::show_rows) with fixed row heights from rowHeight; sticky header; sortable by clicking a header (adds/flips the first sort); column resize (columnSize) and drag-reorder (order); cell selection, copy/paste, the shortcuts of Table view.md; group headers with count and group summary; summary row at the bottom.
  • properties_popover.rs: the picker of the screenshots: search box, checkbox per property (note, file, formula), drag to reorder, "New formula" at the end.
  • filter_builder.rs: groups All / Any / None, rows property | operator | value, operators by property type (text: is, is not, contains, does not contain, starts with, ends with, is empty; number: = != < <= > >=; date: on, before, after, relative; checkbox: is true/false; list/tags: contains, does not contain, is empty), nested groups, "advanced" toggle showing the raw expression.
  • cards.rs, list.rs: per the docs; cards load cover images through the existing image cache.
  • cell.rs: renders by type: text, number right-aligned, checkbox, date, tags as chips, links clickable (open the note, Cmd-click new tab), images, errors.
  • Status line (bottom of the tab): N results, E errors, and for the selected row the usual note stats (backlinks, words, characters), as in the screenshot.
  • Phone width: Table becomes a list of cards with the first property as title (compact shell).
  • All strings through text/views/base_view/ tables and [ui.base-view] at the END of en.toml.
  • Designs: PaperCraft was not running (2026-10-21), so wireframes are in design/plugins/WIREFRAMES.md and PNGs are open task 20; the implementer renders the tab through the snapshot example and compares with the owner's screenshots (Table, Properties picker, Map popup).

D12 - Embedding

  • A fenced block with language base holds inline YAML; it renders as a card with the views as tabs in its header (the first view is selected) in the note, read-only for structure, cells still editable.
  • ![[x.base]] and ![[x.base#View name]] embed the file (existing embed pipeline, views/note_view/blocks/embed.rs; add base to its extension dispatch).
  • Embedded height: height from the view, default 320 pt, internal scroll. this = the embedding note.
  • Cycle guard: a base embedded inside a base-visible note cannot embed itself; embed depth 3 (as notes).

D13 - Map view and the Maps feature (network policy)

What the Map view needs: a coordinates property per note, a marker icon and colour, a tile background, pan/zoom, clusters, a popup card with the view's properties, "embedded height".

Where it lives. The wasm plugin ABI (crates/plugins) offers log vault_read vault_write vault_list search links backlinks active_note insert_text notice now and NO drawing, NO network, NO views. A wasm "Maps plugin" is therefore impossible without a new UI-contribution ABI. Decision: Maps is a first-party optional core feature, shown as a card in Settings > Core plugins ("Maps", off by default, like Calendar/Templates/Diagrams/Whiteboard: core_plugins() in views/settings_view/categories.rs). With it off, type: map views show a placeholder with an "Enable Maps" button; the .base stays valid. Code: pure maths in crates/bases/src/map/ (data model, projection, clustering, no I/O), the tile fetcher and cache in crates/engine/src/bases/tiles.rs (needs ureq, already approved), the view in views/base_view/map/.

Data model (pure, notescraft_bases::map):

pub struct LatLng { lat: f64, lng: f64 }            // lat in [-85.0511, 85.0511], lng in [-180, 180]
pub struct Marker { row: RowId, at: LatLng, icon: Option<String>, color: Option<Color> }
pub fn parse_coordinates(v: &Value) -> Option<LatLng>   // "lat, lng" | ["lat","lng"] | [lat, lng] | {lat,lng}
pub fn project(at: LatLng, zoom: u8) -> (f64, f64)       // Web Mercator world pixels, 256 px tiles
pub fn cluster(markers: &[Marker], zoom: u8, radius_px: f64) -> Vec<Cluster>   // grid clustering
pub fn fit(markers: &[Marker], viewport_px: (f64, f64)) -> (LatLng, u8)

Invalid or out-of-range coordinates are skipped and counted ("12 notes without coordinates"), never clamped (AGENTS.md: invalid input returns Err/skip, no silent fixing). Clusters: grid of radius_px (default 48) cells in world pixels at the current zoom; a cluster shows the count; a click zooms to its bounds; at max zoom it opens a list popup. 360 markers cluster in < 1 ms.

Tile policy (network content). OpenStreetMap's tile servers are a volunteer service with a usage policy (operations.osmfoundation.org/policies/tiles/): valid identifying User-Agent, attribution, caching, no bulk download, no heavy use. NotesCraft therefore:

  1. Fetches NOTHING until the user enables Maps in Settings, and the first enable shows a one-time notice: "Maps downloads map tiles from tile.openstreetmap.org. Your viewed area is sent to that server." with Enable / Cancel. A second switch "Use tiles" can be turned off to keep the grid.
  2. Fetches only the tiles of the current viewport (plus a 1-tile margin), never prefetches or crawls, at most 2 concurrent requests and at most 1 request/s sustained beyond the first burst of 8 (token bucket), on a worker thread, results polled each frame.
  3. User-Agent: NotesCraft/<version> (+<repo URL>), Referer not sent, HTTPS only, ureq with rustls, 5 s timeout, response at most 256 KiB, must be image/png, decoded with the existing image decoder; anything else is discarded (hostile input).
  4. Caches under .notescraft/cache/tiles/{provider}/{z}/{x}/{y}.png, honouring Cache-Control/ Expires with a floor of 7 days (OSM asks for >= 7), size cap 200 MiB (default, setting) with least-recently-used eviction; .notescraft/cache is private and never synced (add to the sync ignore list). Writes go through craft-atomic.
  5. Attribution "© OpenStreetMap contributors" always visible in the map's corner as a link to openstreetmap.org/copyright; never hideable.
  6. The tile URL template is a setting (tiles, default https://tile.openstreetmap.org/{z}/{x}/{y}.png); the notice names the host; only https:// templates with {z}, {x}, {y} are accepted. Vector/TileJSON URLs from the docs (tiles.openfreemap.org/styles/...) are refused in v1 with a clear message (Non-Goal).
  7. Offline or failed fetch or "Use tiles" off: a neutral grid fallback (graticule lines every N degrees at the current zoom with lat/lng labels, land-less) and the markers still work; a chip "Offline: tiles not loaded". A tile already cached is used offline.
  8. Honesty in the UI: the Maps card in Settings states the host, the cache size with a "Clear cache" button, and that nothing is sent when the map is closed. Engine commands: maps.status (enabled, cache bytes, host), maps.clear_cache; the setting keys in prefs/*_config.rs (maps.enabled, maps.tiles, maps.tile_url, maps.cache_mb).

View settings: coordinates property, marker icon property (a Lucide name; we ship the icons in assets/icons, unknown names fall back to a pin), marker colour property (#hex, rgb(), named CSS colours; var(--color-x) maps to the theme tokens red orange yellow green cyan blue purple pink of the active theme), center, zoom, min/max zoom, height, tiles. Popup card: opens on marker click, shows the view's order properties (title = first), "Open note" button. Interaction: drag pan, scroll/pinch zoom around the pointer with the 150 ms ease, double-click zoom, +/- buttons, right-click "Copy coordinates" (lat, lng, the format the docs say to copy).

User stories

  1. As a reader of books, I open Books.base and see title, author, year, genre, pages and tags as columns, sorted by year, with the 30 results counted.
  2. I open the Properties picker, tick pages, untick genre, drag tags before year; the file order: changes and Obsidian shows the same columns.
  3. I add a filter year >= 2000 and genre is "sci-fi" in the filter builder and see the YAML.
  4. I edit a status cell in place; the note's frontmatter changes and Cmd-Z undoes it.
  5. I add a formula ppu = (price / age).toFixed(2) and sort by it.
  6. I switch the same base to Cards with the cover property as the image.
  7. I group by status and see a count and an Average of pages per group.
  8. I turn on Maps, open Places.base in the Map view, see 360 markers cluster, click one and read its properties in a popup, click "Open note".
  9. I embed base ... in a project note with file.hasLink(this.file) and see its backlinks-like list.
  10. Offline, the Map still shows markers over the grid.

Acceptance Criteria

Format:

  1. Every example in Bases syntax.md, Formulas.md and the four Layouts/*.md parses; saving without edits returns the file byte-equal modulo YAML comments (test over the help vault's embedded examples copied into crates/bases/tests/fixtures/).
  2. An unknown top-level key, unknown view key and unknown view type survive a view edit.
  3. A malformed file shows the raw-YAML fallback with line and column; no write happens.

Evaluator: 4. Every function of D6 has a test; now() + "1 day", today() + "7d", date("2024-12-01") + "1M" + "4h" + "3m" == 2025-01-01 04:03:00, (now() + "1d") - now() == 86 400 000 (docs). 5. price.toFixed(2), if(price, ...), list(type)[0].asFile().properties.icon, file.hasLink(this.file), link(file.name) evaluate as the docs say. 6. A formula cycle, a type error, a division by zero and a 64-deep expression never panic; each gives an error value; cargo test -p notescraft-bases includes a fuzz-ish test over 10 000 random expression strings that asserts no panic and a bounded time.

Engine: 7. base.query over a TempVault of 50 notes returns the rows, order, groups, summaries and counts of a golden JSON; a limit and offset slice. 8. base.row.set changes one frontmatter key, leaves the body byte-identical, honours the property type, refuses file.size and formula.x, refuses an invalid date, and is undoable when the note is open. 9. base.* write ids are blocked in a read-only session and listed in the assistant gate.

Performance (15 000-note synthetic vault, release, M-class laptop; numbers recorded in the test): 10. Cold base.query of a 3-column table with one property filter and one sort: < 60 ms. 11. Warm re-run after one note changed: < 10 ms. Index property fill adds < 15% to the full-index time and < 40 MB RSS at 15 000 notes x 8 properties. 12. The table frame (virtualised, 40 visible rows) paints in < 4 ms median; scrolling 15 000 rows never allocates per-row cells outside the visible window (asserted by a counter in the test). 13. file.backlinks in a column of 15 000 rows computes off the UI thread and the table stays interactive.

UI: 14. A .base opens as a tab with the toolbar; Sort, Group, Filter, Properties popovers change the file and the view; the kittest drives them with real events. 15. Cards and List render the same query; the Map view renders markers, clusters, the popup and the attribution; with Maps off the placeholder offers Enable. 16. ![[x.base]] and ```base render inside a note. 17. Phone width renders the Table as stacked cards.

Maps: 18. No socket is opened while Maps is off (test with a counting fetcher trait). 19. Tile fetcher: the User-Agent, the 2-concurrency limit, the 1 req/s bucket, the 256 KiB cap, the content-type check, the cache hit and the 7-day floor are unit-tested against a local stub HTTP server (copy the publish_stub pattern); the grid fallback renders when the stub is down.

Constraints

  • AGENTS.md: never crash (no unwrap/expect/panic, no [i] on input-derived indices), files under ~400 lines, thiserror, rustdoc on every public item, clippy::pedantic clean, every disk write through craft-atomic, strings in en.toml, no new dependency without owner approval (this spec needs none: YAML is in-house, ureq is approved, notescraft-regex is in the workspace).
  • Layers: bases L2 (markdown only below it); engine glue in crates/engine/src/bases/; UI L6.
  • crates/engine/src/ holds 12 loose files: the module is a new folder bases/ declared with one line.
  • crates/ui-egui/src/views/ is a directory of folders; base_view/ is a new folder.
  • Obsidian compatibility: a .base written by us opens in Obsidian 1.9+; unsupported keys survive.

Open Questions (owner)

  1. YAML: approve a YAML crate (saphyr/yaml-rust2) for .base or keep the in-house subset (recommended: in-house, 400 lines, no new dependency; risk: exotic YAML from Obsidian).
  2. Maps is not a wasm plugin. OK to ship it as an optional core feature behind a Settings card (recommended), or build a UI-contribution ABI for wasm plugins first (large, see roadmap P-ABI)?
  3. Tile provider default: OSM standard tiles (policy-compliant at personal scale, shown with notice) or default to the grid until the user pastes a tile URL (recommended for a shipped app with many users: OSM bans heavy default use)?
  4. Index growth: storing all properties in the index costs memory; OK, or store only the properties named by open bases (lazy, recommended later optimisation)?
  5. Kanban view: in scope for a later task? (Obsidian 1.14.)
  6. Replace the query embed and the Dataview-style queries with bases once Table lands?

Tasks

See tasks.md: five phases, 20 tasks.

Designs

design/plugins/WIREFRAMES.md (PaperCraft was not running on 2026-10-21; no PNGs yet).