Reuse the companion's prose.ts rules engine as the single source of
deterministic detection instead of duplicating it. Applyable rules now
also emit exact-span fixes (original -> replacement) that surface as
suggestion cards; awareness-only rules (run-ons, splices, ...) stay
companion bubbles. The companion hides fix-bearing hints so a span is
never both a bubble and a card.
Spans are widened to a distinctive phrase ("a old" -> "an old",
"She have" -> "She has") so they re-anchor by string in the editor; a
lone lowercase "i" stays awareness-only since a single char can't anchor.
Backend: detection lives client-side, so the new persist-only
POST /docs/{id}/mechanics endpoint receives findings and stores them as
the 'mechanics' family with their exact offsets. It honours
actioned-suppression, leaves the LLM families untouched, and a checkpoint
no longer wipes it. fetchPending dedupes spans with mechanics winning any
collision against an LLM card (its span is exact). Migration 0008 adds the
'mechanics' suggestion type.
Client renders the mechanics fixes immediately (no LLM wait) and the cards
use a calm sage "Tidy-up" accent.
Verified end-to-end in a real browser on millenia: detect -> persist ->
render -> accept applies the fix.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
383 lines
14 KiB
Go
383 lines
14 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;
|
|
`,
|
|
},
|
|
{
|
|
// Collocation coach (Phase 12). Adds a third suggestion family,
|
|
// 'collocation', for gentle "natives usually say…" hints on
|
|
// non-native word pairings. The `type` column carries a CHECK
|
|
// constraint and SQLite cannot ALTER one in place, so we rebuild the
|
|
// suggestions table with the extended CHECK, copy every row across,
|
|
// and recreate its index. Nothing references suggestions, so dropping
|
|
// the old table is safe; the new table keeps the same ON DELETE
|
|
// CASCADE to documents.
|
|
name: "0005_collocation_suggestion_type",
|
|
stmt: `
|
|
CREATE TABLE suggestions_new (
|
|
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','collocation')),
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
INSERT INTO suggestions_new (id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at)
|
|
SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at FROM suggestions;
|
|
|
|
DROP TABLE suggestions;
|
|
ALTER TABLE suggestions_new RENAME TO suggestions;
|
|
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
|
`,
|
|
},
|
|
{
|
|
// Vocabulary garden (Phase 13). Every word the writer looks up is
|
|
// captured here and put on a gentle spaced-repetition schedule, turning
|
|
// passive lookups into real vocabulary. `example` holds the sentence the
|
|
// word appeared in (captured at lookup) for context during review;
|
|
// `doc_id` links back to where she met the word (nulled if that doc is
|
|
// deleted — the word stays in the garden). The SM-2-lite scheduling
|
|
// columns (due_at/interval_days/ease/reps/lapses/last_reviewed) drive a
|
|
// Leitner-style ladder (see internal/vocab/scheduler.go). UNIQUE on
|
|
// (user_id, word) makes capture an idempotent upsert.
|
|
name: "0006_vocab_garden",
|
|
stmt: `
|
|
CREATE TABLE vocab_words (
|
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
user_id TEXT NOT NULL REFERENCES users(id),
|
|
word TEXT NOT NULL,
|
|
gloss TEXT NOT NULL DEFAULT '',
|
|
phonetic TEXT NOT NULL DEFAULT '',
|
|
example TEXT NOT NULL DEFAULT '',
|
|
doc_id TEXT REFERENCES documents(id) ON DELETE SET NULL,
|
|
due_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
interval_days INTEGER NOT NULL DEFAULT 0,
|
|
ease REAL NOT NULL DEFAULT 2.5,
|
|
reps INTEGER NOT NULL DEFAULT 0,
|
|
lapses INTEGER NOT NULL DEFAULT 0,
|
|
last_reviewed DATETIME,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(user_id, word)
|
|
);
|
|
|
|
CREATE INDEX idx_vocab_due ON vocab_words(user_id, due_at);
|
|
`,
|
|
},
|
|
{
|
|
// Vocabulary garden, follow-up. Some looked-up words have an English
|
|
// definition but no Chinese gloss; those produced an unanswerable review
|
|
// card (review reveals only the gloss). `definition` stores a short
|
|
// English sense captured at lookup as a fallback "meaning" so such words
|
|
// are still reviewable.
|
|
name: "0007_vocab_definition",
|
|
stmt: `
|
|
ALTER TABLE vocab_words ADD COLUMN definition TEXT NOT NULL DEFAULT '';
|
|
`,
|
|
},
|
|
{
|
|
// Deterministic mechanics pass. Adds a 'mechanics' suggestion family for
|
|
// rule-based fixes (doubled words, spacing/punctuation, lowercase "i",
|
|
// curated confusables) detected in pure Go — no LLM. As with 0005, the
|
|
// `type` CHECK can't be ALTERed in place, so rebuild the table with the
|
|
// extended constraint, copy every row across, and recreate the index.
|
|
name: "0008_mechanics_suggestion_type",
|
|
stmt: `
|
|
CREATE TABLE suggestions_new (
|
|
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','collocation','mechanics')),
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
INSERT INTO suggestions_new (id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at)
|
|
SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at FROM suggestions;
|
|
|
|
DROP TABLE suggestions;
|
|
ALTER TABLE suggestions_new RENAME TO suggestions;
|
|
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
|
|
`,
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|