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"` 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, 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.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) } writeJSON(w, http.StatusOK, out) } type captureRequest struct { Word string `json:"word"` Gloss string `json:"gloss"` 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 } word := 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, 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, 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.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()) }