Kerekes Stefan52 downloadsMirror your Zotero library into a git-versioned folder in the vault — one JSON file per item plus per-PDF annotations — synced incrementally via Zotero's local HTTP API, with regenerable notes and Dataview views that work with Zotero closed.
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.
Everything — connection to Zotero, mirror folder, sync triggers, generated notes and Dataview dashboards — is configured from this single settings tab.
| 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 |
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:
?since=<version>, cheap), then updates exactly the changed item files.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.
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/<itemKey>.json # ONE FILE PER ITEM — the core of the mirror
│ # (bibliographic items, notes, attachments AND annotations)
└── annotations/<attachmentKey>.json # per-PDF aggregated annotation files
items/<key>.json stores the raw Zotero API record (key, version, meta,
data with creators/tags/collections/relations, …). annotations/<key>.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.
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/<key>.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/<attachmentKey>.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, anddv.io.loadonly reads files Obsidian has indexed — unreliable for mirror files. Dataview does exposedv.app, so read mirror JSON straight from disk: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
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 <!-- zotero-views:generated --> 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):
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(", ")]));
}
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);
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.
| 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.
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 ]
<!-- zotero-vault-sync:start -->
# Title
> [!info]- Item
> **Author, A.** · journalArticle, *Journal*…
…
<!-- zotero-vault-sync:end -->
[ 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.
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 |
npm install && npm run build in this folder.<vault>/.obsidian/plugins/zotero-vault-sync/
and enable Zotero Vault Sync in Settings → Community plugins.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://select/items/<key> route,
which current Zotero does not act on. Zotero only honours
zotero://select/library/items/<key> (My Library) and
zotero://select/groups/<groupID>/items/<key> (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.MIT © 2026 Kerekes Stefan