Cross-document FTS5 search (trigram tokenizer for EN + space-free CJK, kept in sync by triggers, back-filled from existing docs). GET /api/search uses the FTS index for queries >=3 runes and a LIKE fallback for 1-2 (so 2-char Chinese words resolve); snippets are built in Go with rune-aware boundaries and sentinel highlights. Tags: user-scoped tags + document_tags join (both cascade), idempotent create/assign, per-tag doc counts. Doc list and search carry each doc's tags (one tagsByDoc query). Frontend: useTags, TagChip/TagPicker/SearchBox, rewritten DocList with chips + filter bar + search. Tablet/touch: responsive sidebar drawer (hamburger + scrim <768px), coarse- pointer tap targets, tap-to-open + outside-pointerdown-close for suggestion cards. Warm LLM-down state: useCheckpoint llmDown flag drives a gentle bilingual StatusBar note (writing still saves locally). Migration 0004 (tags + FTS). Tests: tags lifecycle, search EN/CJK/LIKE/update- reindex. go build/vet/test, tsc, vite all clean; verified live on deployment host. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
278 lines
9.0 KiB
Go
278 lines
9.0 KiB
Go
// Package db owns the SQLite connection, schema migrations, and the core data
|
|
// models. It uses modernc.org/sqlite (pure Go, no cgo) so the app stays a
|
|
// single static binary.
|
|
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// LocalUserID is the id of the single hardcoded user the app runs as while auth
|
|
// is deferred. The seeded row keeps foreign keys valid; real auth replaces it
|
|
// later without a schema change.
|
|
const LocalUserID = "local"
|
|
|
|
// DB wraps the SQL handle. It's a thin alias today, leaving room for prepared
|
|
// statements or helpers later without churning call sites.
|
|
type DB struct {
|
|
*sql.DB
|
|
}
|
|
|
|
// Open initialises the database at path: it ensures the parent directory
|
|
// exists, opens the connection with foreign keys and WAL enabled, runs all
|
|
// pending migrations, and seeds the local user.
|
|
func Open(path string) (*DB, error) {
|
|
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("create data dir: %w", err)
|
|
}
|
|
}
|
|
|
|
sqlDB, err := sql.Open("sqlite", dsn(path))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
|
}
|
|
// SQLite is a single writer; one connection avoids "database is locked"
|
|
// churn while keeping WAL's concurrent readers.
|
|
sqlDB.SetMaxOpenConns(1)
|
|
|
|
if err := sqlDB.Ping(); err != nil {
|
|
_ = sqlDB.Close()
|
|
return nil, fmt.Errorf("ping sqlite: %w", err)
|
|
}
|
|
|
|
d := &DB{sqlDB}
|
|
if err := d.migrate(); err != nil {
|
|
_ = sqlDB.Close()
|
|
return nil, fmt.Errorf("migrate: %w", err)
|
|
}
|
|
if err := d.seed(); err != nil {
|
|
_ = sqlDB.Close()
|
|
return nil, fmt.Errorf("seed: %w", err)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// dsn builds the modernc.org/sqlite connection string with the pragmas we want
|
|
// applied to every connection.
|
|
func dsn(path string) string {
|
|
q := url.Values{}
|
|
q.Add("_pragma", "foreign_keys(1)")
|
|
q.Add("_pragma", "busy_timeout(5000)")
|
|
q.Add("_pragma", "journal_mode(WAL)")
|
|
return "file:" + path + "?" + q.Encode()
|
|
}
|
|
|
|
// migration is one ordered, idempotent schema step. Append new migrations to the
|
|
// slice in migrate(); never edit or reorder an already-shipped one.
|
|
type migration struct {
|
|
name string
|
|
stmt string
|
|
}
|
|
|
|
// migrate runs every migration not yet recorded in schema_migrations, inside a
|
|
// transaction each, in order.
|
|
func (d *DB) migrate() error {
|
|
if _, err := d.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
name TEXT PRIMARY KEY,
|
|
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)`); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, m := range migrations() {
|
|
var exists bool
|
|
if err := d.QueryRow(
|
|
`SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE name = ?)`, m.name,
|
|
).Scan(&exists); err != nil {
|
|
return err
|
|
}
|
|
if exists {
|
|
continue
|
|
}
|
|
|
|
tx, err := d.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(m.stmt); err != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("migration %q: %w", m.name, err)
|
|
}
|
|
if _, err := tx.Exec(`INSERT INTO schema_migrations (name) VALUES (?)`, m.name); err != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("record migration %q: %w", m.name, err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// migrations returns the ordered schema history. The initial migration mirrors
|
|
// the schema in petal-spec.md.
|
|
func migrations() []migration {
|
|
return []migration{
|
|
{
|
|
name: "0001_initial_schema",
|
|
stmt: `
|
|
CREATE TABLE users (
|
|
id TEXT PRIMARY KEY,
|
|
email TEXT NOT NULL,
|
|
display_name TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE documents (
|
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
user_id TEXT NOT NULL REFERENCES users(id),
|
|
title TEXT NOT NULL DEFAULT 'Untitled',
|
|
content TEXT NOT NULL DEFAULT '{}',
|
|
content_text TEXT NOT NULL DEFAULT '',
|
|
word_count INTEGER NOT NULL DEFAULT 0,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE suggestions (
|
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
from_pos INTEGER NOT NULL,
|
|
to_pos INTEGER NOT NULL,
|
|
original TEXT NOT NULL,
|
|
replacement TEXT NOT NULL,
|
|
explanation TEXT NOT NULL,
|
|
type TEXT NOT NULL CHECK(type IN ('grammar','phrasing','idiom','clarity','voice')),
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE plagiarism_reports (
|
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
backend TEXT NOT NULL DEFAULT 'copyleaks',
|
|
similarity_pct REAL,
|
|
status TEXT NOT NULL DEFAULT 'pending'
|
|
CHECK(status IN ('pending','complete','error')),
|
|
result_json TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
|
CREATE INDEX idx_plagiarism_doc_id ON plagiarism_reports(doc_id);
|
|
`,
|
|
},
|
|
{
|
|
// Per-document tone: guides the grammar-checkpoint LLM so advice fits
|
|
// the writer's target register (academic essay vs casual journal).
|
|
// 'general' means no specific tone steering.
|
|
name: "0002_document_tone",
|
|
stmt: `ALTER TABLE documents ADD COLUMN tone TEXT NOT NULL DEFAULT 'general';`,
|
|
},
|
|
{
|
|
// Version history: point-in-time snapshots of a document's body so a
|
|
// bad edit or LLM mishap is always recoverable. `kind` distinguishes
|
|
// throttled background snapshots ('auto'), explicit user restore
|
|
// points ('manual'), and the safety copy taken right before a restore
|
|
// ('pre_restore') so restoring is itself undoable. Snapshots cascade
|
|
// with the document. Stored fully (content + content_text) so a
|
|
// restore is a plain copy with no re-derivation.
|
|
name: "0003_document_versions",
|
|
stmt: `
|
|
CREATE TABLE document_versions (
|
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
title TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
content_text TEXT NOT NULL,
|
|
word_count INTEGER NOT NULL DEFAULT 0,
|
|
kind TEXT NOT NULL DEFAULT 'auto'
|
|
CHECK(kind IN ('auto','manual','pre_restore')),
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE INDEX idx_versions_doc_id ON document_versions(doc_id, created_at DESC);
|
|
`,
|
|
},
|
|
{
|
|
// Organization & search (Phase 10). Two parts:
|
|
//
|
|
// 1. Tags. A small, user-scoped label set; `color` holds a palette key
|
|
// (rose/mint/peach/lavender/sky/honey) the frontend maps to CSS.
|
|
// document_tags is the many-to-many join; both sides cascade so
|
|
// deleting a doc or a tag cleans up its assignments.
|
|
//
|
|
// 2. Full-text search. A standalone FTS5 virtual table over title +
|
|
// content_text using the `trigram` tokenizer so search works for
|
|
// both English and space-free Chinese (the default tokenizer treats a
|
|
// CJK run as one token). It carries an UNINDEXED doc_id to map hits
|
|
// back to documents, kept in sync by AFTER INSERT/UPDATE/DELETE
|
|
// triggers, and is back-filled from the existing documents here.
|
|
// (Trigram needs ≥3 chars to MATCH; the search handler falls back to
|
|
// LIKE for shorter queries — common for 2-character Chinese words.)
|
|
name: "0004_tags_and_search",
|
|
stmt: `
|
|
CREATE TABLE tags (
|
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
user_id TEXT NOT NULL REFERENCES users(id),
|
|
name TEXT NOT NULL,
|
|
color TEXT NOT NULL DEFAULT 'rose',
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(user_id, name)
|
|
);
|
|
|
|
CREATE TABLE document_tags (
|
|
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
|
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (doc_id, tag_id)
|
|
);
|
|
|
|
CREATE INDEX idx_document_tags_tag ON document_tags(tag_id);
|
|
|
|
CREATE VIRTUAL TABLE documents_fts USING fts5(
|
|
doc_id UNINDEXED,
|
|
title,
|
|
content_text,
|
|
tokenize='trigram'
|
|
);
|
|
|
|
CREATE TRIGGER documents_ai AFTER INSERT ON documents BEGIN
|
|
INSERT INTO documents_fts (doc_id, title, content_text)
|
|
VALUES (new.id, new.title, new.content_text);
|
|
END;
|
|
|
|
CREATE TRIGGER documents_ad AFTER DELETE ON documents BEGIN
|
|
DELETE FROM documents_fts WHERE doc_id = old.id;
|
|
END;
|
|
|
|
CREATE TRIGGER documents_au AFTER UPDATE ON documents BEGIN
|
|
UPDATE documents_fts
|
|
SET title = new.title, content_text = new.content_text
|
|
WHERE doc_id = old.id;
|
|
END;
|
|
|
|
INSERT INTO documents_fts (doc_id, title, content_text)
|
|
SELECT id, title, content_text FROM documents;
|
|
`,
|
|
},
|
|
}
|
|
}
|
|
|
|
// seed inserts the hardcoded local user if it doesn't already exist. It's
|
|
// idempotent, so it runs safely on every startup.
|
|
func (d *DB) seed() error {
|
|
_, err := d.Exec(
|
|
`INSERT INTO users (id, email, display_name) VALUES (?, ?, ?)
|
|
ON CONFLICT(id) DO NOTHING`,
|
|
LocalUserID, "local@petal.local", "Writer",
|
|
)
|
|
return err
|
|
}
|