bookmark-research 0.5.0-beta.1
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.
- package/.agents/plugins/marketplace.json +20 -0
- package/.codex-plugin/plugin.json +46 -0
- package/LICENSE +674 -0
- package/README.md +128 -0
- package/README.zh.md +126 -0
- package/bin/bookmark-research.js +30 -0
- package/config/providers.json +98 -0
- package/docs/bookmark-research-architecture.md +124 -0
- package/docs/cli-launcher-plan.md +58 -0
- package/docs/details.en.md +272 -0
- package/docs/details.md +93 -0
- package/docs/harness-compatibility.md +304 -0
- package/docs/harness-sources.json +342 -0
- package/docs/host-validation.json +298 -0
- package/docs/install-design-0.2.0.md +57 -0
- package/docs/installation.en.md +197 -0
- package/docs/installation.md +316 -0
- package/docs/instructions.en.md +42 -0
- package/docs/instructions.md +42 -0
- package/docs/mcp-aggregation-0.2.0.md +92 -0
- package/docs/multi-host-installation-plan.md +163 -0
- package/docs/prompt-reference.en.md +104 -0
- package/docs/prompt-reference.md +104 -0
- package/docs/provider-smoke-0.2.0.json +235 -0
- package/docs/release-notes-0.5.0-beta.1.md +33 -0
- package/docs/research-0.2.0.md +140 -0
- package/docs/research-sources-0.2.0.json +9360 -0
- package/docs/user-guide.en.md +74 -0
- package/docs/user-guide.md +74 -0
- package/docs/validation-0.2.0.md +126 -0
- package/docs/validation-0.4.0.md +154 -0
- package/docs/wiki-quality.md +105 -0
- package/docs/wiki-quality.zh.md +105 -0
- package/hosts/claude/runtime.js +13 -0
- package/hosts/codex/delegate.md +14 -0
- package/hosts/codex/prepare.py +61 -0
- package/hosts/dsh/plugin.js +36 -0
- package/hosts/dsh/runtime.js +14 -0
- package/hosts/dsh/workflow-call.py +45 -0
- package/hosts/pi/register-workflow.py +72 -0
- package/hosts/pi/runtime.js +18 -0
- package/hosts/shared/research-call.py +76 -0
- package/hosts/shared/research-flow.js +208 -0
- package/hosts/shared/workflow.json +15 -0
- package/install.sh +310 -0
- package/package.json +12 -0
- package/scripts/build_zip.py +143 -0
- package/scripts/export_bundle.py +496 -0
- package/scripts/host_assets.py +70 -0
- package/scripts/host_clients.py +196 -0
- package/scripts/host_install.py +277 -0
- package/scripts/install.py +593 -0
- package/scripts/launcher.py +186 -0
- package/scripts/verify_fixture.py +220 -0
- package/scripts/verify_live.py +62 -0
- package/scripts/verify_quality.py +87 -0
- package/skills/bookmark-research/SKILL.md +80 -0
- package/skills/bookmark-research/agents/openai.yaml +3 -0
- package/skills/bookmark-research/references/cli.md +150 -0
- package/skills/bookmark-research/references/deep-research.md +164 -0
- package/skills/bookmark-research/references/github-and-sync.md +30 -0
- package/skills/bookmark-research/references/host-workflows.md +101 -0
- package/skills/bookmark-research/references/package-semantics.md +78 -0
- package/skills/bookmark-research/references/research-methods.md +51 -0
- package/skills/bookmark-research/references/research-services.md +72 -0
- package/skills/bookmark-research/references/research-workflow.md +57 -0
- package/skills/bookmark-research/references/settings-and-archive.md +104 -0
- package/skills/bookmark-research/references/source-lifecycle.md +75 -0
- package/skills/bookmark-research/references/wiki-and-evaluation.md +63 -0
- package/skills/bookmark-research/references/zh/cli.md +150 -0
- package/skills/bookmark-research/references/zh/deep-research.md +164 -0
- package/skills/bookmark-research/references/zh/github-and-sync.md +30 -0
- package/skills/bookmark-research/references/zh/host-workflows.md +101 -0
- package/skills/bookmark-research/references/zh/package-semantics.md +78 -0
- package/skills/bookmark-research/references/zh/research-methods.md +51 -0
- package/skills/bookmark-research/references/zh/research-services.md +70 -0
- package/skills/bookmark-research/references/zh/research-workflow.md +57 -0
- package/skills/bookmark-research/references/zh/settings-and-archive.md +104 -0
- package/skills/bookmark-research/references/zh/skill-guide.md +75 -0
- package/skills/bookmark-research/references/zh/source-lifecycle.md +75 -0
- package/skills/bookmark-research/references/zh/wiki-and-evaluation.md +63 -0
- package/src/archive.py +302 -0
- package/src/bookmark_index.py +913 -0
- package/src/cli.py +435 -0
- package/src/credentials.py +82 -0
- package/src/evaluation.py +337 -0
- package/src/jina.py +96 -0
- package/src/mcp_server.py +641 -0
- package/src/onboarding.py +352 -0
- package/src/provider_adapters.py +152 -0
- package/src/readiness.py +283 -0
- package/src/remote_mcp.py +295 -0
- package/src/research.py +1334 -0
- package/src/research_coverage.py +160 -0
- package/src/research_services.py +684 -0
- package/src/routing.py +202 -0
- package/src/search_results.py +244 -0
- package/src/service_http.py +139 -0
- package/src/settings.py +247 -0
- package/src/source_inputs.py +201 -0
- package/src/source_manager.py +348 -0
- package/src/source_watcher.py +68 -0
- package/src/textio.py +9 -0
- package/src/tui.py +586 -0
- package/src/web_search.py +603 -0
- package/src/wiki.py +383 -0
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
"""Read-only Bookmark Canvas package ingestion and a persistent SQL query API.
|
|
2
|
+
|
|
3
|
+
Only the derived database is written. JSON/Canvas remains the source of truth.
|
|
4
|
+
Absent section files are retained; a supplied canvas replaces the previous entry.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import sqlite3
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _dump(value):
|
|
18
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _now():
|
|
22
|
+
return datetime.now(timezone.utc).isoformat()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _inside(path, directory):
|
|
26
|
+
try:
|
|
27
|
+
path.relative_to(directory)
|
|
28
|
+
return True
|
|
29
|
+
except ValueError:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _unique_object(pairs):
|
|
34
|
+
result = {}
|
|
35
|
+
for key, value in pairs:
|
|
36
|
+
if key in result:
|
|
37
|
+
raise ValueError("Duplicate JSON key: " + key)
|
|
38
|
+
result[key] = value
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _reject_constant(value):
|
|
43
|
+
raise ValueError("Non-finite JSON value: " + value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _string(value, label, required=False):
|
|
47
|
+
if not isinstance(value, str) or "\x00" in value or (required and not value):
|
|
48
|
+
raise ValueError("Expected %s string" % label)
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _reference(reference):
|
|
53
|
+
"""Resolve the protocol directory suffix, not the referring file's parent.
|
|
54
|
+
|
|
55
|
+
The vault root is external to an exported/moved package. Retaining the full
|
|
56
|
+
protocol suffix accommodates its vault prefix without reading outside it.
|
|
57
|
+
"""
|
|
58
|
+
_string(reference, "vault-relative reference", True)
|
|
59
|
+
parts = PurePosixPath(reference).parts
|
|
60
|
+
if reference.startswith(("/", "\\")) or "\\" in reference or ".." in parts:
|
|
61
|
+
raise ValueError("Unsafe vault reference: " + reference)
|
|
62
|
+
if parts and ":" in parts[0]:
|
|
63
|
+
raise ValueError("Absolute/URI references are not package files")
|
|
64
|
+
for index, part in enumerate(parts):
|
|
65
|
+
if part in ("永久栏目", "临时栏目"):
|
|
66
|
+
relative = PurePosixPath(*parts[index:])
|
|
67
|
+
if relative.suffix != ".json":
|
|
68
|
+
break
|
|
69
|
+
return str(relative)
|
|
70
|
+
raise ValueError("Reference must name an internal section JSON: " + reference)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
SCHEMA = """
|
|
74
|
+
CREATE TABLE IF NOT EXISTS sources (
|
|
75
|
+
source_id TEXT PRIMARY KEY, package_path TEXT NOT NULL,
|
|
76
|
+
created_at TEXT NOT NULL, last_sync TEXT NOT NULL
|
|
77
|
+
);
|
|
78
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
79
|
+
source_id TEXT NOT NULL, file_path TEXT NOT NULL, kind TEXT NOT NULL,
|
|
80
|
+
sha256 TEXT NOT NULL, raw_json TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
|
|
81
|
+
PRIMARY KEY(source_id,file_path),
|
|
82
|
+
FOREIGN KEY(source_id) REFERENCES sources(source_id)
|
|
83
|
+
);
|
|
84
|
+
CREATE TABLE IF NOT EXISTS sections (
|
|
85
|
+
source_id TEXT NOT NULL, section_id TEXT NOT NULL, file_path TEXT NOT NULL,
|
|
86
|
+
section_type TEXT NOT NULL, label TEXT NOT NULL, slot TEXT NOT NULL,
|
|
87
|
+
title TEXT NOT NULL, description_md TEXT NOT NULL, is_anchor INTEGER NOT NULL,
|
|
88
|
+
inherit_path TEXT, canonical_id TEXT, raw_json TEXT NOT NULL,
|
|
89
|
+
revision INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(source_id,section_id),
|
|
90
|
+
FOREIGN KEY(source_id) REFERENCES sources(source_id)
|
|
91
|
+
);
|
|
92
|
+
CREATE INDEX IF NOT EXISTS sections_file ON sections(source_id,file_path);
|
|
93
|
+
CREATE TABLE IF NOT EXISTS items (
|
|
94
|
+
pk INTEGER PRIMARY KEY, source_id TEXT NOT NULL, section_id TEXT NOT NULL,
|
|
95
|
+
item_id TEXT NOT NULL, parent_id TEXT, item_type TEXT NOT NULL,
|
|
96
|
+
position INTEGER NOT NULL, preorder INTEGER NOT NULL, title TEXT NOT NULL,
|
|
97
|
+
url TEXT NOT NULL, note TEXT NOT NULL, note_color TEXT,
|
|
98
|
+
tags_json TEXT NOT NULL, tag_text TEXT NOT NULL, path_json TEXT NOT NULL,
|
|
99
|
+
folder_path TEXT NOT NULL, ancestor_ids_json TEXT NOT NULL,
|
|
100
|
+
raw_json TEXT NOT NULL, metadata_json TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
|
|
101
|
+
UNIQUE(source_id,section_id,item_id),
|
|
102
|
+
FOREIGN KEY(source_id,section_id) REFERENCES sections(source_id,section_id)
|
|
103
|
+
);
|
|
104
|
+
CREATE INDEX IF NOT EXISTS items_parent ON items(source_id,section_id,parent_id);
|
|
105
|
+
CREATE INDEX IF NOT EXISTS items_url ON items(source_id,url);
|
|
106
|
+
CREATE TABLE IF NOT EXISTS item_tags (
|
|
107
|
+
item_pk INTEGER NOT NULL REFERENCES items(pk) ON DELETE CASCADE,
|
|
108
|
+
position INTEGER NOT NULL, color TEXT NOT NULL, text TEXT NOT NULL,
|
|
109
|
+
raw_json TEXT NOT NULL, PRIMARY KEY(item_pk,color,text)
|
|
110
|
+
);
|
|
111
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5(
|
|
112
|
+
title,url,note,tag_text,folder_path,content='items',content_rowid='pk'
|
|
113
|
+
);
|
|
114
|
+
CREATE TRIGGER IF NOT EXISTS items_fts_ai AFTER INSERT ON items BEGIN
|
|
115
|
+
INSERT INTO items_fts(rowid,title,url,note,tag_text,folder_path)
|
|
116
|
+
VALUES(new.pk,new.title,new.url,new.note,new.tag_text,new.folder_path);
|
|
117
|
+
END;
|
|
118
|
+
CREATE TRIGGER IF NOT EXISTS items_fts_ad AFTER DELETE ON items BEGIN
|
|
119
|
+
INSERT INTO items_fts(items_fts,rowid,title,url,note,tag_text,folder_path)
|
|
120
|
+
VALUES('delete',old.pk,old.title,old.url,old.note,old.tag_text,old.folder_path);
|
|
121
|
+
END;
|
|
122
|
+
CREATE TRIGGER IF NOT EXISTS items_fts_au AFTER UPDATE ON items BEGIN
|
|
123
|
+
INSERT INTO items_fts(items_fts,rowid,title,url,note,tag_text,folder_path)
|
|
124
|
+
VALUES('delete',old.pk,old.title,old.url,old.note,old.tag_text,old.folder_path);
|
|
125
|
+
INSERT INTO items_fts(rowid,title,url,note,tag_text,folder_path)
|
|
126
|
+
VALUES(new.pk,new.title,new.url,new.note,new.tag_text,new.folder_path);
|
|
127
|
+
END;
|
|
128
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
129
|
+
source_id TEXT NOT NULL, canvas_path TEXT NOT NULL, node_id TEXT NOT NULL,
|
|
130
|
+
node_type TEXT NOT NULL, section_id TEXT, file_path TEXT, label TEXT NOT NULL,
|
|
131
|
+
text TEXT NOT NULL, x REAL NOT NULL, y REAL NOT NULL, width REAL NOT NULL,
|
|
132
|
+
height REAL NOT NULL, position INTEGER NOT NULL, raw_json TEXT NOT NULL,
|
|
133
|
+
revision INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(source_id,canvas_path,node_id)
|
|
134
|
+
);
|
|
135
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
136
|
+
source_id TEXT NOT NULL, canvas_path TEXT NOT NULL, edge_id TEXT NOT NULL,
|
|
137
|
+
from_node TEXT NOT NULL, to_node TEXT NOT NULL,
|
|
138
|
+
from_end TEXT NOT NULL, to_end TEXT NOT NULL, label TEXT NOT NULL,
|
|
139
|
+
raw_json TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
|
|
140
|
+
PRIMARY KEY(source_id,canvas_path,edge_id)
|
|
141
|
+
);
|
|
142
|
+
CREATE TABLE IF NOT EXISTS memberships (
|
|
143
|
+
source_id TEXT NOT NULL, canvas_path TEXT NOT NULL,
|
|
144
|
+
group_id TEXT NOT NULL, node_id TEXT NOT NULL,
|
|
145
|
+
PRIMARY KEY(source_id,canvas_path,group_id,node_id)
|
|
146
|
+
);
|
|
147
|
+
PRAGMA user_version=1;
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class BookmarkIndex:
|
|
152
|
+
def __init__(self, db_path):
|
|
153
|
+
self.db_path = None if str(db_path) == ":memory:" else Path(db_path).expanduser().resolve()
|
|
154
|
+
if self.db_path is not None:
|
|
155
|
+
# Refuse before connecting, so even an accidentally chosen package
|
|
156
|
+
# path does not acquire an empty database, journal or WAL file.
|
|
157
|
+
for parent in self.db_path.parents:
|
|
158
|
+
if ((parent / "永久栏目").is_dir() or (parent / "临时栏目").is_dir()
|
|
159
|
+
or any(parent.glob("*.canvas"))):
|
|
160
|
+
raise ValueError("Database must be outside imported package directories")
|
|
161
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
self.connection = sqlite3.connect(str(self.db_path) if self.db_path else ":memory:")
|
|
163
|
+
self.connection.row_factory = sqlite3.Row
|
|
164
|
+
self.connection.execute("PRAGMA foreign_keys=ON")
|
|
165
|
+
self.connection.execute("PRAGMA busy_timeout=5000")
|
|
166
|
+
version = self.connection.execute("PRAGMA user_version").fetchone()[0]
|
|
167
|
+
if version not in (0, 1):
|
|
168
|
+
self.close()
|
|
169
|
+
raise ValueError("Unsupported bookmark index schema version: %s" % version)
|
|
170
|
+
try:
|
|
171
|
+
self.connection.executescript(SCHEMA)
|
|
172
|
+
except Exception:
|
|
173
|
+
self.close()
|
|
174
|
+
raise
|
|
175
|
+
|
|
176
|
+
def close(self):
|
|
177
|
+
self.connection.close()
|
|
178
|
+
|
|
179
|
+
def __enter__(self):
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
def __exit__(self, *_):
|
|
183
|
+
self.close()
|
|
184
|
+
|
|
185
|
+
def _source(self, source_id):
|
|
186
|
+
row = self.connection.execute("SELECT * FROM sources WHERE source_id=?", (source_id,)).fetchone()
|
|
187
|
+
if row is None:
|
|
188
|
+
raise ValueError("Unknown source_id: " + str(source_id))
|
|
189
|
+
return dict(row)
|
|
190
|
+
|
|
191
|
+
def _upsert(self, table, keys, values):
|
|
192
|
+
"""Internal table/column names only; user values are always parameters."""
|
|
193
|
+
where = " AND ".join(key + "=?" for key in keys)
|
|
194
|
+
row = self.connection.execute("SELECT * FROM %s WHERE %s" % (table, where), tuple(keys.values())).fetchone()
|
|
195
|
+
if row is not None and all(row[key] == value for key, value in values.items()):
|
|
196
|
+
return "unchanged"
|
|
197
|
+
if row is None:
|
|
198
|
+
fields = dict(keys, **values)
|
|
199
|
+
self.connection.execute("INSERT INTO %s (%s) VALUES (%s)" % (
|
|
200
|
+
table, ",".join(fields), ",".join("?" for _ in fields)), tuple(fields.values()))
|
|
201
|
+
return "inserted"
|
|
202
|
+
self.connection.execute("UPDATE %s SET %s,revision=revision+1 WHERE %s" % (
|
|
203
|
+
table, ",".join(key + "=?" for key in values), where), tuple(values.values()) + tuple(keys.values()))
|
|
204
|
+
return "updated"
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def _section(file_path, obj):
|
|
208
|
+
if obj.get("format") != "bookmark-canvas-section":
|
|
209
|
+
raise ValueError("Not a Bookmark Canvas section: " + file_path)
|
|
210
|
+
section_type = obj.get("sectionType")
|
|
211
|
+
if section_type not in ("permanent", "temporary"):
|
|
212
|
+
raise ValueError("Unknown sectionType in " + file_path)
|
|
213
|
+
expected = "永久栏目/" if section_type == "permanent" else "临时栏目/"
|
|
214
|
+
if not file_path.startswith(expected):
|
|
215
|
+
raise ValueError("sectionType does not match protocol directory: " + file_path)
|
|
216
|
+
anchor = obj.get("fileRole") == "copy-anchor" or obj.get("anchorOnly") is True
|
|
217
|
+
slot = _string(obj.get("slot", ""), "slot")
|
|
218
|
+
if section_type == "permanent":
|
|
219
|
+
if not slot:
|
|
220
|
+
raise ValueError("Permanent section needs slot: " + file_path)
|
|
221
|
+
if anchor:
|
|
222
|
+
if "tree" in obj:
|
|
223
|
+
raise ValueError("Copy anchors must not duplicate a tree")
|
|
224
|
+
copy_id = _string(obj.get("copyId"), "copyId", True)
|
|
225
|
+
section_id = obj.get("id", "permanent-section-copy-" + copy_id)
|
|
226
|
+
else:
|
|
227
|
+
section_id = obj.get("id", "permanent-section" if slot == "A" else "permanent-section-" + slot)
|
|
228
|
+
label = slot
|
|
229
|
+
else:
|
|
230
|
+
if anchor:
|
|
231
|
+
raise ValueError("Only permanent sections can be copy anchors")
|
|
232
|
+
section_id = obj.get("id")
|
|
233
|
+
label = obj.get("label", "unknown")
|
|
234
|
+
_string(section_id, "section id", True)
|
|
235
|
+
_string(label, "section label", True)
|
|
236
|
+
return section_id, {
|
|
237
|
+
"file_path": file_path, "section_type": section_type, "label": label,
|
|
238
|
+
"slot": slot, "title": _string(obj.get("title", ""), "section title"),
|
|
239
|
+
"description_md": _string(obj.get("descriptionMd", ""), "descriptionMd"),
|
|
240
|
+
"is_anchor": int(anchor), "inherit_path": _reference(obj.get("inheritFrom")) if anchor else None,
|
|
241
|
+
"raw_json": _dump(obj),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
def _items(section_id, section, obj):
|
|
246
|
+
if section["is_anchor"]:
|
|
247
|
+
return []
|
|
248
|
+
permanent = section["section_type"] == "permanent"
|
|
249
|
+
roots = obj.get("tree") if permanent else obj.get("items")
|
|
250
|
+
if permanent and isinstance(roots, dict):
|
|
251
|
+
roots = [roots]
|
|
252
|
+
if not isinstance(roots, list):
|
|
253
|
+
raise ValueError("Section needs a complete tree/items value: " + section_id)
|
|
254
|
+
metadata = obj.get("identityMap", []) if permanent else []
|
|
255
|
+
if isinstance(metadata, dict):
|
|
256
|
+
meta = {}
|
|
257
|
+
for key, value in metadata.items():
|
|
258
|
+
if not isinstance(value, dict):
|
|
259
|
+
raise ValueError("identityMap entries must be objects")
|
|
260
|
+
if value.get("syncId", key) != key:
|
|
261
|
+
raise ValueError("identityMap key/syncId mismatch")
|
|
262
|
+
meta[key] = value
|
|
263
|
+
elif isinstance(metadata, list):
|
|
264
|
+
meta = {}
|
|
265
|
+
for value in metadata:
|
|
266
|
+
if not isinstance(value, dict):
|
|
267
|
+
raise ValueError("identityMap entries must be objects")
|
|
268
|
+
key = _string(value.get("syncId"), "identityMap syncId", True)
|
|
269
|
+
if key in meta:
|
|
270
|
+
raise ValueError("Duplicate identityMap syncId: " + key)
|
|
271
|
+
meta[key] = value
|
|
272
|
+
else:
|
|
273
|
+
raise ValueError("identityMap must be an array or keyed object")
|
|
274
|
+
result, seen = [], set()
|
|
275
|
+
|
|
276
|
+
def visit(nodes, parent_id=None, titles=(), ancestor_ids=()):
|
|
277
|
+
for position, node in enumerate(nodes):
|
|
278
|
+
if not isinstance(node, dict):
|
|
279
|
+
raise ValueError("Items must be JSON objects")
|
|
280
|
+
item_id = _string(node.get("id"), "item id", True)
|
|
281
|
+
if item_id in seen:
|
|
282
|
+
raise ValueError("Duplicate item id in section: " + item_id)
|
|
283
|
+
seen.add(item_id)
|
|
284
|
+
if permanent and "parentId" in node and node["parentId"] != parent_id:
|
|
285
|
+
raise ValueError("parentId disagrees with tree position: " + item_id)
|
|
286
|
+
if not permanent and node.get("sectionId", section_id) != section_id:
|
|
287
|
+
raise ValueError("Temporary item sectionId mismatch: " + item_id)
|
|
288
|
+
title = _string(node.get("title", ""), "item title")
|
|
289
|
+
url = _string(node.get("url", ""), "item URL")
|
|
290
|
+
item_type = ("bookmark" if url else "folder") if permanent else node.get("type")
|
|
291
|
+
if item_type not in ("bookmark", "folder") or (item_type == "bookmark" and not url):
|
|
292
|
+
raise ValueError("Invalid bookmark/folder type: " + item_id)
|
|
293
|
+
children = node.get("children", [])
|
|
294
|
+
if not isinstance(children, list) or (children and item_type == "bookmark"):
|
|
295
|
+
raise ValueError("Invalid item children: " + item_id)
|
|
296
|
+
item_meta = meta.get(item_id, {}) if permanent else node
|
|
297
|
+
tags = item_meta.get("tags", [])
|
|
298
|
+
if not isinstance(tags, list):
|
|
299
|
+
raise ValueError("tags must be an array")
|
|
300
|
+
normalized, tag_keys = [], set()
|
|
301
|
+
for tag in tags:
|
|
302
|
+
if not isinstance(tag, dict):
|
|
303
|
+
raise ValueError("tags must contain color/text objects")
|
|
304
|
+
key = (_string(tag.get("color"), "tag color", True), _string(tag.get("text", ""), "tag text"))
|
|
305
|
+
if key not in tag_keys:
|
|
306
|
+
normalized.append(tag)
|
|
307
|
+
tag_keys.add(key)
|
|
308
|
+
note = _string(item_meta.get("note", ""), "note").strip()
|
|
309
|
+
note_color = item_meta.get("noteColor", "orange") if note else None
|
|
310
|
+
if note_color is not None:
|
|
311
|
+
_string(note_color, "noteColor")
|
|
312
|
+
result.append((item_id, {
|
|
313
|
+
"parent_id": parent_id, "item_type": item_type, "position": position,
|
|
314
|
+
"preorder": len(result), "title": title, "url": url, "note": note,
|
|
315
|
+
"note_color": note_color, "tags_json": _dump(normalized),
|
|
316
|
+
"tag_text": "\n".join(tag["text"] for tag in normalized),
|
|
317
|
+
"path_json": _dump(list(titles)), "folder_path": " / ".join(titles),
|
|
318
|
+
"ancestor_ids_json": _dump(list(ancestor_ids)),
|
|
319
|
+
# Full child arrays also survive in files/sections.raw_json.
|
|
320
|
+
"raw_json": _dump({key: value for key, value in node.items() if key != "children"}),
|
|
321
|
+
"metadata_json": _dump(item_meta if permanent else {key: node[key] for key in ("tags", "note", "noteColor") if key in node}),
|
|
322
|
+
}))
|
|
323
|
+
visit(children, item_id, titles + ((title,) if title else ()), ancestor_ids + (item_id,))
|
|
324
|
+
|
|
325
|
+
visit(roots)
|
|
326
|
+
return result
|
|
327
|
+
|
|
328
|
+
@staticmethod
|
|
329
|
+
def _canvas(obj):
|
|
330
|
+
if not isinstance(obj.get("nodes"), list) or not isinstance(obj.get("edges"), list):
|
|
331
|
+
raise ValueError("Canvas requires nodes[] and edges[]")
|
|
332
|
+
nodes, edges, seen = [], [], set()
|
|
333
|
+
for position, node in enumerate(obj["nodes"]):
|
|
334
|
+
if not isinstance(node, dict):
|
|
335
|
+
raise ValueError("Canvas nodes must be objects")
|
|
336
|
+
node_id = _string(node.get("id"), "node id", True)
|
|
337
|
+
if node_id in seen:
|
|
338
|
+
raise ValueError("Duplicate canvas node: " + node_id)
|
|
339
|
+
seen.add(node_id)
|
|
340
|
+
node_type = node.get("type")
|
|
341
|
+
if node_type not in ("file", "text", "group"):
|
|
342
|
+
raise ValueError("Unsupported package canvas node type: " + str(node_type))
|
|
343
|
+
geometry = {}
|
|
344
|
+
for key in ("x", "y", "width", "height"):
|
|
345
|
+
value = node.get(key)
|
|
346
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
347
|
+
raise ValueError("Invalid canvas geometry: " + key)
|
|
348
|
+
if key in ("width", "height") and value <= 0:
|
|
349
|
+
raise ValueError("Canvas dimensions must be positive")
|
|
350
|
+
geometry[key] = value
|
|
351
|
+
nodes.append((node_id, dict(geometry, node_type=node_type,
|
|
352
|
+
file_path=_reference(node.get("file")) if node_type == "file" else None,
|
|
353
|
+
label=_string(node.get("label", ""), "node label"),
|
|
354
|
+
text=_string(node.get("text", ""), "node text"), position=position, raw_json=_dump(node))))
|
|
355
|
+
edge_ids = set()
|
|
356
|
+
for edge in obj["edges"]:
|
|
357
|
+
if not isinstance(edge, dict):
|
|
358
|
+
raise ValueError("Edges must be objects")
|
|
359
|
+
edge_id = _string(edge.get("id"), "edge id", True)
|
|
360
|
+
if edge_id in edge_ids:
|
|
361
|
+
raise ValueError("Duplicate edge id")
|
|
362
|
+
edge_ids.add(edge_id)
|
|
363
|
+
if edge.get("fromNode") not in seen or edge.get("toNode") not in seen:
|
|
364
|
+
raise ValueError("Canvas edge endpoint does not exist: " + edge_id)
|
|
365
|
+
from_end, to_end = edge.get("fromEnd", "none"), edge.get("toEnd", "arrow")
|
|
366
|
+
if from_end not in ("none", "arrow") or to_end not in ("none", "arrow"):
|
|
367
|
+
raise ValueError("Unknown canvas edge end")
|
|
368
|
+
edges.append((edge_id, {"from_node": edge["fromNode"], "to_node": edge["toNode"],
|
|
369
|
+
"from_end": from_end, "to_end": to_end, "label": _string(edge.get("label", ""), "edge label"),
|
|
370
|
+
"raw_json": _dump(edge)}))
|
|
371
|
+
return nodes, edges
|
|
372
|
+
|
|
373
|
+
@contextmanager
|
|
374
|
+
def read_snapshot(self):
|
|
375
|
+
self.connection.execute("SAVEPOINT bookmark_read")
|
|
376
|
+
try:
|
|
377
|
+
yield
|
|
378
|
+
finally:
|
|
379
|
+
self.connection.execute("RELEASE SAVEPOINT bookmark_read")
|
|
380
|
+
|
|
381
|
+
@contextmanager
|
|
382
|
+
def transaction(self):
|
|
383
|
+
"""Serialize writers; allow an importer to publish metadata atomically."""
|
|
384
|
+
nested = self.connection.in_transaction
|
|
385
|
+
if nested:
|
|
386
|
+
self.connection.execute("SAVEPOINT bookmark_sync")
|
|
387
|
+
else:
|
|
388
|
+
self.connection.execute("BEGIN IMMEDIATE")
|
|
389
|
+
try:
|
|
390
|
+
yield
|
|
391
|
+
if nested:
|
|
392
|
+
self.connection.execute("RELEASE SAVEPOINT bookmark_sync")
|
|
393
|
+
else:
|
|
394
|
+
self.connection.commit()
|
|
395
|
+
except BaseException:
|
|
396
|
+
if nested:
|
|
397
|
+
self.connection.execute("ROLLBACK TO SAVEPOINT bookmark_sync")
|
|
398
|
+
self.connection.execute("RELEASE SAVEPOINT bookmark_sync")
|
|
399
|
+
else:
|
|
400
|
+
self.connection.rollback()
|
|
401
|
+
raise
|
|
402
|
+
|
|
403
|
+
def sync(self, package_path, source_id=None, completeness="partial"):
|
|
404
|
+
with self.transaction():
|
|
405
|
+
return self._sync(package_path, source_id, completeness)
|
|
406
|
+
|
|
407
|
+
def _sync(self, package_path, source_id, completeness):
|
|
408
|
+
if completeness not in ("partial", "complete"):
|
|
409
|
+
raise ValueError("completeness must be partial or complete")
|
|
410
|
+
package = Path(package_path).expanduser().resolve()
|
|
411
|
+
if not package.is_dir():
|
|
412
|
+
raise ValueError("Package directory does not exist: " + str(package))
|
|
413
|
+
if self.db_path is not None and _inside(self.db_path, package):
|
|
414
|
+
raise ValueError("Database must be outside the imported package")
|
|
415
|
+
if source_id is None:
|
|
416
|
+
existing = self.connection.execute("SELECT source_id FROM sources WHERE package_path=?", (str(package),)).fetchall()
|
|
417
|
+
if len(existing) > 1:
|
|
418
|
+
raise ValueError("Multiple sources use this path; provide source_id")
|
|
419
|
+
source_id = existing[0][0] if existing else "source-" + hashlib.sha256(str(package).encode()).hexdigest()[:12]
|
|
420
|
+
_string(source_id, "source_id", True)
|
|
421
|
+
paths = list(package.glob("*.canvas"))
|
|
422
|
+
if len(paths) > 1:
|
|
423
|
+
raise ValueError("A package must have at most one .canvas entry; use separate sources for separate canvases")
|
|
424
|
+
canvas_path = paths[0].relative_to(package).as_posix() if paths else None
|
|
425
|
+
for directory in ("永久栏目", "临时栏目"):
|
|
426
|
+
paths.extend((package / directory).rglob("*.json"))
|
|
427
|
+
paths = sorted(paths)
|
|
428
|
+
if not paths:
|
|
429
|
+
raise ValueError("No protocol JSON or .canvas files in package")
|
|
430
|
+
old_files = {row["file_path"]: dict(row) for row in self.connection.execute(
|
|
431
|
+
"SELECT * FROM files WHERE source_id=?", (source_id,))}
|
|
432
|
+
indexed_section_files = {row[0] for row in self.connection.execute(
|
|
433
|
+
"SELECT file_path FROM sections WHERE source_id=?", (source_id,))}
|
|
434
|
+
changed, observed, section_ids = [], set(), set()
|
|
435
|
+
# Parse and validate every changed file before starting the mutation.
|
|
436
|
+
for path in paths:
|
|
437
|
+
if not _inside(path.resolve(), package):
|
|
438
|
+
raise ValueError("Package contains an external symlink: " + str(path))
|
|
439
|
+
relative = path.relative_to(package).as_posix()
|
|
440
|
+
observed.add(relative)
|
|
441
|
+
raw = path.read_bytes()
|
|
442
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
443
|
+
if (relative in old_files and old_files[relative]["sha256"] == digest
|
|
444
|
+
and (path.suffix == ".canvas" or relative in indexed_section_files)):
|
|
445
|
+
continue
|
|
446
|
+
try:
|
|
447
|
+
obj = json.loads(raw.decode("utf-8-sig"), object_pairs_hook=_unique_object, parse_constant=_reject_constant)
|
|
448
|
+
if not isinstance(obj, dict):
|
|
449
|
+
raise ValueError("File must contain one JSON object")
|
|
450
|
+
if path.suffix == ".canvas":
|
|
451
|
+
parsed = self._canvas(obj)
|
|
452
|
+
else:
|
|
453
|
+
section_id, section = self._section(relative, obj)
|
|
454
|
+
if section_id in section_ids:
|
|
455
|
+
raise ValueError("Duplicate section ID in package: " + section_id)
|
|
456
|
+
section_ids.add(section_id)
|
|
457
|
+
parsed = (section_id, section, self._items(section_id, section, obj))
|
|
458
|
+
except (ValueError, TypeError, RecursionError) as exc:
|
|
459
|
+
raise ValueError("Invalid package file %s: %s" % (relative, exc)) from exc
|
|
460
|
+
changed.append((relative, digest, obj, parsed))
|
|
461
|
+
summary = {"source_id": source_id, "package_path": str(package),
|
|
462
|
+
"changed_files": len(changed), "skipped_files": len(observed) - len(changed),
|
|
463
|
+
"items_inserted": 0, "items_updated": 0, "items_deleted": 0,
|
|
464
|
+
"retained_missing_files": sorted(set(old_files) - observed), "warnings": []}
|
|
465
|
+
summary["replaced_canvas_files"] = [relative for relative, record in old_files.items()
|
|
466
|
+
if canvas_path is not None and record["kind"] == "canvas" and relative != canvas_path]
|
|
467
|
+
timestamp = _now()
|
|
468
|
+
with self.transaction():
|
|
469
|
+
self.connection.execute("""INSERT INTO sources VALUES(?,?,?,?)
|
|
470
|
+
ON CONFLICT(source_id) DO UPDATE SET package_path=excluded.package_path,last_sync=excluded.last_sync""",
|
|
471
|
+
(source_id, str(package), timestamp, timestamp))
|
|
472
|
+
# One logical source has one canvas entry. A supplied entry replaces
|
|
473
|
+
# its prior path, while a partial export without one retains the layout.
|
|
474
|
+
for previous in summary["replaced_canvas_files"]:
|
|
475
|
+
for table in ("memberships", "edges", "nodes"):
|
|
476
|
+
self.connection.execute("DELETE FROM %s WHERE source_id=? AND canvas_path=?" % table,
|
|
477
|
+
(source_id, previous))
|
|
478
|
+
self.connection.execute("DELETE FROM files WHERE source_id=? AND file_path=?", (source_id, previous))
|
|
479
|
+
for relative, digest, obj, parsed in changed:
|
|
480
|
+
if relative.endswith(".canvas"):
|
|
481
|
+
continue
|
|
482
|
+
section_id, values, items = parsed
|
|
483
|
+
# A renamed file may retain its stable section ID. A second
|
|
484
|
+
# currently observed file with that ID is ambiguous, however.
|
|
485
|
+
prior = self.connection.execute("SELECT file_path FROM sections WHERE source_id=? AND section_id=?", (source_id, section_id)).fetchone()
|
|
486
|
+
if prior and prior[0] != relative and prior[0] in observed:
|
|
487
|
+
raise ValueError("Two present files share section ID: " + section_id)
|
|
488
|
+
self._upsert("sections", {"source_id": source_id, "section_id": section_id}, values)
|
|
489
|
+
present_items = set()
|
|
490
|
+
for item_id, item in items:
|
|
491
|
+
present_items.add(item_id)
|
|
492
|
+
action = self._upsert("items", {"source_id": source_id, "section_id": section_id, "item_id": item_id}, item)
|
|
493
|
+
if action != "unchanged":
|
|
494
|
+
summary["items_" + action] += 1
|
|
495
|
+
pk = self.connection.execute("SELECT pk FROM items WHERE source_id=? AND section_id=? AND item_id=?", (source_id, section_id, item_id)).fetchone()[0]
|
|
496
|
+
self.connection.execute("DELETE FROM item_tags WHERE item_pk=?", (pk,))
|
|
497
|
+
for position, tag in enumerate(json.loads(item["tags_json"])):
|
|
498
|
+
self.connection.execute("INSERT INTO item_tags VALUES(?,?,?,?,?)", (pk, position, tag["color"], tag["text"], _dump(tag)))
|
|
499
|
+
for row in self.connection.execute("SELECT pk,item_id FROM items WHERE source_id=? AND section_id=?", (source_id, section_id)).fetchall():
|
|
500
|
+
if row["item_id"] not in present_items:
|
|
501
|
+
self.connection.execute("DELETE FROM items WHERE pk=?", (row["pk"],))
|
|
502
|
+
summary["items_deleted"] += 1
|
|
503
|
+
# A complete directory is a mirror. Resolve renames by section ID
|
|
504
|
+
# first so moving a file preserves item row IDs and revisions.
|
|
505
|
+
summary["removed_files"] = []
|
|
506
|
+
if completeness == "complete":
|
|
507
|
+
for row in self.connection.execute("SELECT section_id,file_path FROM sections WHERE source_id=?", (source_id,)).fetchall():
|
|
508
|
+
if row["file_path"] not in observed:
|
|
509
|
+
summary["items_deleted"] += self.connection.execute(
|
|
510
|
+
"DELETE FROM items WHERE source_id=? AND section_id=?", (source_id, row["section_id"])).rowcount
|
|
511
|
+
self.connection.execute("DELETE FROM sections WHERE source_id=? AND section_id=?", (source_id, row["section_id"]))
|
|
512
|
+
for previous in set(old_files) - observed:
|
|
513
|
+
for table in ("memberships", "edges", "nodes"):
|
|
514
|
+
self.connection.execute("DELETE FROM %s WHERE source_id=? AND canvas_path=?" % table, (source_id, previous))
|
|
515
|
+
self.connection.execute("DELETE FROM files WHERE source_id=? AND file_path=?", (source_id, previous))
|
|
516
|
+
summary["removed_files"].append(previous)
|
|
517
|
+
summary["removed_files"].sort()
|
|
518
|
+
sections = {row["section_id"]: dict(row) for row in self.connection.execute("SELECT * FROM sections WHERE source_id=?", (source_id,))}
|
|
519
|
+
by_file = {}
|
|
520
|
+
for sid, section in sections.items():
|
|
521
|
+
if section["file_path"] in by_file:
|
|
522
|
+
raise ValueError("A section file changed identity; use a new source or preserve its ID")
|
|
523
|
+
by_file[section["file_path"]] = sid
|
|
524
|
+
|
|
525
|
+
def canonical(sid, visiting=()):
|
|
526
|
+
if sid in visiting:
|
|
527
|
+
raise ValueError("Copy-anchor inheritance cycle")
|
|
528
|
+
section = sections[sid]
|
|
529
|
+
if not section["is_anchor"]:
|
|
530
|
+
return sid
|
|
531
|
+
target = by_file.get(section["inherit_path"])
|
|
532
|
+
if target is None:
|
|
533
|
+
# A retained anchor still identifies its previously resolved
|
|
534
|
+
# tree when a partial export renames that stable section ID.
|
|
535
|
+
prior = section["canonical_id"]
|
|
536
|
+
if section["file_path"] not in observed and prior in sections:
|
|
537
|
+
summary["warnings"].append("Retained copy-anchor uses prior file reference: " + sid)
|
|
538
|
+
target = prior
|
|
539
|
+
else:
|
|
540
|
+
return None
|
|
541
|
+
if sections[target]["section_type"] != "permanent":
|
|
542
|
+
raise ValueError("Copy anchor must inherit a permanent section")
|
|
543
|
+
return canonical(target, visiting + (sid,))
|
|
544
|
+
|
|
545
|
+
for sid, section in sections.items():
|
|
546
|
+
resolved = canonical(sid)
|
|
547
|
+
if resolved != section["canonical_id"]:
|
|
548
|
+
self.connection.execute("UPDATE sections SET canonical_id=? WHERE source_id=? AND section_id=?", (resolved, source_id, sid))
|
|
549
|
+
if resolved is None:
|
|
550
|
+
summary["warnings"].append("Unresolved copy-anchor target: " + sid)
|
|
551
|
+
for relative, digest, obj, parsed in changed:
|
|
552
|
+
if relative.endswith(".canvas"):
|
|
553
|
+
nodes, edges = parsed
|
|
554
|
+
for table, id_column, rows in (("nodes", "node_id", nodes), ("edges", "edge_id", edges)):
|
|
555
|
+
retained = set()
|
|
556
|
+
for identifier, values in rows:
|
|
557
|
+
retained.add(identifier)
|
|
558
|
+
if table == "nodes":
|
|
559
|
+
values = dict(values, section_id=by_file.get(values["file_path"]))
|
|
560
|
+
self._upsert(table, {"source_id": source_id, "canvas_path": relative, id_column: identifier}, values)
|
|
561
|
+
old = self.connection.execute("SELECT %s FROM %s WHERE source_id=? AND canvas_path=?" % (id_column, table), (source_id, relative)).fetchall()
|
|
562
|
+
for row in old:
|
|
563
|
+
if row[0] not in retained:
|
|
564
|
+
self.connection.execute("DELETE FROM %s WHERE source_id=? AND canvas_path=? AND %s=?" % (table, id_column), (source_id, relative, row[0]))
|
|
565
|
+
self._memberships(source_id, relative)
|
|
566
|
+
self._upsert("files", {"source_id": source_id, "file_path": relative}, {
|
|
567
|
+
"kind": "canvas" if relative.endswith(".canvas") else "section", "sha256": digest, "raw_json": _dump(obj)})
|
|
568
|
+
# A stable section ID moving to a new path supersedes its old file
|
|
569
|
+
# hash. Keeping that hash would skip parsing if it later moved back.
|
|
570
|
+
# Missing sections themselves remain retained for partial exports.
|
|
571
|
+
self.connection.execute("""DELETE FROM files WHERE source_id=? AND kind='section'
|
|
572
|
+
AND NOT EXISTS (SELECT 1 FROM sections s
|
|
573
|
+
WHERE s.source_id=files.source_id AND s.file_path=files.file_path)""", (source_id,))
|
|
574
|
+
# A partial package can supply a previously unresolved referenced
|
|
575
|
+
# section without changing any canvas bytes.
|
|
576
|
+
for node in self.connection.execute("SELECT * FROM nodes WHERE source_id=? AND node_type='file'", (source_id,)).fetchall():
|
|
577
|
+
resolved = by_file.get(node["file_path"])
|
|
578
|
+
if resolved is None and node["canvas_path"] not in observed and node["section_id"] in sections:
|
|
579
|
+
# Keep known card identity for an omitted (older) layout; a
|
|
580
|
+
# currently supplied canvas always resolves its actual paths.
|
|
581
|
+
resolved = node["section_id"]
|
|
582
|
+
summary["warnings"].append("Retained canvas uses prior file reference: " + node["file_path"])
|
|
583
|
+
if resolved != node["section_id"]:
|
|
584
|
+
self.connection.execute("UPDATE nodes SET section_id=? WHERE source_id=? AND canvas_path=? AND node_id=?", (resolved, source_id, node["canvas_path"], node["node_id"]))
|
|
585
|
+
if resolved is None:
|
|
586
|
+
summary["warnings"].append("Unresolved canvas file: " + node["file_path"])
|
|
587
|
+
if self.connection.execute("PRAGMA foreign_key_check").fetchone():
|
|
588
|
+
raise ValueError("Derived database failed relationship validation")
|
|
589
|
+
summary["counts"] = self.status(source_id)["counts"]
|
|
590
|
+
summary["retained_missing_files"] = sorted(row[0] for row in self.connection.execute(
|
|
591
|
+
"SELECT file_path FROM files WHERE source_id=?", (source_id,)) if row[0] not in observed)
|
|
592
|
+
return summary
|
|
593
|
+
|
|
594
|
+
def _memberships(self, source_id, canvas_path):
|
|
595
|
+
nodes = self.connection.execute("SELECT * FROM nodes WHERE source_id=? AND canvas_path=?", (source_id, canvas_path)).fetchall()
|
|
596
|
+
expected = set()
|
|
597
|
+
for group in nodes:
|
|
598
|
+
if group["node_type"] != "group":
|
|
599
|
+
continue
|
|
600
|
+
for node in nodes:
|
|
601
|
+
if group["node_id"] == node["node_id"]:
|
|
602
|
+
continue
|
|
603
|
+
if (group["x"] <= node["x"] and group["y"] <= node["y"]
|
|
604
|
+
and node["x"] + node["width"] <= group["x"] + group["width"]
|
|
605
|
+
and node["y"] + node["height"] <= group["y"] + group["height"]):
|
|
606
|
+
# Equal rectangles do not define reciprocal group nesting.
|
|
607
|
+
if node["node_type"] == "group" and all(node[k] == group[k] for k in ("x", "y", "width", "height")):
|
|
608
|
+
continue
|
|
609
|
+
expected.add((group["node_id"], node["node_id"]))
|
|
610
|
+
old = {(r[0], r[1]) for r in self.connection.execute("SELECT group_id,node_id FROM memberships WHERE source_id=? AND canvas_path=?", (source_id, canvas_path))}
|
|
611
|
+
for pair in old - expected:
|
|
612
|
+
self.connection.execute("DELETE FROM memberships WHERE source_id=? AND canvas_path=? AND group_id=? AND node_id=?", (source_id, canvas_path) + pair)
|
|
613
|
+
for pair in expected - old:
|
|
614
|
+
self.connection.execute("INSERT INTO memberships VALUES(?,?,?,?)", (source_id, canvas_path) + pair)
|
|
615
|
+
|
|
616
|
+
def refresh(self, source_id):
|
|
617
|
+
return self.sync(self._source(source_id)["package_path"], source_id=source_id)
|
|
618
|
+
|
|
619
|
+
def _sections(self, source_id, selector=None):
|
|
620
|
+
rows = self.connection.execute("SELECT * FROM sections WHERE source_id=? ORDER BY file_path,section_id", (source_id,)).fetchall()
|
|
621
|
+
if selector is not None:
|
|
622
|
+
rows = [row for row in rows if selector in (row["section_id"], row["label"], row["slot"], row["file_path"])]
|
|
623
|
+
if not rows:
|
|
624
|
+
raise ValueError("Unknown section: " + str(selector))
|
|
625
|
+
return rows
|
|
626
|
+
|
|
627
|
+
def _group(self, source_id, group_id):
|
|
628
|
+
rows = self.connection.execute("SELECT * FROM nodes WHERE source_id=? AND node_type='group' AND node_id=?", (source_id, group_id)).fetchall()
|
|
629
|
+
if not rows:
|
|
630
|
+
rows = self.connection.execute("SELECT * FROM nodes WHERE source_id=? AND node_type='group' AND label=?", (source_id, group_id)).fetchall()
|
|
631
|
+
if len(rows) != 1:
|
|
632
|
+
raise ValueError("Unknown or ambiguous group; use a unique group ID: " + str(group_id))
|
|
633
|
+
return rows[0]
|
|
634
|
+
|
|
635
|
+
def _scope(self, source_id, section=None, group_id=None, folder_id=None, tags=None):
|
|
636
|
+
self._source(source_id)
|
|
637
|
+
clauses, args = ["i.source_id=?", "i.item_type='bookmark'"], [source_id]
|
|
638
|
+
sections = self._sections(source_id, section) if section is not None or group_id is not None else None
|
|
639
|
+
if group_id is not None:
|
|
640
|
+
group = self._group(source_id, group_id)
|
|
641
|
+
members = {row[0] for row in self.connection.execute("""SELECT n.section_id FROM memberships m
|
|
642
|
+
JOIN nodes n ON n.source_id=m.source_id AND n.canvas_path=m.canvas_path AND n.node_id=m.node_id
|
|
643
|
+
WHERE m.source_id=? AND m.canvas_path=? AND m.group_id=?""",
|
|
644
|
+
(source_id, group["canvas_path"], group["node_id"]))}
|
|
645
|
+
# Intersect the requested cards before following copy anchors to
|
|
646
|
+
# the shared tree: a copy's group does not contain its primary card.
|
|
647
|
+
sections = [row for row in sections if row["section_id"] in members]
|
|
648
|
+
if sections is not None:
|
|
649
|
+
ids = {row["canonical_id"] for row in sections if row["canonical_id"]}
|
|
650
|
+
clauses.append("i.section_id IN (%s)" % (",".join("?" for _ in ids) or "NULL"))
|
|
651
|
+
args.extend(sorted(ids))
|
|
652
|
+
if folder_id is not None:
|
|
653
|
+
folders = self.connection.execute("SELECT section_id FROM items WHERE source_id=? AND item_id=? AND item_type='folder'", (source_id, folder_id)).fetchall()
|
|
654
|
+
if not folders:
|
|
655
|
+
raise ValueError("Unknown folder ID: " + str(folder_id))
|
|
656
|
+
# Use relational ancestry rather than requiring SQLite's optional
|
|
657
|
+
# JSON1 extension or inferring ancestry from folder-name strings.
|
|
658
|
+
clauses.append("""EXISTS (WITH RECURSIVE subtree(section_id,item_id) AS (
|
|
659
|
+
SELECT section_id,item_id FROM items
|
|
660
|
+
WHERE source_id=? AND item_id=? AND item_type='folder'
|
|
661
|
+
UNION ALL
|
|
662
|
+
SELECT child.section_id,child.item_id FROM items child JOIN subtree p
|
|
663
|
+
ON child.section_id=p.section_id AND child.parent_id=p.item_id
|
|
664
|
+
WHERE child.source_id=?
|
|
665
|
+
) SELECT 1 FROM subtree d WHERE d.section_id=i.section_id AND d.item_id=i.item_id)""")
|
|
666
|
+
args.extend((source_id, folder_id, source_id))
|
|
667
|
+
for tag in tags or []:
|
|
668
|
+
_string(tag, "tag", True)
|
|
669
|
+
clauses.append("EXISTS (SELECT 1 FROM item_tags t WHERE t.item_pk=i.pk AND t.text=?)")
|
|
670
|
+
args.append(tag)
|
|
671
|
+
return clauses, args
|
|
672
|
+
|
|
673
|
+
@staticmethod
|
|
674
|
+
def _match(target):
|
|
675
|
+
# LIKE provides a literal substring fallback for Chinese short names.
|
|
676
|
+
# Escape all SQL wildcard characters; FTS query syntax is not exposed.
|
|
677
|
+
escaped = target.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
678
|
+
fields = ("title", "url", "note", "tag_text", "folder_path")
|
|
679
|
+
return "(" + " OR ".join("i.%s LIKE ? ESCAPE '\\'" % field for field in fields) + ")", ["%" + escaped + "%"] * len(fields)
|
|
680
|
+
|
|
681
|
+
def _item_result(self, row):
|
|
682
|
+
result = {key: row[key] for key in ("source_id", "section_id", "item_id", "parent_id", "item_type", "title", "url", "note", "note_color", "position")}
|
|
683
|
+
result.update(source=row["source_id"], section=row["section_id"], item=row["item_id"],
|
|
684
|
+
path=json.loads(row["path_json"]), folder_path=row["folder_path"], tags=json.loads(row["tags_json"]),
|
|
685
|
+
raw_json=json.loads(row["raw_json"]), metadata=json.loads(row["metadata_json"]))
|
|
686
|
+
section = self.connection.execute("SELECT label,file_path FROM sections WHERE source_id=? AND section_id=?", (row["source_id"], row["section_id"])).fetchone()
|
|
687
|
+
result["section_label"], result["file_path"] = section[0], section[1]
|
|
688
|
+
return result
|
|
689
|
+
|
|
690
|
+
def search(self, source_id, targets=None, section=None, group_id=None, folder_id=None, tags=None, limit=20, offset=0):
|
|
691
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 1000:
|
|
692
|
+
raise ValueError("limit must be an integer from 1 to 1000")
|
|
693
|
+
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
|
|
694
|
+
raise ValueError("offset must be a non-negative integer")
|
|
695
|
+
if isinstance(targets, str) or isinstance(tags, str):
|
|
696
|
+
raise ValueError("targets and tags must be lists of strings")
|
|
697
|
+
targets = list(dict.fromkeys(targets or []))
|
|
698
|
+
if len(targets) > 100:
|
|
699
|
+
raise ValueError("At most 100 targets per request")
|
|
700
|
+
for target in targets:
|
|
701
|
+
_string(target, "target", True)
|
|
702
|
+
clauses, args = self._scope(source_id, section, group_id, folder_id, tags)
|
|
703
|
+
|
|
704
|
+
def query(chosen):
|
|
705
|
+
predicates, parameters = list(clauses), list(args)
|
|
706
|
+
if chosen:
|
|
707
|
+
matches = [self._match(target) for target in chosen]
|
|
708
|
+
predicates.append("(" + " OR ".join(match[0] for match in matches) + ")")
|
|
709
|
+
parameters.extend(value for _, values in matches for value in values)
|
|
710
|
+
where = " AND ".join(predicates)
|
|
711
|
+
total = self.connection.execute("SELECT count(*) FROM items i WHERE " + where, parameters).fetchone()[0]
|
|
712
|
+
rows = self.connection.execute("SELECT i.* FROM items i WHERE " + where + " ORDER BY i.section_id,i.preorder,i.item_id LIMIT ? OFFSET ?", parameters + [limit, offset]).fetchall()
|
|
713
|
+
return {"total": total, "limit": limit, "offset": offset, "results": [self._item_result(row) for row in rows]}
|
|
714
|
+
|
|
715
|
+
result = query(targets)
|
|
716
|
+
result.update(source_id=source_id, query_scope={"section": section, "group_id": group_id, "folder_id": folder_id, "tags": list(tags or [])},
|
|
717
|
+
match_mode="literal_substring", searched_fields=["title", "url", "note", "tags.text", "folder_path"])
|
|
718
|
+
result["targets"] = [dict(target=target, **query([target])) for target in targets]
|
|
719
|
+
return result
|
|
720
|
+
|
|
721
|
+
def context(self, source_id, section=None, group_id=None, item_id=None):
|
|
722
|
+
source = self._source(source_id)
|
|
723
|
+
sections = self._sections(source_id, section)
|
|
724
|
+
nodes = [dict(row) for row in self.connection.execute("SELECT * FROM nodes WHERE source_id=? ORDER BY canvas_path,position", (source_id,))]
|
|
725
|
+
all_nodes = list(nodes)
|
|
726
|
+
memberships = [dict(row) for row in self.connection.execute("SELECT * FROM memberships WHERE source_id=? ORDER BY canvas_path,group_id,node_id", (source_id,))]
|
|
727
|
+
edges = [dict(row) for row in self.connection.execute("SELECT * FROM edges WHERE source_id=? ORDER BY canvas_path,edge_id", (source_id,))]
|
|
728
|
+
item_rows = []
|
|
729
|
+
if item_id is not None:
|
|
730
|
+
canonical_ids = {row["canonical_id"] for row in sections}
|
|
731
|
+
item_rows = [row for row in self.connection.execute("SELECT * FROM items WHERE source_id=? AND item_id=?", (source_id, item_id)) if row["section_id"] in canonical_ids]
|
|
732
|
+
if not item_rows:
|
|
733
|
+
raise ValueError("Unknown item in requested scope: " + str(item_id))
|
|
734
|
+
ids = {row["section_id"] for row in item_rows}
|
|
735
|
+
sections = [row for row in sections if row["canonical_id"] in ids]
|
|
736
|
+
if group_id is not None:
|
|
737
|
+
group = self._group(source_id, group_id)
|
|
738
|
+
member_ids = {m["node_id"] for m in memberships if m["canvas_path"] == group["canvas_path"] and m["group_id"] == group["node_id"]}
|
|
739
|
+
selected = [node for node in nodes if node["canvas_path"] == group["canvas_path"] and node["node_id"] in member_ids | {group["node_id"]}]
|
|
740
|
+
section_ids = {node["section_id"] for node in selected if node["section_id"]}
|
|
741
|
+
sections = [row for row in sections if row["section_id"] in section_ids]
|
|
742
|
+
if item_id is not None:
|
|
743
|
+
canonical_ids = {row["canonical_id"] for row in sections}
|
|
744
|
+
item_rows = [row for row in item_rows if row["section_id"] in canonical_ids]
|
|
745
|
+
if not item_rows:
|
|
746
|
+
raise ValueError("Item is outside the requested group")
|
|
747
|
+
if section is not None or item_id is not None:
|
|
748
|
+
section_ids = {row["section_id"] for row in sections}
|
|
749
|
+
selected = [node for node in selected
|
|
750
|
+
if node["section_id"] in section_ids or node["node_id"] == group["node_id"]]
|
|
751
|
+
nodes = selected
|
|
752
|
+
elif section is not None or item_id is not None:
|
|
753
|
+
section_ids = {row["section_id"] for row in sections}
|
|
754
|
+
selected_ids = {(node["canvas_path"], node["node_id"]) for node in nodes if node["section_id"] in section_ids}
|
|
755
|
+
selected_ids.update((m["canvas_path"], m["group_id"]) for m in memberships if (m["canvas_path"], m["node_id"]) in selected_ids)
|
|
756
|
+
nodes = [node for node in nodes if (node["canvas_path"], node["node_id"]) in selected_ids]
|
|
757
|
+
if section is not None or group_id is not None or item_id is not None:
|
|
758
|
+
selected_ids = {(node["canvas_path"], node["node_id"]) for node in nodes}
|
|
759
|
+
edges = [edge for edge in edges if (edge["canvas_path"], edge["from_node"]) in selected_ids or (edge["canvas_path"], edge["to_node"]) in selected_ids]
|
|
760
|
+
memberships = [m for m in memberships if (m["canvas_path"], m["group_id"]) in selected_ids or (m["canvas_path"], m["node_id"]) in selected_ids]
|
|
761
|
+
for edge in edges:
|
|
762
|
+
edge["direction"] = ("both" if edge["from_end"] == edge["to_end"] == "arrow" else
|
|
763
|
+
"forward" if edge["to_end"] == "arrow" else "reverse" if edge["from_end"] == "arrow" else "none")
|
|
764
|
+
edge["raw_json"] = json.loads(edge["raw_json"])
|
|
765
|
+
for node in nodes:
|
|
766
|
+
node["raw_json"] = json.loads(node["raw_json"])
|
|
767
|
+
selected_node_ids = {(node["canvas_path"], node["node_id"]) for node in nodes}
|
|
768
|
+
endpoint_ids = {(edge["canvas_path"], edge[end]) for edge in edges for end in ("from_node", "to_node")}
|
|
769
|
+
endpoint_ids.update((member["canvas_path"], member[end])
|
|
770
|
+
for member in memberships for end in ("group_id", "node_id"))
|
|
771
|
+
related_nodes = [node for node in all_nodes
|
|
772
|
+
if (node["canvas_path"], node["node_id"]) in endpoint_ids - selected_node_ids]
|
|
773
|
+
for node in related_nodes:
|
|
774
|
+
node["raw_json"] = json.loads(node["raw_json"])
|
|
775
|
+
section_results = []
|
|
776
|
+
for row in sections:
|
|
777
|
+
entry = dict(row)
|
|
778
|
+
# Context is bounded to section headers. The entire source object,
|
|
779
|
+
# including all unknown fields, remains in the database raw_json.
|
|
780
|
+
entry["raw_json"] = {key: value for key, value in json.loads(entry["raw_json"]).items()
|
|
781
|
+
if key not in ("tree", "items", "identityMap")}
|
|
782
|
+
entry["bookmark_count"] = self.connection.execute("SELECT count(*) FROM items WHERE source_id=? AND section_id=? AND item_type='bookmark'", (source_id, entry["canonical_id"])).fetchone()[0]
|
|
783
|
+
section_results.append(entry)
|
|
784
|
+
items = [self._item_result(row) for row in item_rows]
|
|
785
|
+
for item, row in zip(items, item_rows):
|
|
786
|
+
item["ancestors"] = []
|
|
787
|
+
for ancestor in json.loads(row["ancestor_ids_json"]):
|
|
788
|
+
ancestor_row = self.connection.execute("SELECT * FROM items WHERE source_id=? AND section_id=? AND item_id=?", (source_id, row["section_id"], ancestor)).fetchone()
|
|
789
|
+
if ancestor_row:
|
|
790
|
+
item["ancestors"].append(self._item_result(ancestor_row))
|
|
791
|
+
return {"source_id": source_id, "source": source, "sections": section_results,
|
|
792
|
+
"items": items, "nodes": nodes, "groups": [node for node in nodes if node["node_type"] == "group"],
|
|
793
|
+
"edges": edges, "related_nodes": related_nodes, "memberships": memberships}
|
|
794
|
+
|
|
795
|
+
def inventory(self, source_ids):
|
|
796
|
+
"""Freeze the complete indexed input, independently of query previews.
|
|
797
|
+
|
|
798
|
+
Exact URLs identify research rows; bookmarks remain distinct instances.
|
|
799
|
+
Copy anchors add appearances of a canonical tree, never extra instances.
|
|
800
|
+
One SQLite read transaction keeps files, items and canvas links coherent
|
|
801
|
+
when another client synchronizes the index at the same time.
|
|
802
|
+
"""
|
|
803
|
+
if not isinstance(source_ids, list) or not source_ids:
|
|
804
|
+
raise ValueError("source_ids must contain indexed package IDs")
|
|
805
|
+
identifiers = sorted(set(_string(value, "source_id", True) for value in source_ids))
|
|
806
|
+
from archive import SourceArchive
|
|
807
|
+
result = {"schema_version": 1, "index_state": "last_synchronized",
|
|
808
|
+
"source_ids": identifiers, "sources": [], "files": [],
|
|
809
|
+
"sections": [], "folders": [], "nodes": [], "edges": [],
|
|
810
|
+
"memberships": [], "entries": []}
|
|
811
|
+
by_url = {}
|
|
812
|
+
self.connection.execute("SAVEPOINT inventory_snapshot")
|
|
813
|
+
try:
|
|
814
|
+
for source_id in identifiers:
|
|
815
|
+
context = self.context(source_id)
|
|
816
|
+
files = [dict(row) for row in self.connection.execute(
|
|
817
|
+
"SELECT source_id,file_path,kind,sha256,revision FROM files "
|
|
818
|
+
"WHERE source_id=? ORDER BY file_path", (source_id,))]
|
|
819
|
+
version_files = [{key: row[key] for key in ("file_path", "kind", "sha256")} for row in files]
|
|
820
|
+
result["sources"].append({**context["source"], "input_version":
|
|
821
|
+
hashlib.sha256(_dump(version_files).encode("utf-8")).hexdigest()})
|
|
822
|
+
result["files"].extend(files)
|
|
823
|
+
for collection in ("sections", "nodes", "edges", "memberships"):
|
|
824
|
+
result[collection].extend(context[collection])
|
|
825
|
+
appearances = {}
|
|
826
|
+
for section in context["sections"]:
|
|
827
|
+
if not section["canonical_id"]:
|
|
828
|
+
continue
|
|
829
|
+
nodes = [node for node in context["nodes"] if node["section_id"] == section["section_id"]]
|
|
830
|
+
node_ids = {(node["canvas_path"], node["node_id"]) for node in nodes}
|
|
831
|
+
appearance = {key: section[key] for key in
|
|
832
|
+
("section_id", "file_path", "label", "title", "description_md", "is_anchor", "canonical_id")}
|
|
833
|
+
appearance["nodes"] = [{"canvas_path": node["canvas_path"], "node_id": node["node_id"]} for node in nodes]
|
|
834
|
+
appearance["memberships"] = [member for member in context["memberships"]
|
|
835
|
+
if (member["canvas_path"], member["node_id"]) in node_ids]
|
|
836
|
+
appearance["edges"] = [edge for edge in context["edges"]
|
|
837
|
+
if any((edge["canvas_path"], edge[end]) in node_ids for end in ("from_node", "to_node"))]
|
|
838
|
+
appearances.setdefault(section["canonical_id"], []).append(appearance)
|
|
839
|
+
rows = self.connection.execute("SELECT * FROM items WHERE source_id=? "
|
|
840
|
+
"ORDER BY section_id,preorder,item_id", (source_id,)).fetchall()
|
|
841
|
+
for row in rows:
|
|
842
|
+
item = self._item_result(row)
|
|
843
|
+
item.update(ancestor_ids=json.loads(row["ancestor_ids_json"]),
|
|
844
|
+
revision=row["revision"], preorder=row["preorder"])
|
|
845
|
+
if row["item_type"] == "folder":
|
|
846
|
+
# Descendants are preserved as individual rows; avoid
|
|
847
|
+
# duplicating an entire nested tree at each ancestor.
|
|
848
|
+
item["raw_json"] = {key: value for key, value in item["raw_json"].items() if key != "children"}
|
|
849
|
+
result["folders"].append(item)
|
|
850
|
+
continue
|
|
851
|
+
original = row["url"]
|
|
852
|
+
if original not in by_url:
|
|
853
|
+
try:
|
|
854
|
+
retrieval = SourceArchive._key(original)
|
|
855
|
+
except (ValueError, UnicodeError):
|
|
856
|
+
retrieval = None
|
|
857
|
+
try:
|
|
858
|
+
scheme = urlsplit(original).scheme
|
|
859
|
+
except ValueError:
|
|
860
|
+
scheme = ""
|
|
861
|
+
by_url[original] = {"id": "u-" + hashlib.sha256(original.encode("utf-8")).hexdigest()[:32],
|
|
862
|
+
"original_url": original, "retrieval_url": retrieval,
|
|
863
|
+
"url_kind": "web" if retrieval else "local" if scheme == "file" else "unsupported",
|
|
864
|
+
"instances": []}
|
|
865
|
+
identity = [source_id, row["section_id"], row["item_id"]]
|
|
866
|
+
item.update(instance_id="b-" + hashlib.sha256(_dump(identity).encode("utf-8")).hexdigest()[:32],
|
|
867
|
+
canonical_section_id=row["section_id"],
|
|
868
|
+
appearances=appearances.get(row["section_id"], []))
|
|
869
|
+
by_url[original]["instances"].append(item)
|
|
870
|
+
result["entries"] = sorted(by_url.values(), key=lambda row: row["id"])
|
|
871
|
+
version = [{key: row[key] for key in ("source_id", "file_path", "kind", "sha256")}
|
|
872
|
+
for row in result["files"]]
|
|
873
|
+
result["input_version"] = hashlib.sha256(_dump(version).encode("utf-8")).hexdigest()
|
|
874
|
+
result["counts"] = {"unique_urls": len(result["entries"]),
|
|
875
|
+
"bookmark_instances": sum(len(row["instances"]) for row in result["entries"]),
|
|
876
|
+
**{key: len(result[key]) for key in ("sources", "files", "sections", "folders", "nodes", "edges", "memberships")}}
|
|
877
|
+
return result
|
|
878
|
+
finally:
|
|
879
|
+
self.connection.execute("RELEASE SAVEPOINT inventory_snapshot")
|
|
880
|
+
|
|
881
|
+
def input_version(self, source_ids):
|
|
882
|
+
"""Fingerprint the indexed input without rebuilding the full inventory."""
|
|
883
|
+
records = []
|
|
884
|
+
self.connection.execute("SAVEPOINT input_version_snapshot")
|
|
885
|
+
try:
|
|
886
|
+
for source_id in sorted(set(source_ids)):
|
|
887
|
+
self._source(source_id)
|
|
888
|
+
records.extend(dict(row) for row in self.connection.execute(
|
|
889
|
+
"SELECT source_id,file_path,kind,sha256 FROM files WHERE source_id=? ORDER BY file_path", (source_id,)))
|
|
890
|
+
return hashlib.sha256(_dump(records).encode("utf-8")).hexdigest()
|
|
891
|
+
finally:
|
|
892
|
+
self.connection.execute("RELEASE SAVEPOINT input_version_snapshot")
|
|
893
|
+
|
|
894
|
+
def status(self, source_id=None):
|
|
895
|
+
if source_id is None:
|
|
896
|
+
ids = [row[0] for row in self.connection.execute("SELECT source_id FROM sources ORDER BY source_id")]
|
|
897
|
+
return {"database": str(self.db_path) if self.db_path else ":memory:", "schema_version": 1,
|
|
898
|
+
"sources": [self.status(identifier) for identifier in ids]}
|
|
899
|
+
result = self._source(source_id)
|
|
900
|
+
counts = dict(self.connection.execute("""SELECT count(*) AS items,
|
|
901
|
+
coalesce(sum(item_type='bookmark'),0) AS bookmarks,
|
|
902
|
+
coalesce(sum(item_type='folder'),0) AS folders,
|
|
903
|
+
count(DISTINCT CASE WHEN item_type='bookmark' THEN url END) AS unique_urls
|
|
904
|
+
FROM items WHERE source_id=?""", (source_id,)).fetchone())
|
|
905
|
+
for table in ("sections", "files", "nodes", "edges", "memberships"):
|
|
906
|
+
counts[table] = self.connection.execute("SELECT count(*) FROM " + table + " WHERE source_id=?", (source_id,)).fetchone()[0]
|
|
907
|
+
result["counts"] = counts
|
|
908
|
+
result.update(bookmarks=counts["bookmarks"], unique_urls=counts["unique_urls"])
|
|
909
|
+
package = Path(result["package_path"])
|
|
910
|
+
result["package_exists"] = package.is_dir()
|
|
911
|
+
result["retained_missing_files"] = [row[0] for row in self.connection.execute("SELECT file_path FROM files WHERE source_id=? ORDER BY file_path", (source_id,)) if not (package / row[0]).is_file()]
|
|
912
|
+
result["sections"] = [{"section_id": row["section_id"], "label": row["label"], "is_anchor": bool(row["is_anchor"]), "canonical_id": row["canonical_id"]} for row in self._sections(source_id)]
|
|
913
|
+
return result
|