Search...Search plugins and themes...
⌘K
Sign in
  • Get started
  • Download
  • Pricing
  • Enterprise
  • Account
  • Obsidian
  • Overview
  • Sync
  • Publish
  • Canvas
  • Mobile
  • Web Clipper
  • CLI
  • Learn
  • Help
  • Developers
  • Changelog
  • About
  • Roadmap
  • Blog
  • Resources
  • System status
  • License overview
  • Terms of service
  • Privacy policy
  • Security
  • Community
  • Plugins
  • Themes
  • Discord
  • Forum / 中文论坛
  • Merch store
  • Brand guidelines
Follow us
DiscordTwitterBlueskyThreadsMastodonYouTubeGitHub
© 2026 Obsidian

Zotero Vault Sync

Kerekes StefanKerekes Stefan52 downloads

Mirror 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.

Add to Obsidian
  • Overview
  • Scorecard
  • Updates6

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=<version>, 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/<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.

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/<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, 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:

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 <!-- 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):

Items overview (one file read)

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)

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

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 ]

<!-- 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.

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 <vault>/.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/<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.
  • 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

HealthExcellent
ReviewPassed
About
Mirror your Zotero library inside your vault as a live, hidden, git-versionable folder that stores one file per item plus per-PDF annotation files. Read, search, and generate notes or dashboards from that mirror offline while incremental automatic syncs update only changed items and reconcile deletions.
SyncingIntegrationsResearch
Details
Current version
0.0.6
Last updated
14 hours ago
Created
2 weeks ago
Updates
6 releases
Downloads
52
Compatible with
Obsidian 1.13.0+
Platforms
Desktop only
License
MIT
Report bugRequest featureReport plugin
Author
Kerekes StefanKerekes Stefanstef4678
GitHubstef4678
  1. Community
  2. Plugins
  3. Syncing
  4. Zotero Vault Sync

Related plugins

Self-hosted LiveSync

Sync vaults securely to self-hosted servers or WEBRTC.

Fast Note Sync

Real-time sync of your vaults across server, mobile, and web; shareable with anyone; supports REST and MCP integrations to build your personal AI knowledge base.

Google Drive Sync

Syncs a vault into Google Drive for cross-platform use (works for iOS).

Zotero Integration

Insert and import citations, bibliographies, notes, and PDF annotations from Zotero.

Sync Engine

The extensible vault synchronization engine: Fast · Free · Reliable. Supports WebDAV, S3, and Google Drive.

Nutstore Sync

Sync your vault with Nutstore (坚果云) using WebDAV protocol.

OneDrive Sync

Sync your Obsidian vault with OneDrive Personal/Consumer

Remotely Save

Sync notes between local and cloud with smart conflict: S3, Dropbox, webdav, OneDrive, Google Drive, Box, pCloud, Yandex Disk, Koofr, Azure Blob Storage.

Nextcloud Sync

Bidirectional sync between Obsidian and Nextcloud using hash-based change detection.

Air Sync

Keep your vault in sync across devices without managing sync—sync only what changed, preserve conflicting edits, and use your own Google Drive, OneDrive, or Dropbox.