Coverage for src/local_deep_research/database/models/zotero.py: 94%
36 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +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 datetime import datetime
27from sqlalchemy import (
28 Boolean,
29 Column,
30 ForeignKey,
31 Integer,
32 String,
33 Text,
34 UniqueConstraint,
35)
36from sqlalchemy.orm import Mapped, mapped_column
37from sqlalchemy_utc import UtcDateTime, utcnow
39from .base import Base
42class ZoteroSyncState(Base):
43 """Per-collection sync bookkeeping for the Zotero integration."""
45 __tablename__ = "zotero_sync_state"
46 __table_args__ = (
47 UniqueConstraint(
48 "library_type",
49 "library_id",
50 "collection_key",
51 name="uq_zotero_sync_state_library_collection",
52 ),
53 )
55 id = Column(Integer, primary_key=True, autoincrement=True)
57 # Zotero library identity
58 library_type = Column(String(10), nullable=False) # 'user' | 'group'
59 library_id = Column(String(64), nullable=False)
60 # Empty string means "the entire library" (no specific collection).
61 # Stored as "" rather than NULL so the UNIQUE constraint is effective on
62 # SQLite (which treats NULLs as distinct).
63 collection_key = Column(String(16), nullable=False, default="")
65 # LDR collection these items are imported into
66 ldr_collection_id = Column(
67 String(36),
68 ForeignKey("collections.id", ondelete="CASCADE"),
69 nullable=False,
70 index=True,
71 )
73 # Zotero library version reached by the last successful sync.
74 # Informational (surfaced in the status UI) — NOT used as a ``?since=``
75 # cursor; each sync fetches the full current version map (see module
76 # docstring for why). 0 means "never synced".
77 last_version = Column(Integer, nullable=False, default=0)
79 last_synced_at = Column(UtcDateTime, nullable=True)
80 last_status = Column(
81 String(20), nullable=False, default="pending"
82 ) # pending | syncing | completed | failed
83 last_error = Column(Text, nullable=True)
84 item_count = Column(Integer, nullable=False, default=0)
86 created_at = Column(UtcDateTime, default=utcnow(), nullable=False)
87 updated_at = Column(
88 UtcDateTime, default=utcnow(), onupdate=utcnow(), nullable=False
89 )
91 def __repr__(self) -> str:
92 return (
93 f"<ZoteroSyncState(library={self.library_type}:{self.library_id}, "
94 f"collection={self.collection_key or 'ALL'}, "
95 f"version={self.last_version})>"
96 )
99class ZoteroItemMap(Base):
100 """Maps a single Zotero item to the LDR document it was imported as."""
102 __tablename__ = "zotero_item_map"
103 __table_args__ = (
104 UniqueConstraint(
105 "ldr_collection_id",
106 "zotero_item_key",
107 name="uq_zotero_item_map_collection_item",
108 ),
109 )
111 id: Mapped[int] = mapped_column(
112 Integer, primary_key=True, autoincrement=True
113 )
115 ldr_collection_id: Mapped[str] = mapped_column(
116 String(36),
117 ForeignKey("collections.id", ondelete="CASCADE"),
118 nullable=False,
119 index=True,
120 )
121 # The Zotero (parent) item key this row tracks.
122 zotero_item_key: Mapped[str] = mapped_column(
123 String(16), nullable=False, index=True
124 )
125 # Zotero item version at last import — used for change detection.
126 zotero_version: Mapped[int] = mapped_column(
127 Integer, nullable=False, default=0
128 )
130 # The LDR document this item was imported as. SET NULL on document
131 # deletion so a removed document doesn't dangle. A null document_id marks
132 # an item deliberately not imported (skipped, or its document was deleted
133 # locally); the sync service re-imports it only when the item's Zotero
134 # version changes. A non-null document_id whose Document row went missing
135 # IS re-imported on the next sync.
136 document_id: Mapped[str | None] = mapped_column(
137 String(36),
138 ForeignKey("documents.id", ondelete="SET NULL"),
139 nullable=True,
140 index=True,
141 )
142 has_pdf: Mapped[bool] = mapped_column(
143 Boolean, nullable=False, default=False
144 )
146 last_synced_at: Mapped[datetime | None] = mapped_column(
147 UtcDateTime, nullable=True
148 )
149 created_at: Mapped[datetime] = mapped_column(
150 UtcDateTime, default=utcnow(), nullable=False
151 )
152 updated_at: Mapped[datetime] = mapped_column(
153 UtcDateTime, default=utcnow(), onupdate=utcnow(), nullable=False
154 )
156 def __repr__(self) -> str:
157 return (
158 f"<ZoteroItemMap(item={self.zotero_item_key}, "
159 f"document={self.document_id}, version={self.zotero_version})>"
160 )