Skip to content

feat(zotero): add Zotero connector#61

Open
thiswillbeyourgithub wants to merge 15 commits into
open-webui:mainfrom
thiswillbeyourgithub:add-zotero
Open

feat(zotero): add Zotero connector#61
thiswillbeyourgithub wants to merge 15 commits into
open-webui:mainfrom
thiswillbeyourgithub:add-zotero

Conversation

@thiswillbeyourgithub

@thiswillbeyourgithub thiswillbeyourgithub commented Jun 22, 2026

Copy link
Copy Markdown

Summary

Adds a Zotero connector to oikb. It syncs the text of the PDF attachments of items in a Zotero collection (and its subcollections) into an Open WebUI Knowledge Base, mapping the Zotero collection hierarchy onto KB directories. It is read-only with respect to Zotero: it never modifies, deletes, or adds anything to the library.

This is a Claude Code port of an earlier standalone tool I wrote, https://github.com/thiswillbeyourgithub/openwebui-knowledge-zotero-sync, reworked to fit oikb's connector interface.

Zotero connector

  • New zotero:<hierarchy> source scheme. %% separates collection names
    (e.g. zotero:Research%%Machine Learning); a bare zotero: syncs every top-level collection.
  • Text extraction prefers Zotero's indexed fulltext API, falling back to PyMuPDF on the downloaded PDF.
  • Collection hierarchy maps to KB directories; an item that lives in several subcollections is handled.
  • Change detection via ZOTERO_CHECKSUM: version (cheap, hashes the Zotero item version, no download, default) or content (hashes the extracted text).
  • ZOTERO_EXCLUDE skips subcollections, relative to the synced root.
  • Auth/options via env: ZOTERO_LIBRARY_ID, ZOTERO_API_KEY, ZOTERO_LIBRARY_TYPE, ZOTERO_CHECKSUM, ZOTERO_EXCLUDE.
  • Optional dependency group: pip install oikb[zotero] (pyzotero + pymupdf).
  • README and docs/guide.md updated.

Sync change: unavailable source files are warnings, not fatal errors

A file the source advertises in its manifest but cannot provide content for (e.g. a Zotero attachment whose bytes aren't in storage: a web link, a linked file, or a WebDAV-only item) is a source-side data gap, not an oikb failure. Previously any such case landed in result.errors and exited the whole sync with code 1.

This PR adds a SourceFileUnavailable exception to the connector base. run_sync catches it specifically (no retry) and routes the file to a new result.warnings
list instead of result.errors, so a sync whose only problems are unavailable source files still succeeds (exit 0). Any other read_file() exception still fails the run exactly as before. The CLI and daemon surface warnings separately from errors; only result.errors affects the exit code / success status.

Tests

tests/test_sync_warnings.py covers all three paths: the Zotero missing-file mapping to SourceFileUnavailable, warning routing in run_sync, and that a generic read failure is still an error.

Notes

  • Opening as a draft for early feedback on the connector shape and on whether the warnings-vs-errors sync change is acceptable as written.
  • Implemented with Claude Code.
  • I initially wanted to add support for using only the yaml instead of env variables to simplify setup but it resuired some fundamental changes I felt could go beyond the scope of a single PR. Let me know if you'd like me to streamline this.

thiswillbeyourgithub and others added 9 commits June 22, 2026 16:51
Extracts text from PDF attachments in a Zotero collection (and its
subcollections) and exposes them as .txt files for sync. Collection
hierarchy maps to KB directories. Read-only with respect to Zotero.

Checksum mode is configurable via ZOTERO_CHECKSUM: 'version' (cheap,
default) or 'content' (hashes extracted text).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Personal, untracked scripts and configs (e.g. the Zotero sync cheatsheet)
live in perso/ and should not be tracked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The diff's mkdir list had no guaranteed order, so a nested directory could
be processed before its parent. Since the parent's id is looked up from
directory_map (populated as dirs are created), an out-of-order child would
get parent_id=None and be created at the wrong level. Sorting lexicographically
puts "a" before "a/b", guaranteeing parent-first creation, and also makes the
dry-run "Dirs to create" output stable and readable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a Zotero attachment's bytes aren't in zotero.org storage (file sync
off, storage quota exceeded, WebDAV-only, or a web link), the /file
endpoint returns 404. In 'version' checksum mode this already surfaced
per-file at upload time and the rest of the sync completed. But in
'content' mode the download happens during build_manifest, so a single
404 propagated out of run_sync and aborted the entire run with nothing
uploaded.

Make _checksum degrade to the version checksum when text can't be
retrieved, so the manifest is always built and the failure is reported
through the normal per-file upload error path (sync everything we can,
list what failed at the end). Also wrap the file-download failure in a
clear message explaining the bytes aren't in Zotero storage instead of
dumping a raw HTTP 404.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A file the source advertises in its manifest but cannot actually provide
content for (e.g. a Zotero attachment whose bytes aren't in storage: a web
link, linked file, or WebDAV-only item) is a source-side data gap, not an
oikb failure. Previously any such case landed in result.errors and made the
whole sync exit 1, so a library with a few file-less attachments could never
report success (a recurring systemd/daemon run would always look "failed").

Introduce a dedicated SourceFileUnavailable exception in the connectors base.
The Zotero connector raises it (instead of a bare RuntimeError) for the
no-downloadable-file case. run_sync catches it specifically (no retry) and
routes the file to a new result.warnings list instead of result.errors; the
CLI and daemon surface warnings but only result.errors affects the exit code
and success/partial status. Any other read_file() exception still fails the
run exactly as before, so this narrows to that one error class only.

Tests cover all three paths: the Zotero missing-file mapping, warning routing
in run_sync, and that a generic read failure is still an error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@thiswillbeyourgithub thiswillbeyourgithub marked this pull request as ready for review June 22, 2026 15:39
thiswillbeyourgithub added a commit to thiswillbeyourgithub/openwebui-knowledge-zotero-sync that referenced this pull request Jun 22, 2026
The Zotero connector port has been opened as a draft PR against the official
open-webui/oikb project (open-webui/oikb#61). Update the
deprecation notice to point at it; this repo will be sunset once it lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@psuckrow-routine

Copy link
Copy Markdown

Nice work on this! One thing that'd make it even more useful: an option to also pull in notes and highlights/annotations, not just the PDF body text. A lot of the value in Zotero libraries lives in child notes and PDF annotations, and right now those get skipped since _collect_items only keeps itemType == "attachment" children.

Maybe something like a ZOTERO_INCLUDE_NOTES / ZOTERO_INCLUDE_ANNOTATIONS flag that appends note content and annotation text/comments to (or alongside) the extracted PDF text?

@psuckrow-routine

Copy link
Copy Markdown

Hey, got the connector working with a Docker setup. Works great for items in collections but I noticed items sitting directly in "My Library" (unfiled, not in any collection) never get picked up regardless of whether you use zotero: bare or a named hierarchy.

Makes sense given build_manifest() walks the collection tree, but would be nice to also sweep root library items when no hierarchy is specified. Could land them in a virtual _unfiled/ directory to keep things tidy. Or maybe a zotero:* source mode that opts into full-library sync.

thiswillbeyourgithub and others added 6 commits July 14, 2026 15:30
Optionally append child-note text and PDF highlights/comments to each
attachment's extracted .txt, since much of a Zotero library's value lives
in notes and annotations that were previously skipped.

- Notes come back with the item's children (no extra API calls); items that
  have notes but no PDF now surface as their own notes-only .txt so enabling
  the flag never silently drops content.
- Annotations are children of the attachment, not the item, so they cost one
  extra children() call per attachment (in version mode; content mode already
  downloads everything).
- Change detection stays correct: content mode hashes the assembled text, and
  version mode folds in note/annotation versions (editing a note or annotation
  bumps its own version, not the parent item's).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fake-pyzotero tests for both new flags: notes and annotations appended to
the PDF .txt, note-only items surfacing as their own file, both flags off
reproducing PDF-only output, and version-mode checksums reacting to note and
annotation version bumps (each edit bumps its own version, not the parent's).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the two flags to the README env table, explain the append layout and the
per-attachment API cost of annotations in version mode, and add an Unreleased
CHANGELOG entry. Documentation updated with Claude Code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Items sitting directly in the library (in no collection) were invisible: the
manifest only walked the collection tree. A whole-library sync (bare "zotero:")
now also collects them and lands them in a virtual directory, ZOTERO_UNFILED_DIR
(default "_unfiled"). A named hierarchy is unchanged and never includes them.

The Zotero API has no "unfiled" query, so we fetch top-level items via top() and
keep the ones whose data.collections is empty. Per-item processing (attachments,
notes, skip rules) is refactored out of _collect_items into a shared
_process_item so the collection walk and the unfiled sweep can't drift; the
exclude check becomes _is_excluded, letting ZOTERO_EXCLUDE=<unfiled dir> drop it.

The existing notes/annotations tests gain a top() on their fake client and mark
their items as filed, so the new sweep correctly finds nothing unfiled there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bare zotero: surfaces loose items under _unfiled/ (note-only ones gated on
ZOTERO_INCLUDE_NOTES), a named hierarchy never does, filed items are not
duplicated into _unfiled/, ZOTERO_UNFILED_DIR renames the directory, and
ZOTERO_EXCLUDE can drop it entirely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…_DIR

Note that bare zotero: now also syncs items in no collection into
ZOTERO_UNFILED_DIR (default _unfiled/), explain the top() fetch and that a
named hierarchy never sweeps unfiled items, add the env-table row and an
Unreleased CHANGELOG entry. Docs updated with Claude Code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thiswillbeyourgithub

Copy link
Copy Markdown
Author

Hi @psuckrow-routine , thanks for giving it a try and thanks for the nice words.

I pushes a few things. Can you try and see if it solves the issues you raised?

@psuckrow-routine

Copy link
Copy Markdown

Hey, thanks so much for the work. Yes I will try it out and let you know !

@psuckrow-routine

Copy link
Copy Markdown

When oikb sync fails to upload a file, the error is silently swallowed — the CLI exits with code 1 and the summary count is off, but neither the default nor verbose (-v) output reveals which file failed or why.

Steps to reproduce:

  1. Run oikb sync with a Zotero source
  2. One file fails to upload (transient network error / API timeout)
  3. Output shows e.g. Done: 4 added when 5 were expected — exit code 1

Running -v produces identical output to the default. --dry-run correctly lists the stuck file as pending, but re-running sync still adds 0 with no error shown.

Expected: per-file error messages — when a file fails, show the filename and the actual error (HTTP status / exception), e.g.:

✗ _unfiled/Large Language Models: A Survey.txt — 500 Internal Server Error

Actual:

Done: 4 added   ← no indication of which file or why
exit code 1

@psuckrow-routine

Copy link
Copy Markdown

WebDAV storage support

When Zotero is configured to use WebDAV storage instead of Zotero's native cloud storage, oikb sync fails with a 404 on the file endpoint:

Zotero has no downloadable file for attachment 7AHE5BCC (
Code: 404
URL: https://api.zotero.org/users/.../items/7AHE5BCC/file

This is expected — Zotero's API doesn't proxy WebDAV files. But oikb currently has no way to reach the WebDAV server directly.

Feature request: add ZOTERO_WEBDAV_URL + ZOTERO_WEBDAV_USER / ZOTERO_WEBDAV_PASSWORD env vars so oikb can fetch files from WebDAV storage when the Zotero API file endpoint returns 404. The WebDAV setting should only override how the file content is fetched — annotations, notes, and metadata should continue to be retrieved via the Zotero API as normal (since those live in Zotero's own database regardless of storage backend).

In the meantime, notes and annotations (ZOTERO_INCLUDE_NOTES / ZOTERO_INCLUDE_ANNOTATIONS) still sync correctly for WebDAV items — only the full PDF text extraction is blocked.

@psuckrow-routine

Copy link
Copy Markdown

Daemon interval sync not firing

When running oikb daemon, the scheduled interval sync never fires automatically. The daemon starts and reports the correct number of configured sources, but no syncs run until triggered manually via oikb sync.

Tested with interval: 1min in .oikb.yaml — after waiting several minutes with the daemon running, GET /history returns {"entries":[]}. Only a manual oikb sync call produces a history entry.

Expected: the daemon should run the first sync shortly after startup and then repeat on the configured interval.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants