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

1""" 

2Zotero integration models. 

3 

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: 

6 

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. 

15 

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. 

22 

23""" 

24 

25from sqlalchemy import ( 

26 Boolean, 

27 Column, 

28 ForeignKey, 

29 Integer, 

30 String, 

31 Text, 

32 UniqueConstraint, 

33) 

34from sqlalchemy_utc import UtcDateTime, utcnow 

35 

36from .base import Base 

37 

38 

39class ZoteroSyncState(Base): 

40 """Per-collection sync bookkeeping for the Zotero integration.""" 

41 

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 ) 

51 

52 id = Column(Integer, primary_key=True, autoincrement=True) 

53 

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="") 

61 

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 ) 

69 

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) 

75 

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) 

82 

83 created_at = Column(UtcDateTime, default=utcnow(), nullable=False) 

84 updated_at = Column( 

85 UtcDateTime, default=utcnow(), onupdate=utcnow(), nullable=False 

86 ) 

87 

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 ) 

94 

95 

96class ZoteroItemMap(Base): 

97 """Maps a single Zotero item to the LDR document it was imported as.""" 

98 

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 ) 

107 

108 id = Column(Integer, primary_key=True, autoincrement=True) 

109 

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) 

120 

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) 

134 

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 ) 

140 

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 )