File size: 10,551 Bytes
3ac10cb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 | import sqlite3
import struct
import json
from pathlib import Path
from contextlib import contextmanager
from typing import Optional
DB_PATH = Path(__file__).parent.parent / "data" / "bookmarks.db"
SCHEMA = """
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
description TEXT DEFAULT '',
context TEXT DEFAULT '',
folder_path TEXT DEFAULT '',
domain TEXT DEFAULT '',
favicon_url TEXT DEFAULT '',
auto_tags TEXT DEFAULT '[]',
user_tags TEXT DEFAULT '[]',
content_snippet TEXT DEFAULT '',
embedding_text TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_visited_at TIMESTAMP,
visit_count INTEGER DEFAULT 0,
is_dead BOOLEAN DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_bookmarks_domain ON bookmarks(domain);
CREATE INDEX IF NOT EXISTS idx_bookmarks_created ON bookmarks(created_at);
"""
FTS_SCHEMA = """
CREATE VIRTUAL TABLE IF NOT EXISTS bookmarks_fts USING fts5(
title, description, context, auto_tags, user_tags, content_snippet,
content='bookmarks', content_rowid='id'
);
"""
VEC_SCHEMA = """
CREATE VIRTUAL TABLE IF NOT EXISTS vec_bookmarks USING vec0(
id INTEGER PRIMARY KEY,
embedding FLOAT[384]
);
"""
FTS_TRIGGERS = """
CREATE TRIGGER IF NOT EXISTS bookmarks_ai AFTER INSERT ON bookmarks BEGIN
INSERT INTO bookmarks_fts(rowid, title, description, context, auto_tags, user_tags, content_snippet)
VALUES (new.id, new.title, new.description, new.context, new.auto_tags, new.user_tags, new.content_snippet);
END;
CREATE TRIGGER IF NOT EXISTS bookmarks_ad AFTER DELETE ON bookmarks BEGIN
INSERT INTO bookmarks_fts(bookmarks_fts, rowid, title, description, context, auto_tags, user_tags, content_snippet)
VALUES ('delete', old.id, old.title, old.description, old.context, old.auto_tags, old.user_tags, old.content_snippet);
END;
CREATE TRIGGER IF NOT EXISTS bookmarks_au AFTER UPDATE ON bookmarks BEGIN
INSERT INTO bookmarks_fts(bookmarks_fts, rowid, title, description, context, auto_tags, user_tags, content_snippet)
VALUES ('delete', old.id, old.title, old.description, old.context, old.auto_tags, old.user_tags, old.content_snippet);
INSERT INTO bookmarks_fts(rowid, title, description, context, auto_tags, user_tags, content_snippet)
VALUES (new.id, new.title, new.description, new.context, new.auto_tags, new.user_tags, new.content_snippet);
END;
"""
def _row_to_dict(row: sqlite3.Row, columns: Optional[list[str]] = None) -> dict:
"""Convert a sqlite3.Row to a plain dict."""
return dict(row)
@contextmanager
def get_db(db_path: Optional[str] = None):
"""Context manager yielding a sqlite3 Connection with sqlite_vec loaded and schema ensured."""
path = Path(db_path) if db_path else DB_PATH
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(path))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.enable_load_extension(True)
try:
import sqlite_vec
sqlite_vec.load(conn)
except ImportError:
raise RuntimeError("sqlite_vec is required for vector operations. Install it with 'pip install sqlite-vec'")
conn.enable_load_extension(False)
_ensure_schema(conn)
try:
yield conn
finally:
conn.close()
def _ensure_schema(conn: sqlite3.Connection) -> None:
"""Create tables and triggers if they do not exist."""
for statement in SCHEMA.strip().split(";"):
stmt = statement.strip()
if stmt:
conn.execute(stmt)
for statement in FTS_SCHEMA.strip().split(";"):
stmt = statement.strip()
if stmt:
conn.execute(stmt)
for statement in VEC_SCHEMA.strip().split(";"):
stmt = statement.strip()
if stmt:
conn.execute(stmt)
for statement in FTS_TRIGGERS.strip().split(";"):
stmt = statement.strip()
if stmt:
conn.execute(stmt)
conn.commit()
def init_db(db_path: Optional[str] = None) -> None:
"""Explicitly initialise the database (creates all tables/triggers)."""
with get_db(db_path) as conn:
pass # schema is ensured inside get_db
def add_bookmark(
conn: sqlite3.Connection,
url: str,
title: str,
description: str,
context: str,
folder_path: str,
domain: str,
auto_tags: list[str],
user_tags: list[str],
content_snippet: str,
embedding_text: str,
embedding_blob: bytes,
) -> int:
"""Insert a bookmark and its vector. Returns the new row id."""
cursor = conn.execute(
"""
INSERT INTO bookmarks
(url, title, description, context, folder_path, domain,
auto_tags, user_tags, content_snippet, embedding_text)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
url,
title,
description,
context,
folder_path,
domain,
json.dumps(auto_tags, ensure_ascii=False),
json.dumps(user_tags, ensure_ascii=False),
content_snippet,
embedding_text,
),
)
new_id = cursor.lastrowid
conn.execute(
"INSERT INTO vec_bookmarks(id, embedding) VALUES (?, ?)",
(new_id, embedding_blob),
)
return new_id
def get_bookmark(conn: sqlite3.Connection, id: int) -> Optional[dict]:
"""Fetch a single bookmark by id, or None if not found."""
row = conn.execute("SELECT * FROM bookmarks WHERE id = ?", (id,)).fetchone()
if row is None:
return None
bm = dict(row)
bm["auto_tags"] = json.loads(bm.get("auto_tags") or "[]")
bm["user_tags"] = json.loads(bm.get("user_tags") or "[]")
return bm
def update_bookmark(conn: sqlite3.Connection, id: int, **kwargs) -> None:
"""Update specified fields on a bookmark. updated_at is set automatically."""
if not kwargs:
return
# Define allowed fields for update
ALLOWED_FIELDS = {
"url", "title", "description", "context", "folder_path", "domain",
"auto_tags", "user_tags", "content_snippet", "embedding_text",
"created_at", "updated_at", "last_visited_at", "visit_count", "is_dead"
}
# Validate that all provided fields are allowed
for key in kwargs:
if key not in ALLOWED_FIELDS:
raise ValueError(f"Unknown field '{key}' for bookmark update. Allowed fields: {ALLOWED_FIELDS}")
# Serialise tag lists if provided
for key in ("auto_tags", "user_tags"):
if key in kwargs and isinstance(kwargs[key], list):
kwargs[key] = json.dumps(kwargs[key], ensure_ascii=False)
kwargs["updated_at"] = "CURRENT_TIMESTAMP"
set_clauses = []
values = []
for key, value in kwargs.items():
if key == "updated_at":
set_clauses.append("updated_at = CURRENT_TIMESTAMP")
else:
set_clauses.append(f"{key} = ?")
values.append(value)
values.append(id)
conn.execute(
f"UPDATE bookmarks SET {', '.join(set_clauses)} WHERE id = ?",
values,
)
def delete_bookmark(conn: sqlite3.Connection, id: int) -> None:
"""Delete a bookmark and its vector entry."""
conn.execute("DELETE FROM bookmarks WHERE id = ?", (id,))
conn.execute("DELETE FROM vec_bookmarks WHERE id = ?", (id,))
def list_bookmarks(
conn: sqlite3.Connection, page: int = 1, per_page: int = 20
) -> tuple[list[dict], int]:
"""Return a page of bookmarks and the total count."""
total = conn.execute("SELECT COUNT(*) FROM bookmarks").fetchone()[0]
offset = (page - 1) * per_page
rows = conn.execute(
"SELECT * FROM bookmarks ORDER BY created_at DESC LIMIT ? OFFSET ?",
(per_page, offset),
).fetchall()
bookmarks = []
for row in rows:
bm = dict(row)
bm["auto_tags"] = json.loads(bm.get("auto_tags") or "[]")
bm["user_tags"] = json.loads(bm.get("user_tags") or "[]")
bookmarks.append(bm)
return bookmarks, total
def get_all_tags(conn: sqlite3.Connection) -> list[tuple[str, int]]:
"""Return all unique tags with their occurrence counts, sorted by count desc."""
rows = conn.execute("SELECT auto_tags, user_tags FROM bookmarks").fetchall()
counts: dict[str, int] = {}
for row in rows:
for col in ("auto_tags", "user_tags"):
tags = json.loads(row[col] or "[]")
for tag in tags:
if tag:
counts[tag] = counts.get(tag, 0) + 1
return sorted(counts.items(), key=lambda x: -x[1])
def get_stats(conn: sqlite3.Connection) -> dict:
"""Return aggregate statistics."""
total = conn.execute("SELECT COUNT(*) FROM bookmarks").fetchone()[0]
dead_links = conn.execute(
"SELECT COUNT(*) FROM bookmarks WHERE is_dead = TRUE"
).fetchone()[0]
top_domains_rows = conn.execute(
"""
SELECT domain, COUNT(*) as cnt
FROM bookmarks
WHERE domain != ''
GROUP BY domain
ORDER BY cnt DESC
LIMIT 10
"""
).fetchall()
top_domains = [{"domain": r["domain"], "count": r["cnt"]} for r in top_domains_rows]
return {
"total_bookmarks": total,
"unique_domains": len(top_domains),
"dead_links": dead_links,
"top_domains": top_domains,
}
def search_fts(
conn: sqlite3.Connection, query: str, limit: int = 20
) -> list[tuple[int, float]]:
"""FTS5 full-text search. Returns list of (rowid, rank)."""
try:
rows = conn.execute(
"""
SELECT rowid, rank
FROM bookmarks_fts
WHERE bookmarks_fts MATCH ?
ORDER BY rank
LIMIT ?
""",
(query, limit),
).fetchall()
return [(row[0], row[1]) for row in rows]
except sqlite3.OperationalError:
# Invalid FTS query (e.g. bare special chars)
return []
def search_vec(
conn: sqlite3.Connection, query_blob: bytes, limit: int = 20
) -> list[tuple[int, float]]:
"""sqlite-vec KNN search. Returns list of (id, distance)."""
rows = conn.execute(
"""
SELECT id, distance
FROM vec_bookmarks
WHERE embedding MATCH ?
ORDER BY distance
LIMIT ?
""",
(query_blob, limit),
).fetchall()
return [(row[0], row[1]) for row in rows]
|