textmachine/backend/internal/lang/reader.go

150 lines
6.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package lang
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// reader.go: the READER-FACING words a target language supplies for what a book FILE has to say about
// itself — the mark a reader meets at a hole in the text and the notice that the book is not whole
// (the engine's book writer, internal/pipeline bookbuild.go). They are RENDER data (what the file says),
// not TRANSLATION data (what the model sees), and the distinction is load-bearing:
//
// - They live at <langpack_root>/<TARGET lang>/reader.txt — keyed by the language the READER reads,
// not by the pair, because «this fragment is not translated» is the same sentence for a zh→ru and
// a ja→ru book.
// - They are read by THIS loader and by nothing else. Load (langpack.go) reads only the source and
// the pair directories and folds THOSE bytes into Pack.Version(), the value the run snapshot carries;
// a target directory is outside its reach by construction, so adding or editing reader.txt moves no
// snapshot and re-bills no book. That is the property the file was placed here for: a rendering fact
// must never look like a translation fact to the money.
//
// A target language that ships no file gets DefaultReaderWords: a non-verbal form — a symbol and the
// numbers the engine knows — so a book with no language data still cannot look whole when it is not,
// and no Go source needs to know how to say «chapter» or «not translated» in any language.
// ReaderWords are the templates the book writer fills. Each is a reader-facing sentence or mark; the
// placeholders in braces are the only things the engine substitutes. A template may omit a placeholder
// it has no use for.
type ReaderWords struct {
// HolePending marks a unit the run has not reached: {chapter} {unit}.
HolePending string
// HoleWithheld marks a unit whose text was deliberately not shipped (a substantive flag upstream):
// {chapter} {unit}.
HoleWithheld string
// HoleIncomplete marks a unit whose text IS present but lost a piece (a member chunk the editor
// left out): {chapter} {unit} {dropped}.
HoleIncomplete string
// HoleStale marks a unit whose translation is of a source that has since changed (the engine re-buys
// it on the next run; the old text is not shown): {chapter} {unit}.
HoleStale string
// HoleGhost marks a chapter that has translated text the current cut of the book cannot place:
// {chapter} {ghost}.
HoleGhost string
// NoticeHoles is the book-level notice for a book with holes: {holes} {total}.
NoticeHoles string
// NoticeGhost is the book-level notice for text outside the current cut: {ghost}.
NoticeGhost string
}
// readerKeys maps reader.txt keys to the field they fill. Every key is REQUIRED when the file exists:
// a file that names four of six marks leaves the other two to the non-verbal form in the same book,
// which is a mixed register no author intended.
var readerKeys = map[string]func(*ReaderWords, string){
"hole.pending": func(w *ReaderWords, v string) { w.HolePending = v },
"hole.withheld": func(w *ReaderWords, v string) { w.HoleWithheld = v },
"hole.incomplete": func(w *ReaderWords, v string) { w.HoleIncomplete = v },
"hole.stale": func(w *ReaderWords, v string) { w.HoleStale = v },
"hole.ghost": func(w *ReaderWords, v string) { w.HoleGhost = v },
"notice.holes": func(w *ReaderWords, v string) { w.NoticeHoles = v },
"notice.ghost": func(w *ReaderWords, v string) { w.NoticeGhost = v },
}
// readerFile is the file name under the target-language directory.
const readerFile = "reader.txt"
// DefaultReaderWords is the NON-VERBAL form used when the target language ships no reader.txt: a warning
// sign and the numbers the engine knows. It contains no word of any language on purpose — a word here
// would be the engine deciding how a language says something (12-go-style-notes §0).
func DefaultReaderWords() ReaderWords {
return ReaderWords{
HolePending: "⚠ {chapter}.{unit}",
HoleWithheld: "⚠ {chapter}.{unit}",
HoleIncomplete: "⚠ {chapter}.{unit} {dropped}",
HoleStale: "⚠ {chapter}.{unit}",
HoleGhost: "⚠ {chapter} +{ghost}",
NoticeHoles: "⚠ {holes}/{total}",
NoticeGhost: "⚠ +{ghost}",
}
}
// LoadReaderWords reads <root>/<targetLang>/reader.txt. An empty root (a book with no langpack) or an
// absent file yields DefaultReaderWords and present=false; a present file must be whole and well-formed
// (every key once, no unknown key, no empty value) or the load fails loud — a half-written vocabulary is
// a corrupt data file, not an intended mixture.
func LoadReaderWords(root, targetLang string) (words ReaderWords, present bool, err error) {
words = DefaultReaderWords()
if root == "" || strings.TrimSpace(targetLang) == "" {
return words, false, nil
}
b, err := os.ReadFile(filepath.Join(root, targetLang, readerFile))
if err != nil {
if os.IsNotExist(err) {
return words, false, nil
}
return words, false, fmt.Errorf("reader words %s/%s: %w", targetLang, readerFile, err)
}
parsed, err := parseReaderWords(b)
if err != nil {
return words, false, fmt.Errorf("reader words %s/%s: %w", targetLang, readerFile, err)
}
return parsed, true, nil
}
// parseReaderWords reads the `key<TAB>value` lines of reader.txt (comments and blank lines skipped).
func parseReaderWords(b []byte) (ReaderWords, error) {
var w ReaderWords
seen := map[string]bool{}
for i, raw := range strings.Split(string(b), "\n") {
t := strings.TrimSpace(strings.TrimRight(raw, "\r"))
if t == "" || strings.HasPrefix(t, "#") {
continue
}
f := strings.SplitN(t, "\t", 2)
if len(f) != 2 {
return w, fmt.Errorf("line %d: want `key<TAB>value`, got %q", i+1, t)
}
key, val := strings.TrimSpace(f[0]), strings.TrimSpace(f[1])
set, ok := readerKeys[key]
if !ok {
return w, fmt.Errorf("line %d: unknown key %q", i+1, key)
}
if seen[key] {
return w, fmt.Errorf("line %d: key %q given twice", i+1, key)
}
if val == "" {
return w, fmt.Errorf("line %d: key %q has an empty value", i+1, key)
}
seen[key] = true
set(&w, val)
}
for key := range readerKeys {
if !seen[key] {
return w, fmt.Errorf("missing key %q (every mark must be given; omit the file to use the non-verbal form)", key)
}
}
return w, nil
}
// FillReaderTemplate substitutes {name} placeholders in tmpl from pairs (name, value, name, value…).
// A placeholder the template does not use is simply not there; a name the caller did not pass stays as
// written, which is visible in the output rather than silently blank.
func FillReaderTemplate(tmpl string, pairs ...string) string {
oldnew := make([]string, 0, len(pairs))
for i := 0; i+1 < len(pairs); i += 2 {
oldnew = append(oldnew, "{"+pairs[i]+"}", pairs[i+1])
}
return strings.NewReplacer(oldnew...).Replace(tmpl)
}