marky

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

commit cc4249f287ba6c604f11e99f79bc03f4264125c3
parent 57c2c3a99a69e71c8852e6885262d003230560fd
Author: David Eisinger <[email protected]>
Date:   Mon,  6 Oct 2025 13:40:35 -0400

fulltext search

Diffstat:
MREADME.md | 6++++--
Mcmd/marky.go | 54+++++++++++++++++++++++++++++++++++++++++++++---------
Minternal/database.go | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/migrations/0003_fts.sql | 10++++++++++
Ainternal/migrations/0004_unique_url.sql | 4++++
5 files changed, 192 insertions(+), 11 deletions(-)

diff --git a/README.md b/README.md @@ -64,11 +64,12 @@ curl -i -X POST http://localhost:8080/bookmarks \ ### GET /bookmarks -List bookmarks in reverse chronological order. Supports basic pagination. +List bookmarks in reverse chronological order, or search with full-text. - Query params: - `limit` (optional, default 50, max 200) - `offset` (optional, default 0) + - `q` (optional) full-text query across title, url, description, and archived content - Responses: - `200 OK` with JSON array of bookmarks - `400 Bad Request` for invalid `limit` or `offset` @@ -77,13 +78,14 @@ List bookmarks in reverse chronological order. Supports basic pagination. ``` curl -s http://localhost:8080/bookmarks | jq curl -s 'http://localhost:8080/bookmarks?limit=20&offset=20' | jq +curl -s 'http://localhost:8080/bookmarks?q=example' | jq ``` ## Data Model Table: `bookmarks` - `id` INTEGER PRIMARY KEY AUTOINCREMENT (starts at 1) -- `url` TEXT NOT NULL +- `url` TEXT NOT NULL UNIQUE - `title` TEXT NOT NULL - `description` TEXT NULL - `created_at` TEXT NOT NULL (RFC3339) diff --git a/cmd/marky.go b/cmd/marky.go @@ -97,22 +97,52 @@ func main() { http.Error(w, "failed to archive URL", http.StatusBadGateway) return } - created, err := db.CreateBookmarkWithArchive(r.Context(), req.URL, req.Title, req.Description, txt) - if err != nil { - log.Printf("create bookmark with archive error: %v", err) + // If URL exists, re-archive and touch updated_at; else create + if existing, err := db.GetBookmarkByURL(r.Context(), req.URL); err != nil { + log.Printf("lookup by url error: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return + } else if existing != nil { + if err := db.SaveArchive(r.Context(), existing.ID, txt); err != nil { + log.Printf("save archive error: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if err := db.TouchBookmark(r.Context(), existing.ID); err != nil { + log.Printf("touch bookmark error: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + updated, err := db.GetBookmark(r.Context(), existing.ID) + if err != nil || updated == nil { + if err != nil { + log.Printf("refetch bookmark error: %v", err) + } + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + // 200 OK since we updated existing bookmark + _ = json.NewEncoder(w).Encode(toResp(updated)) + return + } else { + created, err := db.CreateBookmarkWithArchive(r.Context(), req.URL, req.Title, req.Description, txt) + if err != nil { + log.Printf("create bookmark with archive error: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(toResp(created)) } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(toResp(created)) - case http.MethodGet: - // Optional pagination: limit and offset + // Optional pagination and search: limit, offset, q q := r.URL.Query() limit := 50 offset := 0 + query := strings.TrimSpace(q.Get("q")) if s := q.Get("limit"); s != "" { if v, err := strconv.Atoi(s); err == nil && v > 0 { if v > 200 { @@ -133,7 +163,13 @@ func main() { } } - items, err := db.ListBookmarks(r.Context(), limit, offset) + var items []dbpkg.Bookmark + var err error + if query != "" { + items, err = db.SearchBookmarks(r.Context(), query, limit, offset) + } else { + items, err = db.ListBookmarks(r.Context(), limit, offset) + } if err != nil { log.Printf("list bookmarks error: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) diff --git a/internal/database.go b/internal/database.go @@ -35,6 +35,11 @@ type DB struct { dsn string } +// execer abstracts *sql.DB and *sql.Tx for executing SQL with context. +type execer interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +} + // DefaultPath returns the default SQLite file path: ~/.marky/bookmarks.sql func DefaultPath() (string, error) { home, err := os.UserHomeDir() @@ -175,6 +180,14 @@ func (db *DB) CreateBookmark(ctx context.Context, url, title string, description if err != nil { return nil, fmt.Errorf("last insert id: %w", err) } + // Replace FTS row (empty content until archive exists) + ftsContent := "" + if c, _, ok, _ := db.GetArchive(ctx, id); ok { + ftsContent = c + } + if err := ftsReplace(ctx, db.sql, id, title, url, nullString(desc), ftsContent); err != nil { + return nil, fmt.Errorf("fts replace after create: %w", err) + } return &Bookmark{ ID: id, URL: url, @@ -208,6 +221,29 @@ func (db *DB) GetBookmark(ctx context.Context, id int64) (*Bookmark, error) { return &b, nil } +// GetBookmarkByURL returns a bookmark by exact URL. +func (db *DB) GetBookmarkByURL(ctx context.Context, u string) (*Bookmark, error) { + var b Bookmark + var created, updated string + if err := db.sql.QueryRowContext(ctx, ` + SELECT id, url, title, description, created_at, updated_at + FROM bookmarks WHERE url = ? + `, u).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &created, &updated); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("select bookmark by url: %w", err) + } + var err error + if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil { + return nil, fmt.Errorf("parse created_at: %w", err) + } + if b.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated); err != nil { + return nil, fmt.Errorf("parse updated_at: %w", err) + } + return &b, nil +} + // ListBookmarks returns bookmarks ordered by created_at desc. func (db *DB) ListBookmarks(ctx context.Context, limit, offset int) ([]Bookmark, error) { if limit <= 0 { @@ -269,9 +305,30 @@ func (db *DB) UpdateBookmark(ctx context.Context, id int64, url, title string, d if n == 0 { return nil, nil } + // Update FTS metadata while preserving content + var descForFTS sql.NullString + if description != nil { + descForFTS = sql.NullString{String: *description, Valid: true} + } + ftsContent := "" + if c, _, ok, _ := db.GetArchive(ctx, id); ok { + ftsContent = c + } + if err := ftsReplace(ctx, db.sql, id, title, url, nullString(descForFTS), ftsContent); err != nil { + return nil, fmt.Errorf("fts replace after update: %w", err) + } return db.GetBookmark(ctx, id) } +// TouchBookmark updates only the updated_at timestamp. +func (db *DB) TouchBookmark(ctx context.Context, id int64) error { + _, err := db.sql.ExecContext(ctx, `UPDATE bookmarks SET updated_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339Nano), id) + if err != nil { + return fmt.Errorf("touch bookmark: %w", err) + } + return nil +} + // DeleteBookmark deletes a bookmark by ID. Returns true if a row was deleted. func (db *DB) DeleteBookmark(ctx context.Context, id int64) (bool, error) { res, err := db.sql.ExecContext(ctx, `DELETE FROM bookmarks WHERE id = ?`, id) @@ -282,6 +339,11 @@ func (db *DB) DeleteBookmark(ctx context.Context, id int64) (bool, error) { if err != nil { return false, fmt.Errorf("rows affected: %w", err) } + if n > 0 { + if _, err := db.sql.ExecContext(ctx, `DELETE FROM bookmarks_fts WHERE rowid = ?`, id); err != nil { + return false, fmt.Errorf("delete fts row: %w", err) + } + } return n > 0, nil } @@ -328,6 +390,11 @@ func (db *DB) CreateBookmarkWithArchive(ctx context.Context, url, title string, _ = tx.Rollback() return nil, fmt.Errorf("insert archive: %w", err) } + // Replace FTS row within the same transaction + if err := ftsReplace(ctx, tx, id, title, url, nullString(desc), archiveContent); err != nil { + _ = tx.Rollback() + return nil, fmt.Errorf("insert fts row: %w", err) + } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit: %w", err) } @@ -354,6 +421,16 @@ func (db *DB) SaveArchive(ctx context.Context, bookmarkID int64, content string) if err != nil { return fmt.Errorf("save archive: %w", err) } + // Replace into FTS with latest content and metadata + b, err := db.GetBookmark(ctx, bookmarkID) + if err != nil { + return fmt.Errorf("fetch bookmark for fts: %w", err) + } + if b != nil { + if err := ftsReplace(ctx, db.sql, bookmarkID, b.Title, b.URL, nullString(b.Description), content); err != nil { + return fmt.Errorf("fts replace after archive: %w", err) + } + } return nil } @@ -376,3 +453,55 @@ func (db *DB) GetArchive(ctx context.Context, bookmarkID int64) (content string, } return content, at, true, nil } + +// SearchBookmarks performs a full-text search using FTS5 and returns matching bookmarks +// ordered by a simple relevance score derived from offsets() length. +func (db *DB) SearchBookmarks(ctx context.Context, query string, limit, offset int) ([]Bookmark, error) { + if limit <= 0 { + limit = 50 + } + rows, err := db.sql.QueryContext(ctx, ` + SELECT b.id, b.url, b.title, b.description, b.created_at, b.updated_at + FROM bookmarks_fts AS f + JOIN bookmarks AS b ON b.id = f.rowid + WHERE bookmarks_fts MATCH ? + ORDER BY bm25(bookmarks_fts) + LIMIT ? OFFSET ? + `, query, limit, offset) + if err != nil { + return nil, fmt.Errorf("fts query: %w", err) + } + defer rows.Close() + out := make([]Bookmark, 0, limit) + for rows.Next() { + var b Bookmark + var created, updated string + if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &created, &updated); err != nil { + return nil, fmt.Errorf("scan fts row: %w", err) + } + if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil { + return nil, fmt.Errorf("parse created_at: %w", err) + } + if b.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated); err != nil { + return nil, fmt.Errorf("parse updated_at: %w", err) + } + out = append(out, b) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate fts: %w", err) + } + return out, nil +} + +// ftsReplace replaces a row in the contentless FTS5 table using DELETE + INSERT, +// because UPSERT/REPLACE is not implemented for virtual tables. +func ftsReplace(ctx context.Context, ex execer, rowid int64, title, url string, description any, content string) error { + if _, err := ex.ExecContext(ctx, `DELETE FROM bookmarks_fts WHERE rowid = ?`, rowid); err != nil { + return err + } + _, err := ex.ExecContext(ctx, ` + INSERT INTO bookmarks_fts(rowid, title, url, description, content) + VALUES(?, ?, ?, ?, ?) + `, rowid, title, url, description, content) + return err +} diff --git a/internal/migrations/0003_fts.sql b/internal/migrations/0003_fts.sql @@ -0,0 +1,10 @@ +-- FTS5 virtual table for full-text search across bookmark metadata and archived content. +-- We use a contentless FTS5 table and manage rows explicitly from application code. +CREATE VIRTUAL TABLE IF NOT EXISTS bookmarks_fts USING fts5( + title, + url, + description, + content, + content='' +); + diff --git a/internal/migrations/0004_unique_url.sql b/internal/migrations/0004_unique_url.sql @@ -0,0 +1,4 @@ +-- Ensure URL uniqueness. +DROP INDEX IF EXISTS idx_bookmarks_url; +CREATE UNIQUE INDEX IF NOT EXISTS idx_bookmarks_url_unique ON bookmarks(url); +