66 lines
2.8 KiB
Go
66 lines
2.8 KiB
Go
package store
|
|
|
|
import "database/sql"
|
|
|
|
// ruby.go: the ruby/furigana readings captured on ingest (шаг 3a, v4 schema).
|
|
// This is CAPTURE-ONLY storage — the reading layer that epub-v1 text extraction
|
|
// would otherwise silently drop (04-unhappy §4 / D9). Memory v2 (шаг 4) reads it
|
|
// to seed a glossary name-lock; nothing here injects into a prompt, so no field
|
|
// is snapshot- or wire-load-bearing (it never touches request_hash).
|
|
//
|
|
// The store is dumb storage (mirrors chunkstatus.go): the aggregation "one row per
|
|
// (base,reading) with first_chapter=MIN and occurrences=full-book count" happens in
|
|
// the pipeline (ingest.go); this layer only upserts and reads back.
|
|
|
|
// RubyReading is one (book, base, reading) row.
|
|
type RubyReading struct {
|
|
BookID string
|
|
Base string // the ruby body (kanji/base surface form)
|
|
Reading string // the <rt> reading (furigana)
|
|
FirstChapter int // MIN 1-based chapter the pair appears in
|
|
Occurrences int // full-book count of the pair
|
|
}
|
|
|
|
// ReplaceRubyReadings replaces a book's ENTIRE ruby set with rows in one
|
|
// transaction (DELETE-by-book, then INSERT each). The caller (persistRuby)
|
|
// recomputes the whole-book aggregate on every ingest, so a full replace is the
|
|
// correct idempotency model: re-ingesting the identical source rewrites identical
|
|
// rows; a source edit converges every column to the truth — including a pair the
|
|
// edit REMOVED, which a prior upsert-only path left as a permanent phantom (a
|
|
// name-lock on a name no longer in the book — external-review #6). Scoped to
|
|
// book_id, so other books are untouched. Each row's BookID must equal bookID.
|
|
func (s *Store) ReplaceRubyReadings(bookID string, rows []RubyReading) error {
|
|
ctx, cancel := opContext()
|
|
defer cancel()
|
|
tx, err := s.w.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM ruby_readings WHERE book_id = ?`, bookID); err != nil {
|
|
return err
|
|
}
|
|
for _, rr := range rows {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO ruby_readings (book_id, base, reading, first_chapter, occurrences)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
rr.BookID, rr.Base, rr.Reading, rr.FirstChapter, rr.Occurrences); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// RubyReadingsForBook returns every captured reading for a book, ordered
|
|
// deterministically (first_chapter, base, reading) for a stable report/consumer.
|
|
func (s *Store) RubyReadingsForBook(bookID string) ([]RubyReading, error) {
|
|
return queryAll(s.r, `
|
|
SELECT base, reading, first_chapter, occurrences
|
|
FROM ruby_readings WHERE book_id = ?
|
|
ORDER BY first_chapter, base, reading`,
|
|
func(rows *sql.Rows) (RubyReading, error) {
|
|
rr := RubyReading{BookID: bookID}
|
|
err := rows.Scan(&rr.Base, &rr.Reading, &rr.FirstChapter, &rr.Occurrences)
|
|
return rr, err
|
|
}, bookID)
|
|
}
|