bookmark-research 0.6.0 → 0.7.0
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 +1 -1
- package/.codex-plugin/plugin.json +4 -1
- package/README.md +15 -9
- package/README.zh.md +16 -9
- package/config/providers.json +26 -0
- package/config/settings.example.json +36 -0
- package/docs/bookmark-research-architecture.md +2 -2
- package/docs/canvas-tree-reading-acceptance.md +98 -0
- package/docs/configuration.en.md +70 -0
- package/docs/configuration.md +70 -0
- package/docs/details.en.md +10 -6
- package/docs/details.md +3 -3
- package/docs/harness-compatibility.md +1 -1
- package/docs/installation.en.md +4 -2
- package/docs/installation.md +4 -2
- package/docs/instructions.en.md +1 -0
- package/docs/instructions.md +1 -0
- package/docs/user-guide.en.md +1 -1
- package/docs/user-guide.md +1 -1
- package/hosts/dsh/plugin.js +3 -0
- package/package.json +1 -1
- package/scripts/bench_sync.py +191 -0
- package/scripts/bench_tools_list.py +129 -0
- package/scripts/evaluate_ladder.py +149 -0
- package/scripts/export_bundle.py +2 -0
- package/scripts/host_install.py +4 -1
- package/scripts/install.py +120 -28
- package/skills/bookmark-research/SKILL.md +28 -9
- package/skills/bookmark-research/references/package-semantics.md +56 -6
- package/skills/bookmark-research/references/research-workflow.md +5 -2
- package/skills/bookmark-research/references/settings-and-archive.md +5 -3
- package/skills/bookmark-research/references/wiki.md +3 -1
- package/skills/bookmark-research/references/zh/package-semantics.md +56 -6
- package/skills/bookmark-research/references/zh/research-workflow.md +5 -2
- package/skills/bookmark-research/references/zh/settings-and-archive.md +5 -3
- package/skills/bookmark-research/references/zh/skill-guide.md +20 -8
- package/skills/bookmark-research/references/zh/wiki.md +3 -1
- package/src/archive.py +100 -0
- package/src/bookmark_index.py +808 -41
- package/src/browser_client.py +342 -0
- package/src/cli.py +298 -14
- package/src/credentials.py +2 -1
- package/src/fetch_cache.py +60 -0
- package/src/grok_search.py +272 -0
- package/src/jobs.py +349 -0
- package/src/junk.py +33 -0
- package/src/locate.py +862 -0
- package/src/mcp_dispatch.py +22 -6
- package/src/mcp_server.py +573 -23
- package/src/netscape.py +124 -0
- package/src/onboarding.py +59 -9
- package/src/perplexity.py +78 -0
- package/src/raw_library.py +183 -45
- package/src/readiness.py +40 -5
- package/src/ref_codec.py +70 -0
- package/src/research.py +125 -12
- package/src/research_plan.py +11 -1
- package/src/routing.py +5 -2
- package/src/search_results.py +80 -2
- package/src/settings.py +61 -16
- package/src/source_inputs.py +109 -3
- package/src/source_manager.py +71 -12
- package/src/source_versions.py +20 -2
- package/src/source_watcher.py +4 -2
- package/src/storage.py +60 -0
- package/src/url_classify.py +45 -0
- package/src/web_search.py +106 -10
- package/src/wiki.py +269 -18
- package/src/wiki_search.py +119 -27
package/src/bookmark_index.py
CHANGED
|
@@ -14,6 +14,7 @@ from datetime import datetime, timezone
|
|
|
14
14
|
from pathlib import Path, PurePosixPath
|
|
15
15
|
from urllib.parse import urlsplit
|
|
16
16
|
|
|
17
|
+
import storage
|
|
17
18
|
from package_paths import (
|
|
18
19
|
PROTOCOL_DIRS,
|
|
19
20
|
dir_matches_section_type,
|
|
@@ -65,6 +66,7 @@ def url_kind(url):
|
|
|
65
66
|
# into bigrams because a phrase the user typed rarely appears verbatim.
|
|
66
67
|
PLAN_RUN = re.compile(r"[0-9A-Za-z][0-9A-Za-z+._-]*|[㐀-䶿一-鿿]+")
|
|
67
68
|
QUESTION_URL = re.compile(r"https?://[^\s<>()\"']+")
|
|
69
|
+
PLAN_CJK_SPLITTERS = re.compile(r"(?:有哪些|有什么|是什么|怎么|如何|为什么|怎样|[有的和在与或等及从到把被让我你他它这那])")
|
|
68
70
|
PLAN_MAX_TERMS = 12
|
|
69
71
|
|
|
70
72
|
|
|
@@ -192,6 +194,10 @@ CREATE TABLE IF NOT EXISTS sources (
|
|
|
192
194
|
source_id TEXT PRIMARY KEY, package_path TEXT NOT NULL,
|
|
193
195
|
created_at TEXT NOT NULL, last_sync TEXT NOT NULL
|
|
194
196
|
);
|
|
197
|
+
CREATE TABLE IF NOT EXISTS source_redirects (
|
|
198
|
+
old_source_id TEXT PRIMARY KEY, new_source_id TEXT NOT NULL,
|
|
199
|
+
created_at TEXT NOT NULL, reason TEXT NOT NULL
|
|
200
|
+
);
|
|
195
201
|
CREATE TABLE IF NOT EXISTS files (
|
|
196
202
|
source_id TEXT NOT NULL, file_path TEXT NOT NULL, kind TEXT NOT NULL,
|
|
197
203
|
sha256 TEXT NOT NULL, raw_json TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
|
|
@@ -222,6 +228,7 @@ CREATE TABLE IF NOT EXISTS items (
|
|
|
222
228
|
);
|
|
223
229
|
CREATE INDEX IF NOT EXISTS items_parent ON items(source_id,section_id,parent_id);
|
|
224
230
|
CREATE INDEX IF NOT EXISTS items_url ON items(source_id,url);
|
|
231
|
+
CREATE INDEX IF NOT EXISTS items_url_lookup ON items(url);
|
|
225
232
|
CREATE TABLE IF NOT EXISTS item_tags (
|
|
226
233
|
item_pk INTEGER NOT NULL REFERENCES items(pk) ON DELETE CASCADE,
|
|
227
234
|
position INTEGER NOT NULL, color TEXT NOT NULL, text TEXT NOT NULL,
|
|
@@ -268,38 +275,68 @@ PRAGMA user_version=2;
|
|
|
268
275
|
|
|
269
276
|
|
|
270
277
|
class BookmarkIndex:
|
|
271
|
-
def __init__(self, db_path):
|
|
278
|
+
def __init__(self, db_path, readonly=False):
|
|
272
279
|
self.db_path = None if str(db_path) == ":memory:" else Path(db_path).expanduser().resolve()
|
|
280
|
+
self.connection = None
|
|
281
|
+
self.readonly = False
|
|
273
282
|
if self.db_path is not None:
|
|
274
283
|
# Refuse before connecting, so even an accidentally chosen package
|
|
275
284
|
# path does not acquire an empty database, journal or WAL file.
|
|
276
285
|
for parent in self.db_path.parents:
|
|
277
286
|
if is_package_dir(parent):
|
|
278
287
|
raise ValueError("Database must be outside imported package directories")
|
|
288
|
+
modes = [bool(readonly), not bool(readonly)] if self.db_path is not None else [False]
|
|
289
|
+
first_error = None
|
|
290
|
+
for mode in modes:
|
|
291
|
+
try:
|
|
292
|
+
self._initialize(mode)
|
|
293
|
+
break
|
|
294
|
+
except (OSError, sqlite3.DatabaseError) as error:
|
|
295
|
+
self.close()
|
|
296
|
+
if first_error is None:
|
|
297
|
+
first_error = error
|
|
298
|
+
if not mode and not self._read_only_open_failure(error):
|
|
299
|
+
raise
|
|
300
|
+
except Exception:
|
|
301
|
+
self.close()
|
|
302
|
+
raise
|
|
303
|
+
else:
|
|
304
|
+
raise first_error
|
|
305
|
+
|
|
306
|
+
@staticmethod
|
|
307
|
+
def _read_only_open_failure(error):
|
|
308
|
+
return storage.is_readonly_failure(error)
|
|
309
|
+
|
|
310
|
+
def _initialize(self, readonly):
|
|
311
|
+
if self.db_path is None:
|
|
312
|
+
connection = sqlite3.connect(":memory:")
|
|
313
|
+
elif readonly:
|
|
314
|
+
connection = storage.connect(self.db_path, readonly=True)
|
|
315
|
+
else:
|
|
279
316
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
280
|
-
|
|
317
|
+
connection = sqlite3.connect(str(self.db_path))
|
|
318
|
+
self.connection = connection
|
|
319
|
+
self.readonly = readonly
|
|
281
320
|
self.connection.row_factory = sqlite3.Row
|
|
282
321
|
self.connection.execute("PRAGMA foreign_keys=ON")
|
|
283
322
|
self.connection.execute("PRAGMA busy_timeout=5000")
|
|
284
323
|
version = self.connection.execute("PRAGMA user_version").fetchone()[0]
|
|
285
324
|
if version not in (0, 1, 2):
|
|
286
|
-
self.close()
|
|
287
325
|
raise ValueError("Unsupported bookmark index schema version: %s" % version)
|
|
288
|
-
if
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
326
|
+
if readonly:
|
|
327
|
+
if version != 2:
|
|
328
|
+
raise sqlite3.OperationalError("Read-only bookmark index requires schema version 2")
|
|
329
|
+
row = self.connection.execute(
|
|
330
|
+
"SELECT sql FROM sqlite_master WHERE name='items_fts'").fetchone()
|
|
331
|
+
self._fts_mode = "trigram" if row is not None and "trigram" in (row["sql"] or "") else "plain"
|
|
332
|
+
else:
|
|
333
|
+
if version == 1:
|
|
334
|
+
# Add the added-at columns and fill them from stored raw_json before
|
|
335
|
+
# the schema script runs: CREATE TABLE IF NOT EXISTS cannot add a
|
|
336
|
+
# column, and the script would advance the version past this repair.
|
|
293
337
|
self._upgrade_items_added_at()
|
|
294
|
-
except Exception:
|
|
295
|
-
self.close()
|
|
296
|
-
raise
|
|
297
|
-
try:
|
|
298
338
|
self.connection.executescript(SCHEMA)
|
|
299
|
-
|
|
300
|
-
self.close()
|
|
301
|
-
raise
|
|
302
|
-
self._fts_mode = self._prepare_search_index()
|
|
339
|
+
self._fts_mode = self._prepare_search_index()
|
|
303
340
|
self._window_functions = self._supports_window_functions()
|
|
304
341
|
|
|
305
342
|
def _upgrade_items_added_at(self):
|
|
@@ -366,7 +403,9 @@ class BookmarkIndex:
|
|
|
366
403
|
return True
|
|
367
404
|
|
|
368
405
|
def close(self):
|
|
369
|
-
self.connection
|
|
406
|
+
if self.connection is not None:
|
|
407
|
+
self.connection.close()
|
|
408
|
+
self.connection = None
|
|
370
409
|
|
|
371
410
|
def __enter__(self):
|
|
372
411
|
return self
|
|
@@ -374,8 +413,21 @@ class BookmarkIndex:
|
|
|
374
413
|
def __exit__(self, *_):
|
|
375
414
|
self.close()
|
|
376
415
|
|
|
416
|
+
def resolve_source_id(self, source_id):
|
|
417
|
+
"""Resolve retired source IDs to their surviving merge target."""
|
|
418
|
+
current, seen = source_id, set()
|
|
419
|
+
while current not in seen:
|
|
420
|
+
seen.add(current)
|
|
421
|
+
row = self.connection.execute(
|
|
422
|
+
"SELECT new_source_id FROM source_redirects WHERE old_source_id=?", (current,)).fetchone()
|
|
423
|
+
if row is None:
|
|
424
|
+
return current
|
|
425
|
+
current = row[0]
|
|
426
|
+
raise ValueError("Source redirect cycle: " + str(source_id))
|
|
427
|
+
|
|
377
428
|
def _source(self, source_id):
|
|
378
|
-
|
|
429
|
+
resolved = self.resolve_source_id(source_id)
|
|
430
|
+
row = self.connection.execute("SELECT * FROM sources WHERE source_id=?", (resolved,)).fetchone()
|
|
379
431
|
if row is None:
|
|
380
432
|
raise ValueError("Unknown source_id: " + str(source_id))
|
|
381
433
|
return dict(row)
|
|
@@ -443,8 +495,13 @@ class BookmarkIndex:
|
|
|
443
495
|
else:
|
|
444
496
|
if anchor:
|
|
445
497
|
raise ValueError("Only permanent sections can be copy anchors")
|
|
446
|
-
section_id = obj.get("id")
|
|
447
498
|
label = obj.get("label", "unknown")
|
|
499
|
+
section_id = obj.get("id")
|
|
500
|
+
if not section_id:
|
|
501
|
+
# The canvas right-click export omits ids; derive a stable one so
|
|
502
|
+
# re-importing the same file keeps refs and duplicate groups.
|
|
503
|
+
digest = hashlib.sha256(("temporary|" + str(label) + "|" + _string(obj.get("title", ""), "section title") + "|" + file_path).encode("utf-8")).hexdigest()[:12]
|
|
504
|
+
section_id = "temp-section-export-" + digest
|
|
448
505
|
_string(section_id, "section id", True)
|
|
449
506
|
_string(label, "section label", True)
|
|
450
507
|
return section_id, {
|
|
@@ -491,7 +548,14 @@ class BookmarkIndex:
|
|
|
491
548
|
for position, node in enumerate(nodes):
|
|
492
549
|
if not isinstance(node, dict):
|
|
493
550
|
raise ValueError("Items must be JSON objects")
|
|
494
|
-
|
|
551
|
+
if node.get("id") is None:
|
|
552
|
+
# Exported items carry no id; derive one from position and
|
|
553
|
+
# content so re-imports stay stable and duplicates still group.
|
|
554
|
+
seed = "|".join([section_id, parent_id or "", str(position),
|
|
555
|
+
str(node.get("url") or ""), str(node.get("title") or "")])
|
|
556
|
+
item_id = ("tempId_" if not permanent else "persistId_") + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
|
|
557
|
+
else:
|
|
558
|
+
item_id = _string(node.get("id"), "item id", True)
|
|
495
559
|
if item_id in seen:
|
|
496
560
|
raise ValueError("Duplicate item id in section: " + item_id)
|
|
497
561
|
seen.add(item_id)
|
|
@@ -500,8 +564,9 @@ class BookmarkIndex:
|
|
|
500
564
|
if not permanent and node.get("sectionId", section_id) != section_id:
|
|
501
565
|
raise ValueError("Temporary item sectionId mismatch: " + item_id)
|
|
502
566
|
title = _string(node.get("title", ""), "item title")
|
|
503
|
-
url = _string(node.get("url"
|
|
504
|
-
item_type = ("bookmark" if url else "folder") if permanent
|
|
567
|
+
url = _string(node.get("url") or "", "item URL")
|
|
568
|
+
item_type = (("bookmark" if url else "folder") if permanent
|
|
569
|
+
else (node.get("type") or ("bookmark" if url else "folder")))
|
|
505
570
|
if item_type not in ("bookmark", "folder") or (item_type == "bookmark" and not url):
|
|
506
571
|
raise ValueError("Invalid bookmark/folder type: " + item_id)
|
|
507
572
|
children = node.get("children", [])
|
|
@@ -531,8 +596,12 @@ class BookmarkIndex:
|
|
|
531
596
|
"tag_text": "\n".join(tag["text"] for tag in normalized),
|
|
532
597
|
"path_json": _dump(list(titles)), "folder_path": " / ".join(titles),
|
|
533
598
|
"ancestor_ids_json": _dump(list(ancestor_ids)),
|
|
534
|
-
#
|
|
535
|
-
|
|
599
|
+
# Keep tree structure in parent_id/path/position and in the
|
|
600
|
+
# source-file snapshot; duplicating descendants here makes
|
|
601
|
+
# every ancestor appear changed when one child is edited.
|
|
602
|
+
"raw_json": _dump({key: value for key, value in node.items()
|
|
603
|
+
if key != "children"
|
|
604
|
+
and not (item_type == "folder" and key == "url" and value == "")}),
|
|
536
605
|
"metadata_json": _dump(item_meta if permanent else {key: node[key] for key in ("tags", "note", "noteColor") if key in node}),
|
|
537
606
|
"added_at": added[0], "added_at_source": added[1], "added_at_confidence": added[2],
|
|
538
607
|
}))
|
|
@@ -597,6 +666,8 @@ class BookmarkIndex:
|
|
|
597
666
|
@contextmanager
|
|
598
667
|
def transaction(self):
|
|
599
668
|
"""Serialize writers; allow an importer to publish metadata atomically."""
|
|
669
|
+
if self.readonly:
|
|
670
|
+
raise sqlite3.OperationalError("attempt to write a readonly bookmark index")
|
|
600
671
|
nested = self.connection.in_transaction
|
|
601
672
|
if nested:
|
|
602
673
|
self.connection.execute("SAVEPOINT bookmark_sync")
|
|
@@ -850,7 +921,8 @@ class BookmarkIndex:
|
|
|
850
921
|
return rows[0]
|
|
851
922
|
|
|
852
923
|
def _scope(self, source_id, section=None, group_id=None, folder_id=None, tags=None, tag_colors=None,
|
|
853
|
-
item_types=None):
|
|
924
|
+
item_types=None, has_note=None, has_tags=None, metadata_match="all"):
|
|
925
|
+
source_id = self.resolve_source_id(source_id)
|
|
854
926
|
self._source(source_id)
|
|
855
927
|
selected_types = list(item_types) if item_types else ["bookmark"]
|
|
856
928
|
clauses = ["i.source_id=?", "i.item_type IN (%s)" % ",".join("?" for _ in selected_types)]
|
|
@@ -892,15 +964,189 @@ class BookmarkIndex:
|
|
|
892
964
|
_string(color, "tag color", True)
|
|
893
965
|
clauses.append("EXISTS (SELECT 1 FROM item_tags t WHERE t.item_pk=i.pk AND t.color=?)")
|
|
894
966
|
args.append(color)
|
|
967
|
+
metadata_clauses = []
|
|
968
|
+
if has_note is not None:
|
|
969
|
+
metadata_clauses.append("trim(i.note)<>''" if has_note else "trim(i.note)=''")
|
|
970
|
+
if has_tags is not None:
|
|
971
|
+
metadata_clauses.append("EXISTS (SELECT 1 FROM item_tags t WHERE t.item_pk=i.pk)"
|
|
972
|
+
if has_tags else
|
|
973
|
+
"NOT EXISTS (SELECT 1 FROM item_tags t WHERE t.item_pk=i.pk)")
|
|
974
|
+
if metadata_clauses:
|
|
975
|
+
clauses.append("(" + (" OR " if metadata_match == "any" else " AND ").join(metadata_clauses) + ")")
|
|
895
976
|
return clauses, args
|
|
896
977
|
|
|
978
|
+
def source_summary(self, source_id):
|
|
979
|
+
"""Recognise a freshly imported package: counts, sections, noise, duplicates."""
|
|
980
|
+
source_id = self.resolve_source_id(source_id)
|
|
981
|
+
from url_classify import is_search_result_url
|
|
982
|
+
counts = {}
|
|
983
|
+
for row in self.connection.execute(
|
|
984
|
+
"SELECT item_type, COUNT(*) AS c FROM items WHERE source_id=? GROUP BY item_type",
|
|
985
|
+
(source_id,)):
|
|
986
|
+
counts[row["item_type"]] = row["c"]
|
|
987
|
+
notes = self.connection.execute(
|
|
988
|
+
"SELECT COUNT(*) FROM items WHERE source_id=? AND note!=''", (source_id,)).fetchone()[0]
|
|
989
|
+
notes_bookmarks = self.connection.execute(
|
|
990
|
+
"SELECT COUNT(*) FROM items WHERE source_id=? AND note!='' AND item_type='bookmark'",
|
|
991
|
+
(source_id,)).fetchone()[0]
|
|
992
|
+
tagged = self.connection.execute(
|
|
993
|
+
"SELECT COUNT(*) FROM items WHERE source_id=? AND tag_text!=''", (source_id,)).fetchone()[0]
|
|
994
|
+
section_counts = {row["section_id"]: row["c"] for row in self.connection.execute(
|
|
995
|
+
"SELECT section_id, COUNT(*) AS c FROM items WHERE source_id=? GROUP BY section_id",
|
|
996
|
+
(source_id,))}
|
|
997
|
+
sections = []
|
|
998
|
+
for row in self.connection.execute(
|
|
999
|
+
"SELECT section_id, label, title, section_type, file_path, raw_json FROM sections"
|
|
1000
|
+
" WHERE source_id=? ORDER BY section_id", (source_id,)):
|
|
1001
|
+
try:
|
|
1002
|
+
raw = json.loads(row["raw_json"]) if row["raw_json"] else {}
|
|
1003
|
+
except ValueError:
|
|
1004
|
+
raw = {}
|
|
1005
|
+
sections.append({**dict(row), "items": section_counts.get(row["section_id"], 0),
|
|
1006
|
+
"source": raw.get("source") or "",
|
|
1007
|
+
"temp_kind": raw.get("tempKind") or ""})
|
|
1008
|
+
sections[-1].pop("raw_json", None)
|
|
1009
|
+
urls = [row["url"] for row in self.connection.execute(
|
|
1010
|
+
"SELECT DISTINCT url FROM items WHERE source_id=? AND url!=''", (source_id,))]
|
|
1011
|
+
search_pages = sum(1 for url in urls if is_search_result_url(url))
|
|
1012
|
+
from junk import is_junk
|
|
1013
|
+
junk_items = 0
|
|
1014
|
+
for row in self.connection.execute(
|
|
1015
|
+
"SELECT title, url FROM items WHERE source_id=? AND item_type='bookmark'",
|
|
1016
|
+
(source_id,)):
|
|
1017
|
+
if is_junk(row["title"], row["url"]):
|
|
1018
|
+
junk_items += 1
|
|
1019
|
+
duplicates = self.duplicate_urls(source_id, include_search=False, limit=1)
|
|
1020
|
+
return {"source_id": source_id,
|
|
1021
|
+
"counts": {"items": sum(counts.values()), "bookmarks": counts.get("bookmark", 0),
|
|
1022
|
+
"folders": counts.get("folder", 0)},
|
|
1023
|
+
"notes": notes, "notes_bookmarks": notes_bookmarks,
|
|
1024
|
+
"notes_folders": notes - notes_bookmarks, "tagged": tagged, "unique_urls": len(urls),
|
|
1025
|
+
"search_pages": search_pages, "junk_items": junk_items,
|
|
1026
|
+
"duplicate_url_groups": duplicates["total_groups"],
|
|
1027
|
+
"sections": sections}
|
|
1028
|
+
|
|
1029
|
+
def duplicate_urls(self, source_id, min_count=2, limit=50, offset=0, include_search=False):
|
|
1030
|
+
"""Group bookmarks that point at the same URL and suggest which copy to keep.
|
|
1031
|
+
|
|
1032
|
+
The suggestion prefers an instance with a note, then the earliest added
|
|
1033
|
+
copy, then the shallowest folder path. It is a proposal, never an edit.
|
|
1034
|
+
"""
|
|
1035
|
+
source_id = self.resolve_source_id(source_id)
|
|
1036
|
+
if isinstance(min_count, bool) or not isinstance(min_count, int) or min_count < 2:
|
|
1037
|
+
raise ValueError("min_count must be an integer of at least 2")
|
|
1038
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 500:
|
|
1039
|
+
raise ValueError("limit must be an integer from 1 to 500")
|
|
1040
|
+
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
|
|
1041
|
+
raise ValueError("offset must be a non-negative integer")
|
|
1042
|
+
if type(include_search) is not bool:
|
|
1043
|
+
raise ValueError("include_search must be a boolean")
|
|
1044
|
+
from junk import is_junk
|
|
1045
|
+
from url_classify import is_search_result_url
|
|
1046
|
+
base = ("FROM items WHERE source_id=? AND item_type='bookmark'"
|
|
1047
|
+
" AND url IS NOT NULL AND url!=''")
|
|
1048
|
+
rows = self.connection.execute(
|
|
1049
|
+
"SELECT url, COUNT(*) AS copies, MIN(added_at) AS first_added " + base +
|
|
1050
|
+
" GROUP BY url HAVING copies>=? ORDER BY copies DESC, first_added, url",
|
|
1051
|
+
(source_id, min_count)).fetchall()
|
|
1052
|
+
groups = [dict(row) for row in rows]
|
|
1053
|
+
# Placeholder rows (divider searches) are not real duplicates; drop them
|
|
1054
|
+
# before counting groups so the report stays honest.
|
|
1055
|
+
junk_seen = 0
|
|
1056
|
+
kept_groups = []
|
|
1057
|
+
for group in groups:
|
|
1058
|
+
if is_junk("", group["url"]):
|
|
1059
|
+
junk_seen += group["copies"]
|
|
1060
|
+
continue
|
|
1061
|
+
kept_groups.append(group)
|
|
1062
|
+
groups = kept_groups
|
|
1063
|
+
if not include_search:
|
|
1064
|
+
groups = [group for group in groups if not is_search_result_url(group["url"])]
|
|
1065
|
+
urls = [group["url"] for group in groups]
|
|
1066
|
+
by_url = {}
|
|
1067
|
+
if urls:
|
|
1068
|
+
placeholders = ",".join("?" for _ in urls)
|
|
1069
|
+
instance_rows = self.connection.execute(
|
|
1070
|
+
"SELECT section_id, item_id, url, title, note, note_color, folder_path, added_at, position "
|
|
1071
|
+
+ base + " AND url IN (" + placeholders + ") ORDER BY url, added_at, folder_path, item_id",
|
|
1072
|
+
[source_id, *urls]).fetchall()
|
|
1073
|
+
for row in instance_rows:
|
|
1074
|
+
by_url.setdefault(row["url"], []).append(dict(row))
|
|
1075
|
+
kept = []
|
|
1076
|
+
for group in groups:
|
|
1077
|
+
copies = []
|
|
1078
|
+
for instance in by_url.get(group["url"], []):
|
|
1079
|
+
if is_junk(instance.get("title"), instance.get("url")):
|
|
1080
|
+
junk_seen += 1
|
|
1081
|
+
continue
|
|
1082
|
+
instance["depth"] = (instance.get("folder_path") or "").count("/") + 1
|
|
1083
|
+
copies.append(instance)
|
|
1084
|
+
if len(copies) < min_count:
|
|
1085
|
+
continue
|
|
1086
|
+
keep = sorted(copies, key=lambda item: (
|
|
1087
|
+
0 if item.get("note") else 1, item.get("added_at") or "9999",
|
|
1088
|
+
item["depth"], item["section_id"], item["item_id"]))[0]
|
|
1089
|
+
group["copies"] = len(copies)
|
|
1090
|
+
group["keep"] = {key: keep[key] for key in ("section_id", "item_id", "title",
|
|
1091
|
+
"folder_path", "note", "added_at")}
|
|
1092
|
+
group["instances"] = copies
|
|
1093
|
+
kept.append(group)
|
|
1094
|
+
total_groups = len(kept)
|
|
1095
|
+
page = kept[offset:offset + limit]
|
|
1096
|
+
shown = offset + len(page)
|
|
1097
|
+
return {"source_id": source_id, "total_groups": total_groups, "groups": page,
|
|
1098
|
+
"limit": limit, "offset": offset,
|
|
1099
|
+
"next_offset": shown if shown < total_groups else None,
|
|
1100
|
+
"include_search": include_search, "junk_instances_excluded": junk_seen}
|
|
1101
|
+
|
|
1102
|
+
def folder_children(self, source_id, folder_id, section=None, tags=None, tag_colors=None,
|
|
1103
|
+
has_note=None, has_tags=None, metadata_match="all", limit=8, offset=0):
|
|
1104
|
+
"""List one folder's direct bookmark/folder children with stable pagination."""
|
|
1105
|
+
source_id = self.resolve_source_id(source_id)
|
|
1106
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 1000:
|
|
1107
|
+
raise ValueError("limit must be an integer from 1 to 1000")
|
|
1108
|
+
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
|
|
1109
|
+
raise ValueError("offset must be a non-negative integer")
|
|
1110
|
+
self._source(source_id)
|
|
1111
|
+
rows = self.connection.execute(
|
|
1112
|
+
"SELECT * FROM items WHERE source_id=? AND item_type='folder' AND item_id=?",
|
|
1113
|
+
(source_id, folder_id)).fetchall()
|
|
1114
|
+
if section is not None:
|
|
1115
|
+
selected_sections = {row["canonical_id"] or row["section_id"]
|
|
1116
|
+
for row in self._sections(source_id, section)}
|
|
1117
|
+
rows = [row for row in rows if row["section_id"] in selected_sections]
|
|
1118
|
+
unique = {(row["section_id"], row["item_id"]): row for row in rows}
|
|
1119
|
+
if not unique:
|
|
1120
|
+
raise ValueError("Unknown folder ID: " + str(folder_id))
|
|
1121
|
+
if len(unique) > 1:
|
|
1122
|
+
raise ValueError("Ambiguous folder ID; pass section to select one: " + str(folder_id))
|
|
1123
|
+
folder = next(iter(unique.values()))
|
|
1124
|
+
clauses, parameters = self._scope(
|
|
1125
|
+
source_id, section=folder["section_id"], tags=tags, tag_colors=tag_colors,
|
|
1126
|
+
item_types=["bookmark", "folder"], has_note=has_note, has_tags=has_tags,
|
|
1127
|
+
metadata_match=metadata_match)
|
|
1128
|
+
clauses.append("i.parent_id=?")
|
|
1129
|
+
parameters.append(folder_id)
|
|
1130
|
+
where = " AND ".join(clauses)
|
|
1131
|
+
total = self.connection.execute("SELECT count(*) FROM items i WHERE " + where, parameters).fetchone()[0]
|
|
1132
|
+
children = self.connection.execute(
|
|
1133
|
+
"SELECT i.* FROM items i WHERE " + where +
|
|
1134
|
+
" ORDER BY i.position,i.preorder,i.item_id LIMIT ? OFFSET ?",
|
|
1135
|
+
parameters + [limit, offset]).fetchall()
|
|
1136
|
+
anchors = self._section_anchors(source_id)
|
|
1137
|
+
return {"source_id": source_id, "folder": self._item_result(folder, anchors),
|
|
1138
|
+
"total": total, "limit": limit, "offset": offset,
|
|
1139
|
+
"results": [self._item_result(row, anchors) for row in children]}
|
|
1140
|
+
|
|
897
1141
|
@staticmethod
|
|
898
1142
|
def plan_terms(question):
|
|
899
1143
|
"""Question text into literal search terms; no semantics, no guessing.
|
|
900
1144
|
|
|
901
1145
|
ASCII runs of two or more characters are kept whole. A CJK run of four
|
|
902
|
-
or fewer characters is kept whole as well
|
|
903
|
-
|
|
1146
|
+
or fewer characters is kept whole as well, with boundary-aligned bigrams
|
|
1147
|
+
when exactly four characters. A longer run contributes boundary-aligned
|
|
1148
|
+
bigrams without cross-word fragments, so 本地部署 still reaches a card
|
|
1149
|
+
that only says 部署.
|
|
904
1150
|
A single ASCII character is dropped, because it would match almost
|
|
905
1151
|
everything; a single CJK character is kept, because it is often the
|
|
906
1152
|
whole entity. The order is the order of appearance and the list is
|
|
@@ -918,14 +1164,26 @@ class BookmarkIndex:
|
|
|
918
1164
|
if run[0].isascii():
|
|
919
1165
|
candidates = [run] if len(run) >= 2 else []
|
|
920
1166
|
else:
|
|
921
|
-
candidates = [
|
|
922
|
-
|
|
1167
|
+
candidates = []
|
|
1168
|
+
sub_segments = [s for s in PLAN_CJK_SPLITTERS.split(run) if s]
|
|
1169
|
+
for segment in sub_segments:
|
|
1170
|
+
if len(segment) <= 4:
|
|
1171
|
+
candidates.append(segment)
|
|
1172
|
+
if len(segment) == 4:
|
|
1173
|
+
candidates.extend([segment[:2], segment[2:]])
|
|
1174
|
+
else:
|
|
1175
|
+
if len(segment) % 2 == 0:
|
|
1176
|
+
candidates.extend(segment[i:i + 2] for i in range(0, len(segment) - 1, 2))
|
|
1177
|
+
else:
|
|
1178
|
+
for i in range(0, len(segment) - 1, 2):
|
|
1179
|
+
candidates.append(segment[i:i + 2])
|
|
1180
|
+
candidates.append(segment[-2:])
|
|
923
1181
|
for candidate in candidates:
|
|
924
1182
|
if candidate not in terms:
|
|
925
1183
|
terms.append(candidate)
|
|
926
1184
|
return terms[:PLAN_MAX_TERMS]
|
|
927
1185
|
|
|
928
|
-
def plan_scope(self, source_id, question, max_cards=3, max_urls=8, homepage_limit=0):
|
|
1186
|
+
def plan_scope(self, source_id, question, max_cards=3, max_urls=8, homepage_limit=0, field_score=False):
|
|
929
1187
|
"""Rank the cards a question touches and the bookmarks inside them.
|
|
930
1188
|
|
|
931
1189
|
Local only: literal matching over indexed metadata, no fetch and no
|
|
@@ -942,6 +1200,7 @@ class BookmarkIndex:
|
|
|
942
1200
|
cards, deduplicated by URL, with the terms each URL's own rows matched
|
|
943
1201
|
and the source-wide instance count, which is not the selected scope.
|
|
944
1202
|
"""
|
|
1203
|
+
source_id = self.resolve_source_id(source_id)
|
|
945
1204
|
source = self._source(source_id)
|
|
946
1205
|
package = self._input_path(source_id, source["package_path"])
|
|
947
1206
|
# Partial imports keep cards whose file disappeared. They stay indexed,
|
|
@@ -956,6 +1215,8 @@ class BookmarkIndex:
|
|
|
956
1215
|
raise ValueError("max_urls must be an integer from 1 to 8")
|
|
957
1216
|
if type(homepage_limit) is not int or not 0 <= homepage_limit <= 8:
|
|
958
1217
|
raise ValueError("homepage_limit must be an integer from 0 to 8")
|
|
1218
|
+
if type(field_score) is not bool:
|
|
1219
|
+
raise ValueError("field_score must be a boolean")
|
|
959
1220
|
terms = self.plan_terms(question)
|
|
960
1221
|
result = {"source_id": source_id, "terms": terms, "term_cards": {}, "cards": [], "urls": [],
|
|
961
1222
|
"truncated": False, "errors": []}
|
|
@@ -1029,8 +1290,20 @@ class BookmarkIndex:
|
|
|
1029
1290
|
# ascending tie-breaks, the second orders by score, hits and freshness,
|
|
1030
1291
|
# and equal rows keep the order the first pass gave them.
|
|
1031
1292
|
ordered = sorted(hits, key=lambda value: (sections[value]["file_path"], value))
|
|
1032
|
-
|
|
1033
|
-
|
|
1293
|
+
if field_score:
|
|
1294
|
+
card_field_scores = {}
|
|
1295
|
+
for sid in scores:
|
|
1296
|
+
sec_items = [dict(r) for r in self.connection.execute(
|
|
1297
|
+
"SELECT * FROM items i WHERE i.source_id=? AND i.section_id=? AND i.item_type='bookmark' AND (" + where + ")",
|
|
1298
|
+
(source_id, sid, *parameters)).fetchall()]
|
|
1299
|
+
total_f = sum(BookmarkIndex.score_item(r, terms)[0] for r in sec_items)
|
|
1300
|
+
card_field_scores[sid] = total_f / (bookmarks.get(sid) or 1)
|
|
1301
|
+
ordered.sort(key=lambda value: (card_field_scores.get(value, 0.0) * density[value],
|
|
1302
|
+
scores[value] * density[value], hits[value],
|
|
1303
|
+
(newest.get(value) or (None, None))[0] or ""), reverse=True)
|
|
1304
|
+
else:
|
|
1305
|
+
ordered.sort(key=lambda value: (scores[value] * density[value], hits[value],
|
|
1306
|
+
(newest.get(value) or (None, None))[0] or ""), reverse=True)
|
|
1034
1307
|
trees, ranked = set(), []
|
|
1035
1308
|
for section_id in ordered:
|
|
1036
1309
|
row = sections[section_id]
|
|
@@ -1152,8 +1425,10 @@ class BookmarkIndex:
|
|
|
1152
1425
|
def _item_result(self, row, anchors=None, section_cache=None):
|
|
1153
1426
|
keys = row.keys()
|
|
1154
1427
|
result = {key: row[key] for key in ("source_id", "section_id", "item_id", "parent_id", "item_type", "title",
|
|
1155
|
-
"
|
|
1428
|
+
"note", "note_color", "position")
|
|
1156
1429
|
+ (("added_at", "added_at_source", "added_at_confidence") if "added_at" in keys else ())}
|
|
1430
|
+
if row["item_type"] != "folder" or row["url"]:
|
|
1431
|
+
result["url"] = row["url"]
|
|
1157
1432
|
result["added_at_precision"] = ADDED_AT_SOURCES.get(result.get("added_at_source"), (None,))[0]
|
|
1158
1433
|
result.update(source=row["source_id"], section=row["section_id"], item=row["item_id"],
|
|
1159
1434
|
path=json.loads(row["path_json"]), folder_path=row["folder_path"], tags=json.loads(row["tags_json"]),
|
|
@@ -1177,15 +1452,93 @@ class BookmarkIndex:
|
|
|
1177
1452
|
result["section_label"], result["file_path"] = section[0], section[1]
|
|
1178
1453
|
return result
|
|
1179
1454
|
|
|
1455
|
+
@staticmethod
|
|
1456
|
+
def score_item(row, targets=None):
|
|
1457
|
+
"""Compute non-binding weighted scoring hints for a candidate item.
|
|
1458
|
+
|
|
1459
|
+
Scoring tiers:
|
|
1460
|
+
- title_prefix: 140
|
|
1461
|
+
- title_contains: 135
|
|
1462
|
+
- url: 120
|
|
1463
|
+
- tag: 110
|
|
1464
|
+
- note: 90
|
|
1465
|
+
- folder_path: inherited evidence only; it is not an item score
|
|
1466
|
+
Time confidence tier bonus:
|
|
1467
|
+
- high: +10
|
|
1468
|
+
- medium: +5
|
|
1469
|
+
- low: +2
|
|
1470
|
+
"""
|
|
1471
|
+
targets = targets or []
|
|
1472
|
+
keys = set(row.keys()) if hasattr(row, "keys") else set()
|
|
1473
|
+
title = (row["title"] if "title" in keys and row["title"] else "").casefold()
|
|
1474
|
+
note = (row["note"] if "note" in keys and row["note"] else "").casefold()
|
|
1475
|
+
folder_path = (row["folder_path"] if "folder_path" in keys and row["folder_path"] else "").casefold()
|
|
1476
|
+
url = (row["url"] if "url" in keys and row["url"] else "").casefold()
|
|
1477
|
+
tag_text = (row["tag_text"] if "tag_text" in keys and row["tag_text"] else "").casefold()
|
|
1478
|
+
|
|
1479
|
+
field_hits = []
|
|
1480
|
+
score = 0
|
|
1481
|
+
for target in targets:
|
|
1482
|
+
t = (target or "").casefold().strip()
|
|
1483
|
+
if not t:
|
|
1484
|
+
continue
|
|
1485
|
+
if title.startswith(t):
|
|
1486
|
+
if "title_prefix" not in field_hits:
|
|
1487
|
+
field_hits.append("title_prefix")
|
|
1488
|
+
score += 140
|
|
1489
|
+
elif t in title:
|
|
1490
|
+
if "title_contains" not in field_hits:
|
|
1491
|
+
field_hits.append("title_contains")
|
|
1492
|
+
score += 135
|
|
1493
|
+
if t in tag_text:
|
|
1494
|
+
if "tag" not in field_hits:
|
|
1495
|
+
field_hits.append("tag")
|
|
1496
|
+
score += 110
|
|
1497
|
+
if t in note:
|
|
1498
|
+
if "note" not in field_hits:
|
|
1499
|
+
field_hits.append("note")
|
|
1500
|
+
score += 90
|
|
1501
|
+
if t in folder_path and "folder_path" not in field_hits:
|
|
1502
|
+
field_hits.append("folder_path")
|
|
1503
|
+
if t in url:
|
|
1504
|
+
if "url" not in field_hits:
|
|
1505
|
+
field_hits.append("url")
|
|
1506
|
+
score += 120
|
|
1507
|
+
|
|
1508
|
+
conf = row["added_at_confidence"] if "added_at_confidence" in keys else None
|
|
1509
|
+
if conf == "high":
|
|
1510
|
+
score += 10
|
|
1511
|
+
field_hits.append("time_high")
|
|
1512
|
+
elif conf == "medium":
|
|
1513
|
+
score += 5
|
|
1514
|
+
field_hits.append("time_medium")
|
|
1515
|
+
elif conf == "low":
|
|
1516
|
+
score += 2
|
|
1517
|
+
field_hits.append("time_low")
|
|
1518
|
+
|
|
1519
|
+
return score, field_hits
|
|
1520
|
+
|
|
1180
1521
|
def search(self, source_id, targets=None, section=None, group_id=None, folder_id=None, tags=None,
|
|
1181
|
-
limit=20, offset=0, tag_colors=None, item_types=None, count_only=False, compact=False
|
|
1522
|
+
limit=20, offset=0, tag_colors=None, item_types=None, count_only=False, compact=False,
|
|
1523
|
+
score=False, has_note=None, has_tags=None, metadata_match="all", sort_by="default"):
|
|
1182
1524
|
"""Literal structured search. ``limit=0`` or ``count_only`` returns counts only."""
|
|
1525
|
+
source_id = self.resolve_source_id(source_id)
|
|
1183
1526
|
if isinstance(limit, bool) or not isinstance(limit, int) or not 0 <= limit <= 1000:
|
|
1184
1527
|
raise ValueError("limit must be an integer from 0 to 1000; 0 returns counts only")
|
|
1185
1528
|
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
|
|
1186
1529
|
raise ValueError("offset must be a non-negative integer")
|
|
1187
1530
|
if type(compact) is not bool:
|
|
1188
1531
|
raise ValueError("compact must be a boolean")
|
|
1532
|
+
if type(score) is not bool:
|
|
1533
|
+
raise ValueError("score must be a boolean")
|
|
1534
|
+
if has_note is not None and type(has_note) is not bool:
|
|
1535
|
+
raise ValueError("has_note must be a boolean or null")
|
|
1536
|
+
if has_tags is not None and type(has_tags) is not bool:
|
|
1537
|
+
raise ValueError("has_tags must be a boolean or null")
|
|
1538
|
+
if metadata_match not in ("all", "any"):
|
|
1539
|
+
raise ValueError("metadata_match must be all or any")
|
|
1540
|
+
if sort_by not in ("default", "added_at_desc", "added_at_asc"):
|
|
1541
|
+
raise ValueError("sort_by must be default, added_at_desc or added_at_asc")
|
|
1189
1542
|
if isinstance(targets, str) or isinstance(tags, str) or isinstance(tag_colors, str):
|
|
1190
1543
|
raise ValueError("targets, tags and tag_colors must be lists of strings")
|
|
1191
1544
|
targets = list(dict.fromkeys(targets or []))
|
|
@@ -1198,7 +1551,8 @@ class BookmarkIndex:
|
|
|
1198
1551
|
or any(value not in ("bookmark", "folder") for value in item_types)
|
|
1199
1552
|
or len(set(item_types)) != len(item_types)):
|
|
1200
1553
|
raise ValueError("item_types must select bookmark and/or folder without duplicates")
|
|
1201
|
-
clauses, args = self._scope(source_id, section, group_id, folder_id, tags, tag_colors, item_types
|
|
1554
|
+
clauses, args = self._scope(source_id, section, group_id, folder_id, tags, tag_colors, item_types,
|
|
1555
|
+
has_note, has_tags, metadata_match)
|
|
1202
1556
|
page_size = 0 if count_only else limit
|
|
1203
1557
|
anchors_by_section = self._section_anchors(source_id)
|
|
1204
1558
|
section_cache = {}
|
|
@@ -1212,34 +1566,94 @@ class BookmarkIndex:
|
|
|
1212
1566
|
where = " AND ".join(predicates)
|
|
1213
1567
|
rows, total = [], None
|
|
1214
1568
|
if page and page_size:
|
|
1215
|
-
if
|
|
1569
|
+
if score:
|
|
1570
|
+
total = self.connection.execute(
|
|
1571
|
+
"SELECT count(*) FROM items i WHERE " + where, parameters).fetchone()[0]
|
|
1572
|
+
candidate_limit = max(1000, offset + page_size * 5)
|
|
1573
|
+
if total > candidate_limit:
|
|
1574
|
+
all_rows = self.connection.execute(
|
|
1575
|
+
"SELECT i.* FROM items i WHERE " + where +
|
|
1576
|
+
" ORDER BY (i.added_at IS NULL), i.added_at DESC, i.section_id, i.preorder LIMIT ?",
|
|
1577
|
+
parameters + [candidate_limit]).fetchall()
|
|
1578
|
+
else:
|
|
1579
|
+
all_rows = self.connection.execute(
|
|
1580
|
+
"SELECT i.* FROM items i WHERE " + where, parameters).fetchall()
|
|
1581
|
+
scored_rows = []
|
|
1582
|
+
for r in all_rows:
|
|
1583
|
+
s_val, s_hits = BookmarkIndex.score_item(r, chosen)
|
|
1584
|
+
scored_rows.append((r, s_val, s_hits))
|
|
1585
|
+
scored_rows.sort(key=lambda t: (t[0]["section_id"], t[0]["preorder"], t[0]["item_id"]))
|
|
1586
|
+
if sort_by == "default":
|
|
1587
|
+
scored_rows.sort(key=lambda t: t[0]["added_at"] or "", reverse=True)
|
|
1588
|
+
scored_rows.sort(key=lambda t: 0 if t[0]["added_at"] is not None else 1)
|
|
1589
|
+
scored_rows.sort(key=lambda t: t[1], reverse=True)
|
|
1590
|
+
else:
|
|
1591
|
+
scored_rows.sort(key=lambda t: t[1], reverse=True)
|
|
1592
|
+
scored_rows.sort(key=lambda t: t[0]["added_at"] or "",
|
|
1593
|
+
reverse=sort_by == "added_at_desc")
|
|
1594
|
+
scored_rows.sort(key=lambda t: 0 if t[0]["added_at"] is not None else 1)
|
|
1595
|
+
|
|
1596
|
+
page_slice = scored_rows[offset : offset + page_size]
|
|
1597
|
+
rows_result = []
|
|
1598
|
+
for r, s_val, s_hits in page_slice:
|
|
1599
|
+
item = self._item_result(r, anchors_by_section, section_cache)
|
|
1600
|
+
item["score"] = s_val
|
|
1601
|
+
item["score_hint"] = True
|
|
1602
|
+
item["field_hits"] = s_hits
|
|
1603
|
+
rows_result.append(item)
|
|
1604
|
+
elif self._window_functions:
|
|
1216
1605
|
# One statement returns the page and its pre-LIMIT count, so a
|
|
1217
1606
|
# batch of targets does not pay a second scan per target.
|
|
1607
|
+
order = ("(i.added_at IS NULL),i.added_at " + ("DESC" if sort_by == "added_at_desc" else "ASC")
|
|
1608
|
+
if sort_by != "default" else "i.section_id,i.preorder,i.item_id")
|
|
1218
1609
|
rows = self.connection.execute(
|
|
1219
1610
|
"SELECT i.*, count(*) OVER () AS computed_total FROM items i WHERE " + where +
|
|
1220
|
-
" ORDER BY i.section_id,i.preorder,i.item_id
|
|
1611
|
+
" ORDER BY " + order + (",i.section_id,i.preorder,i.item_id" if sort_by != "default" else "") +
|
|
1612
|
+
" LIMIT ? OFFSET ?",
|
|
1221
1613
|
parameters + [page_size, offset]).fetchall()
|
|
1222
1614
|
total = rows[0]["computed_total"] if rows else self.connection.execute(
|
|
1223
1615
|
"SELECT count(*) FROM items i WHERE " + where, parameters).fetchone()[0]
|
|
1616
|
+
rows_result = [self._item_result(row, anchors_by_section, section_cache) for row in rows]
|
|
1224
1617
|
else:
|
|
1225
1618
|
total = self.connection.execute("SELECT count(*) FROM items i WHERE " + where, parameters).fetchone()[0]
|
|
1619
|
+
order = ("(i.added_at IS NULL),i.added_at " + ("DESC" if sort_by == "added_at_desc" else "ASC")
|
|
1620
|
+
if sort_by != "default" else "i.section_id,i.preorder,i.item_id")
|
|
1226
1621
|
rows = self.connection.execute(
|
|
1227
|
-
"SELECT i.* FROM items i WHERE " + where + " ORDER BY
|
|
1622
|
+
"SELECT i.* FROM items i WHERE " + where + " ORDER BY " + order +
|
|
1623
|
+
(",i.section_id,i.preorder,i.item_id" if sort_by != "default" else "") + " LIMIT ? OFFSET ?",
|
|
1228
1624
|
parameters + [page_size, offset]).fetchall()
|
|
1625
|
+
rows_result = [self._item_result(row, anchors_by_section, section_cache) for row in rows]
|
|
1229
1626
|
else:
|
|
1230
1627
|
total = self.connection.execute("SELECT count(*) FROM items i WHERE " + where, parameters).fetchone()[0]
|
|
1628
|
+
rows_result = []
|
|
1231
1629
|
unique = self.connection.execute(
|
|
1232
1630
|
"SELECT count(DISTINCT i.url) FROM items i WHERE " + where + " AND i.url<>''", parameters).fetchone()[0]
|
|
1233
1631
|
return {"total": total, "unique_urls": unique, "limit": limit, "offset": offset,
|
|
1234
|
-
"results":
|
|
1632
|
+
"results": rows_result}
|
|
1235
1633
|
|
|
1236
1634
|
result = query(targets)
|
|
1635
|
+
if score:
|
|
1636
|
+
result["score_mode"] = "hint"
|
|
1637
|
+
result["score_candidate_pool"] = {
|
|
1638
|
+
"truncated": result["total"] > max(1000, offset + page_size * 5),
|
|
1639
|
+
"selection": "newest_added_at_first",
|
|
1640
|
+
"candidate_limit": max(1000, offset + page_size * 5),
|
|
1641
|
+
}
|
|
1642
|
+
result["score_note"] = (
|
|
1643
|
+
"Item scores are non-binding ranking hints based on field weights "
|
|
1644
|
+
"(title_prefix: 140, title_contains: 135, url: 120, tag: 110, note: 90) "
|
|
1645
|
+
"plus time confidence bonus (high: +10, medium: +5, low: +2). folder_path is inherited evidence "
|
|
1646
|
+
"for folder aggregation and does not increase an item's score. Filter matching remains literal substring. "
|
|
1647
|
+
"When score_candidate_pool.truncated is true, only the newest-added candidates were ranked."
|
|
1648
|
+
)
|
|
1237
1649
|
section_rows = self._sections(source_id, section) if section is not None else []
|
|
1238
1650
|
anchors = {row["section_id"]: row["canonical_id"] for row in section_rows if row["is_anchor"]}
|
|
1239
1651
|
result.update(source_id=source_id,
|
|
1240
1652
|
query_scope={"section": section, "group_id": group_id, "folder_id": folder_id,
|
|
1241
1653
|
"tags": list(tags or []), "tag_colors": list(tag_colors or []),
|
|
1242
|
-
"item_types": list(item_types) if item_types else ["bookmark"]
|
|
1654
|
+
"item_types": list(item_types) if item_types else ["bookmark"],
|
|
1655
|
+
"has_note": has_note, "has_tags": has_tags,
|
|
1656
|
+
"metadata_match": metadata_match, "sort_by": sort_by},
|
|
1243
1657
|
match_mode="literal_substring", match_index="fts_trigram+like" if self._fts_mode == "trigram" else "like",
|
|
1244
1658
|
searched_fields=["title", "url", "note", "tags.text", "folder_path"])
|
|
1245
1659
|
result["counts"] = {"instances": result["total"], "unique_urls": result["unique_urls"],
|
|
@@ -1306,6 +1720,7 @@ class BookmarkIndex:
|
|
|
1306
1720
|
return matches
|
|
1307
1721
|
|
|
1308
1722
|
def context(self, source_id, section=None, group_id=None, item_id=None):
|
|
1723
|
+
source_id = self.resolve_source_id(source_id)
|
|
1309
1724
|
source = self._source(source_id)
|
|
1310
1725
|
sections = self._sections(source_id, section)
|
|
1311
1726
|
nodes = [dict(row) for row in self.connection.execute("SELECT * FROM nodes WHERE source_id=? ORDER BY canvas_path,position", (source_id,))]
|
|
@@ -1405,6 +1820,352 @@ class BookmarkIndex:
|
|
|
1405
1820
|
"instances": instances, "unique_urls": len(urls),
|
|
1406
1821
|
"note": "Section totals, not item or folder subtree totals. Sum of counted_sections only; copy anchors share another card's tree and are excluded."}
|
|
1407
1822
|
|
|
1823
|
+
def lookup_url(self, url, source_id=None, limit=100, offset=0):
|
|
1824
|
+
"""Find all instances of a bookmark URL across sections and sources.
|
|
1825
|
+
|
|
1826
|
+
Returns matching item instances with source, section, folder path,
|
|
1827
|
+
chronological added_at, and associated copy anchor cards.
|
|
1828
|
+
"""
|
|
1829
|
+
if not isinstance(url, str) or not url.strip():
|
|
1830
|
+
raise ValueError("url must be a non-empty string")
|
|
1831
|
+
url = url.strip()
|
|
1832
|
+
if source_id is not None:
|
|
1833
|
+
_string(source_id, "source_id", True)
|
|
1834
|
+
source_id = self.resolve_source_id(source_id)
|
|
1835
|
+
self._source(source_id)
|
|
1836
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 1000:
|
|
1837
|
+
raise ValueError("limit must be an integer between 1 and 1000")
|
|
1838
|
+
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
|
|
1839
|
+
raise ValueError("offset must be a non-negative integer")
|
|
1840
|
+
|
|
1841
|
+
clauses = ["i.item_type = 'bookmark'", "i.url = ?"]
|
|
1842
|
+
params = [url]
|
|
1843
|
+
if source_id is not None:
|
|
1844
|
+
clauses.append("i.source_id = ?")
|
|
1845
|
+
params.append(source_id)
|
|
1846
|
+
where = " AND ".join(clauses)
|
|
1847
|
+
|
|
1848
|
+
total = self.connection.execute(f"SELECT count(*) FROM items i WHERE {where}", params).fetchone()[0]
|
|
1849
|
+
distinct_sources = [r[0] for r in self.connection.execute(
|
|
1850
|
+
f"SELECT DISTINCT i.source_id FROM items i WHERE {where} ORDER BY i.source_id", params).fetchall()]
|
|
1851
|
+
|
|
1852
|
+
first_seen_row = self.connection.execute(
|
|
1853
|
+
f"SELECT min(i.added_at) FROM items i WHERE {where} AND i.added_at IS NOT NULL", params).fetchone()
|
|
1854
|
+
first_seen = first_seen_row[0] if first_seen_row and first_seen_row[0] else None
|
|
1855
|
+
|
|
1856
|
+
if source_id is not None:
|
|
1857
|
+
anchors_by_section = self._section_anchors(source_id)
|
|
1858
|
+
else:
|
|
1859
|
+
anchors_by_section = {}
|
|
1860
|
+
for row in self.connection.execute(
|
|
1861
|
+
"SELECT source_id, section_id, canonical_id FROM sections WHERE is_anchor=1 AND canonical_id IS NOT NULL AND canonical_id != section_id"):
|
|
1862
|
+
anchors_by_section.setdefault((row["source_id"], row["canonical_id"]), []).append(row["section_id"])
|
|
1863
|
+
|
|
1864
|
+
order_by = "ORDER BY (i.added_at IS NULL), i.added_at ASC, i.source_id ASC, i.section_id ASC, i.preorder ASC, i.item_id ASC"
|
|
1865
|
+
query_sql = f"""
|
|
1866
|
+
SELECT i.source_id, i.section_id, i.item_id, i.parent_id, i.title, i.url,
|
|
1867
|
+
i.note, i.folder_path, i.path_json, i.added_at, i.added_at_source,
|
|
1868
|
+
i.added_at_confidence, s.label AS section_label, s.title AS section_title,
|
|
1869
|
+
s.file_path, s.is_anchor, s.canonical_id
|
|
1870
|
+
FROM items i
|
|
1871
|
+
LEFT JOIN sections s ON i.source_id = s.source_id AND i.section_id = s.section_id
|
|
1872
|
+
WHERE {where}
|
|
1873
|
+
{order_by}
|
|
1874
|
+
LIMIT ? OFFSET ?
|
|
1875
|
+
"""
|
|
1876
|
+
rows = self.connection.execute(query_sql, params + [limit, offset]).fetchall()
|
|
1877
|
+
|
|
1878
|
+
instances = []
|
|
1879
|
+
for row in rows:
|
|
1880
|
+
sid = row["source_id"]
|
|
1881
|
+
sec_id = row["section_id"]
|
|
1882
|
+
if source_id is not None:
|
|
1883
|
+
anchors = anchors_by_section.get(sec_id, [])
|
|
1884
|
+
else:
|
|
1885
|
+
anchors = anchors_by_section.get((sid, sec_id), [])
|
|
1886
|
+
try:
|
|
1887
|
+
path = json.loads(row["path_json"]) if row["path_json"] else []
|
|
1888
|
+
except (ValueError, TypeError):
|
|
1889
|
+
path = []
|
|
1890
|
+
instances.append({
|
|
1891
|
+
"source_id": sid,
|
|
1892
|
+
"section_id": sec_id,
|
|
1893
|
+
"section_label": row["section_label"] or "",
|
|
1894
|
+
"section_title": row["section_title"] or "",
|
|
1895
|
+
"file_path": row["file_path"] or "",
|
|
1896
|
+
"item_id": row["item_id"],
|
|
1897
|
+
"parent_id": row["parent_id"],
|
|
1898
|
+
"title": row["title"],
|
|
1899
|
+
"url": row["url"],
|
|
1900
|
+
"folder_path": row["folder_path"],
|
|
1901
|
+
"path": path,
|
|
1902
|
+
"added_at": row["added_at"],
|
|
1903
|
+
"added_at_source": row["added_at_source"],
|
|
1904
|
+
"added_at_confidence": row["added_at_confidence"],
|
|
1905
|
+
"shown_in_anchors": sorted(anchors),
|
|
1906
|
+
})
|
|
1907
|
+
|
|
1908
|
+
next_offset = (offset + len(instances)) if (offset + len(instances)) < total else None
|
|
1909
|
+
return {
|
|
1910
|
+
"url": url,
|
|
1911
|
+
"source_filter": source_id,
|
|
1912
|
+
"total_instances": total,
|
|
1913
|
+
"unique_sources": len(distinct_sources),
|
|
1914
|
+
"sources": distinct_sources,
|
|
1915
|
+
"first_seen": first_seen,
|
|
1916
|
+
"instances": instances,
|
|
1917
|
+
"limit": limit,
|
|
1918
|
+
"offset": offset,
|
|
1919
|
+
"next_offset": next_offset,
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
def traverse(self, source_id, section=None, group_id=None, item_id=None, node_id=None,
|
|
1923
|
+
max_depth=2, direction="all", limit=100, offset=0):
|
|
1924
|
+
"""Multi-hop graph traversal starting from nodes, sections, groups, or items.
|
|
1925
|
+
|
|
1926
|
+
Traverses along canvas edges and group memberships up to max_depth hops.
|
|
1927
|
+
Returns the impact surface: affected sections, groups, text cards,
|
|
1928
|
+
and traversed paths with deterministic sorting and pagination.
|
|
1929
|
+
"""
|
|
1930
|
+
source_id = self.resolve_source_id(source_id)
|
|
1931
|
+
source = self._source(source_id)
|
|
1932
|
+
if isinstance(max_depth, bool) or not isinstance(max_depth, int) or not 1 <= max_depth <= 10:
|
|
1933
|
+
raise ValueError("max_depth must be an integer between 1 and 10")
|
|
1934
|
+
if direction not in ("all", "out", "in"):
|
|
1935
|
+
raise ValueError("direction must be 'all', 'out', or 'in'")
|
|
1936
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 1000:
|
|
1937
|
+
raise ValueError("limit must be an integer between 1 and 1000")
|
|
1938
|
+
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
|
|
1939
|
+
raise ValueError("offset must be a non-negative integer")
|
|
1940
|
+
|
|
1941
|
+
all_nodes = [dict(row) for row in self.connection.execute(
|
|
1942
|
+
"SELECT * FROM nodes WHERE source_id=? ORDER BY canvas_path,position", (source_id,))]
|
|
1943
|
+
all_edges = [dict(row) for row in self.connection.execute(
|
|
1944
|
+
"SELECT * FROM edges WHERE source_id=? ORDER BY canvas_path,edge_id", (source_id,))]
|
|
1945
|
+
all_memberships = [dict(row) for row in self.connection.execute(
|
|
1946
|
+
"SELECT * FROM memberships WHERE source_id=? ORDER BY canvas_path,group_id,node_id", (source_id,))]
|
|
1947
|
+
sections_map = {row["section_id"]: dict(row) for row in self._sections(source_id)}
|
|
1948
|
+
|
|
1949
|
+
for edge in all_edges:
|
|
1950
|
+
edge["direction"] = ("both" if edge["from_end"] == edge["to_end"] == "arrow" else
|
|
1951
|
+
"forward" if edge["to_end"] == "arrow" else
|
|
1952
|
+
"reverse" if edge["from_end"] == "arrow" else "none")
|
|
1953
|
+
try:
|
|
1954
|
+
edge["raw_json"] = json.loads(edge["raw_json"])
|
|
1955
|
+
except (ValueError, TypeError):
|
|
1956
|
+
edge["raw_json"] = {}
|
|
1957
|
+
edge["added_at"] = _added_at_from_edge_id(edge["edge_id"])
|
|
1958
|
+
|
|
1959
|
+
for node in all_nodes:
|
|
1960
|
+
try:
|
|
1961
|
+
node["raw_json"] = json.loads(node["raw_json"])
|
|
1962
|
+
except (ValueError, TypeError):
|
|
1963
|
+
node["raw_json"] = {}
|
|
1964
|
+
|
|
1965
|
+
node_map = {(n["canvas_path"], n["node_id"]): n for n in all_nodes}
|
|
1966
|
+
start_nodes = []
|
|
1967
|
+
start_scope = {}
|
|
1968
|
+
|
|
1969
|
+
if item_id is not None:
|
|
1970
|
+
start_scope["item_id"] = item_id
|
|
1971
|
+
item_rows = self.connection.execute(
|
|
1972
|
+
"SELECT section_id FROM items WHERE source_id=? AND item_id=?", (source_id, item_id)).fetchall()
|
|
1973
|
+
if not item_rows:
|
|
1974
|
+
raise ValueError("Unknown item in requested scope: " + str(item_id))
|
|
1975
|
+
item_sections = {r[0] for r in item_rows}
|
|
1976
|
+
start_nodes = [n for n in all_nodes if n["section_id"] in item_sections]
|
|
1977
|
+
elif group_id is not None:
|
|
1978
|
+
start_scope["group_id"] = group_id
|
|
1979
|
+
group = self._group(source_id, group_id)
|
|
1980
|
+
start_nodes = [n for n in all_nodes if n["canvas_path"] == group["canvas_path"] and n["node_id"] == group["node_id"]]
|
|
1981
|
+
elif node_id is not None:
|
|
1982
|
+
start_scope["node_id"] = node_id
|
|
1983
|
+
start_nodes = [n for n in all_nodes if n["node_id"] == node_id]
|
|
1984
|
+
elif section is not None:
|
|
1985
|
+
start_scope["section"] = section
|
|
1986
|
+
matched_sections = self._sections(source_id, section)
|
|
1987
|
+
if not matched_sections:
|
|
1988
|
+
raise ValueError("Unknown section: " + str(section))
|
|
1989
|
+
sec_ids = {r["section_id"] for r in matched_sections}
|
|
1990
|
+
start_nodes = [n for n in all_nodes if n["section_id"] in sec_ids]
|
|
1991
|
+
# Match get_context: include enclosing group containers in start nodes (depth 0)
|
|
1992
|
+
group_ids = {m["group_id"] for m in all_memberships if any(
|
|
1993
|
+
n["canvas_path"] == m["canvas_path"] and n["node_id"] == m["node_id"] for n in start_nodes)}
|
|
1994
|
+
for n in all_nodes:
|
|
1995
|
+
if n["node_type"] == "group" and n["node_id"] in group_ids and n not in start_nodes:
|
|
1996
|
+
start_nodes.append(n)
|
|
1997
|
+
else:
|
|
1998
|
+
raise ValueError("Specify at least one of section, group_id, item_id, or node_id")
|
|
1999
|
+
|
|
2000
|
+
if not start_nodes:
|
|
2001
|
+
raise ValueError("No matching canvas nodes found for the specified start target")
|
|
2002
|
+
|
|
2003
|
+
adj = {}
|
|
2004
|
+
for edge in all_edges:
|
|
2005
|
+
cp = edge["canvas_path"]
|
|
2006
|
+
u = (cp, edge["from_node"])
|
|
2007
|
+
v = (cp, edge["to_node"])
|
|
2008
|
+
edir = edge["direction"]
|
|
2009
|
+
adj.setdefault(u, []).append(("edge", v, edge, "out", edir))
|
|
2010
|
+
adj.setdefault(v, []).append(("edge", u, edge, "in", edir))
|
|
2011
|
+
|
|
2012
|
+
for m in all_memberships:
|
|
2013
|
+
cp = m["canvas_path"]
|
|
2014
|
+
grp = (cp, m["group_id"])
|
|
2015
|
+
mbr = (cp, m["node_id"])
|
|
2016
|
+
adj.setdefault(grp, []).append(("membership", mbr, m, "contains", None))
|
|
2017
|
+
adj.setdefault(mbr, []).append(("membership", grp, m, "member_of", None))
|
|
2018
|
+
|
|
2019
|
+
start_keys = {(n["canvas_path"], n["node_id"]) for n in start_nodes}
|
|
2020
|
+
visited = {key: {"depth": 0, "node": node_map[key], "path": []} for key in start_keys if key in node_map}
|
|
2021
|
+
queue = [(key, 0, []) for key in visited]
|
|
2022
|
+
|
|
2023
|
+
traversed_edges = {}
|
|
2024
|
+
traversed_memberships = set()
|
|
2025
|
+
|
|
2026
|
+
while queue:
|
|
2027
|
+
curr_key, curr_d, curr_path = queue.pop(0)
|
|
2028
|
+
if curr_d >= max_depth:
|
|
2029
|
+
continue
|
|
2030
|
+
|
|
2031
|
+
for hop_type, neighbor_key, obj, rel_or_dir, edge_dir in adj.get(curr_key, []):
|
|
2032
|
+
if neighbor_key not in node_map:
|
|
2033
|
+
continue
|
|
2034
|
+
|
|
2035
|
+
if hop_type == "edge":
|
|
2036
|
+
if direction == "out":
|
|
2037
|
+
if rel_or_dir == "out" and edge_dir not in ("forward", "both", "none"):
|
|
2038
|
+
continue
|
|
2039
|
+
if rel_or_dir == "in" and edge_dir not in ("reverse", "both", "none"):
|
|
2040
|
+
continue
|
|
2041
|
+
elif direction == "in":
|
|
2042
|
+
if rel_or_dir == "out" and edge_dir not in ("reverse", "both", "none"):
|
|
2043
|
+
continue
|
|
2044
|
+
if rel_or_dir == "in" and edge_dir not in ("forward", "both", "none"):
|
|
2045
|
+
continue
|
|
2046
|
+
|
|
2047
|
+
traversed_edges[obj["edge_id"]] = obj
|
|
2048
|
+
step = {
|
|
2049
|
+
"hop": curr_d + 1,
|
|
2050
|
+
"type": "edge",
|
|
2051
|
+
"edge_id": obj["edge_id"],
|
|
2052
|
+
"label": obj.get("label", ""),
|
|
2053
|
+
"direction": obj["direction"],
|
|
2054
|
+
"from_node": obj["from_node"],
|
|
2055
|
+
"to_node": obj["to_node"],
|
|
2056
|
+
}
|
|
2057
|
+
else:
|
|
2058
|
+
traversed_memberships.add((obj["canvas_path"], obj["group_id"], obj["node_id"]))
|
|
2059
|
+
step = {
|
|
2060
|
+
"hop": curr_d + 1,
|
|
2061
|
+
"type": "membership",
|
|
2062
|
+
"relation": rel_or_dir,
|
|
2063
|
+
"group_id": obj["group_id"],
|
|
2064
|
+
"node_id": obj["node_id"],
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
if neighbor_key not in visited:
|
|
2068
|
+
new_path = curr_path + [step]
|
|
2069
|
+
visited[neighbor_key] = {
|
|
2070
|
+
"depth": curr_d + 1,
|
|
2071
|
+
"node": node_map[neighbor_key],
|
|
2072
|
+
"path": new_path,
|
|
2073
|
+
}
|
|
2074
|
+
queue.append((neighbor_key, curr_d + 1, new_path))
|
|
2075
|
+
|
|
2076
|
+
seen_affected_sections = {}
|
|
2077
|
+
affected_groups = []
|
|
2078
|
+
affected_text_cards = []
|
|
2079
|
+
all_visited_nodes = []
|
|
2080
|
+
|
|
2081
|
+
max_depth_reached = max(v["depth"] for v in visited.values()) if visited else 0
|
|
2082
|
+
|
|
2083
|
+
sorted_nodes = sorted(
|
|
2084
|
+
visited.values(),
|
|
2085
|
+
key=lambda item: (item["depth"], item["node"]["canvas_path"], item["node"]["position"], item["node"]["node_id"])
|
|
2086
|
+
)
|
|
2087
|
+
|
|
2088
|
+
for entry in sorted_nodes:
|
|
2089
|
+
d = entry["depth"]
|
|
2090
|
+
n = entry["node"]
|
|
2091
|
+
node_repr = {
|
|
2092
|
+
"depth": d,
|
|
2093
|
+
"canvas_path": n["canvas_path"],
|
|
2094
|
+
"node_id": n["node_id"],
|
|
2095
|
+
"node_type": n["node_type"],
|
|
2096
|
+
"label": n["label"],
|
|
2097
|
+
"text": n["text"],
|
|
2098
|
+
"section_id": n["section_id"],
|
|
2099
|
+
"path": entry["path"],
|
|
2100
|
+
}
|
|
2101
|
+
if n["section_id"] and n["section_id"] in sections_map:
|
|
2102
|
+
s = sections_map[n["section_id"]]
|
|
2103
|
+
target_sec_id = s.get("canonical_id") or s["section_id"]
|
|
2104
|
+
b_count = self.connection.execute(
|
|
2105
|
+
"SELECT count(*) FROM items WHERE source_id=? AND section_id=? AND item_type='bookmark'",
|
|
2106
|
+
(source_id, target_sec_id)).fetchone()[0]
|
|
2107
|
+
node_repr["section"] = {
|
|
2108
|
+
"section_id": s["section_id"],
|
|
2109
|
+
"label": s["label"],
|
|
2110
|
+
"title": s["title"],
|
|
2111
|
+
"file_path": s["file_path"],
|
|
2112
|
+
"is_anchor": bool(s["is_anchor"]),
|
|
2113
|
+
"canonical_id": s["canonical_id"],
|
|
2114
|
+
"bookmark_count": b_count,
|
|
2115
|
+
}
|
|
2116
|
+
if d > 0:
|
|
2117
|
+
sec_id = s["section_id"]
|
|
2118
|
+
if sec_id not in seen_affected_sections:
|
|
2119
|
+
seen_affected_sections[sec_id] = dict(depth=d, **node_repr["section"])
|
|
2120
|
+
elif d < seen_affected_sections[sec_id]["depth"]:
|
|
2121
|
+
seen_affected_sections[sec_id]["depth"] = d
|
|
2122
|
+
|
|
2123
|
+
if d > 0:
|
|
2124
|
+
if n["node_type"] == "group":
|
|
2125
|
+
affected_groups.append({"depth": d, "canvas_path": n["canvas_path"], "node_id": n["node_id"], "label": n["label"]})
|
|
2126
|
+
elif n["node_type"] == "text":
|
|
2127
|
+
affected_text_cards.append({"depth": d, "canvas_path": n["canvas_path"], "node_id": n["node_id"], "text": n["text"]})
|
|
2128
|
+
|
|
2129
|
+
all_visited_nodes.append(node_repr)
|
|
2130
|
+
|
|
2131
|
+
affected_sections = list(seen_affected_sections.values())
|
|
2132
|
+
|
|
2133
|
+
reached_only = [n for n in all_visited_nodes if n["depth"] > 0]
|
|
2134
|
+
total_reached = len(reached_only)
|
|
2135
|
+
paged_nodes = reached_only[offset : offset + limit]
|
|
2136
|
+
next_offset = (offset + len(paged_nodes)) if (offset + len(paged_nodes)) < total_reached else None
|
|
2137
|
+
|
|
2138
|
+
return {
|
|
2139
|
+
"source_id": source_id,
|
|
2140
|
+
"start_scope": start_scope,
|
|
2141
|
+
"start_nodes": [{"node_id": n["node_id"], "canvas_path": n["canvas_path"], "node_type": n["node_type"], "label": n["label"], "section_id": n["section_id"]} for n in start_nodes],
|
|
2142
|
+
"max_depth": max_depth,
|
|
2143
|
+
"direction": direction,
|
|
2144
|
+
"depth_reached": max_depth_reached,
|
|
2145
|
+
"counts": {
|
|
2146
|
+
"start_nodes": len(start_nodes),
|
|
2147
|
+
"total_visited": len(visited),
|
|
2148
|
+
"reached_nodes": total_reached,
|
|
2149
|
+
"affected_sections": len(affected_sections),
|
|
2150
|
+
"affected_groups": len(affected_groups),
|
|
2151
|
+
"affected_text_cards": len(affected_text_cards),
|
|
2152
|
+
"traversed_edges": len(traversed_edges),
|
|
2153
|
+
"traversed_memberships": len(traversed_memberships),
|
|
2154
|
+
},
|
|
2155
|
+
"affected_sections": affected_sections,
|
|
2156
|
+
"affected_groups": affected_groups,
|
|
2157
|
+
"affected_text_cards": affected_text_cards,
|
|
2158
|
+
"traversed_edges": sorted(traversed_edges.values(), key=lambda e: (e["canvas_path"], e["edge_id"])),
|
|
2159
|
+
"traversed_memberships": [{"canvas_path": cp, "group_id": gid, "node_id": nid} for cp, gid, nid in sorted(traversed_memberships)],
|
|
2160
|
+
"nodes": paged_nodes,
|
|
2161
|
+
"pagination": {
|
|
2162
|
+
"limit": limit,
|
|
2163
|
+
"offset": offset,
|
|
2164
|
+
"total": total_reached,
|
|
2165
|
+
"next_offset": next_offset,
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
|
|
1408
2169
|
def inventory(self, source_ids):
|
|
1409
2170
|
"""Freeze the complete indexed input, independently of query previews.
|
|
1410
2171
|
|
|
@@ -1415,7 +2176,7 @@ class BookmarkIndex:
|
|
|
1415
2176
|
"""
|
|
1416
2177
|
if not isinstance(source_ids, list) or not source_ids:
|
|
1417
2178
|
raise ValueError("source_ids must contain indexed package IDs")
|
|
1418
|
-
identifiers = sorted(set(_string(value, "source_id", True) for value in source_ids))
|
|
2179
|
+
identifiers = sorted(set(self.resolve_source_id(_string(value, "source_id", True)) for value in source_ids))
|
|
1419
2180
|
from archive import SourceArchive
|
|
1420
2181
|
result = {"schema_version": 1, "index_state": "last_synchronized",
|
|
1421
2182
|
"source_ids": identifiers, "sources": [], "files": [],
|
|
@@ -1498,6 +2259,7 @@ class BookmarkIndex:
|
|
|
1498
2259
|
self.connection.execute("SAVEPOINT input_version_snapshot")
|
|
1499
2260
|
try:
|
|
1500
2261
|
for source_id in sorted(set(source_ids)):
|
|
2262
|
+
source_id = self.resolve_source_id(source_id)
|
|
1501
2263
|
self._source(source_id)
|
|
1502
2264
|
records.extend(dict(row) for row in self.connection.execute(
|
|
1503
2265
|
"SELECT source_id,file_path,kind,sha256 FROM files WHERE source_id=? ORDER BY file_path", (source_id,)))
|
|
@@ -1510,6 +2272,8 @@ class BookmarkIndex:
|
|
|
1510
2272
|
ids = [row[0] for row in self.connection.execute("SELECT source_id FROM sources ORDER BY source_id")]
|
|
1511
2273
|
return {"database": str(self.db_path) if self.db_path else ":memory:", "schema_version": 1,
|
|
1512
2274
|
"sources": [self.status(identifier) for identifier in ids]}
|
|
2275
|
+
requested_source_id = source_id
|
|
2276
|
+
source_id = self.resolve_source_id(source_id)
|
|
1513
2277
|
result = self._source(source_id)
|
|
1514
2278
|
counts = dict(self.connection.execute("""SELECT count(*) AS items,
|
|
1515
2279
|
coalesce(sum(item_type='bookmark'),0) AS bookmarks,
|
|
@@ -1524,6 +2288,9 @@ class BookmarkIndex:
|
|
|
1524
2288
|
counts[table] = self.connection.execute("SELECT count(*) FROM " + table + " WHERE source_id=?", (source_id,)).fetchone()[0]
|
|
1525
2289
|
result["counts"] = counts
|
|
1526
2290
|
result.update(bookmarks=counts["bookmarks"], unique_urls=counts["unique_urls"])
|
|
2291
|
+
if requested_source_id != source_id:
|
|
2292
|
+
result["resolved_via_redirect"] = True
|
|
2293
|
+
result["requested_source_id"] = requested_source_id
|
|
1527
2294
|
package = Path(result["package_path"])
|
|
1528
2295
|
result["package_exists"] = package.is_dir()
|
|
1529
2296
|
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()]
|