150 lines
6.7 KiB
Go
150 lines
6.7 KiB
Go
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)
|
||
}
|