v2.8.4 · Zotero 7 / 8 / 9 · GPL-3.0

Drop a PDF.
It lands where it belongs.

A Zotero plugin that watches a folder, imports new files into the right collection, fetches metadata, renames them from a template, and keeps your collection tree mirrored to disk — both ways, with a recoverable trash and conflict gate so nothing gets shredded by accident.

929 unit tests passing 3 sync modes 33 tunable preferences 0 release-blocking issues
The problem

Your downloads folder is where citations go to die.

You download a paper. You mean to add it to Zotero. You don't. Six months later it's 1706.03762.pdf next to 1706.03762 (3).pdf, and you've already forgotten what either one was.

Without it

Manual, lossy, error-prone

  • Drag-and-drop into Zotero, one paper at a time.
  • Click Retrieve metadata. Hope it works.
  • Rename the file by hand. Or don't.
  • Duplicates pile up because nothing catches them.
  • Disk and library drift apart silently.
With it

Drop. Done.

  • Drop a PDF in your watched folder — it imports within 5s.
  • Metadata is fetched automatically (_needs-review tag on failure).
  • File is renamed via your template, sanitized, length-capped.
  • Hash + DOI + ISBN + fuzzy-title dedup, before and after metadata.
  • Optional: your folder tree is your collection tree.
Three modes — pick your level of sync

From "just import" to "two-way mirror with safe delete."

Modes are a single preference. Switch any time — the plugin starts and stops the relevant pipeline in-process, no Zotero restart required.

New in v2.7: whole-library scope (default). By default the plugin now mirrors your entire Zotero library, not just one collection. A PDF dropped at the watch-folder root becomes an Unfiled item; every top-level collection becomes a top-level folder; nested collections map to nested folders. Special views (Duplicates, Unfiled view, saved searches, Trash) are never mirrored. Existing installs are automatically pinned to their current collection-scope behavior on upgrade — whole-library scope is opt-in for them via re-running setup. The scopeMode preference controls this: library (new default) or collection (single chosen sync-root, legacy behavior).

Mode 1

Import only

The safe, classic mode. Disk → Zotero, never the other way.
  • New files imported into matching collections
  • Metadata, rename, dedup, smart rules
  • Recursive subfolder → collection path
  • · No mirroring of changes back to disk
  • · Trashing in Zotero never touches the disk
Best for: anyone trying the plugin for the first time.
Mode 2

Mirror — no delete

Zotero ↔ disk in lockstep, but destructive ops only warn.
  • Everything in Mode 1
  • Rename a collection → folder renames on disk
  • Move an item between collections → file moves on disk
  • New collection in Zotero → new folder on disk
  • · Deletes are warn-only — nothing is removed
Best for: writing-up phase — you want the structure, not the risk.
Mode 3

Mirror + safe delete

Full two-way sync, with a recoverable plugin trash and bulk-delete guard.
  • Everything in Mode 2
  • Trash in Zotero → file → .zotero-watch-trash/
  • Delete folder on disk → collection removed
  • Restore from Zotero bin → file restored from trash
  • Bulk-delete confirm at >10 files or >20% of tree
Best for: settled workflows where you trust the mirror to be authoritative.
Real use

Three people, three workflows.

The plugin is intentionally boring: the interesting part is what it enables. Here's how three different setups use it.

The PhD student doing a literature review

"I download from Google Scholar all day."

Configures the watch folder to ~/Downloads/papers, sync root Research/Thesis, Mode 2. Every PDF saved from a browser lands in Zotero within seconds, properly named, in the right sub-collection. Smart rule auto-tags anything with "transformer" in the title.

Save PDF Hash dedup Fetch metadata Rename Tag via smart rule Filed in Zotero collection
The researcher with a curated tree

"My folder structure on disk is my mental model."

Already has a deep folder tree under ~/Library/PDFs from years of manual organization. Runs first-run baseline in Mode 3 — the plugin adopts every existing PDF into matching collections, mkdirs empty collections, and from then on keeps them in lockstep. Renaming a collection in Zotero renames the folder on disk; deleting a folder on disk trashes the matching collection (with a confirm dialog for anything > 10 files).

Existing tree Baseline reconcile Adopt-into-scope Two-way ops Recoverable trash
The collaborator on a synced folder

"My advisor drops PDFs in our shared Dropbox."

Points the watch folder at the shared Dropbox/OneDrive/Syncthing directory in Mode 1 (import-only — collaborators editing the folder shouldn't affect their Zotero library). New files her advisor drops appear in her library overnight, dedup'd against what she already has, tagged for review if metadata couldn't be retrieved.

Advisor drops PDF Cloud syncs locally Watcher polls Imports if new Skips if duplicate
What's inside

Every feature that ships in v2.8.

Reality, not roadmap. If it's on this list, it works end-to-end against a live Zotero and is covered by the unit suite.

Folder watcher

Polling-based with adaptive backoff. Recursive subfolder scan; subfolders become collection paths.

Full-file SHA-256 dedup

Hash-first dedup catches re-imports even when the local tracking store is wiped. Falls back to DOI / ISBN / fuzzy title after metadata.

Metadata retrieval

Queued, throttled, polite. Failed lookups get a _needs-review tag so nothing slips through silently.

Template-based rename

{firstCreator} - {year} - {title} and friends. Sanitized, length-capped, optional.

Smart rules

Match on title / author / DOI / tags / filename. Apply add-to-collection, add-tag, set-field, or skip-import. JSON editor opens in a separate window from the prefs pane's Advanced section.

Two-way mirror

Collection ↔ folder lockstep in Mode 2/3. Renames, moves, and new folders propagate both ways.

L

Whole-library scope

Mirror your entire Zotero library by default (scopeMode: library). A PDF dropped at the watch-folder root lands in Unfiled; every top-level collection becomes a top-level folder. Switch to collection scope for the legacy single-root mode. Library-scale Mode 3 adds cloud-eviction pause (if >50% of top-level folders vanish at once, deletion pauses) and a one-time enable confirmation before the first delete.

Safe-delete + restore

Mode 3 moves to .zotero-watch-trash/ (recoverable). Six-case restore matrix: file or folder, by hash or by attachment.

Bulk-delete guard

Any single op affecting >10 files or >20% of tracked items prompts confirmation. Refuses if the prompt can't render.

🔒

Conflict gate

If a file's content has drifted, no move happens. The record flips to conflict-blocked and surfaces in the prefs pane.

First-run baseline

Adopts an existing tree without re-importing: hash-based cross-path reconcile, mkdir for empty collections, idempotent per sync-root.

Warning ring buffer

Last 100 warnings categorized (conflict, missing file, I/O, suppressed, unknown). Visible from the prefs pane.

Runtime mode switch

Flip mode1mode2mode3 live. Coordinators start/stop in-process — no restart.

Check & Repair

A Settings tool that scans for inconsistencies between your watch folder, Zotero, and the plugin's tracking. Each issue is shown with a plain-English explanation and a recommended fix; applies only what you approve. Items with annotations or notes are shown first and never deleted. Never deletes files and never bypasses Zotero — repairs are tracking fixes and additive collection membership only.

Why you can trust it with your library

The deletes don't surprise you.

Most of the v2.2 work went into the destructive-path safety net. Here's what stands between a misclick and a lost paper.

Recoverable trash by default

Mode 3 trashes go to .zotero-watch-trash/<rel> under your watch root. Collision-suffixed (name.<ms>.ext) so nothing is overwritten. Skipped by the scanner so it isn't re-imported.

Bulk-delete refuses without confirmation

If a single operation would affect more than 10 files or 20% of your tracked items, the plugin shows a Services.prompt confirm. If the prompt can't render (no UI thread), it refuses rather than silently executing.

Conflict gate before any move

canSafelyMove hashes the live file against the last known stamp. Drift = no move; record flips to conflict-blocked; you resolve via the prefs UX (re-stamp / discard / pause sync).

Shadow guard against cascading trash

If you keep two copies of the same file under the watch root, only the canonical copy is ever disk-deleted. Shadow records are dropped from tracking without any FS action — the Zotero attachment is never trashed by accident.

Per-key serialization

Every mutation goes through a single executor with promise-chain locks per attachment:<key> / collection:<key>. Concurrent notifier batches can't interleave through stale state.

Restore matrix RST.1–RST.6

Files can be restored four different ways: un-trash in Zotero, restore the local file by hash, re-attach to a parent whose attachment was purged, or restore an entire folder. All collision-handled.

Getting started

Install in four minutes.

No CLI required for end users. The .xpi install is a single click; the rest is a guided preference pane.

  1. Download the .xpi

    Grab the latest release from GitHub Releases. Current build: v2.8.4.

  2. Install in Zotero

    Open Tools → Add-ons (Zotero 7) or Tools → Plugins (Zotero 8/9). Click the gear, choose Install Add-on From File…, pick the .xpi. Restart Zotero when prompted.

  3. Pick your watch folder

    Open Edit → Settings → Watch Folder. Set Source Folder to where your PDFs land — ~/Downloads, a Dropbox folder, anything writable.

  4. Pick your sync root + mode

    Choose the Zotero collection that anchors your sync. Pick mode1 for import-only, or step up to mode2 / mode3 once you trust the mirror. Toggle Enable Watch Folder. You're done.

The user-facing preferences, in plain language

Configurable, but you don't have to.

Defaults are sane. These are the knobs you'd reach for first. Everything lives under extensions.zotero.watchFolder.* and is accessible from about:config if you need to script it.

PreferenceDefaultWhat it does
enabled false Master on/off switch. Toggles the scanner and sync coordinator in-process.
sourcePath "" The folder being watched on disk.
mode mode1 Sync mode: mode1 (import only), mode2 (mirror no delete), mode3 (mirror with safe delete).
syncRootCollectionKey "" The 8-char Zotero collection key that anchors the sync. Only relevant when scopeMode is collection.
scopeMode library What the plugin mirrors: library (default in v2.7+) mirrors your entire personal library — every top-level collection becomes a top-level folder, a PDF dropped at the root lands in Unfiled; collection is the legacy mode that mirrors a single chosen sync-root collection. Existing installs are automatically pinned to collection on upgrade; re-run setup to opt in to library.
pollInterval 5 sec Seconds between scans. Adaptive backoff doubles this on idle scans (caps at 2× base).
fileTypes pdf Comma-separated extensions. pdf,epub,djvu all work.
pdfStorageStrategy stored Where PDFs live (separate from sync mode): stored (Zotero manages + syncs them), linked_watch_folder (Zotero links to the watch-folder file — saves Zotero Storage), or stored_plus_mirror (both). The prefs pane offers a guided "Reclaim Zotero Storage" / "Build/Repair Mirror" conversion.
importMode stored Legacy, superseded by pdfStorageStrategy. linked is treated as linked_watch_folder.
postImportAction leave After import: leave the source file, delete it, or move it to a sibling folder.
autoRetrieveMetadata true Run Zotero's PDF metadata recognizer after import. Failures tagged _needs-review.
autoRename + renamePattern true · {firstCreator} - {year} - {title} Template-based file rename. Available vars: {firstCreator}, {creators}, {year}, {title}, {shortTitle}, {DOI}, {itemType}, {publicationTitle}.
diskDeleteOnTrash ask When you trash in Zotero: ask, plugin_trash (recoverable, default in dialog), os_trash, permanent, never. Only effective in Mode 3.
diskDeleteSync auto When you delete a tracked file on disk: auto trashes the Zotero item (with bulk guard), never does nothing.
duplicateCheck true Skip imports that match an existing item by hash → DOI → ISBN → fuzzy title (threshold 85).
smartRulesEnabled + smartRules false · [] Toggle the rules engine and provide a JSON array of rules. Editor in prefs pane.
adaptivePolling true Back off poll interval on quiet scans; reset on any non-empty scan.
Questions you'll have

FAQ.

Will this re-import everything if I move my Zotero data?

No. The plugin stamps a hash into each item's Extra field (watchfolder-hash:<sha256>). Even if the local tracking store is wiped, the library-side hash catches re-imports during the dedup pass.

Is it safe to start in Mode 3 on an existing collection?

Yes. The first-run baseline (B.1–B.7) reconciles your existing collection tree against the disk tree before any destructive op is allowed. It mkdirs empty collections, adopts files that already match by hash, and only then does any normal mode-3 propagation begin.

What happens if I have two copies of the same PDF in different folders?

The first becomes the canonical record; the second becomes a "shadow" record with the same Zotero attachment key. The cascading-trash guard ensures only the canonical path is ever disk-deleted — Zotero will not trash the attachment because of a shadow's deletion.

What if metadata retrieval fails for a paper?

The item still imports. It gets a _needs-review tag and stays in your inbox / target collection until you fix it manually. Rename templates with missing fields fall back gracefully.

How is "bulk delete" defined?

More than 10 files or more than 20% of your tracked items in a single operation. Both _handleZoteroTrash (Zotero-side bulk trash) and _handleExternalDeletions (disk-side bulk rm) go through the same guard. Decline at the prompt and propagation is demoted to "mark missing".

Can I run it on Zotero 7? Zotero 9?

Yes to both. The manifest declares strict_min_version: 6.999 and strict_max_version: 9.*. Primary development target is Zotero 8.

Where do my PDFs actually live — Zotero or my folder?

Your choice, via the PDF storage strategy (separate from the sync mode). Store in Zotero keeps PDFs in Zotero's storage and syncs them via Zotero Storage/WebDAV. Link from watch folder leaves PDFs in your folder and links Zotero to them — this saves Zotero Storage space, and your folder-sync tool (pCloud, Dropbox, Syncthing…) backs them up. Store + mirror keeps both. Either way, Zotero still syncs your metadata, notes, and highlights — those are independent of where the PDF files sit. If you're already storing PDFs in Zotero and want to free up storage, the prefs pane has a guided "Reclaim Zotero Storage" tool (it keeps annotated PDFs stored, and the old copies go to Zotero's recoverable trash).

What's not done yet?

A form-based smart-rules editor (the engine is in place; the editor is still a JSON window) and broader live-Zotero MCP coverage of less-common Mode 3 scenarios. None of these are release-blocking.

Where we are, where we're going

Roadmap.

v2.8.4 is the current stable release: a redesigned Settings pane — a live status header plus a tabbed layout — on top of the v2.8.3 no-orphaned-records delete, the v2.8.2 two-way-delete prompt, the v2.8.1 audit-hardening pass, the v2.8.0 Check & Repair tool, the v2.7 whole-library mirror default and the v2.6.x data-safety wave. The bar to ship was: 929 unit tests passing across 27 files, live-Zotero MCP validation on Zotero 9.0.4, and no known data-loss path.

For developers

Hack on it locally.

Plain ES modules under content/, bundled to an IIFE by esbuild, loaded by the bootstrap. No frameworks. No build step heavier than esbuild.

# clone
git clone https://github.com/josesiqueira/zotero-watch-folder
cd zotero-watch-folder
npm install

# unit tests (929 passing, 27 files)
npm test

# build the bundle Zotero actually runs
npm run bundle    # content/index.mjs → dist/content/scripts/watchFolder.js
npm run build     # copies the rest of dist/
npm run package   # produces the .xpi + sha256 + update.json

# reload in a running Zotero (via MCP bridge)
zotero_plugin_reload { pluginId: "watch-folder@zotero-plugin.org" }

Module layout, invariants, and "don't touch without understanding" notes live in CLAUDE.md. Test overview in test/README.md.