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.
- deeptutor/api/routers/knowledge.py +5225 -0
- deeptutor/api/routers/marginnote4.py +278 -0
- deeptutor/capabilities/ima/__init__.py +6 -0
- deeptutor/capabilities/ima/binding.py +118 -0
- deeptutor/capabilities/ima/capability.py +92 -0
- deeptutor/capabilities/ima/prompts/en/system.md +33 -0
- deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
- deeptutor/capabilities/ima/tools.py +476 -0
- deeptutor/capabilities/marginnote4/__init__.py +13 -0
- deeptutor/capabilities/marginnote4/binding.py +66 -0
- deeptutor/capabilities/marginnote4/capability.py +80 -0
- deeptutor/capabilities/marginnote4/models.py +173 -0
- deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
- deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
- deeptutor/capabilities/marginnote4/store.py +562 -0
- deeptutor/capabilities/marginnote4/tools.py +329 -0
- deeptutor/capabilities/obsidian/__init__.py +6 -0
- deeptutor/capabilities/obsidian/binding.py +75 -0
- deeptutor/capabilities/obsidian/capability.py +82 -0
- deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
- deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
- deeptutor/capabilities/obsidian/tools.py +399 -0
- deeptutor/capabilities/obsidian/vault.py +341 -0
- deeptutor/knowledge/__init__.py +28 -0
- deeptutor/knowledge/add_documents.py +734 -0
- deeptutor/knowledge/indexing_run.py +432 -0
- deeptutor/knowledge/initializer.py +448 -0
- deeptutor/knowledge/manager.py +2570 -0
- deeptutor/knowledge/module_service.py +29 -0
- deeptutor/knowledge/naming.py +40 -0
- deeptutor/knowledge/progress_events.py +45 -0
- deeptutor/knowledge/progress_tracker.py +355 -0
- deeptutor/knowledge/staging.py +69 -0
- deeptutor/services/config/knowledge_base_config.py +239 -0
- deeptutor/services/embedding/__init__.py +46 -0
- deeptutor/services/embedding/adapters/__init__.py +40 -0
- deeptutor/services/embedding/adapters/base.py +196 -0
- deeptutor/services/embedding/adapters/cohere.py +176 -0
- deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
- deeptutor/services/embedding/adapters/gemini.py +462 -0
- deeptutor/services/embedding/adapters/jina.py +161 -0
- deeptutor/services/embedding/adapters/ollama.py +145 -0
- deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
- deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
- deeptutor/services/embedding/client.py +323 -0
- deeptutor/services/embedding/config.py +114 -0
- deeptutor/services/embedding/request_options.py +46 -0
- deeptutor/services/embedding/validation.py +128 -0
- deeptutor/services/github_source/__init__.py +5 -0
- deeptutor/services/github_source/client.py +164 -0
- deeptutor/services/github_source/sync.py +384 -0
- deeptutor/services/github_source/sync_service.py +72 -0
- deeptutor/services/rag/__init__.py +21 -0
- deeptutor/services/rag/embedding_binding.py +331 -0
- deeptutor/services/rag/embedding_signature.py +63 -0
- deeptutor/services/rag/eval/__init__.py +92 -0
- deeptutor/services/rag/eval/dataset.py +244 -0
- deeptutor/services/rag/eval/diff.py +312 -0
- deeptutor/services/rag/eval/matching.py +218 -0
- deeptutor/services/rag/eval/metrics.py +213 -0
- deeptutor/services/rag/eval/report.py +111 -0
- deeptutor/services/rag/eval/runner.py +309 -0
- deeptutor/services/rag/factory.py +369 -0
- deeptutor/services/rag/file_routing.py +5 -0
- deeptutor/services/rag/index_probe.py +327 -0
- deeptutor/services/rag/index_versioning.py +387 -0
- deeptutor/services/rag/kb_paths.py +50 -0
- deeptutor/services/rag/linked_kb.py +237 -0
- deeptutor/services/rag/pipelines/__init__.py +7 -0
- deeptutor/services/rag/pipelines/base.py +41 -0
- deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
- deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
- deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
- deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
- deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
- deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
- deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
- deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
- deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
- deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
- deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
- deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
- deeptutor/services/rag/pipelines/ima/client.py +364 -0
- deeptutor/services/rag/pipelines/ima/config.py +140 -0
- deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
- deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
- deeptutor/services/rag/pipelines/ima/media.py +190 -0
- deeptutor/services/rag/pipelines/ima/models.py +348 -0
- deeptutor/services/rag/pipelines/ima/notes.py +240 -0
- deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
- deeptutor/services/rag/pipelines/ima/probe.py +105 -0
- deeptutor/services/rag/pipelines/ima/sources.py +105 -0
- deeptutor/services/rag/pipelines/ima/transport.py +130 -0
- deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
- deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
- deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
- deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
- deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
- deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
- deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
- deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
- deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
- deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
- deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
- deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
- deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
- deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
- deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
- deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
- deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
- deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
- deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
- deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
- deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
- deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
- deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
- deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
- deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
- deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
- deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
- deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
- deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
- deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
- deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
- deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
- deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
- deeptutor/services/rag/pipelines/modes.py +48 -0
- deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
- deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
- deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
- deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
- deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
- deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
- deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
- deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
- deeptutor/services/rag/pipelines/weknora/client.py +106 -0
- deeptutor/services/rag/pipelines/weknora/config.py +56 -0
- deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
- deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
- deeptutor/services/rag/preflight.py +274 -0
- deeptutor/services/rag/provider_binding.py +70 -0
- deeptutor/services/rag/query_rewrite.py +186 -0
- deeptutor/services/rag/service.py +379 -0
- deeptutor/services/rag/smart_retriever.py +81 -0
- deeptutor/services/rag/source_pages.py +292 -0
- deeptutor/services/rag/source_visuals.py +434 -0
- deeptutor/services/rag/visual_assets.py +430 -0
- deeptutor/services/rag/visual_coverage.py +165 -0
- deeptutor/services/web_source/__init__.py +1 -0
- deeptutor/services/web_source/bilingual.py +255 -0
- deeptutor/services/web_source/crawler.py +681 -0
- deeptutor/services/web_source/html_extractor.py +643 -0
- deeptutor/services/web_source/markdown.py +28 -0
- deeptutor/services/web_source/navigation.py +72 -0
- deeptutor/services/web_source/repository.py +640 -0
- deeptutor/services/web_source/robots.py +182 -0
- deeptutor/services/web_source/scheduler.py +336 -0
- deeptutor/services/web_source/snapshot_assets.py +171 -0
- deeptutor/services/web_source/sync.py +253 -0
- deeptutor/services/workspace/kb_move.py +446 -0
- deeptutor/tools/rag_tool.py +110 -0
- deeptutor_cli/kb.py +756 -0
- deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
- deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
- deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
- 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"]
|