// Package i18n переводит сообщения менеджера на язык веб-панели. // // Исходный язык кода — русский, он же служит ключом перевода: словарь связывает // русскую фразу с английской, а незнакомая фраза остаётся как есть. Благодаря // этому через перевод можно безопасно пропускать и вывод игрового сервера — он // английский и в словаре не встретится. package i18n import ( "fmt" "net/http" "regexp" "sort" "strings" ) // Lang — язык сообщений панели. type Lang string const ( RU Lang = "ru" EN Lang = "en" // Default — язык, на котором сообщения написаны в коде. Default = RU ) // CookieName — кука, которой панель сообщает выбранный человеком язык. const CookieName = "pzm_lang" // Parse разбирает код языка; для незнакомого возвращает язык по умолчанию. func Parse(value string) (Lang, bool) { switch strings.ToLower(strings.TrimSpace(value)) { case "ru": return RU, true case "en": return EN, true } return Default, false } // FromRequest определяет язык ответа. Выбор человека (кука, которую ставит // панель) важнее списка языков браузера: на сервер могут заходить с чужой // машины, а язык панели человек выбирает сам. func FromRequest(r *http.Request) Lang { if r == nil { return Default } if cookie, err := r.Cookie(CookieName); err == nil { if lang, ok := Parse(cookie.Value); ok { return lang } } return fromAcceptLanguage(r.Header.Get("Accept-Language")) } // fromAcceptLanguage берёт первый понятный язык из заголовка. Веса (q=) не // разбираем: браузеры и так перечисляют языки по убыванию предпочтения. func fromAcceptLanguage(header string) Lang { for _, part := range strings.Split(header, ",") { tag := strings.TrimSpace(part) if i := strings.IndexByte(tag, ';'); i >= 0 { tag = tag[:i] } if i := strings.IndexByte(tag, '-'); i >= 0 { tag = tag[:i] } if lang, ok := Parse(tag); ok { return lang } } return Default } // T переводит сообщение на указанный язык. Неизвестное сообщение возвращается // без изменений: потерять текст хуже, чем показать его на чужом языке. func T(lang Lang, message string) string { if lang == Default || lang == "" || message == "" { return message } return translate(message, 0) } // maxDepth ограничивает разбор вложенных ошибок: «прочитать X: создать Y: ...». const maxDepth = 4 func translate(message string, depth int) string { if text, ok := messages[message]; ok { return text } if depth >= maxDepth { return message } for _, p := range patterns { match := p.re.FindStringSubmatch(message) if match == nil { continue } // Подставленные значения переводим тоже: в них попадает вложенная // ошибка целиком, и она может быть знакома словарю. args := make([]any, 0, len(match)-1) for _, group := range match[1:] { args = append(args, translate(group, depth+1)) } return fmt.Sprintf(p.en, args...) } return message } // pattern — пара форматов «русский → английский». Значения, подставленные в // сообщение при его сборке, вылавливаются группами регулярного выражения. type pattern struct { re *regexp.Regexp en string // weight — длина неизменяемой части. Чем она больше, тем конкретнее // шаблон, и тем раньше его стоит примерить. weight int } var patterns []pattern func init() { patterns = make([]pattern, 0, len(formats)) for _, f := range formats { patterns = append(patterns, compile(f[0], f[1])) } sort.SliceStable(patterns, func(i, j int) bool { return patterns[i].weight > patterns[j].weight }) } func compile(ru, en string) pattern { parts := strings.Split(ru, "%s") weight := 0 for _, part := range parts { weight += len(part) } var body strings.Builder body.WriteString(regexp.QuoteMeta(parts[0])) for i := 1; i < len(parts); i++ { // Последняя группа жадная: в неё попадает вложенная ошибка целиком, // вместе со своими двоеточиями. group := "(.+?)" if i == len(parts)-1 && parts[i] == "" { group = "(.+)" } body.WriteString(group) body.WriteString(regexp.QuoteMeta(parts[i])) } // (?s) — сообщения бывают многострочными. return pattern{re: regexp.MustCompile("(?s)^" + body.String() + "$"), en: en, weight: weight} }