Correctness: - useCheckpoint: clear the busy flag unconditionally so overlapping explicit passes don't strand each other's spinner; explicit actions now also supersede a queued auto-check and clear the stranded "checking" dot. Deduped runVoice/runCollocation into runExplicitPass. - EditorCore: token-guard the auto-capture so a late capture can't resurrect a removed word; move toggleSaveWord side effects out of the setWordInfo updater (StrictMode double-fire); fix sentenceAround offset desync via shared exampleAt (textBetween + parentOffset, single resolve); optimistic saved state so the heart doesn't flash unsaved. - vocab capture: normalize word to lower+trim (matches lexicon) so "Apple"/"apple" don't make duplicate cards; check rows.Err() in queryList. - GardenPanel: Promise.allSettled so a /due failure doesn't blank the whole garden; scrim click during review ends the review (mirrors Esc); gate footer on !error; O(1) due lookup via a Set. Features requested in review: - Definition-only review card: add vocab_words.definition (migration 0007) as an English fallback meaning, threaded through capture and used by review/garden when there's no Chinese gloss. - Scheduler caps: maxEase 3.0 + maxInterval 365d so "easy" growth can't push a word out of rotation for years. Tests: TestCaptureCaseInsensitive, TestCaptureStoresDefinitionFallback, TestCapsBoundGrowth. go build/vet/test, tsc, vitest 51/51, vite build clean. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
257 lines
8.0 KiB
Go
257 lines
8.0 KiB
Go
package vocab
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
|
)
|
|
|
|
// Word is one entry in the vocabulary garden: the looked-up word with its gloss,
|
|
// phonetic, and the sentence it was met in, plus its spaced-repetition state.
|
|
type Word struct {
|
|
ID string `json:"id"`
|
|
Word string `json:"word"`
|
|
Gloss string `json:"gloss"`
|
|
Definition string `json:"definition"` // English fallback meaning when there's no Chinese gloss
|
|
Phonetic string `json:"phonetic"`
|
|
Example string `json:"example"`
|
|
DocID *string `json:"doc_id"`
|
|
DueAt time.Time `json:"due_at"`
|
|
IntervalDays int `json:"interval_days"`
|
|
Ease float64 `json:"ease"`
|
|
Reps int `json:"reps"`
|
|
Lapses int `json:"lapses"`
|
|
LastReviewed *time.Time `json:"last_reviewed"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// Handler owns the vocabulary-garden routes. Everything is scoped to the local
|
|
// user while auth is deferred.
|
|
type Handler struct {
|
|
DB *db.DB
|
|
}
|
|
|
|
func New(database *db.DB) *Handler { return &Handler{DB: database} }
|
|
|
|
// Routes mounts the garden endpoints under /api/vocab.
|
|
func (h *Handler) Routes() chi.Router {
|
|
r := chi.NewRouter()
|
|
r.Get("/", h.list) // the whole garden
|
|
r.Get("/due", h.due) // only the cards due for review now
|
|
r.Post("/", h.capture) // record/upsert a looked-up word
|
|
r.Post("/{id}/review", h.review)
|
|
r.Delete("/{id}", h.remove)
|
|
return r
|
|
}
|
|
|
|
const vocabColumns = `id, word, gloss, definition, phonetic, example, doc_id,
|
|
due_at, interval_days, ease, reps, lapses, last_reviewed, created_at`
|
|
|
|
func scanWord(s interface {
|
|
Scan(dest ...any) error
|
|
}) (Word, error) {
|
|
var w Word
|
|
err := s.Scan(
|
|
&w.ID, &w.Word, &w.Gloss, &w.Definition, &w.Phonetic, &w.Example, &w.DocID,
|
|
&w.DueAt, &w.IntervalDays, &w.Ease, &w.Reps, &w.Lapses, &w.LastReviewed, &w.CreatedAt,
|
|
)
|
|
return w, err
|
|
}
|
|
|
|
// list returns the full garden, newest blossoms first.
|
|
func (h *Handler) list(w http.ResponseWriter, _ *http.Request) {
|
|
h.queryList(w, `SELECT `+vocabColumns+` FROM vocab_words
|
|
WHERE user_id = ? ORDER BY created_at DESC`, db.LocalUserID)
|
|
}
|
|
|
|
// due returns only the cards whose review time has arrived, soonest first.
|
|
func (h *Handler) due(w http.ResponseWriter, _ *http.Request) {
|
|
h.queryList(w, `SELECT `+vocabColumns+` FROM vocab_words
|
|
WHERE user_id = ? AND due_at <= datetime('now') ORDER BY due_at ASC`, db.LocalUserID)
|
|
}
|
|
|
|
func (h *Handler) queryList(w http.ResponseWriter, query string, args ...any) {
|
|
rows, err := h.DB.Query(query, args...)
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
out := []Word{}
|
|
for rows.Next() {
|
|
word, err := scanWord(rows)
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
out = append(out, word)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
type captureRequest struct {
|
|
Word string `json:"word"`
|
|
Gloss string `json:"gloss"`
|
|
Definition string `json:"definition"`
|
|
Phonetic string `json:"phonetic"`
|
|
Example string `json:"example"`
|
|
DocID *string `json:"doc_id"`
|
|
}
|
|
|
|
// capture records a looked-up word. It's an idempotent upsert keyed on the word:
|
|
// a new word lands due tomorrow (interval 1 day); an existing word keeps its
|
|
// schedule untouched but refreshes its gloss/phonetic/example/doc_id so the most
|
|
// recent context wins. Looking words up IS the data source — no extra effort.
|
|
func (h *Handler) capture(w http.ResponseWriter, r *http.Request) {
|
|
var req captureRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
errorJSON(w, http.StatusBadRequest, "invalid body")
|
|
return
|
|
}
|
|
// Normalize the same way the lexicon does (internal/lexicon: lower+trim) so
|
|
// the UNIQUE(user_id, word) upsert is genuinely idempotent — otherwise
|
|
// "Apple" at a sentence start and "apple" mid-line would create two separate
|
|
// cards on independent schedules.
|
|
word := strings.ToLower(strings.TrimSpace(req.Word))
|
|
if word == "" {
|
|
errorJSON(w, http.StatusBadRequest, "word is required")
|
|
return
|
|
}
|
|
|
|
// New rows start due tomorrow; ON CONFLICT refreshes context but leaves the
|
|
// schedule (due_at/reps/interval/ease) alone so re-looking-up a word never
|
|
// resets its progress.
|
|
_, err := h.DB.Exec(
|
|
`INSERT INTO vocab_words (user_id, word, gloss, definition, phonetic, example, doc_id, due_at, interval_days)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', '+1 day'), 1)
|
|
ON CONFLICT(user_id, word) DO UPDATE SET
|
|
gloss = excluded.gloss,
|
|
definition = excluded.definition,
|
|
phonetic = excluded.phonetic,
|
|
example = CASE WHEN excluded.example != '' THEN excluded.example ELSE vocab_words.example END,
|
|
doc_id = COALESCE(excluded.doc_id, vocab_words.doc_id)`,
|
|
db.LocalUserID, word, req.Gloss, req.Definition, req.Phonetic, req.Example, req.DocID,
|
|
)
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
|
|
out, err := h.fetch(word)
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, out)
|
|
}
|
|
|
|
// fetch loads one word row by its (user, word) key.
|
|
func (h *Handler) fetch(word string) (Word, error) {
|
|
return scanWord(h.DB.QueryRow(
|
|
`SELECT `+vocabColumns+` FROM vocab_words WHERE user_id = ? AND word = ?`,
|
|
db.LocalUserID, word,
|
|
))
|
|
}
|
|
|
|
type reviewRequest struct {
|
|
Grade Grade `json:"grade"`
|
|
}
|
|
|
|
// review grades one card and reschedules it. The grade drives the SM-2-lite
|
|
// scheduler; the new interval is applied as `due_at = now + interval days`.
|
|
func (h *Handler) review(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
var req reviewRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
errorJSON(w, http.StatusBadRequest, "invalid body")
|
|
return
|
|
}
|
|
if req.Grade != GradeAgain && req.Grade != GradeGood && req.Grade != GradeEasy {
|
|
errorJSON(w, http.StatusBadRequest, "grade must be again, good, or easy")
|
|
return
|
|
}
|
|
|
|
var cur State
|
|
err := h.DB.QueryRow(
|
|
`SELECT reps, interval_days, ease, lapses FROM vocab_words WHERE id = ? AND user_id = ?`,
|
|
id, db.LocalUserID,
|
|
).Scan(&cur.Reps, &cur.Interval, &cur.Ease, &cur.Lapses)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
errorJSON(w, http.StatusNotFound, "word not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
|
|
nxt := cur.next(req.Grade)
|
|
// `datetime('now', '+N days')` keeps the stored value in SQLite's canonical
|
|
// text format, matching CURRENT_TIMESTAMP and the due query's comparison.
|
|
offset := "+" + strconv.Itoa(nxt.Interval) + " days"
|
|
if _, err := h.DB.Exec(
|
|
`UPDATE vocab_words SET
|
|
reps = ?, interval_days = ?, ease = ?, lapses = ?,
|
|
last_reviewed = datetime('now'), due_at = datetime('now', ?)
|
|
WHERE id = ? AND user_id = ?`,
|
|
nxt.Reps, nxt.Interval, nxt.Ease, nxt.Lapses, offset, id, db.LocalUserID,
|
|
); err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
|
|
out, err := scanWord(h.DB.QueryRow(
|
|
`SELECT `+vocabColumns+` FROM vocab_words WHERE id = ? AND user_id = ?`, id, db.LocalUserID,
|
|
))
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// remove deletes a word from the garden (e.g. the writer already knows it).
|
|
func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
|
|
res, err := h.DB.Exec(
|
|
`DELETE FROM vocab_words WHERE id = ? AND user_id = ?`,
|
|
chi.URLParam(r, "id"), db.LocalUserID,
|
|
)
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
errorJSON(w, http.StatusNotFound, "word not found")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// --- response helpers -------------------------------------------------------
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func errorJSON(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
}
|
|
|
|
func serverError(w http.ResponseWriter, err error) {
|
|
errorJSON(w, http.StatusInternalServerError, err.Error())
|
|
}
|