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]