deeptutor-knowledge 1.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. deeptutor/api/routers/knowledge.py +5225 -0
  2. deeptutor/api/routers/marginnote4.py +278 -0
  3. deeptutor/capabilities/ima/__init__.py +6 -0
  4. deeptutor/capabilities/ima/binding.py +118 -0
  5. deeptutor/capabilities/ima/capability.py +92 -0
  6. deeptutor/capabilities/ima/prompts/en/system.md +33 -0
  7. deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
  8. deeptutor/capabilities/ima/tools.py +476 -0
  9. deeptutor/capabilities/marginnote4/__init__.py +13 -0
  10. deeptutor/capabilities/marginnote4/binding.py +66 -0
  11. deeptutor/capabilities/marginnote4/capability.py +80 -0
  12. deeptutor/capabilities/marginnote4/models.py +173 -0
  13. deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
  14. deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
  15. deeptutor/capabilities/marginnote4/store.py +562 -0
  16. deeptutor/capabilities/marginnote4/tools.py +329 -0
  17. deeptutor/capabilities/obsidian/__init__.py +6 -0
  18. deeptutor/capabilities/obsidian/binding.py +75 -0
  19. deeptutor/capabilities/obsidian/capability.py +82 -0
  20. deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
  21. deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
  22. deeptutor/capabilities/obsidian/tools.py +399 -0
  23. deeptutor/capabilities/obsidian/vault.py +341 -0
  24. deeptutor/knowledge/__init__.py +28 -0
  25. deeptutor/knowledge/add_documents.py +734 -0
  26. deeptutor/knowledge/indexing_run.py +432 -0
  27. deeptutor/knowledge/initializer.py +448 -0
  28. deeptutor/knowledge/manager.py +2570 -0
  29. deeptutor/knowledge/module_service.py +29 -0
  30. deeptutor/knowledge/naming.py +40 -0
  31. deeptutor/knowledge/progress_events.py +45 -0
  32. deeptutor/knowledge/progress_tracker.py +355 -0
  33. deeptutor/knowledge/staging.py +69 -0
  34. deeptutor/services/config/knowledge_base_config.py +239 -0
  35. deeptutor/services/embedding/__init__.py +46 -0
  36. deeptutor/services/embedding/adapters/__init__.py +40 -0
  37. deeptutor/services/embedding/adapters/base.py +196 -0
  38. deeptutor/services/embedding/adapters/cohere.py +176 -0
  39. deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
  40. deeptutor/services/embedding/adapters/gemini.py +462 -0
  41. deeptutor/services/embedding/adapters/jina.py +161 -0
  42. deeptutor/services/embedding/adapters/ollama.py +145 -0
  43. deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
  44. deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
  45. deeptutor/services/embedding/client.py +323 -0
  46. deeptutor/services/embedding/config.py +114 -0
  47. deeptutor/services/embedding/request_options.py +46 -0
  48. deeptutor/services/embedding/validation.py +128 -0
  49. deeptutor/services/github_source/__init__.py +5 -0
  50. deeptutor/services/github_source/client.py +164 -0
  51. deeptutor/services/github_source/sync.py +384 -0
  52. deeptutor/services/github_source/sync_service.py +72 -0
  53. deeptutor/services/rag/__init__.py +21 -0
  54. deeptutor/services/rag/embedding_binding.py +331 -0
  55. deeptutor/services/rag/embedding_signature.py +63 -0
  56. deeptutor/services/rag/eval/__init__.py +92 -0
  57. deeptutor/services/rag/eval/dataset.py +244 -0
  58. deeptutor/services/rag/eval/diff.py +312 -0
  59. deeptutor/services/rag/eval/matching.py +218 -0
  60. deeptutor/services/rag/eval/metrics.py +213 -0
  61. deeptutor/services/rag/eval/report.py +111 -0
  62. deeptutor/services/rag/eval/runner.py +309 -0
  63. deeptutor/services/rag/factory.py +369 -0
  64. deeptutor/services/rag/file_routing.py +5 -0
  65. deeptutor/services/rag/index_probe.py +327 -0
  66. deeptutor/services/rag/index_versioning.py +387 -0
  67. deeptutor/services/rag/kb_paths.py +50 -0
  68. deeptutor/services/rag/linked_kb.py +237 -0
  69. deeptutor/services/rag/pipelines/__init__.py +7 -0
  70. deeptutor/services/rag/pipelines/base.py +41 -0
  71. deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
  72. deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
  73. deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
  74. deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
  75. deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
  76. deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
  77. deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
  78. deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
  79. deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
  80. deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
  81. deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
  82. deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
  83. deeptutor/services/rag/pipelines/ima/client.py +364 -0
  84. deeptutor/services/rag/pipelines/ima/config.py +140 -0
  85. deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
  86. deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
  87. deeptutor/services/rag/pipelines/ima/media.py +190 -0
  88. deeptutor/services/rag/pipelines/ima/models.py +348 -0
  89. deeptutor/services/rag/pipelines/ima/notes.py +240 -0
  90. deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
  91. deeptutor/services/rag/pipelines/ima/probe.py +105 -0
  92. deeptutor/services/rag/pipelines/ima/sources.py +105 -0
  93. deeptutor/services/rag/pipelines/ima/transport.py +130 -0
  94. deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
  95. deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
  96. deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
  97. deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
  98. deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
  99. deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
  100. deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
  101. deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
  102. deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
  103. deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
  104. deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
  105. deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
  106. deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
  107. deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
  108. deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
  109. deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
  110. deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
  111. deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
  112. deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
  113. deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
  114. deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
  115. deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
  116. deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
  117. deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
  118. deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
  119. deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
  120. deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
  121. deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
  122. deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
  123. deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
  124. deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
  125. deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
  126. deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
  127. deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
  128. deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
  129. deeptutor/services/rag/pipelines/modes.py +48 -0
  130. deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
  131. deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
  132. deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
  133. deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
  134. deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
  135. deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
  136. deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
  137. deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
  138. deeptutor/services/rag/pipelines/weknora/client.py +106 -0
  139. deeptutor/services/rag/pipelines/weknora/config.py +56 -0
  140. deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
  141. deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
  142. deeptutor/services/rag/preflight.py +274 -0
  143. deeptutor/services/rag/provider_binding.py +70 -0
  144. deeptutor/services/rag/query_rewrite.py +186 -0
  145. deeptutor/services/rag/service.py +379 -0
  146. deeptutor/services/rag/smart_retriever.py +81 -0
  147. deeptutor/services/rag/source_pages.py +292 -0
  148. deeptutor/services/rag/source_visuals.py +434 -0
  149. deeptutor/services/rag/visual_assets.py +430 -0
  150. deeptutor/services/rag/visual_coverage.py +165 -0
  151. deeptutor/services/web_source/__init__.py +1 -0
  152. deeptutor/services/web_source/bilingual.py +255 -0
  153. deeptutor/services/web_source/crawler.py +681 -0
  154. deeptutor/services/web_source/html_extractor.py +643 -0
  155. deeptutor/services/web_source/markdown.py +28 -0
  156. deeptutor/services/web_source/navigation.py +72 -0
  157. deeptutor/services/web_source/repository.py +640 -0
  158. deeptutor/services/web_source/robots.py +182 -0
  159. deeptutor/services/web_source/scheduler.py +336 -0
  160. deeptutor/services/web_source/snapshot_assets.py +171 -0
  161. deeptutor/services/web_source/sync.py +253 -0
  162. deeptutor/services/workspace/kb_move.py +446 -0
  163. deeptutor/tools/rag_tool.py +110 -0
  164. deeptutor_cli/kb.py +756 -0
  165. deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
  166. deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
  167. deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
  168. deeptutor_knowledge-1.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,562 @@
1
+ """SQLite-backed storage for synced MarginNote 4 objects.
2
+
3
+ This is the *only* module that knows the on-disk schema. Every other layer --
4
+ tools, capability, HTTP bridge -- goes through the public methods here.
5
+
6
+ Schema overview
7
+ ---------------
8
+ * ``mn4_objects`` -- one row per synced MN4 entity, JSON payload in ``raw``.
9
+ * ``mn4_devices`` -- paired devices with hashed tokens.
10
+ * ``mn4_cursors`` -- per-device sync position.
11
+ * ``mn4_tombstones`` -- deleted object IDs (soft-delete for conflict resolution).
12
+
13
+ The database file lives under ``data/marginnote4/<kb_name>.db`` by default.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Iterator
19
+ from contextlib import contextmanager
20
+ from datetime import datetime, timezone
21
+ import hashlib
22
+ import json
23
+ import logging
24
+ from pathlib import Path
25
+ import secrets
26
+ import sqlite3
27
+ from typing import Any
28
+
29
+ from deeptutor.capabilities.marginnote4.models import (
30
+ ALL_TYPES,
31
+ MarginNoteObject,
32
+ PairedDevice,
33
+ SyncBatch,
34
+ SyncResult,
35
+ )
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ _SCHEMA = """
40
+ CREATE TABLE IF NOT EXISTS mn4_objects (
41
+ object_id TEXT NOT NULL,
42
+ device_id TEXT NOT NULL,
43
+ object_type TEXT NOT NULL,
44
+ title TEXT NOT NULL DEFAULT '',
45
+ content TEXT NOT NULL DEFAULT '',
46
+ excerpt TEXT,
47
+ document_id TEXT,
48
+ document_title TEXT,
49
+ page INTEGER,
50
+ tags TEXT NOT NULL DEFAULT '[]',
51
+ links TEXT NOT NULL DEFAULT '[]',
52
+ color TEXT,
53
+ created_at TEXT NOT NULL DEFAULT '',
54
+ updated_at TEXT NOT NULL DEFAULT '',
55
+ synced_at TEXT NOT NULL DEFAULT '',
56
+ raw TEXT NOT NULL DEFAULT '{}',
57
+ PRIMARY KEY (object_id, device_id)
58
+ );
59
+
60
+ CREATE INDEX IF NOT EXISTS idx_mn4_type ON mn4_objects (object_type);
61
+ CREATE INDEX IF NOT EXISTS idx_mn4_doc ON mn4_objects (document_id);
62
+ CREATE INDEX IF NOT EXISTS idx_mn4_search ON mn4_objects (title);
63
+
64
+ CREATE TABLE IF NOT EXISTS mn4_devices (
65
+ device_id TEXT PRIMARY KEY,
66
+ device_name TEXT NOT NULL DEFAULT '',
67
+ device_kind TEXT NOT NULL DEFAULT 'macos',
68
+ token_hash TEXT NOT NULL,
69
+ paired_at TEXT NOT NULL DEFAULT '',
70
+ last_seen TEXT NOT NULL DEFAULT '',
71
+ active INTEGER NOT NULL DEFAULT 1
72
+ );
73
+
74
+ CREATE TABLE IF NOT EXISTS mn4_cursors (
75
+ device_id TEXT PRIMARY KEY,
76
+ cursor TEXT NOT NULL DEFAULT ''
77
+ );
78
+
79
+ CREATE TABLE IF NOT EXISTS mn4_tombstones (
80
+ object_id TEXT NOT NULL,
81
+ device_id TEXT NOT NULL,
82
+ deleted_at TEXT NOT NULL DEFAULT '',
83
+ PRIMARY KEY (object_id, device_id)
84
+ );
85
+ """
86
+
87
+
88
+ def _now_iso() -> str:
89
+ return datetime.now(timezone.utc).isoformat()
90
+
91
+
92
+ def _hash_token(token: str) -> str:
93
+ return hashlib.sha256(token.encode()).hexdigest()
94
+
95
+
96
+ def default_db_path(kb_name: str, *, path_service: Any = None) -> Path:
97
+ """Default database location under DeepTutor's data directory.
98
+
99
+ ``path_service`` overrides whose workspace is used. The device-token
100
+ endpoints carry no session, so they must name the workspace they mean
101
+ rather than inherit whatever the ambient request context happens to be —
102
+ see ``api/routers/marginnote4.py``.
103
+ """
104
+ safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in kb_name)
105
+ if path_service is None:
106
+ from deeptutor.services.path_service import get_path_service
107
+
108
+ path_service = get_path_service()
109
+ return path_service.user_data_dir / "marginnote4" / f"{safe}.db"
110
+
111
+
112
+ def resolve_db_path(
113
+ kb_name: str,
114
+ *,
115
+ metadata: dict[str, Any] | None = None,
116
+ path_service: Any = None,
117
+ ) -> Path:
118
+ """The store a connected MN4 library actually uses.
119
+
120
+ A KB entry may pin ``db_path``; otherwise the path is derived from the name.
121
+ That rule has three readers — the capability binding, the session endpoints
122
+ that pair and list devices, and the device endpoints that sync — and a token
123
+ issued against one store is invisible to a sync resolved against another, so
124
+ it lives here rather than at each of them.
125
+
126
+ Pass ``metadata`` when the caller already resolved the KB entry. Otherwise
127
+ it is looked up, and a failure (no such KB, no request context) falls
128
+ through to the derived path.
129
+ """
130
+ if metadata is None:
131
+ metadata = _kb_metadata(kb_name)
132
+ pinned = str((metadata or {}).get("db_path") or "").strip()
133
+ if pinned:
134
+ return Path(pinned)
135
+ return default_db_path(kb_name, path_service=path_service)
136
+
137
+
138
+ def _kb_metadata(kb_name: str) -> dict[str, Any] | None:
139
+ try:
140
+ from deeptutor.multi_user.knowledge_access import resolve_kb_metadata
141
+
142
+ return resolve_kb_metadata(kb_name)
143
+ except Exception: # noqa: BLE001 - unresolvable KB → derive from the name
144
+ return None
145
+
146
+
147
+ class MarginNoteStore:
148
+ """CRUD + search over synced MN4 objects backed by SQLite.
149
+
150
+ A single store instance serves one connected MarginNote 4 KB. The store
151
+ owns its SQLite connection and is safe for sequential use from async code
152
+ (each public method opens a short-lived connection).
153
+ """
154
+
155
+ def __init__(self, db_path: str | Path) -> None:
156
+ self.db_path = Path(db_path)
157
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
158
+ self._init_schema()
159
+
160
+ @classmethod
161
+ def open_existing(cls, db_path: str | Path) -> "MarginNoteStore | None":
162
+ """Open an already-paired store, or ``None`` when there is none.
163
+
164
+ Constructing a store creates its directory and schema, so the
165
+ device-token endpoints — which are reached *before* any credential has
166
+ been checked — must not construct one from caller-supplied input. Use
167
+ this instead: no file, no store, and nothing written to disk by an
168
+ unauthenticated request.
169
+ """
170
+ path = Path(db_path)
171
+ if not path.is_file():
172
+ return None
173
+ return cls(path)
174
+
175
+ # -- internal helpers ---------------------------------------------------
176
+
177
+ @contextmanager
178
+ def _connect(self) -> Iterator[sqlite3.Connection]:
179
+ # A connection's own context manager commits or rolls back but never
180
+ # closes, so the file stayed open until the GC reached it. On Windows
181
+ # an open file cannot be removed: deleting the KB left the store, and
182
+ # its paired device tokens, for a library reconnected under the same
183
+ # name to pick up again. ``with conn`` keeps each call one transaction.
184
+ conn = sqlite3.connect(str(self.db_path))
185
+ conn.row_factory = sqlite3.Row
186
+ conn.execute("PRAGMA journal_mode=WAL")
187
+ try:
188
+ with conn:
189
+ yield conn
190
+ finally:
191
+ conn.close()
192
+
193
+ def _init_schema(self) -> None:
194
+ with self._connect() as conn:
195
+ conn.executescript(_SCHEMA)
196
+
197
+ @staticmethod
198
+ def _row_to_object(row: sqlite3.Row) -> MarginNoteObject:
199
+ return MarginNoteObject(
200
+ object_id=row["object_id"],
201
+ object_type=row["object_type"],
202
+ title=row["title"],
203
+ content=row["content"],
204
+ excerpt=row["excerpt"],
205
+ document_id=row["document_id"],
206
+ document_title=row["document_title"],
207
+ page=row["page"],
208
+ tags=json.loads(row["tags"]),
209
+ links=json.loads(row["links"]),
210
+ color=row["color"],
211
+ created_at=row["created_at"],
212
+ updated_at=row["updated_at"],
213
+ synced_at=row["synced_at"],
214
+ device_id=row["device_id"],
215
+ raw=json.loads(row["raw"]),
216
+ )
217
+
218
+ # -- device pairing -----------------------------------------------------
219
+
220
+ def pair_device(
221
+ self,
222
+ *,
223
+ device_name: str = "",
224
+ device_kind: str = "macos",
225
+ ) -> tuple[PairedDevice, str]:
226
+ """Register a new device. Returns ``(device, plaintext_token)``.
227
+
228
+ The token is returned once; only its SHA-256 hash is stored. The
229
+ caller must deliver it to the device over a trusted channel.
230
+ """
231
+ device_id = secrets.token_urlsafe(12)
232
+ token = secrets.token_urlsafe(32)
233
+ token_hash = _hash_token(token)
234
+ now = _now_iso()
235
+ device = PairedDevice(
236
+ device_id=device_id,
237
+ device_name=device_name,
238
+ device_kind=device_kind,
239
+ paired_at=now,
240
+ last_seen=now,
241
+ )
242
+ with self._connect() as conn:
243
+ conn.execute(
244
+ """INSERT INTO mn4_devices
245
+ (device_id, device_name, device_kind, token_hash,
246
+ paired_at, last_seen, active)
247
+ VALUES (?, ?, ?, ?, ?, ?, 1)""",
248
+ (device_id, device_name, device_kind, token_hash, now, now),
249
+ )
250
+ conn.execute(
251
+ "INSERT OR REPLACE INTO mn4_cursors (device_id, cursor) VALUES (?, '')",
252
+ (device_id,),
253
+ )
254
+ return device, token
255
+
256
+ def revoke_device(self, device_id: str) -> bool:
257
+ """Mark a device inactive. Returns True if a row was affected."""
258
+ with self._connect() as conn:
259
+ cur = conn.execute(
260
+ "UPDATE mn4_devices SET active = 0 WHERE device_id = ? AND active = 1",
261
+ (device_id,),
262
+ )
263
+ return cur.rowcount > 0
264
+
265
+ def verify_token(self, device_id: str, token: str) -> bool:
266
+ """Check a device token against the stored hash."""
267
+ with self._connect() as conn:
268
+ row = conn.execute(
269
+ "SELECT token_hash, active FROM mn4_devices WHERE device_id = ?",
270
+ (device_id,),
271
+ ).fetchone()
272
+ if row is None or not row["active"]:
273
+ return False
274
+ return secrets.compare_digest(row["token_hash"], _hash_token(token))
275
+
276
+ def list_devices(self) -> list[PairedDevice]:
277
+ with self._connect() as conn:
278
+ rows = conn.execute("SELECT * FROM mn4_devices ORDER BY paired_at").fetchall()
279
+ return [_row_to_device(r) for r in rows]
280
+
281
+ def touch_device(self, device_id: str) -> None:
282
+ """Update last_seen timestamp for a device."""
283
+ with self._connect() as conn:
284
+ conn.execute(
285
+ "UPDATE mn4_devices SET last_seen = ? WHERE device_id = ?",
286
+ (_now_iso(), device_id),
287
+ )
288
+
289
+ # -- sync ingest --------------------------------------------------------
290
+
291
+ def ingest(self, batch: SyncBatch) -> SyncResult:
292
+ """Upsert a batch of objects and advance the device cursor."""
293
+ stored = updated = deleted = 0
294
+ now = _now_iso()
295
+ with self._connect() as conn:
296
+ for obj in batch.objects:
297
+ if obj.object_type not in ALL_TYPES:
298
+ logger.warning("Skipping unknown MN4 type: %s", obj.object_type)
299
+ continue
300
+ prev = conn.execute(
301
+ "SELECT synced_at FROM mn4_objects WHERE object_id = ? AND device_id = ?",
302
+ (obj.object_id, batch.device_id),
303
+ ).fetchone()
304
+ synced_at = obj.synced_at or now
305
+ conn.execute(
306
+ """INSERT INTO mn4_objects
307
+ (object_id, device_id, object_type, title, content,
308
+ excerpt, document_id, document_title, page, tags,
309
+ links, color, created_at, updated_at, synced_at, raw)
310
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
311
+ ON CONFLICT(object_id, device_id) DO UPDATE SET
312
+ object_type=excluded.object_type,
313
+ title=excluded.title, content=excluded.content,
314
+ excerpt=excluded.excerpt,
315
+ document_id=excluded.document_id,
316
+ document_title=excluded.document_title,
317
+ page=excluded.page, tags=excluded.tags,
318
+ links=excluded.links, color=excluded.color,
319
+ created_at=excluded.created_at,
320
+ updated_at=excluded.updated_at,
321
+ synced_at=excluded.synced_at, raw=excluded.raw""",
322
+ (
323
+ obj.object_id,
324
+ batch.device_id,
325
+ obj.object_type,
326
+ obj.title,
327
+ obj.content,
328
+ obj.excerpt,
329
+ obj.document_id,
330
+ obj.document_title,
331
+ obj.page,
332
+ json.dumps(obj.tags, ensure_ascii=False),
333
+ json.dumps(obj.links, ensure_ascii=False),
334
+ obj.color,
335
+ obj.created_at,
336
+ obj.updated_at,
337
+ synced_at,
338
+ json.dumps(obj.raw, ensure_ascii=False),
339
+ ),
340
+ )
341
+ if prev:
342
+ updated += 1
343
+ else:
344
+ stored += 1
345
+
346
+ for oid in batch.deleted_ids:
347
+ conn.execute(
348
+ "INSERT OR REPLACE INTO mn4_tombstones "
349
+ "(object_id, device_id, deleted_at) VALUES (?, ?, ?)",
350
+ (oid, batch.device_id, now),
351
+ )
352
+ conn.execute(
353
+ "DELETE FROM mn4_objects WHERE object_id = ? AND device_id = ?",
354
+ (oid, batch.device_id),
355
+ )
356
+ deleted += 1
357
+
358
+ new_cursor = _now_iso()
359
+ conn.execute(
360
+ "INSERT OR REPLACE INTO mn4_cursors (device_id, cursor) VALUES (?, ?)",
361
+ (batch.device_id, new_cursor),
362
+ )
363
+
364
+ return SyncResult(stored=stored, updated=updated, deleted=deleted, new_cursor=new_cursor)
365
+
366
+ def get_cursor(self, device_id: str) -> str:
367
+ with self._connect() as conn:
368
+ row = conn.execute(
369
+ "SELECT cursor FROM mn4_cursors WHERE device_id = ?",
370
+ (device_id,),
371
+ ).fetchone()
372
+ return row["cursor"] if row else ""
373
+
374
+ # -- read operations ----------------------------------------------------
375
+
376
+ def get(self, object_id: str, *, device_id: str = "") -> MarginNoteObject | None:
377
+ """Fetch a single object by ID."""
378
+ with self._connect() as conn:
379
+ if device_id:
380
+ row = conn.execute(
381
+ "SELECT * FROM mn4_objects WHERE object_id = ? AND device_id = ?",
382
+ (object_id, device_id),
383
+ ).fetchone()
384
+ else:
385
+ row = conn.execute(
386
+ "SELECT * FROM mn4_objects WHERE object_id = ? LIMIT 1",
387
+ (object_id,),
388
+ ).fetchone()
389
+ return self._row_to_object(row) if row else None
390
+
391
+ def search(
392
+ self,
393
+ query: str,
394
+ *,
395
+ object_type: str = "",
396
+ device_id: str = "",
397
+ limit: int = 20,
398
+ ) -> list[dict[str, Any]]:
399
+ """Case-insensitive substring search over titles and content."""
400
+ query = (query or "").strip()
401
+ if not query:
402
+ return []
403
+ needle = f"%{query.lower()}%"
404
+ sql = (
405
+ "SELECT * FROM mn4_objects "
406
+ "WHERE (LOWER(title) LIKE ? OR LOWER(content) LIKE ? "
407
+ "OR LOWER(COALESCE(excerpt, '')) LIKE ? "
408
+ "OR LOWER(COALESCE(document_title, '')) LIKE ?)"
409
+ )
410
+ params: list[Any] = [needle, needle, needle, needle]
411
+ if object_type:
412
+ sql += " AND object_type = ?"
413
+ params.append(object_type)
414
+ if device_id:
415
+ sql += " AND device_id = ?"
416
+ params.append(device_id)
417
+ sql += " ORDER BY updated_at DESC LIMIT ?"
418
+ params.append(limit)
419
+ with self._connect() as conn:
420
+ rows = conn.execute(sql, params).fetchall()
421
+ return [_to_summary(self._row_to_object(r), query) for r in rows]
422
+
423
+ def list_objects(
424
+ self,
425
+ *,
426
+ object_type: str = "",
427
+ document_id: str = "",
428
+ device_id: str = "",
429
+ limit: int = 200,
430
+ ) -> list[dict[str, Any]]:
431
+ """List objects with optional type/document filter."""
432
+ sql = "SELECT * FROM mn4_objects WHERE 1=1"
433
+ params: list[Any] = []
434
+ if object_type:
435
+ sql += " AND object_type = ?"
436
+ params.append(object_type)
437
+ if document_id:
438
+ sql += " AND document_id = ?"
439
+ params.append(document_id)
440
+ if device_id:
441
+ sql += " AND device_id = ?"
442
+ params.append(device_id)
443
+ sql += " ORDER BY COALESCE(document_title, title), updated_at DESC LIMIT ?"
444
+ params.append(limit)
445
+ with self._connect() as conn:
446
+ rows = conn.execute(sql, params).fetchall()
447
+ return [_to_summary(self._row_to_object(r), "") for r in rows]
448
+
449
+ def list_documents(self, *, device_id: str = "") -> list[dict[str, Any]]:
450
+ """Distinct source documents with object counts."""
451
+ sql = (
452
+ "SELECT document_id, document_title, COUNT(*) AS n "
453
+ "FROM mn4_objects WHERE document_id IS NOT NULL"
454
+ )
455
+ params: list[Any] = []
456
+ if device_id:
457
+ sql += " AND device_id = ?"
458
+ params.append(device_id)
459
+ sql += " GROUP BY document_id, document_title ORDER BY document_title"
460
+ with self._connect() as conn:
461
+ rows = conn.execute(sql, params).fetchall()
462
+ return [
463
+ {
464
+ "document_id": r["document_id"],
465
+ "title": r["document_title"] or "(untitled)",
466
+ "count": int(r["n"]),
467
+ }
468
+ for r in rows
469
+ ]
470
+
471
+ def linked_objects(self, object_id: str, *, device_id: str = "") -> list[dict[str, Any]]:
472
+ """Return objects linked TO or FROM the given object."""
473
+ obj = self.get(object_id, device_id=device_id)
474
+ if obj is None:
475
+ return []
476
+ linked_ids = set(obj.links)
477
+ with self._connect() as conn:
478
+ rows = conn.execute(
479
+ "SELECT object_id FROM mn4_objects WHERE links LIKE ?",
480
+ (f'%"{object_id}"%',),
481
+ ).fetchall()
482
+ for r in rows:
483
+ linked_ids.add(r["object_id"])
484
+ results: list[dict[str, Any]] = []
485
+ for lid in linked_ids:
486
+ linked = self.get(lid, device_id=device_id)
487
+ if linked:
488
+ results.append(_to_summary(linked, ""))
489
+ return results
490
+
491
+ def collect_tags(self, *, device_id: str = "", limit: int = 200) -> list[dict[str, Any]]:
492
+ """All tags across objects, ranked by frequency."""
493
+ with self._connect() as conn:
494
+ sql = "SELECT tags FROM mn4_objects"
495
+ params: list[Any] = []
496
+ if device_id:
497
+ sql += " WHERE device_id = ?"
498
+ params.append(device_id)
499
+ rows = conn.execute(sql, params).fetchall()
500
+ counts: dict[str, int] = {}
501
+ for r in rows:
502
+ for tag in json.loads(r["tags"]):
503
+ tag = tag.strip()
504
+ if tag:
505
+ counts[tag] = counts.get(tag, 0) + 1
506
+ ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
507
+ return [{"tag": t, "count": c} for t, c in ranked[:limit]]
508
+
509
+ def count(self, *, device_id: str = "") -> int:
510
+ """Total object count, optionally filtered by device."""
511
+ with self._connect() as conn:
512
+ if device_id:
513
+ row = conn.execute(
514
+ "SELECT COUNT(*) AS n FROM mn4_objects WHERE device_id = ?",
515
+ (device_id,),
516
+ ).fetchone()
517
+ else:
518
+ row = conn.execute("SELECT COUNT(*) AS n FROM mn4_objects").fetchone()
519
+ return row["n"]
520
+
521
+
522
+ def _row_to_device(row: sqlite3.Row) -> PairedDevice:
523
+ return PairedDevice(
524
+ device_id=row["device_id"],
525
+ device_name=row["device_name"],
526
+ device_kind=row["device_kind"],
527
+ paired_at=row["paired_at"],
528
+ last_seen=row["last_seen"],
529
+ active=bool(row["active"]),
530
+ )
531
+
532
+
533
+ def _to_summary(obj: MarginNoteObject, query: str) -> dict[str, Any]:
534
+ """Compact dict for search/list results (omits raw payload)."""
535
+ body = obj.content or obj.excerpt or ""
536
+ return {
537
+ "object_id": obj.object_id,
538
+ "object_type": obj.object_type,
539
+ "title": obj.title,
540
+ "document_title": obj.document_title,
541
+ "page": obj.page,
542
+ "tags": obj.tags,
543
+ "snippet": _snippet(body, query),
544
+ "updated_at": obj.updated_at,
545
+ }
546
+
547
+
548
+ def _snippet(body: str, query: str, width: int = 160) -> str:
549
+ if not body:
550
+ return ""
551
+ if not query:
552
+ return body[:width].strip().replace("\n", " ")
553
+ lowered = body.lower()
554
+ idx = lowered.find(query.lower())
555
+ if idx < 0:
556
+ return body[:width].strip().replace("\n", " ")
557
+ start = max(0, idx - width // 3)
558
+ tail = "..." if start + width < len(body) else ""
559
+ return ("..." if start else "") + body[start : start + width].strip().replace("\n", " ") + tail
560
+
561
+
562
+ __all__ = ["MarginNoteStore"]