The Watch Folder plugin only ever does two things: it includes some things (imports them, syncs them, restores them) and it excludes others (skips them, refuses them, suppresses them). Every behavior in the plugin lands in one column or the other. If a scenario isn't on this page, it's a gap — either in the plugin or in the spec.
Files arriving, items being added or moved, restores after a trash, baseline reconciles. The plugin does something visible — imports, copies, renames, mkdirs, recovers.
Duplicates, wrong file types, files in reserved dirs, deletes that shouldn't propagate, conflicts, bulk operations needing confirmation, items outside scope. The plugin holds back.
Each case follows the same shape — When (the trigger), Result (what the plugin does), and a small note where mode-specific behavior matters. Mode tags (M1 M2 M3 ALL) sit beside the case title when behavior depends on which sync mode you're in. Print-friendly — the two columns stay side-by-side on paper.
A new PDF appears at the top level of the watch folder.
Imported as an Unfiled item (no collection) — the plugin mirrors the whole library; a file with no subfolder has no collection home. Metadata fetched. Renamed via template.
A new PDF appears in WatchFolder/X/Y/.
Collections X and Y are created in the library (top-level and nested respectively). Item filed in the leaf collection.
A subfolder exists but has no scannable files in it.
A matching empty subcollection is created in Zotero. No items in it.
User creates a new collection anywhere in the library (top-level or nested).
A matching disk folder is created at the corresponding path under the watch folder. If items are then added to that collection, their files are placed there.
A tracked collection (anywhere in the library) is renamed in Zotero.
Matching disk folder is renamed atomically. Child file records' paths are rewritten.
A tracked disk subfolder is renamed.
Matching Zotero collection renamed (same key, new name). Works in all three modes.
A tracked item is added to (or moved between) library collections.
The file follows on disk. Canonical-collection rule picks the location when an item belongs to multiple collections.
A tracked file disappears from one subfolder and an untracked file with the same hash appears elsewhere.
Treated as a move (not a delete + new). Tracking record's path updates. Zotero item relocates to matching collection.
User attaches a PDF to an existing in-scope parent item by hand.
The file is copied out to the appropriate disk subfolder. A FileRecord is created.
First-run baseline pass finds a Zotero attachment whose file lives in Zotero storage but not on disk.
File is copied out of Zotero storage to the canonical disk path. FileRecord created.
Items without any attached file are silently skipped.
Same file (same SHA-256) exists in both Zotero storage AND on disk at a different path.
Linked in place at the disk path. No copy, no duplicate.
An existing populated collection is added or re-parented anywhere in the library's tracked hierarchy.
Adopt-into-scope path: subfolder mkdir'd, child attachment files copied out, FileRecords created.
A trashed Zotero attachment is un-trashed by setting deleted = false.
File moved out of .zotero-watch-trash/ back to canonical path. FileRecord re-created. Tombstone removed.
A trashed parent item is un-trashed.
All its child attachments are un-trashed too. All their files are restored from plugin trash. Tombstones removed.
A file is copied or recreated on disk whose hash matches an existing tombstone.
Zotero attachment is un-trashed automatically. FileRecord re-created at the new disk path. Tombstone dropped.
Tombstone matches by hash, the Zotero attachment was permanently erased, BUT the parent item still exists.
File re-attached as a new child of that parent via importFromFile({parentItemID}). No duplicate standalone item.
A restore would land at a path occupied by a different file.
Restore writes to <name>.restored.<ms>.<ext>. Existing file is left alone.
User clicks "Restore folders…" in prefs and picks a trashed dir.
Folder moved back from .zotero-watch-trash/. Zotero collection re-created. FileRecords re-attached.
An imported PDF is recognizable by Zotero's metadata recognizer.
Parent item created with title / authors / year / DOI. File renamed via the template (default: {firstCreator} - {year} - {title}).
Failures get a _needs-review tag; the file still imports.
A smart rule's match criteria (title / author / DOI / filename / etc.) succeeds against a newly-imported item.
Configured actions fire — add-tag, add-to-collection, set-field, skip-import — in rule order.
The PDF storage strategy is linked_watch_folder and a new file is imported.
Zotero links to the file in place instead of copying it into storage — saving Zotero Storage space. The watch-folder file is never moved or deleted.
From the prefs pane (linked strategy), the user runs "Reclaim Zotero Storage Space…" after a preview.
Each un-annotated stored PDF is copied to its canonical watch-folder path, hash-verified, replaced by a linked attachment (parent / tags / collections preserved), and the old stored copy is moved to Zotero's recoverable trash.
The strategy is stored_plus_mirror and the user runs "Build/Repair Watch Folder Mirror…".
Stored attachments anywhere in the library are copied to their canonical watch-folder paths. The stored Zotero attachments are kept exactly as-is — this is a redundant backup copy, not a conversion.
After the first-run baseline has completed, the user drags an existing Zotero item into any library collection.
Its attachment is copied to the canonical local path and a FileRecord is created (adopt-into-scope). Before baseline completes, baseline owns the copy and the event is deferred.
The user deletes a tracked local folder on disk (Mode 3, localFolderDeleted direction).
Each contained attachment that passes the Zotero-side freshness check is moved to Zotero's trash (bulk-delete confirmation applies), then the Zotero collection itself is trashed — all recoverable. Drifted children block and keep the collection.
A PDF is dropped at the top level of the watch folder (no subfolder).
Imported as an Unfiled Zotero item — it has no collection parent. Metadata fetched and file renamed via template. Appears in the Unfiled Items view until the user files it manually.
A new folder is created at the watch-folder root; nested folders may exist inside it.
A new top-level Zotero collection is created for that folder. Nested folders become nested collections. The folder hierarchy mirrors into the library with no sync-root anchor.
A tracked item is removed from its last library collection (anywhere in the library), leaving it with no collection membership.
The item becomes Unfiled in Zotero. Its local file is relocated to the top level of the watch folder to stay consistent with the Unfiled state. Tracking record updates accordingly.
The user runs the Check & Repair tool from Settings. Detected issues may include: a file whose canonical copy moved (shadow-orphaned), a stale dead-state tracking record blocking a folder rename, or an orphan tracking entry with no disk or Zotero counterpart.
Each inconsistency is listed with a plain-English explanation and a recommended fix. Items with annotations or notes are shown first and are never a deletion target. Fixes are applied only to those the user approves. Every fix re-validates at apply time; if the relevant state changed since the scan, the fix is skipped. Repairs are additive: tracking corrections and collection membership only — no file deletions, no Zotero bypassing.
A new file's SHA-256 matches an existing tracking record OR the hash-stamp in a Zotero item's Extra field.
Skipped. No second Zotero item. No second tracking record at the same path.
A file's extension isn't in the fileTypes pref (default pdf).
Ignored. No tracking, no import.
.zotero-watch-trash/Files exist inside the plugin trash directory.
Skipped by the scanner. They will never be re-imported while they live there.
imported/Files exist inside the post-import "move" destination subtree.
Skipped by the scanner — they're already processed.
User trashes an attachment in Zotero while in Mode 1.
No propagation to disk. File untouched. Log line: "Mode 1: ignoring trash event".
User deletes a tracked file from disk while in Mode 1.
Zotero attachment NOT trashed. Tracking record flips to state: missing.
User deletes anything on either side in Mode 2.
Warn-only. No disk-side or Zotero-side destructive action. Entry added to the warning sink.
User keeps two copies of the same file (dedup-skip created a shadow record) and deletes one.
Only the shadow's tracking is dropped. Canonical file is left alone. Zotero attachment is NOT trashed.
A single op would affect more than 10 files OR more than 20% of tracked items.
A confirmation dialog appears. Cancel → tracking marked "missing", no destructive action. Headless context (no UI thread) → refuses entirely.
A file's content (hash) has changed since the last sync, and a move is requested.
Move refused. State flips to conflict-blocked. Warning sink records hash-drifted. User resolves in prefs.
An item is removed from its only library collection (leaving it Unfiled or with no tracked path).
File stays on disk. State flips to out-of-scope-suppressed. User resolves via prefs: Re-instate / Keep local / Trash / Move outside.
User picked "Keep local" in the suppression resolver, and later the item is re-added to a library collection.
State stays USER_DETACHED. The auto-clear-on-re-add safety net excludes USER_DETACHED on purpose — it's an explicit user choice.
The watch folder contains subfolders with only non-PDF (or wrong-extension) files.
The folders may be mirrored as empty collections, but their contents are not imported. Only files matching the fileTypes pref are acted on.
Trash, Duplicates, Unfiled, My Publications, saved searches.
Filtered out by isSpecialCollection. Never used as a canonical collection or a sync target.
The library root anchor (formerly "sync-root collection") has been moved to Zotero's trash or the library becomes unreachable.
resolveSyncRoot() throws SyncRootMissingError. Existing catch sites pause sync; no destructive operations run. New imports do not arrive.
Restoring the collection from Zotero's Bin → right-click → Restore brings everything back into scope on the next scan. Hardened in v2.3.2 — pre-fix, this state silently classified every import as out-of-scope-suppressed.
A file is mid-download (size still changing).
File-stability check refuses to import until the file's size is stable across at least one extra check.
A cloud-sync client (Dropbox / iCloud / OneDrive) hasn't materialized the file yet, so it's 0 bytes or a placeholder.
Skipped — fails stability check. Will be picked up once the file is fully synced down locally.
The watch-folder root itself isn't reachable (drive unplugged, network share gone, cloud client logged out).
All tracking records flip to paused. No destructive ops run. Sync resumes when the root is reachable again.
An old v1-format tracking file exists from a prior plugin version.
Refused — clean break. The plugin builds a fresh v2 tracking store. Library-side hash stamps still catch dedup.
A Zotero item anywhere in the library has no file attached (just bibliographic metadata).
Skipped silently at baseline. No "missing file" warning, no false sync target.
A FileRecord is in USER_DETACHED / OUT_OF_SCOPE_SUPPRESSED / CONFLICT_BLOCKED / etc.
Excluded from the _byHash index. A fresh import won't accidentally re-link to a Zotero item the user already chose to stop syncing.
A scan finds a file whose existing tracking record points at exactly the same absolute path.
Skipped — no second record inserted, no re-import. The existing record is reused.
A FileRecord has state: missing because postImportAction = delete ran (or any other deliberate-removal state).
External-deletion sync skips it. The "missing" state isn't treated as a user deletion to propagate.
During "Reclaim Zotero Storage", a stored PDF has Zotero highlights or child notes — or its annotation status can't be confirmed (the annotation/notes API is missing, throws, or returns an unexpected shape).
It is NOT converted — listed as "kept stored" with a specific reason (has-annotations / has-notes / annotation-status-unknown / note-status-unknown / child-status-unknown). Annotations live as child items of the attachment, so converting could orphan them. The check is fail-closed: a PDF converts only when zero annotations AND zero notes are positively proven.
During conversion, the copy written to the watch folder doesn't hash-match the Zotero-stored original.
That PDF is skipped and left stored, untouched — the old stored attachment is never trashed unless the copy is byte-verified.
A Zotero item is trashed, but the matching local file's content changed since the last sync (e.g. you annotated it on disk).
The local file is NOT trashed. The record flips to conflict-blocked and surfaces in the prefs pane — your edited copy is never destroyed by a Zotero-side delete.
A local file is deleted, but the Zotero-stored attachment's bytes changed since last sync — or can't be verified (not downloaded yet).
The Zotero attachment is NOT trashed (freshness gate). The record becomes conflict-blocked for you to resolve. Deletion only propagates when the other side is provably unchanged.
A tracked Zotero collection is deleted, but at least one child file in the matching local folder has drifted from its last-synced hash.
The whole folder move aborts — nothing is trashed. Drifted children are flagged conflict-blocked. The folder is only trashed when every tracked child is unchanged.
A Zotero item is trashed but the local file remains on disk (Mode 2, or Mode 3 with disk-delete set to never / a failed trash).
The record is SUPPRESSED, not dropped. A dropped record plus a present file would make the next scan re-import the file and recreate the item you just trashed — suppression breaks that loop.
Zotero's Trash/Bin, Duplicates, Unfiled view, saved searches, and My Publications view are present in the library.
Filtered out by isSpecialCollection. None of these views is ever turned into a watch-folder subfolder, used as a delete target, or treated as a canonical collection. No disk folder is created for them.
More than 50% of top-level watch-folder directories vanish in a single scan cycle (suspected cloud-sync unmount or eviction).
The folder-deletion pass for that cycle is PAUSED — no Zotero collections are trashed. The missing folders are recorded but treated as transiently unavailable, not deleted. Normal deletion resumes once the root stabilises.
A single scan cycle finds more top-level folder deletions than the plugin's bulk-delete threshold allows in one batch.
The entire batch is refused. No collections are trashed. The warning sink records the refusal. The user must confirm via the bulk-delete dialog or reduce scope.
Mode 3 is active under whole-library scope and a deletion is about to propagate for the first time (first deletion cycle after enabling library-wide scope).
A one-time acknowledgement is required before any deletion proceeds. If no UI is available (headless context), the deletion is refused entirely — the gate is fail-closed, never fail-open.