marky

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

commit 03318522417a694906ea318071a8cb211a4f38ea
parent 361f2f91fa0b4c044bf122e7ae3f28ef0733f7c9
Author: David Eisinger <[email protected]>
Date:   Mon,  6 Oct 2025 12:55:33 -0400

database

Diffstat:
Mgo.mod | 2++
Ainternal/database.go | 294+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/migrations/0001_init.sql | 17+++++++++++++++++
3 files changed, 313 insertions(+), 0 deletions(-)

diff --git a/go.mod b/go.mod @@ -1,3 +1,5 @@ module marky go 1.24.5 + +require modernc.org/sqlite v1.33.0 diff --git a/internal/database.go b/internal/database.go @@ -0,0 +1,294 @@ +package internal + +import ( + "context" + "database/sql" + "embed" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +// Bookmark represents a single bookmark row. +type Bookmark struct { + ID int64 + URL string + Title string + Description sql.NullString + CreatedAt time.Time + UpdatedAt time.Time +} + +// DB wraps *sql.DB with helpers. +type DB struct { + sql *sql.DB + dsn string +} + +// DefaultPath returns the default SQLite file path: ~/.marky/bookmarks.sql +func DefaultPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home dir: %w", err) + } + dir := filepath.Join(home, ".marky") + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("mkdir %s: %w", dir, err) + } + return filepath.Join(dir, "bookmarks.sql"), nil +} + +// Open opens (and initializes) the database at the default path. +func Open(ctx context.Context) (*DB, error) { + p, err := DefaultPath() + if err != nil { + return nil, err + } + return OpenAtPath(ctx, p) +} + +// OpenAtPath opens (and initializes) the database at a specific path. +func OpenAtPath(ctx context.Context, path string) (*DB, error) { + // Use a file: DSN and apply sensible pragmas after open. + dsn := fmt.Sprintf("file:%s", filepath.ToSlash(path)) + + sqldb, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + + // Connection settings. + // WAL improves concurrency; busy_timeout makes writes friendlier. + pragmas := []string{ + "PRAGMA journal_mode=WAL", + "PRAGMA busy_timeout=5000", + "PRAGMA foreign_keys=ON", + } + for _, p := range pragmas { + if _, execErr := sqldb.ExecContext(ctx, p); execErr != nil { + _ = sqldb.Close() + return nil, fmt.Errorf("apply pragma %q: %w", p, execErr) + } + } + + // Migrate schema. + if err := applyMigrations(ctx, sqldb); err != nil { + _ = sqldb.Close() + return nil, err + } + + return &DB{sql: sqldb, dsn: dsn}, nil +} + +// Close closes the database. +func (db *DB) Close() error { return db.sql.Close() } + +// applyMigrations executes embedded SQL migration files in order. +func applyMigrations(ctx context.Context, sqldb *sql.DB) error { + if _, err := sqldb.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TEXT NOT NULL + ); + `); err != nil { + return fmt.Errorf("create schema_migrations: %w", err) + } + + entries, err := fs.ReadDir(migrationsFS, "migrations") + if err != nil { + return fmt.Errorf("read migrations: %w", err) + } + + // Sort by filename ascending. + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".sql") { + continue + } + + // Skip if applied. + var exists int + if err := sqldb.QueryRowContext(ctx, `SELECT 1 FROM schema_migrations WHERE version = ?`, name).Scan(&exists); err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("check migration %s: %w", name, err) + } + } else { + // already applied + continue + } + + // Read SQL. + b, err := migrationsFS.ReadFile(filepath.Join("migrations", name)) + if err != nil { + return fmt.Errorf("read migration %s: %w", name, err) + } + + tx, err := sqldb.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx for %s: %w", name, err) + } + if _, err := tx.ExecContext(ctx, string(b)); err != nil { + _ = tx.Rollback() + return fmt.Errorf("exec migration %s: %w", name, err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`, name, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { + _ = tx.Rollback() + return fmt.Errorf("record migration %s: %w", name, err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit migration %s: %w", name, err) + } + } + return nil +} + +// CreateBookmark inserts a new bookmark and returns it. +func (db *DB) CreateBookmark(ctx context.Context, url, title string, description *string) (*Bookmark, error) { + if strings.TrimSpace(url) == "" || strings.TrimSpace(title) == "" { + return nil, fmt.Errorf("url and title are required") + } + now := time.Now().UTC() + var desc sql.NullString + if description != nil { + desc = sql.NullString{String: *description, Valid: true} + } + res, err := db.sql.ExecContext(ctx, ` + INSERT INTO bookmarks(url, title, description, created_at, updated_at) + VALUES(?, ?, ?, ?, ?) + `, url, title, nullString(desc), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)) + if err != nil { + return nil, fmt.Errorf("insert bookmark: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return nil, fmt.Errorf("last insert id: %w", err) + } + return &Bookmark{ + ID: id, + URL: url, + Title: title, + Description: desc, + CreatedAt: now, + UpdatedAt: now, + }, nil +} + +// GetBookmark returns a bookmark by ID. +func (db *DB) GetBookmark(ctx context.Context, id int64) (*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 id = ? + `, id).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: %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 { + limit = 50 + } + rows, err := db.sql.QueryContext(ctx, ` + SELECT id, url, title, description, created_at, updated_at + FROM bookmarks + ORDER BY created_at DESC + LIMIT ? OFFSET ? + `, limit, offset) + if err != nil { + return nil, fmt.Errorf("query bookmarks: %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 bookmark: %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 bookmarks: %w", err) + } + return out, nil +} + +// UpdateBookmark updates fields for a given bookmark. +func (db *DB) UpdateBookmark(ctx context.Context, id int64, url, title string, description *string) (*Bookmark, error) { + if strings.TrimSpace(url) == "" || strings.TrimSpace(title) == "" { + return nil, fmt.Errorf("url and title are required") + } + now := time.Now().UTC() + var desc sql.NullString + if description != nil { + desc = sql.NullString{String: *description, Valid: true} + } + res, err := db.sql.ExecContext(ctx, ` + UPDATE bookmarks + SET url = ?, title = ?, description = ?, updated_at = ? + WHERE id = ? + `, url, title, nullString(desc), now.Format(time.RFC3339Nano), id) + if err != nil { + return nil, fmt.Errorf("update bookmark: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return nil, fmt.Errorf("rows affected: %w", err) + } + if n == 0 { + return nil, nil + } + return db.GetBookmark(ctx, id) +} + +// 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) + if err != nil { + return false, fmt.Errorf("delete bookmark: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("rows affected: %w", err) + } + return n > 0, nil +} + +func nullString(ns sql.NullString) any { + if ns.Valid { + return ns.String + } + return nil +} + diff --git a/internal/migrations/0001_init.sql b/internal/migrations/0001_init.sql @@ -0,0 +1,17 @@ +-- Enable foreign key support +PRAGMA foreign_keys = ON; + +-- Create bookmarks table +CREATE TABLE IF NOT EXISTS bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- Optional indexes for common lookups +CREATE INDEX IF NOT EXISTS idx_bookmarks_url ON bookmarks(url); +CREATE INDEX IF NOT EXISTS idx_bookmarks_created_at ON bookmarks(created_at); +