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 }