marky

Lightweight bookmarking + plaintext archive
Log | Files | Refs | README | LICENSE

database.go (31262B)


      1 package internal
      2 
      3 import (
      4 	"context"
      5 	"database/sql"
      6 	"embed"
      7 	"errors"
      8 	"fmt"
      9 	"io/fs"
     10 	"os"
     11 	"path/filepath"
     12 	"sort"
     13 	"strings"
     14 	"time"
     15 	"unicode/utf8"
     16 
     17 	_ "modernc.org/sqlite"
     18 )
     19 
     20 //go:embed migrations/*.sql
     21 var migrationsFS embed.FS
     22 
     23 // Bookmark represents a single bookmark row.
     24 type Bookmark struct {
     25 	ID          int64
     26 	URL         string
     27 	Title       string
     28 	Description sql.NullString
     29 	Referrer    sql.NullString
     30 	Public      bool
     31 	UserID      int64
     32 	CreatedAt   time.Time
     33 	UpdatedAt   time.Time
     34 }
     35 
     36 // SearchResult is a bookmark match with an optional excerpt snippet.
     37 type SearchResult struct {
     38 	Bookmark Bookmark
     39 	Excerpt  sql.NullString
     40 }
     41 
     42 // DB wraps *sql.DB with helpers.
     43 type DB struct {
     44 	sql *sql.DB
     45 	dsn string
     46 }
     47 
     48 // execer abstracts *sql.DB and *sql.Tx for executing SQL with context.
     49 type execer interface {
     50 	ExecContext(context.Context, string, ...any) (sql.Result, error)
     51 }
     52 
     53 // DefaultPath returns the default SQLite file path: ~/.marky/bookmarks.sql
     54 func DefaultPath() (string, error) {
     55 	home, err := os.UserHomeDir()
     56 	if err != nil {
     57 		return "", fmt.Errorf("resolve home dir: %w", err)
     58 	}
     59 	dir := filepath.Join(home, ".marky")
     60 	if err := os.MkdirAll(dir, 0o755); err != nil {
     61 		return "", fmt.Errorf("mkdir %s: %w", dir, err)
     62 	}
     63 	return filepath.Join(dir, "bookmarks.sql"), nil
     64 }
     65 
     66 // Open opens (and initializes) the database at the default path.
     67 func Open(ctx context.Context) (*DB, error) {
     68 	p, err := DefaultPath()
     69 	if err != nil {
     70 		return nil, err
     71 	}
     72 	return OpenAtPath(ctx, p)
     73 }
     74 
     75 // OpenAtPath opens (and initializes) the database at a specific path.
     76 func OpenAtPath(ctx context.Context, path string) (*DB, error) {
     77 	// Use a file: DSN and apply sensible pragmas after open.
     78 	dsn := fmt.Sprintf("file:%s", filepath.ToSlash(path))
     79 
     80 	sqldb, err := sql.Open("sqlite", dsn)
     81 	if err != nil {
     82 		return nil, fmt.Errorf("open sqlite: %w", err)
     83 	}
     84 
     85 	// Connection settings.
     86 	// WAL improves concurrency; busy_timeout makes writes friendlier.
     87 	pragmas := []string{
     88 		"PRAGMA journal_mode=WAL",
     89 		"PRAGMA busy_timeout=5000",
     90 		"PRAGMA foreign_keys=ON",
     91 	}
     92 	for _, p := range pragmas {
     93 		if _, execErr := sqldb.ExecContext(ctx, p); execErr != nil {
     94 			_ = sqldb.Close()
     95 			return nil, fmt.Errorf("apply pragma %q: %w", p, execErr)
     96 		}
     97 	}
     98 
     99 	// Migrate schema.
    100 	if err := applyMigrations(ctx, sqldb); err != nil {
    101 		_ = sqldb.Close()
    102 		return nil, err
    103 	}
    104 
    105 	return &DB{sql: sqldb, dsn: dsn}, nil
    106 }
    107 
    108 // Close closes the database.
    109 func (db *DB) Close() error { return db.sql.Close() }
    110 
    111 // applyMigrations executes embedded SQL migration files in order.
    112 func applyMigrations(ctx context.Context, sqldb *sql.DB) error {
    113 	if _, err := sqldb.ExecContext(ctx, `
    114         CREATE TABLE IF NOT EXISTS schema_migrations (
    115             version TEXT PRIMARY KEY,
    116             applied_at TEXT NOT NULL
    117         );
    118     `); err != nil {
    119 		return fmt.Errorf("create schema_migrations: %w", err)
    120 	}
    121 
    122 	entries, err := fs.ReadDir(migrationsFS, "migrations")
    123 	if err != nil {
    124 		return fmt.Errorf("read migrations: %w", err)
    125 	}
    126 
    127 	// Sort by filename ascending.
    128 	sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
    129 
    130 	for _, e := range entries {
    131 		name := e.Name()
    132 		if e.IsDir() || !strings.HasSuffix(name, ".sql") {
    133 			continue
    134 		}
    135 
    136 		// Skip if applied.
    137 		var exists int
    138 		if err := sqldb.QueryRowContext(ctx, `SELECT 1 FROM schema_migrations WHERE version = ?`, name).Scan(&exists); err != nil {
    139 			if !errors.Is(err, sql.ErrNoRows) {
    140 				return fmt.Errorf("check migration %s: %w", name, err)
    141 			}
    142 		} else {
    143 			// already applied
    144 			continue
    145 		}
    146 
    147 		// Read SQL.
    148 		b, err := migrationsFS.ReadFile(filepath.Join("migrations", name))
    149 		if err != nil {
    150 			return fmt.Errorf("read migration %s: %w", name, err)
    151 		}
    152 
    153 		tx, err := sqldb.BeginTx(ctx, nil)
    154 		if err != nil {
    155 			return fmt.Errorf("begin tx for %s: %w", name, err)
    156 		}
    157 		if _, err := tx.ExecContext(ctx, string(b)); err != nil {
    158 			_ = tx.Rollback()
    159 			return fmt.Errorf("exec migration %s: %w", name, err)
    160 		}
    161 		if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`, name, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
    162 			_ = tx.Rollback()
    163 			return fmt.Errorf("record migration %s: %w", name, err)
    164 		}
    165 		if err := tx.Commit(); err != nil {
    166 			return fmt.Errorf("commit migration %s: %w", name, err)
    167 		}
    168 	}
    169 	return nil
    170 }
    171 
    172 // CreateBookmark inserts a new bookmark and returns it.
    173 func (db *DB) CreateBookmark(ctx context.Context, userID int64, url, title string, description *string) (*Bookmark, error) {
    174 	if strings.TrimSpace(url) == "" || strings.TrimSpace(title) == "" {
    175 		return nil, fmt.Errorf("url and title are required")
    176 	}
    177 	now := time.Now().UTC()
    178 	var desc sql.NullString
    179 	if description != nil {
    180 		desc = sql.NullString{String: *description, Valid: true}
    181 	}
    182 	res, err := db.sql.ExecContext(ctx, `
    183         INSERT INTO bookmarks(url, title, description, public, user_id, created_at, updated_at)
    184         VALUES(?, ?, ?, 0, ?, ?, ?)
    185     `, url, title, nullString(desc), userID, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
    186 	if err != nil {
    187 		return nil, fmt.Errorf("insert bookmark: %w", err)
    188 	}
    189 	id, err := res.LastInsertId()
    190 	if err != nil {
    191 		return nil, fmt.Errorf("last insert id: %w", err)
    192 	}
    193 	// Replace FTS row (empty content until archive exists)
    194 	ftsContent := ""
    195 	if c, _, ok, _ := db.GetArchive(ctx, id); ok {
    196 		ftsContent = c
    197 	}
    198 	if err := ftsReplace(ctx, db.sql, id, title, url, nullString(desc), ftsContent); err != nil {
    199 		return nil, fmt.Errorf("fts replace after create: %w", err)
    200 	}
    201 	return &Bookmark{
    202 		ID:          id,
    203 		URL:         url,
    204 		Title:       title,
    205 		Description: desc,
    206 		Public:      false,
    207 		UserID:      userID,
    208 		CreatedAt:   now,
    209 		UpdatedAt:   now,
    210 	}, nil
    211 }
    212 
    213 // CreateBookmarkNoArchive inserts a new bookmark with the desired public flag
    214 // and initializes the FTS row with empty content (no archive entry).
    215 func (db *DB) CreateBookmarkNoArchive(ctx context.Context, userID int64, url, title string, description *string, public bool) (*Bookmark, error) {
    216 	if strings.TrimSpace(url) == "" || strings.TrimSpace(title) == "" {
    217 		return nil, fmt.Errorf("url and title are required")
    218 	}
    219 	now := time.Now().UTC()
    220 	var desc sql.NullString
    221 	if description != nil {
    222 		desc = sql.NullString{String: *description, Valid: true}
    223 	}
    224 	res, err := db.sql.ExecContext(ctx, `
    225         INSERT INTO bookmarks(url, title, description, public, user_id, created_at, updated_at)
    226         VALUES(?, ?, ?, ?, ?, ?, ?)
    227     `, url, title, nullString(desc), publicToInt(public), userID, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
    228 	if err != nil {
    229 		return nil, fmt.Errorf("insert bookmark: %w", err)
    230 	}
    231 	id, err := res.LastInsertId()
    232 	if err != nil {
    233 		return nil, fmt.Errorf("last insert id: %w", err)
    234 	}
    235 	// Initialize FTS row with empty content (no archive).
    236 	if err := ftsReplace(ctx, db.sql, id, title, url, nullString(desc), ""); err != nil {
    237 		return nil, fmt.Errorf("fts replace after create(no-archive): %w", err)
    238 	}
    239 	return &Bookmark{
    240 		ID:          id,
    241 		URL:         url,
    242 		Title:       title,
    243 		Description: desc,
    244 		Public:      public,
    245 		UserID:      userID,
    246 		CreatedAt:   now,
    247 		UpdatedAt:   now,
    248 	}, nil
    249 }
    250 
    251 // GetBookmark returns a bookmark by ID.
    252 func (db *DB) GetBookmark(ctx context.Context, id int64) (*Bookmark, error) {
    253 	var b Bookmark
    254 	var created, updated string
    255 	if err := db.sql.QueryRowContext(ctx, `
    256         SELECT id, url, title, description, referrer, public, user_id, created_at, updated_at
    257         FROM bookmarks WHERE id = ?
    258     `, id).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Referrer, &b.Public, &b.UserID, &created, &updated); err != nil {
    259 		if errors.Is(err, sql.ErrNoRows) {
    260 			return nil, nil
    261 		}
    262 		return nil, fmt.Errorf("select bookmark: %w", err)
    263 	}
    264 	var err error
    265 	if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
    266 		return nil, fmt.Errorf("parse created_at: %w", err)
    267 	}
    268 	if b.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated); err != nil {
    269 		return nil, fmt.Errorf("parse updated_at: %w", err)
    270 	}
    271 	return &b, nil
    272 }
    273 
    274 // GetBookmarkByURL returns a bookmark by exact URL.
    275 func (db *DB) GetBookmarkByURL(ctx context.Context, userID int64, u string) (*Bookmark, error) {
    276 	var b Bookmark
    277 	var created, updated string
    278 	if err := db.sql.QueryRowContext(ctx, `
    279         SELECT id, url, title, description, referrer, public, user_id, created_at, updated_at
    280         FROM bookmarks WHERE url = ? AND user_id = ?
    281     `, u, userID).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Referrer, &b.Public, &b.UserID, &created, &updated); err != nil {
    282 		if errors.Is(err, sql.ErrNoRows) {
    283 			return nil, nil
    284 		}
    285 		return nil, fmt.Errorf("select bookmark by url: %w", err)
    286 	}
    287 	var err error
    288 	if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
    289 		return nil, fmt.Errorf("parse created_at: %w", err)
    290 	}
    291 	if b.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated); err != nil {
    292 		return nil, fmt.Errorf("parse updated_at: %w", err)
    293 	}
    294 	return &b, nil
    295 }
    296 
    297 // ListBookmarks returns bookmarks ordered by updated_at desc.
    298 func (db *DB) ListBookmarks(ctx context.Context, userID int64, limit, offset int) ([]Bookmark, error) {
    299 	if limit <= 0 {
    300 		limit = 50
    301 	}
    302 	rows, err := db.sql.QueryContext(ctx, `
    303         SELECT id, url, title, description, referrer, public, user_id, created_at, updated_at
    304         FROM bookmarks
    305         WHERE user_id = ?
    306         ORDER BY updated_at DESC
    307         LIMIT ? OFFSET ?
    308     `, userID, limit, offset)
    309 	if err != nil {
    310 		return nil, fmt.Errorf("query bookmarks: %w", err)
    311 	}
    312 	defer rows.Close()
    313 	out := make([]Bookmark, 0, limit)
    314 	for rows.Next() {
    315 		var b Bookmark
    316 		var created, updated string
    317 		if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Referrer, &b.Public, &b.UserID, &created, &updated); err != nil {
    318 			return nil, fmt.Errorf("scan bookmark: %w", err)
    319 		}
    320 		if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
    321 			return nil, fmt.Errorf("parse created_at: %w", err)
    322 		}
    323 		if b.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated); err != nil {
    324 			return nil, fmt.Errorf("parse updated_at: %w", err)
    325 		}
    326 		out = append(out, b)
    327 	}
    328 	if err := rows.Err(); err != nil {
    329 		return nil, fmt.Errorf("iterate bookmarks: %w", err)
    330 	}
    331 	return out, nil
    332 }
    333 
    334 // CountBookmarks returns the total number of bookmarks for a user.
    335 func (db *DB) CountBookmarks(ctx context.Context, userID int64) (int, error) {
    336 	var n int
    337 	if err := db.sql.QueryRowContext(ctx, `SELECT COUNT(1) FROM bookmarks WHERE user_id = ?`, userID).Scan(&n); err != nil {
    338 		return 0, fmt.Errorf("count bookmarks: %w", err)
    339 	}
    340 	return n, nil
    341 }
    342 
    343 // UpdateBookmark updates fields for a given bookmark.
    344 func (db *DB) UpdateBookmark(ctx context.Context, id int64, url, title string, description *string, public bool) (*Bookmark, error) {
    345 	if strings.TrimSpace(url) == "" || strings.TrimSpace(title) == "" {
    346 		return nil, fmt.Errorf("url and title are required")
    347 	}
    348 	now := time.Now().UTC()
    349 	var desc sql.NullString
    350 	if description != nil {
    351 		desc = sql.NullString{String: *description, Valid: true}
    352 	}
    353 	res, err := db.sql.ExecContext(ctx, `
    354         UPDATE bookmarks
    355         SET url = ?, title = ?, description = ?, public = ?, updated_at = ?
    356         WHERE id = ?
    357     `, url, title, nullString(desc), publicToInt(public), now.Format(time.RFC3339Nano), id)
    358 	if err != nil {
    359 		return nil, fmt.Errorf("update bookmark: %w", err)
    360 	}
    361 	n, err := res.RowsAffected()
    362 	if err != nil {
    363 		return nil, fmt.Errorf("rows affected: %w", err)
    364 	}
    365 	if n == 0 {
    366 		return nil, nil
    367 	}
    368 	// Update FTS metadata while preserving content
    369 	var descForFTS sql.NullString
    370 	if description != nil {
    371 		descForFTS = sql.NullString{String: *description, Valid: true}
    372 	}
    373 	ftsContent := ""
    374 	if c, _, ok, _ := db.GetArchive(ctx, id); ok {
    375 		ftsContent = c
    376 	}
    377 	if err := ftsReplace(ctx, db.sql, id, title, url, nullString(descForFTS), ftsContent); err != nil {
    378 		return nil, fmt.Errorf("fts replace after update: %w", err)
    379 	}
    380 	return db.GetBookmark(ctx, id)
    381 }
    382 
    383 // UpdateBookmarkWithReferrer updates fields including referrer for a given bookmark.
    384 func (db *DB) UpdateBookmarkWithReferrer(ctx context.Context, id int64, url, title string, description *string, public bool, referrer string) (*Bookmark, error) {
    385 	if strings.TrimSpace(url) == "" || strings.TrimSpace(title) == "" {
    386 		return nil, fmt.Errorf("url and title are required")
    387 	}
    388 	now := time.Now().UTC()
    389 	var desc sql.NullString
    390 	if description != nil {
    391 		desc = sql.NullString{String: *description, Valid: true}
    392 	}
    393 	_, err := db.sql.ExecContext(ctx, `
    394         UPDATE bookmarks
    395         SET url = ?, title = ?, description = ?, public = ?, referrer = ?, updated_at = ?
    396         WHERE id = ?
    397     `, url, title, nullString(desc), publicToInt(public), referrer, now.Format(time.RFC3339Nano), id)
    398 	if err != nil {
    399 		return nil, fmt.Errorf("update bookmark with referrer: %w", err)
    400 	}
    401 	// Update FTS metadata while preserving content
    402 	var descForFTS sql.NullString
    403 	if description != nil {
    404 		descForFTS = sql.NullString{String: *description, Valid: true}
    405 	}
    406 	ftsContent := ""
    407 	if c, _, ok, _ := db.GetArchive(ctx, id); ok {
    408 		ftsContent = c
    409 	}
    410 	if err := ftsReplace(ctx, db.sql, id, title, url, nullString(descForFTS), ftsContent); err != nil {
    411 		return nil, fmt.Errorf("fts replace after update: %w", err)
    412 	}
    413 	return db.GetBookmark(ctx, id)
    414 }
    415 
    416 // TouchBookmark updates only the updated_at timestamp.
    417 func (db *DB) TouchBookmark(ctx context.Context, id int64) error {
    418 	_, err := db.sql.ExecContext(ctx, `UPDATE bookmarks SET updated_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339Nano), id)
    419 	if err != nil {
    420 		return fmt.Errorf("touch bookmark: %w", err)
    421 	}
    422 	return nil
    423 }
    424 
    425 // DeleteBookmark deletes a bookmark by ID. Returns true if a row was deleted.
    426 func (db *DB) DeleteBookmark(ctx context.Context, id int64) (bool, error) {
    427 	res, err := db.sql.ExecContext(ctx, `DELETE FROM bookmarks WHERE id = ?`, id)
    428 	if err != nil {
    429 		return false, fmt.Errorf("delete bookmark: %w", err)
    430 	}
    431 	n, err := res.RowsAffected()
    432 	if err != nil {
    433 		return false, fmt.Errorf("rows affected: %w", err)
    434 	}
    435 	if n > 0 {
    436 		if err := ftsDelete(ctx, db.sql, id); err != nil {
    437 			return false, fmt.Errorf("delete fts row: %w", err)
    438 		}
    439 	}
    440 	return n > 0, nil
    441 }
    442 
    443 // HasArchive reports whether an archive row exists for the given bookmark ID.
    444 func (db *DB) HasArchive(ctx context.Context, id int64) (bool, error) {
    445 	var one int
    446 	err := db.sql.QueryRowContext(ctx, `SELECT 1 FROM archives WHERE bookmark_id = ? LIMIT 1`, id).Scan(&one)
    447 	if err != nil {
    448 		if errors.Is(err, sql.ErrNoRows) {
    449 			return false, nil
    450 		}
    451 		return false, fmt.Errorf("has archive: %w", err)
    452 	}
    453 	return true, nil
    454 }
    455 
    456 // DeleteArchive removes the archive row for a bookmark and clears its FTS
    457 // content by reindexing the metadata with empty content.
    458 func (db *DB) DeleteArchive(ctx context.Context, id int64) error {
    459 	if _, err := db.sql.ExecContext(ctx, `DELETE FROM archives WHERE bookmark_id = ?`, id); err != nil {
    460 		return fmt.Errorf("delete archive: %w", err)
    461 	}
    462 	// Refresh FTS row with empty content
    463 	b, err := db.GetBookmark(ctx, id)
    464 	if err != nil {
    465 		return fmt.Errorf("get bookmark for fts after archive delete: %w", err)
    466 	}
    467 	if b == nil {
    468 		return nil
    469 	}
    470 	if err := ftsReplace(ctx, db.sql, id, b.Title, b.URL, nullString(b.Description), ""); err != nil {
    471 		return fmt.Errorf("fts replace after archive delete: %w", err)
    472 	}
    473 	return nil
    474 }
    475 
    476 func nullString(ns sql.NullString) any {
    477 	if ns.Valid {
    478 		return ns.String
    479 	}
    480 	return nil
    481 }
    482 
    483 func publicToInt(v bool) int {
    484 	if v {
    485 		return 1
    486 	}
    487 	return 0
    488 }
    489 
    490 // CreateBookmarkWithArchive creates a bookmark and saves its plaintext archive
    491 // within a single transaction to ensure consistency.
    492 func (db *DB) CreateBookmarkWithArchive(ctx context.Context, userID int64, url, title string, description *string, public bool, archiveContent string) (*Bookmark, error) {
    493 	if strings.TrimSpace(url) == "" || strings.TrimSpace(title) == "" {
    494 		return nil, fmt.Errorf("url and title are required")
    495 	}
    496 	now := time.Now().UTC()
    497 	var desc sql.NullString
    498 	if description != nil {
    499 		desc = sql.NullString{String: *description, Valid: true}
    500 	}
    501 
    502 	tx, err := db.sql.BeginTx(ctx, nil)
    503 	if err != nil {
    504 		return nil, fmt.Errorf("begin tx: %w", err)
    505 	}
    506 	res, err := tx.ExecContext(ctx, `
    507         INSERT INTO bookmarks(url, title, description, public, user_id, created_at, updated_at)
    508         VALUES(?, ?, ?, ?, ?, ?, ?)
    509     `, url, title, nullString(desc), publicToInt(public), userID, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
    510 	if err != nil {
    511 		_ = tx.Rollback()
    512 		return nil, fmt.Errorf("insert bookmark: %w", err)
    513 	}
    514 	id, err := res.LastInsertId()
    515 	if err != nil {
    516 		_ = tx.Rollback()
    517 		return nil, fmt.Errorf("last insert id: %w", err)
    518 	}
    519 	if _, err := tx.ExecContext(ctx, `
    520         INSERT INTO archives(bookmark_id, content, archived_at)
    521         VALUES(?, ?, ?)
    522     `, id, archiveContent, now.Format(time.RFC3339Nano)); err != nil {
    523 		_ = tx.Rollback()
    524 		return nil, fmt.Errorf("insert archive: %w", err)
    525 	}
    526 	// Replace FTS row within the same transaction
    527 	if err := ftsReplace(ctx, tx, id, title, url, nullString(desc), archiveContent); err != nil {
    528 		_ = tx.Rollback()
    529 		return nil, fmt.Errorf("insert fts row: %w", err)
    530 	}
    531 	if err := tx.Commit(); err != nil {
    532 		return nil, fmt.Errorf("commit: %w", err)
    533 	}
    534 	return &Bookmark{
    535 		ID:          id,
    536 		URL:         url,
    537 		Title:       title,
    538 		Description: desc,
    539 		Public:      public,
    540 		UserID:      userID,
    541 		CreatedAt:   now,
    542 		UpdatedAt:   now,
    543 	}, nil
    544 }
    545 
    546 // SaveArchive upserts the plaintext archive content for a bookmark.
    547 func (db *DB) SaveArchive(ctx context.Context, bookmarkID int64, content string) error {
    548 	now := time.Now().UTC().Format(time.RFC3339Nano)
    549 	_, err := db.sql.ExecContext(ctx, `
    550         INSERT INTO archives(bookmark_id, content, archived_at)
    551         VALUES(?, ?, ?)
    552         ON CONFLICT(bookmark_id) DO UPDATE SET
    553             content=excluded.content,
    554             archived_at=excluded.archived_at
    555     `, bookmarkID, content, now)
    556 	if err != nil {
    557 		return fmt.Errorf("save archive: %w", err)
    558 	}
    559 	// Replace into FTS with latest content and metadata
    560 	b, err := db.GetBookmark(ctx, bookmarkID)
    561 	if err != nil {
    562 		return fmt.Errorf("fetch bookmark for fts: %w", err)
    563 	}
    564 	if b != nil {
    565 		if err := ftsReplace(ctx, db.sql, bookmarkID, b.Title, b.URL, nullString(b.Description), content); err != nil {
    566 			return fmt.Errorf("fts replace after archive: %w", err)
    567 		}
    568 	}
    569 	return nil
    570 }
    571 
    572 // GetArchive returns archived plaintext and timestamp for a bookmark.
    573 // ok is false if no archive exists.
    574 func (db *DB) GetArchive(ctx context.Context, bookmarkID int64) (content string, archivedAt time.Time, ok bool, err error) {
    575 	var ts string
    576 	err = db.sql.QueryRowContext(ctx, `
    577         SELECT content, archived_at FROM archives WHERE bookmark_id = ?
    578     `, bookmarkID).Scan(&content, &ts)
    579 	if err != nil {
    580 		if errors.Is(err, sql.ErrNoRows) {
    581 			return "", time.Time{}, false, nil
    582 		}
    583 		return "", time.Time{}, false, fmt.Errorf("get archive: %w", err)
    584 	}
    585 	at, parseErr := time.Parse(time.RFC3339Nano, ts)
    586 	if parseErr != nil {
    587 		return content, time.Time{}, true, fmt.Errorf("parse archived_at: %w", parseErr)
    588 	}
    589 	return content, at, true, nil
    590 }
    591 
    592 // SearchBookmarks performs a full-text search using FTS5 and returns matching bookmarks
    593 // ordered by a simple relevance score derived from offsets() length.
    594 func (db *DB) SearchBookmarks(ctx context.Context, userID int64, query string, limit, offset int) ([]Bookmark, error) {
    595 	if limit <= 0 {
    596 		limit = 50
    597 	}
    598 	rows, err := db.sql.QueryContext(ctx, `
    599         SELECT b.id, b.url, b.title, b.description, b.public, b.user_id, b.created_at, b.updated_at
    600         FROM bookmarks_fts AS f
    601         JOIN bookmarks AS b ON b.id = f.rowid
    602         WHERE bookmarks_fts MATCH ? AND b.user_id = ?
    603         ORDER BY bm25(bookmarks_fts)
    604         LIMIT ? OFFSET ?
    605     `, query, userID, limit, offset)
    606 	if err != nil {
    607 		return nil, fmt.Errorf("fts query: %w", err)
    608 	}
    609 	defer rows.Close()
    610 	out := make([]Bookmark, 0, limit)
    611 	for rows.Next() {
    612 		var b Bookmark
    613 		var created, updated string
    614 		if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Public, &b.UserID, &created, &updated); err != nil {
    615 			return nil, fmt.Errorf("scan fts row: %w", err)
    616 		}
    617 		if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
    618 			return nil, fmt.Errorf("parse created_at: %w", err)
    619 		}
    620 		if b.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated); err != nil {
    621 			return nil, fmt.Errorf("parse updated_at: %w", err)
    622 		}
    623 		out = append(out, b)
    624 	}
    625 	if err := rows.Err(); err != nil {
    626 		return nil, fmt.Errorf("iterate fts: %w", err)
    627 	}
    628 	return out, nil
    629 }
    630 
    631 // SearchBookmarksWithExcerpt performs an FTS5 search and also returns a snippet excerpt
    632 // from the archived content column highlighting matches.
    633 func (db *DB) SearchBookmarksWithExcerpt(ctx context.Context, userID int64, query string, limit, offset int) ([]SearchResult, error) {
    634 	if limit <= 0 {
    635 		limit = 50
    636 	}
    637 	rows, err := db.sql.QueryContext(ctx, `
    638         SELECT b.id, b.url, b.title, b.description, b.referrer, b.public, b.user_id, b.created_at, b.updated_at
    639         FROM bookmarks_fts AS f
    640         JOIN bookmarks AS b ON b.id = f.rowid
    641         WHERE bookmarks_fts MATCH ? AND b.user_id = ?
    642         ORDER BY bm25(bookmarks_fts)
    643         LIMIT ? OFFSET ?
    644     `, query, userID, limit, offset)
    645 	if err != nil {
    646 		return nil, fmt.Errorf("fts query: %w", err)
    647 	}
    648 	defer rows.Close()
    649 	out := make([]SearchResult, 0, limit)
    650 	for rows.Next() {
    651 		var b Bookmark
    652 		var created, updated string
    653 		if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Referrer, &b.Public, &b.UserID, &created, &updated); err != nil {
    654 			return nil, fmt.Errorf("scan fts row: %w", err)
    655 		}
    656 		if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
    657 			return nil, fmt.Errorf("parse created_at: %w", err)
    658 		}
    659 		if b.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated); err != nil {
    660 			return nil, fmt.Errorf("parse updated_at: %w", err)
    661 		}
    662 		// Build excerpt from archives content in Go for portability
    663 		if content, _, ok, _ := db.GetArchive(ctx, b.ID); ok {
    664 			ex := sql.NullString{String: makeExcerpt(content, query), Valid: true}
    665 			out = append(out, SearchResult{Bookmark: b, Excerpt: ex})
    666 		} else {
    667 			out = append(out, SearchResult{Bookmark: b})
    668 		}
    669 	}
    670 	if err := rows.Err(); err != nil {
    671 		return nil, fmt.Errorf("iterate fts: %w", err)
    672 	}
    673 	return out, nil
    674 }
    675 
    676 // CountSearchBookmarks returns the total number of FTS matches for the user.
    677 func (db *DB) CountSearchBookmarks(ctx context.Context, userID int64, query string) (int, error) {
    678 	var n int
    679 	err := db.sql.QueryRowContext(ctx, `
    680         SELECT COUNT(1)
    681         FROM bookmarks_fts AS f
    682         JOIN bookmarks AS b ON b.id = f.rowid
    683         WHERE bookmarks_fts MATCH ? AND b.user_id = ?
    684     `, query, userID).Scan(&n)
    685 	if err != nil {
    686 		return 0, fmt.Errorf("count fts matches: %w", err)
    687 	}
    688 	return n, nil
    689 }
    690 
    691 // SearchPublicBookmarksWithExcerpt performs an FTS5 search limited to public bookmarks
    692 // and also returns a snippet excerpt from the archived content column highlighting matches.
    693 func (db *DB) SearchPublicBookmarksWithExcerpt(ctx context.Context, userID int64, query string, limit, offset int) ([]SearchResult, error) {
    694 	if limit <= 0 {
    695 		limit = 50
    696 	}
    697 	rows, err := db.sql.QueryContext(ctx, `
    698         SELECT b.id, b.url, b.title, b.description, b.referrer, b.public, b.user_id, b.created_at, b.updated_at
    699         FROM bookmarks_fts AS f
    700         JOIN bookmarks AS b ON b.id = f.rowid
    701         WHERE bookmarks_fts MATCH ? AND b.user_id = ? AND b.public = 1
    702         ORDER BY bm25(bookmarks_fts)
    703         LIMIT ? OFFSET ?
    704     `, query, userID, limit, offset)
    705 	if err != nil {
    706 		return nil, fmt.Errorf("fts public query: %w", err)
    707 	}
    708 	defer rows.Close()
    709 	out := make([]SearchResult, 0, limit)
    710 	for rows.Next() {
    711 		var b Bookmark
    712 		var created, updated string
    713 		if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Referrer, &b.Public, &b.UserID, &created, &updated); err != nil {
    714 			return nil, fmt.Errorf("scan fts public row: %w", err)
    715 		}
    716 		if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
    717 			return nil, fmt.Errorf("parse created_at: %w", err)
    718 		}
    719 		if b.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated); err != nil {
    720 			return nil, fmt.Errorf("parse updated_at: %w", err)
    721 		}
    722 		// Build excerpt from archives content in Go for portability
    723 		if content, _, ok, _ := db.GetArchive(ctx, b.ID); ok {
    724 			ex := sql.NullString{String: makeExcerpt(content, query), Valid: true}
    725 			out = append(out, SearchResult{Bookmark: b, Excerpt: ex})
    726 		} else {
    727 			out = append(out, SearchResult{Bookmark: b})
    728 		}
    729 	}
    730 	if err := rows.Err(); err != nil {
    731 		return nil, fmt.Errorf("iterate fts public: %w", err)
    732 	}
    733 	return out, nil
    734 }
    735 
    736 // CountSearchPublicBookmarks returns the total number of FTS matches for public bookmarks only.
    737 func (db *DB) CountSearchPublicBookmarks(ctx context.Context, userID int64, query string) (int, error) {
    738 	var n int
    739 	err := db.sql.QueryRowContext(ctx, `
    740         SELECT COUNT(1)
    741         FROM bookmarks_fts AS f
    742         JOIN bookmarks AS b ON b.id = f.rowid
    743         WHERE bookmarks_fts MATCH ? AND b.user_id = ? AND b.public = 1
    744     `, query, userID).Scan(&n)
    745 	if err != nil {
    746 		return 0, fmt.Errorf("count fts public matches: %w", err)
    747 	}
    748 	return n, nil
    749 }
    750 
    751 // SearchPrivateBookmarksWithExcerpt performs an FTS5 search limited to private bookmarks
    752 // and also returns a snippet excerpt from the archived content column highlighting matches.
    753 func (db *DB) SearchPrivateBookmarksWithExcerpt(ctx context.Context, userID int64, query string, limit, offset int) ([]SearchResult, error) {
    754 	if limit <= 0 {
    755 		limit = 50
    756 	}
    757 	rows, err := db.sql.QueryContext(ctx, `
    758         SELECT b.id, b.url, b.title, b.description, b.referrer, b.public, b.user_id, b.created_at, b.updated_at
    759         FROM bookmarks_fts AS f
    760         JOIN bookmarks AS b ON b.id = f.rowid
    761         WHERE bookmarks_fts MATCH ? AND b.user_id = ? AND b.public = 0
    762         ORDER BY bm25(bookmarks_fts)
    763         LIMIT ? OFFSET ?
    764     `, query, userID, limit, offset)
    765 	if err != nil {
    766 		return nil, fmt.Errorf("fts private query: %w", err)
    767 	}
    768 	defer rows.Close()
    769 	out := make([]SearchResult, 0, limit)
    770 	for rows.Next() {
    771 		var b Bookmark
    772 		var created, updated string
    773 		if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Referrer, &b.Public, &b.UserID, &created, &updated); err != nil {
    774 			return nil, fmt.Errorf("scan fts private row: %w", err)
    775 		}
    776 		if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
    777 			return nil, fmt.Errorf("parse created_at: %w", err)
    778 		}
    779 		if b.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated); err != nil {
    780 			return nil, fmt.Errorf("parse updated_at: %w", err)
    781 		}
    782 		if content, _, ok, _ := db.GetArchive(ctx, b.ID); ok {
    783 			ex := sql.NullString{String: makeExcerpt(content, query), Valid: true}
    784 			out = append(out, SearchResult{Bookmark: b, Excerpt: ex})
    785 		} else {
    786 			out = append(out, SearchResult{Bookmark: b})
    787 		}
    788 	}
    789 	if err := rows.Err(); err != nil {
    790 		return nil, fmt.Errorf("iterate fts private: %w", err)
    791 	}
    792 	return out, nil
    793 }
    794 
    795 // CountSearchPrivateBookmarks returns the total number of FTS matches for private bookmarks only.
    796 func (db *DB) CountSearchPrivateBookmarks(ctx context.Context, userID int64, query string) (int, error) {
    797 	var n int
    798 	err := db.sql.QueryRowContext(ctx, `
    799         SELECT COUNT(1)
    800         FROM bookmarks_fts AS f
    801         JOIN bookmarks AS b ON b.id = f.rowid
    802         WHERE bookmarks_fts MATCH ? AND b.user_id = ? AND b.public = 0
    803     `, query, userID).Scan(&n)
    804 	if err != nil {
    805 		return 0, fmt.Errorf("count fts private matches: %w", err)
    806 	}
    807 	return n, nil
    808 }
    809 
    810 // makeExcerpt returns a short excerpt from content highlighting the first
    811 // occurrence of any query token using [brackets]. If no token is found, returns
    812 // the leading portion of the content.
    813 func makeExcerpt(content, query string) string {
    814 	c := content
    815 	if len(c) == 0 {
    816 		return ""
    817 	}
    818 	lc := strings.ToLower(c)
    819 	// Split query to tokens by whitespace; ignore empty tokens
    820 	fields := strings.Fields(strings.ToLower(query))
    821 	// Find first occurrence index among tokens
    822 	idx := -1
    823 	token := ""
    824 	for _, t := range fields {
    825 		if t == "" {
    826 			continue
    827 		}
    828 		if i := strings.Index(lc, t); i >= 0 && (idx == -1 || i < idx) {
    829 			idx = i
    830 			token = t
    831 		}
    832 	}
    833 	// Window
    834 	const pre = 80
    835 	const post = 120
    836 	if idx == -1 {
    837 		end := len(c)
    838 		if end > pre+post {
    839 			end = pre + post
    840 		}
    841 		s := c[:end]
    842 		if end < len(c) {
    843 			s += "…"
    844 		}
    845 		return s
    846 	}
    847 	start := idx - pre
    848 	if start < 0 {
    849 		start = 0
    850 	}
    851 	end := idx + len(token) + post
    852 	if end > len(c) {
    853 		end = len(c)
    854 	}
    855 	// Align start/end to UTF-8 rune boundaries to avoid broken characters.
    856 	// Move start backward to the beginning of a rune.
    857 	for start > 0 {
    858 		b := c[start]
    859 		if (b & 0xC0) != 0x80 { // not a continuation byte
    860 			break
    861 		}
    862 		start--
    863 	}
    864 	// Move end forward to the end of the current rune if we cut in the middle.
    865 	for end < len(c) {
    866 		b := c[end]
    867 		if (b & 0xC0) != 0x80 { // at a rune boundary already
    868 			break
    869 		}
    870 		end++
    871 	}
    872 	s := c[start:end]
    873 	if start > 0 {
    874 		s = "…" + s
    875 	}
    876 	if end < len(c) {
    877 		s = s + "…"
    878 	}
    879 	// Highlight all tokens (basic, case-insensitive) using guillemets « ».
    880 	// Iterate by runes to avoid breaking multi-byte characters while matching
    881 	// tokens by byte-slices at rune boundaries.
    882 	ls := strings.ToLower(s)
    883 	var b strings.Builder
    884 	for i := 0; i < len(s); {
    885 		// Try to match any token starting at this byte position
    886 		matched := false
    887 		for _, t := range fields {
    888 			if t == "" {
    889 				continue
    890 			}
    891 			if i+len(t) <= len(s) && ls[i:i+len(t)] == t {
    892 				b.WriteRune('«')
    893 				b.WriteString(s[i : i+len(t)])
    894 				b.WriteRune('»')
    895 				i += len(t)
    896 				matched = true
    897 				break
    898 			}
    899 		}
    900 		if matched {
    901 			continue
    902 		}
    903 		r, size := utf8.DecodeRuneInString(s[i:])
    904 		if r == utf8.RuneError && size == 1 {
    905 			// Write raw byte on decode error to avoid infinite loop
    906 			b.WriteByte(s[i])
    907 			i++
    908 			continue
    909 		}
    910 		b.WriteRune(r)
    911 		i += size
    912 	}
    913 	return b.String()
    914 }
    915 
    916 // ftsReplace replaces a row in the contentless FTS5 table using DELETE + INSERT,
    917 // because UPSERT/REPLACE is not implemented for virtual tables.
    918 func ftsReplace(ctx context.Context, ex execer, rowid int64, title, url string, description any, content string) error {
    919 	// Try regular DELETE first (works for contentful FTS); if it fails, fall back to special delete.
    920 	if _, err := ex.ExecContext(ctx, `DELETE FROM bookmarks_fts WHERE rowid = ?`, rowid); err != nil {
    921 		// Fallback to special delete for contentless/external-content tables
    922 		_, _ = ex.ExecContext(ctx, `INSERT INTO bookmarks_fts(bookmarks_fts, rowid) VALUES('delete', ?)`, rowid)
    923 	}
    924 	_, err := ex.ExecContext(ctx, `
    925         INSERT INTO bookmarks_fts(rowid, title, url, description, content)
    926         VALUES(?, ?, ?, ?, ?)
    927     `, rowid, title, url, description, content)
    928 	return err
    929 }
    930 
    931 func ftsDelete(ctx context.Context, ex execer, rowid int64) error {
    932 	if _, err := ex.ExecContext(ctx, `DELETE FROM bookmarks_fts WHERE rowid = ?`, rowid); err == nil {
    933 		return nil
    934 	}
    935 	// Fallback to special delete form
    936 	_, err := ex.ExecContext(ctx, `INSERT INTO bookmarks_fts(bookmarks_fts, rowid) VALUES('delete', ?)`, rowid)
    937 	return err
    938 }