Coverage for src/local_deep_research/database/models/zotero.py: 94%
34 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
1"""
2Zotero integration models.
4These tables live in each user's encrypted per-user database and track the
5state needed to sync a Zotero library/collection into the document library:
7- ``ZoteroSyncState``: one row per configured (library, collection) pair. It
8 remembers which LDR :class:`Collection` the Zotero collection maps to and
9 the Zotero *library version* reached by the last successful sync
10 (``last_version`` — informational / shown in the status UI; see below).
11- ``ZoteroItemMap``: maps an individual Zotero item to the LDR
12 :class:`Document` it was imported as, plus the item's Zotero version. This
13 enables skipping unchanged items, re-importing changed ones, and detecting
14 items removed from the collection so they can be removed locally.
16Sync compares the *full* current ``{itemKey: version}`` map of the
17collection's top-level items against ``ZoteroItemMap`` — additions/changes by
18version, removals by absence. This deliberately does NOT use a ``?since=``
19delta: a delta (or the library-level ``/deleted`` feed) would not catch items
20*removed from the collection* but still present in the library, whereas the
21full-map comparison does.
23"""
25from sqlalchemy import (
26 Boolean,
27 Column,
28 ForeignKey,
29 Integer,
30 String,
31 Text,
32 UniqueConstraint,
33)
34from sqlalchemy_utc import UtcDateTime, utcnow
36from .base import Base
39class ZoteroSyncState(Base):
40 """Per-collection sync bookkeeping for the Zotero integration."""
42 __tablename__ = "zotero_sync_state"
43 __table_args__ = (
44 UniqueConstraint(
45 "library_type",
46 "library_id",
47 "collection_key",
48 name="uq_zotero_sync_state_library_collection",
49 ),
50 )
52 id = Column(Integer, primary_key=True, autoincrement=True)
54 # Zotero library identity
55 library_type = Column(String(10), nullable=False) # 'user' | 'group'
56 library_id = Column(String(64), nullable=False)
57 # Empty string means "the entire library" (no specific collection).
58 # Stored as "" rather than NULL so the UNIQUE constraint is effective on
59 # SQLite (which treats NULLs as distinct).
60 collection_key = Column(String(16), nullable=False, default="")
62 # LDR collection these items are imported into
63 ldr_collection_id = Column(
64 String(36),
65 ForeignKey("collections.id", ondelete="CASCADE"),
66 nullable=False,
67 index=True,
68 )
70 # Zotero library version reached by the last successful sync.
71 # Informational (surfaced in the status UI) — NOT used as a ``?since=``
72 # cursor; each sync fetches the full current version map (see module
73 # docstring for why). 0 means "never synced".
74 last_version = Column(Integer, nullable=False, default=0)
76 last_synced_at = Column(UtcDateTime, nullable=True)
77 last_status = Column(
78 String(20), nullable=False, default="pending"
79 ) # pending | syncing | completed | failed
80 last_error = Column(Text, nullable=True)
81 item_count = Column(Integer, nullable=False, default=0)
83 created_at = Column(UtcDateTime, default=utcnow(), nullable=False)
84 updated_at = Column(
85 UtcDateTime, default=utcnow(), onupdate=utcnow(), nullable=False
86 )
88 def __repr__(self) -> str:
89 return (
90 f"<ZoteroSyncState(library={self.library_type}:{self.library_id}, "
91 f"collection={self.collection_key or 'ALL'}, "
92 f"version={self.last_version})>"
93 )
96class ZoteroItemMap(Base):
97 """Maps a single Zotero item to the LDR document it was imported as."""
99 __tablename__ = "zotero_item_map"
100 __table_args__ = (
101 UniqueConstraint(
102 "ldr_collection_id",
103 "zotero_item_key",
104 name="uq_zotero_item_map_collection_item",
105 ),
106 )
108 id = Column(Integer, primary_key=True, autoincrement=True)
110 ldr_collection_id = Column(
111 String(36),
112 ForeignKey("collections.id", ondelete="CASCADE"),
113 nullable=False,
114 index=True,
115 )
116 # The Zotero (parent) item key this row tracks.
117 zotero_item_key = Column(String(16), nullable=False, index=True)
118 # Zotero item version at last import — used for change detection.
119 zotero_version = Column(Integer, nullable=False, default=0)
121 # The LDR document this item was imported as. SET NULL on document
122 # deletion so a removed document doesn't dangle. A null document_id marks
123 # an item deliberately not imported (skipped, or its document was deleted
124 # locally); the sync service re-imports it only when the item's Zotero
125 # version changes. A non-null document_id whose Document row went missing
126 # IS re-imported on the next sync.
127 document_id = Column(
128 String(36),
129 ForeignKey("documents.id", ondelete="SET NULL"),
130 nullable=True,
131 index=True,
132 )
133 has_pdf = Column(Boolean, nullable=False, default=False)
135 last_synced_at = Column(UtcDateTime, nullable=True)
136 created_at = Column(UtcDateTime, default=utcnow(), nullable=False)
137 updated_at = Column(
138 UtcDateTime, default=utcnow(), onupdate=utcnow(), nullable=False
139 )
141 def __repr__(self) -> str:
142 return (
143 f"<ZoteroItemMap(item={self.zotero_item_key}, "
144 f"document={self.document_id}, version={self.zotero_version})>"
145 )