package vocab import ( "database/sql" "strings" "unicode" ) // Planting: the garden's second source. // // Capture (handlers.go) records words the writer *sought out*. Planting records // phrasing she was gently *given* — an accepted collocation like "make a // decision" is a learnable chunk exactly like a looked-up word, and the SM-2-lite // scheduler doesn't care that it's three words rather than one. Together the two // halves make the garden a record of both sides of learning. // // Everything here is best-effort by design: planting hangs off accepting a // suggestion, and that accept must succeed whether or not a card comes of it. // Execer is the slice of *sql.DB (or *sql.Tx) that planting needs. type Execer interface { Exec(query string, args ...any) (sql.Result, error) } // Phrase is one chunk to plant. type Phrase struct { Text string // the corrected phrasing, e.g. "make a decision" Meaning string // why it's better — the suggestion's explanation Example string // the sentence she met it in, already corrected DocID *string // where, so "where did I see this?" stays one tap // Lang is the document's verdict ('' | 'en' | 'pair'), because the chunk is // lifted out of her own prose and is therefore in whatever language that // prose is. See migration 0018. Lang string } // Phrase-card caps. A collocation is a short chunk; anything longer is a // rewritten sentence wearing a collocation's label, and a sentence makes a // miserable flashcard. Both bounds are deliberately tight — the cost of // skipping a real chunk is one missing card, the cost of planting a sentence is // a garden the writer stops trusting. const ( maxPhraseRunes = 60 maxPhraseWords = 6 minPhraseWords = 2 ) // PhraseKey normalizes a replacement into a garden key, or returns "" when the // text isn't a plantable chunk. // // Lowercasing matches capture's normalization, so a phrase and a looked-up word // share one UNIQUE(user_id, word) namespace rather than colliding sideways. // Single words are rejected on purpose: a one-word fix is word choice, and word // choice already reaches the garden through lookup — planting it here would give // it a card with no gloss and no phonetic, which reviews badly. func PhraseKey(text string) string { // Collapse all whitespace (a replacement can carry a newline from the // editor) so the key is stable and the word count is honest. s := strings.Join(strings.Fields(strings.ToLower(text)), " ") // Trim the punctuation a phrase picks up from the sentence around it, but // leave inner marks alone: "can't afford" and "in one's own time" are chunks. s = strings.Trim(s, `.,;:!?…"'“”‘’()[]`) s = strings.TrimSpace(s) if s == "" || len([]rune(s)) > maxPhraseRunes { return "" } n := len(strings.Fields(s)) if n < minPhraseWords || n > maxPhraseWords { return "" } // A chunk of pure digits or symbols ("12 000", "-- --") isn't vocabulary. if !strings.ContainsFunc(s, unicode.IsLetter) { return "" } return s } // Plant adds a phrase card to the garden, due tomorrow like any fresh capture. // It reports whether a new card was created. // // ON CONFLICT DO NOTHING, unlike capture's refresh-the-context upsert: accepting // the same collocation again months later is evidence the chunk is still being // learned, and the last thing that should do is overwrite the card's first // context or disturb a schedule it has been climbing. An existing card wins. func Plant(ex Execer, userID string, p Phrase) (bool, error) { key := PhraseKey(p.Text) if key == "" { return false, nil } res, err := ex.Exec( `INSERT INTO vocab_words (user_id, word, gloss, definition, phonetic, example, doc_id, lang, due_at, interval_days) VALUES (?, ?, '', ?, '', ?, ?, ?, datetime('now', '+1 day'), 1) ON CONFLICT(user_id, word) DO NOTHING`, userID, key, clamp(strings.TrimSpace(p.Meaning), maxDefinitionLen), clamp(strings.TrimSpace(p.Example), maxExampleLen), p.DocID, p.Lang, ) if err != nil { return false, err } n, err := res.RowsAffected() return n > 0, err }