# Zotero Vault Sync A different kind of Zotero plugin for Obsidian. Instead of "importing" templated markdown snapshots on demand (static, template bound, pull-on-demand — like other Zotero plugins), **Zotero Vault Sync syncs a database**: it keeps a hidden folder inside your vault that is a live, complete, git-versionable mirror of your Zotero library. Every other feature — search, generated "note views", annotations, metadata — reads from that mirror, so everything is instant and keeps working with Zotero closed. This is the difference between *an import plugin* and *a Zotero client inside Obsidian*. ## Screenshots

Zotero Vault Sync settings — part 1 Zotero Vault Sync settings — part 2

Zotero Vault Sync settings — part 3

*Everything — connection to Zotero, mirror folder, sync triggers, generated notes and Dataview dashboards — is configured from this single settings tab.* --- ## Why a mirror? | | Import plugins (template snapshots) | Zotero Vault Sync | |---|---|---| | Data model | One markdown note per item, created on demand | Full library DB: one JSON file per Zotero item + per-PDF annotation files | | Sync | Pull when you ask | Incremental, automatic whenever Zotero changes | | Zotero closed | New snapshots impossible | Mirror already in the vault — everything else reads it offline | | Git | Duplicated, drifted copies | The mirror is the source of truth; every change is a small, clean diff | | "Notes" | Static snapshots that age | Generated *views* over the mirror, regenerable any time | ## How it works Zotero ships a **local HTTP API** (Settings → Advanced → *"Allow other applications on this computer to communicate with Zotero"*) that serves your local database over `http://localhost:23119/api/` — offline, no API key. Zotero Mirror talks to it using the same incremental protocol Zotero's own clients use: - **Every poll** fetches only objects newer than the last seen *library version* (`?since=`, cheap), then updates exactly the changed item files. - **Deletion reconcile** (on a cadence, at startup, and on full syncs) pulls a complete snapshot and removes mirrored files whose items vanished upstream — deletions are invisible to incremental syncs. - **PDF annotations** are normal Zotero items (type `annotation`, children of attachment items), so they arrive through the same incremental path and are aggregated into one file per PDF. The plugin needs **Zotero 7+** and only runs while Obsidian is open (like every Obsidian plugin): edits you make in Zotero are picked up on the next poll (default every 60 s), on window focus, and on Obsidian startup. Close Obsidian while changing Zotero → changes sync the next time Obsidian opens. A web-API source (zotero.org, needs an API key) is also available for syncing while the Zotero app is closed. ## The mirror folder Everything lives in one folder (default `_zotero/`, configurable — use a leading dot such as `.zotero` if you want Obsidian's file explorer to hide it): ``` _zotero/ ├── README.md # created once, explains the folder ├── .state.json # sync cursor (library version, timestamps) — only written on changes ├── collections.json # collections (key, name, parent) ├── index.json # regenerable search index: one lightweight summary per item ├── notes.json # registry of generated note views (key → note path) ├── items/.json # ONE FILE PER ITEM — the core of the mirror │ # (bibliographic items, notes, attachments AND annotations) └── annotations/.json # per-PDF aggregated annotation files ``` `items/.json` stores the raw Zotero API record (`key`, `version`, `meta`, `data` with creators/tags/collections/relations, …). `annotations/.json` is derived (one entry per highlight with color, page label, text, comment, position) and regenerated whenever the PDF or its highlights change. `index.json` and `notes.json` are derived too — you can delete them and they get rebuilt. **Version the folder with git** and you get full history of your Zotero library (metadata, notes, highlights) in your vault, diffable and greppable, no Zotero needed. ### Reading the mirror from other tools Everything is plain JSON, versioned and readable with Zotero closed: - `_zotero/index.json` — regenerable index: one lightweight summary per item (`key`, `version`, `itemType`, `title`, `creators` (string), `year`, `date`, `parentItem`, `collections` (keys), `tags`). - `_zotero/items/.json` — full API record of one item: `data` holds creators/title/abstractNote/tags/collections/relations/DOI/url/…, `meta` the Zotero-side summary (e.g. `creatorSummary`), plus the object `version`. - `_zotero/annotations/.json` — one per PDF attachment: `filename`, `contentType`, `annotations[]` with `pageLabel`, `pageIndex`, `color`/`colorName`, `text`, `comment`, `position`. - `_zotero/collections.json` — `[{key, name, parentCollection, version}]`. Dataview, Templater, scripts or your own plugins can load any of these files and render the data as tables, cards, counts, graphs, … — that is what "a Zotero client inside Obsidian" means: your vault data, queryable like any other. > **Folder-name note:** keep the underscore spelling (`_zotero`, the default) > for Dataview/tooling queries. Obsidian treats folders starting with a dot as > hidden and does not index their files, so a leading-dot folder is only for > people who never query the mirror and just want it out of the way. > **Dataview API note (verified against Dataview's source):** dataviewjs has no > `dv.io.loadJson`, and `dv.io.load` only reads files Obsidian has *indexed* — > unreliable for mirror files. Dataview does expose `dv.app`, so read mirror > JSON straight from disk: > > ```dataviewjs > async function readJson(p) { > try { return JSON.parse(await dv.app.vault.adapter.read(p)); } catch { return null; } > } > const idx = await readJson("_zotero/index.json"); // works for any file > ``` ### Ready-made Dataview views (generated dashboards) The easiest way to "build views with Dataview": let the plugin write them. After the **first completed mirror** (or after a reset) it creates three dashboard notes — or run *"Zotero Vault Sync: Create/refresh Dataview views"* any time: | View note (in `Zotero Views/`) | Renders | |---|---| | `Zotero Items.md` | every top-level item: clickable title (opens its generated note), creators, year, type, collections, mirror-JSON link | | `Zotero PDFs & Annotations.md` | every PDF with each highlight: page, color, text, comment | | `Zotero Library Stats.md` | mirror snapshot, items by type, most-used tags | The notes contain `dataviewjs` queries that read the mirror JSON live (they use `dv.app.vault.adapter.read`, so they work even though Dataview itself doesn't index the mirror files). They are regenerable: refreshes overwrite a file only while it still carries its `` marker — delete the marker to protect a customized copy. You can embed a dashboard anywhere with `![[Zotero Items]]`. The generated queries also link each item to its note view via the `notes.json` registry, so the views double as an index of your Zotero notes. If you prefer hand-rolled blocks, here are equivalent minimal recipes (all use the `readJson` helper above, which also works for old Dataview builds — no `loadJson` needed; `index.json`, `collections.json` and `annotations/*.json` are regenerable, so experiments are safe): #### Items overview (one file read) ```dataviewjs const MIRROR = "_zotero"; // ← your mirror folder from the settings async function readJson(p) { try { return JSON.parse(await dv.app.vault.adapter.read(p)); } catch { return null; } } const idx = await readJson(MIRROR + "/index.json"); if (!idx) dv.paragraph("Mirror not synced yet — run “Zotero Vault Sync: Sync now”."); else { const coll = {}; for (const c of (await readJson(MIRROR + "/collections.json")) ?? []) coll[c.key] = c.name; const tops = idx.items.filter(i => !i.parentItem && i.itemType !== "attachment"); dv.table(["Title", "Creators", "Year", "Type", "Collections"], tops.map(i => [i.title ?? "(untitled)", i.creators ?? "", i.year ?? "", i.itemType, i.collections.map(k => coll[k] ?? k).join(", ")])); } ``` #### PDFs and their annotations (one read per attachment) ```dataviewjs const MIRROR = "_zotero"; async function readJson(p) { try { return JSON.parse(await dv.app.vault.adapter.read(p)); } catch { return null; } } const idx = await readJson(MIRROR + "/index.json"); const rows = []; for (const pdf of (idx?.items ?? []).filter(i => i.itemType === "attachment")) { const list = (await readJson(MIRROR + "/annotations/" + pdf.key + ".json"))?.annotations ?? []; if (!list.length) rows.push([pdf.title ?? pdf.key, "", "", "— no highlights —", ""]); for (const a of list) rows.push([pdf.title ?? pdf.key, a.pageLabel ?? "", a.colorName ?? a.color ?? "", a.text, a.comment ?? ""]); } dv.table(["PDF", "Page", "Color", "Highlight", "Comment"], rows); ``` #### Full detail for one item, from the current note's frontmatter ```dataviewjs const MIRROR = "_zotero"; async function readJson(p) { try { return JSON.parse(await dv.app.vault.adapter.read(p)); } catch { return null; } } const key = dv.current().zotero-key; if (!key) dv.paragraph("This note has no `zotero-key`."); else { const rec = await readJson(MIRROR + "/items/" + key + ".json"); dv.header(3, rec.data.title ?? key); dv.list([ (rec.data.creators ?? []).map(c => c.name ?? ((c.firstName ?? "") + " " + (c.lastName ?? "")).trim()).filter(Boolean).join("; "), "Type: " + rec.data.itemType + " · Year: " + (rec.data.date ?? ""), rec.data.abstractNote ? "**Abstract:** " + rec.data.abstractNote.replace(/<[^>]+>/g, "") : null, "Tags: " + (rec.data.tags ?? []).map(t => t.tag ?? t).join(", "), ].filter(Boolean)); } ``` Need the data as *prose*, not a table? Use the plugin's generated note views: *"Open generated note for a Zotero item"* renders any item (metadata + child notes + per-PDF highlights) from the same mirror files, and *"Open a Zotero item's mirror JSON file"* opens the raw record in the editor. ## Commands | Command | What it does | |---|---| | **Sync now** | Incremental pull of everything changed since last sync | | **Full sync & reconcile mirror** | Re-pulls everything and removes mirrored files deleted in Zotero | | **Search Zotero items and insert reference card** | Fuzzy search over the mirror → inserts a reference blockquote into the active note | | **Open generated note for a Zotero item** | Picks an item → opens its note view, generating it first if needed | | **Open a Zotero item in the Zotero app** | Picks an item → `zotero://` URI | | **Open a Zotero item's mirror JSON file** | Browse the raw mirrored record | | **Refresh note for the Zotero item in the active document** | Rebuilds the generated view for the note you're reading (`zotero-key` in frontmatter) | | **Refresh all generated Zotero notes from the mirror** | Rebuilds every generated note that still has its markers | | **Create generated notes for every top-level item** | One note view per bibliographic item (existing ones refreshed) | | **Create/refresh Dataview views** | Writes the three Dataview dashboard notes (Items, PDFs & annotations, Stats) into the views folder | The ribbon button and the status bar item ("Zotero: synced … (N)") trigger a sync; right-click the status bar for a menu. The status bar shows mirror size and the last sync time, and turns red/grey when Zotero is unreachable or misconfigured. **Syncs always confirm completion.** Anything you trigger (command, ribbon, status bar) shows a notice even when nothing changed — e.g. *"Up to date. Mirror: 98 items (45 PDFs, 0 annotations)."* Automatic syncs (startup, poll, window focus) are quieter: they announce the first completed mirror, applied changes, and errors, so you never wonder whether the database was mirrored. ## Generated note views Notes are pure **views over the mirror**: rendering reads only `_zotero/items`, never Zotero, so regeneration works offline and after any sync. A generated note looks like: ``` --- zotero-key: ABC123DE zotero-vault-sync: true zotero-item-type: journalArticle title: "…" updated: "2026-…" tags: - "methods" --- [ your own preamble — survives regeneration ] # Title > [!info]- Item > **Author, A.** · journalArticle, *Journal*… … [ your own notes after the block — also survives ] ``` Default behavior (Overwrite: **region only**) rebuilds *only* the text between the markers and updates only the frontmatter keys the plugin manages (`zotero-key`, `zotero-vault-sync`, `zotero-item-type`, `title`, `updated`, `tags`). Everything else is yours. "Full" overwrite mode exists for people who treat the notes as 100% generated. ### Custom templates Set *Settings → Generated note views → Custom template* to any markdown file in the vault. The template renders the **body only** (frontmatter is managed). | Token | Meaning | |---|---| | `{{key}}`, `{{title}}`, `{{year}}`, `{{date}}` | item key, title, year, date | | `{{creators}}`, `{{creatorList}}`, `{{creatorsShort}}` | creators (Last, First / First Last / truncated) | | `{{itemTypeLabel}}`, `{{publicationTitle}}`, `{{abstract}}` | type, venue, abstract (HTML stripped) | | `{{doi}}`, `{{url}}`, `{{collections}}`, `{{tags}}`, `{{citation}}` | fields, collection names, tags, offline citation | | `{{zoteroLink}}`, `{{mirrorLink}}` | "Open in Zotero" and mirror-JSON links | | `{{#metaRows}}\| {{field}} \| {{value}} \|{{/metaRows}}` | metadata table rows | | `{{#childNotes}}…{{/childNotes}}` | child Zotero notes (`{{content}}`) | | `{{#attachments}}…{{/attachments}}` | per attachment (`{{filename}}`, `{{contentType}}`, `{{attachmentLink}}`) | | `{{#annotations}}…{{/annotations}}` | inside attachments: highlights (`{{colorName}}`, `{{colorEmoji}}`, `{{pageLabel}}`, `{{text}}`, `{{comment}}`) | | `{{?list}}…{{/list}}` | render the block once if the list is non-empty | ## Settings - **Source** — *Zotero desktop (local API)* or *zotero.org (Web API)*; local base URL / user id / API key, plus a **Test connection** button. - **Mirror folder** — where the mirror lives; **Reset mirror** wipes it and does a full re-sync (generated notes are never touched). - **Sync triggers** — on startup, on window focus, poll interval (0 = off), deletion-reconcile interval. - **Generated note views** — notes folder, custom template, overwrite mode, what to include (child notes / attachments / annotations), annotation preview length, creators per citation. - **Dataview views** — folder for the generated dashboard notes, auto-create them after the first mirror, and a *Create/refresh views now* button (requires the Dataview community plugin). ## Install (development build) 1. `npm install && npm run build` in this folder. 2. Copy the folder (or symlink it) into `/.obsidian/plugins/zotero-vault-sync/` and enable **Zotero Vault Sync** in Settings → Community plugins. 3. In Zotero: Settings → Advanced → tick *"Allow other applications on this computer to communicate with Zotero"* (Zotero 7+), then run *Zotero Vault Sync: Sync now*. ## Troubleshooting - **"Test connection" fails with ERR_EMPTY_RESPONSE / "requestUrl failed"** — Zotero's local server is a minimal HTTP/1.0 server that *closes connections carrying browser-style headers* (e.g. `Accept-Encoding: gzip, deflate, br`, browser `User-Agent`, `Origin`, `Sec-Fetch-*`), which is exactly what Obsidian's `requestUrl` (Electron/Chromium) sends. Zotero Vault Sync therefore talks to the local API with a plain Node `http` request and a minimal header set, and only falls back to `requestUrl` (used for the HTTPS web API). If the test still fails: Zotero silently switches to a different port when 23119 is occupied, or the *Allow other applications…* preference is off. - **"Zotero refused the request (403)"** — enable the *Allow other applications…* preference in Zotero, then Test connection. - **Status bar shows "Zotero: offline"** — Zotero isn't running or the local API preference is off. The mirror still serves search/notes. - **Changes only appear while Obsidian is open** — that's inherent to Obsidian plugins; edits sync on the next poll/focus/startup. - **Clicking a "Zotero" / "Open in Zotero" link opens Zotero but doesn't select the item** — that link uses the legacy `zotero://select/items/` route, which current Zotero does not act on. Zotero only honours `zotero://select/library/items/` (My Library) and `zotero://select/groups//items/` (group libraries). New links use the working route, but links already written into a note keep the old one: refresh the generated note (or re-insert the reference card) to replace them. - **Items in the Zotero Trash aren't mirrored** — the API excludes trashed items; this mirrors your *library*. - **Never-synced/offline-only Zotero items (version 0)** — full snapshots and reconciles cover these (version maps list every key). - **The mirror folder looks stale in the file explorer** — mirror files are written straight to disk (fast, no note indexing). Restart Obsidian or collapse/reopen the folder if needed; git and other tools always see the current files. A leading-dot folder avoids explorer clutter entirely. - **Generated note has no markers / won't refresh** — marker-based refresh is intentionally conservative: it never silently deletes your edits. Switch overwrite to *full* to replace such a file. ## Roadmap - Group libraries, better-bibtex citekeys, live citation rendering (CSL via Zotero's formatter, cached in the mirror), watch-the-Zotero-log event sync, mobile web-API sync, YAML storage option, mirror browser pane. ## License MIT © 2026 Kerekes Stefan