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 //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 //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 `keyvalue` 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 `keyvalue`, 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) }