Inclusion / Exclusion · the two-column behavior spec

Two columns. Every behavior, classified.

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.

✓ Inclusion

Things the plugin acts on

Files arriving, items being added or moved, restores after a trash, baseline reconciles. The plugin does something visible — imports, copies, renames, mkdirs, recovers.

29 cases
✕ Exclusion

Things the plugin refuses

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.

33 cases

How to read

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.

Inclusion29 cases

I.1 ALL

File dropped at watch-folder root

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.

I.2 ALL

File dropped in a subfolder

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.

I.3 ALL

Empty subfolder on disk

A subfolder exists but has no scannable files in it.

A matching empty subcollection is created in Zotero. No items in it.

I.4 M2M3

New collection in Zotero

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.

I.5 M2M3

Zotero collection renamed

A tracked collection (anywhere in the library) is renamed in Zotero.

Matching disk folder is renamed atomically. Child file records' paths are rewritten.

I.6 ALL

Disk subfolder renamed

A tracked disk subfolder is renamed.

Matching Zotero collection renamed (same key, new name). Works in all three modes.

I.7 M2M3

Item moved between Zotero collections

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.

I.8 ALL

File moved between disk subfolders

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.

I.9 M2M3

Late-attached file in Zotero

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.

I.10 M2M3

Baseline copy — Zotero attachment with no disk file

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.

I.11 M2M3

Baseline reconcile — existing file at non-canonical path

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.

I.12 M2M3

Populated collection adopted into library scope

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.

I.13 M3

Restore trashed attachment

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.

I.14 M3

Restore trashed parent (with attachments)

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.

I.15 M3

File reappears, matching tombstone hash

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.

I.16 M3

Re-attach to live parent (purged attachment)

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.

I.17 M3

Restore collision handled with suffix

A restore would land at a path occupied by a different file.

Restore writes to <name>.restored.<ms>.<ext>. Existing file is left alone.

I.18 M3

Folder restored from plugin trash

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.

I.19 ALL

Metadata recognized + file renamed

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.

I.20 ALL

Smart-rule match on import

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.

I.21 ALL

Import with "Link PDFs from watch folder"

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.

I.22 ALL

Reclaim Zotero Storage Space

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.

I.23 ALL

Build/Repair Watch Folder Mirror

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.

I.24 M2M3

Existing item moved into library scope after baseline

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.

I.25 M3

Local folder deleted → Zotero collection trashed

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.

I.26 ALL

Root drop → Unfiled item

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.

I.27 M2M3

Top-level folder → top-level collection

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.

I.28 M2M3

Item leaves its last collection → becomes Unfiled, file moves to watch root

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.

I.29 ALL

Check & Repair reconciles drift

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.

Exclusion33 cases

E.1 ALL

Duplicate by content hash

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.

E.2 ALL

Wrong file extension

A file's extension isn't in the fileTypes pref (default pdf).

Ignored. No tracking, no import.

E.3 ALL

Files inside .zotero-watch-trash/

Files exist inside the plugin trash directory.

Skipped by the scanner. They will never be re-imported while they live there.

E.4 ALL

Files inside imported/

Files exist inside the post-import "move" destination subtree.

Skipped by the scanner — they're already processed.

E.5 M1

Zotero-side trash in Mode 1

User trashes an attachment in Zotero while in Mode 1.

No propagation to disk. File untouched. Log line: "Mode 1: ignoring trash event".

E.6 M1

Disk-side delete in Mode 1

User deletes a tracked file from disk while in Mode 1.

Zotero attachment NOT trashed. Tracking record flips to state: missing.

E.7 M2

Any delete in Mode 2

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.

E.8 M3

Shadow-record cascading-trash guard

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.

E.9 M3

Bulk delete needs confirmation

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.

E.10 M2M3

Conflict gate on hash drift

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.

E.11 M2M3

Last library collection removed

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.

E.12 M2M3

USER_DETACHED records stay detached

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.

E.13 ALL

Non-PDF content subfolders

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.

E.14 ALL

Special / virtual Zotero collections

Trash, Duplicates, Unfiled, My Publications, saved searches.

Filtered out by isSpecialCollection. Never used as a canonical collection or a sync target.

E.15 ALL

Trashed library root anchor — sync pauses cleanly

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.

E.16 ALL

File still growing on disk

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.

E.17 ALL

Cloud placeholder / zero-byte file

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.

E.18 ALL

Drive disconnected — global pause

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.

E.19 ALL

v1 tracking files refused

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.

E.20 M2M3

Metadata-only Zotero items at baseline

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.

E.21 ALL

Detached records excluded from hash dedup

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.

E.22 ALL

Dedup-skip: existing record at same path

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.

E.23 ALL

Postimport-deleted records ignored by deletion sync

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.

E.24 ALL

Reclaim keeps annotated (and uncertain) PDFs stored

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.

E.25 ALL

Reclaim aborts on hash mismatch

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.

E.26 M3

Locally-edited file not trashed on Zotero trash

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.

E.27 M3

Changed Zotero copy not trashed on local 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.

E.28 M3

Folder with a drifted child not trashed

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.

E.29 M2M3

Mode 2 trash suppresses, never re-imports

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.

E.30 ALL

Special views never mirrored

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.

E.31 M3

Cloud-eviction pause

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.

E.32 M3

Mass-deletion cap

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.

E.33 M3

First library-scale delete needs confirmation

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.