mirror of
https://github.com/AdguardTeam/AdGuardHome.git
synced 2025-12-20 01:11:03 +08:00
Squashed commit of the following: commit 0c931c596316aa3322a8123877650a53b2ba31c3 Author: Elizaveta <e.egorova@adguard.com> Date: Wed Nov 19 14:14:04 2025 +0300 spaces commit 90b8bed07e392c32f2a4c963329563250ca06100 Author: Elizaveta <e.egorova@adguard.com> Date: Wed Nov 19 13:55:56 2025 +0300 update changelog commit 1a0dea21ccdc82603da4f42bd5396b8cbb29160a Merge: c70212ec7ff0ef4f39Author: Elizaveta <e.egorova@adguard.com> Date: Wed Nov 19 13:54:39 2025 +0300 Merge branch 'master' into AGDNS-3312 commit c70212ec761714528216f5f5dafe1f6683c480b5 Author: Elizaveta <e.egorova@adguard.com> Date: Tue Nov 18 17:21:26 2025 +0300 remove redundant line commit b08a8d0341fd7a042f63f960c784a11dce915a18 Author: Elizaveta <e.egorova@adguard.com> Date: Tue Nov 18 14:02:32 2025 +0300 lint commit c78f40bc6986a64b6a8606790b2307076f5b12c8 Author: Elizaveta <e.egorova@adguard.com> Date: Tue Nov 18 14:02:07 2025 +0300 update translations commit dd53e8b47361fa1b138151dc504fe5ee138391c4 Author: Elizaveta <e.egorova@adguard.com> Date: Tue Nov 18 13:55:27 2025 +0300 remove accordion commit af2bd3bdfd71d93fcf83d7fa836d85c319293d6d Author: Elizaveta <e.egorova@adguard.com> Date: Fri Nov 14 16:09:22 2025 +0300 linter commit 19eb2bfdc12f9c2a4c4f525027811719a72da214 Author: Elizaveta <e.egorova@adguard.com> Date: Fri Nov 14 16:07:48 2025 +0300 fix commit fc9a32299e778270354ce3fadd046437cda63a0d Author: Elizaveta <e.egorova@adguard.com> Date: Fri Nov 14 14:29:29 2025 +0300 fix translations commit 9c4dbc5fdebe00f4aa545172ffd733b71028a243 Author: Elizaveta <e.egorova@adguard.com> Date: Thu Nov 13 19:22:51 2025 +0300 linter commit 90107c52fa8391294201ecf5cfb437716e7d1eca Author: Elizaveta <e.egorova@adguard.com> Date: Thu Nov 13 18:07:26 2025 +0300 fix styles commit 4dd170e91a14dcab5f7b168cdf6f410847ad1dda Author: Elizaveta <e.egorova@adguard.com> Date: Thu Nov 13 18:01:52 2025 +0300 update translations commit1a865f0856Author: Eugene Burkov <e.burkov@adguard.com> Date: Wed Nov 12 16:06:19 2025 +0300 Pull request 2523: AGDNS-3398-imp-translations-script Squashed commit of the following: commit 3753e6d308c9dbe23d1c7c41cf57457b364cb253 Author: Eugene Burkov <E.Burkov@AdGuard.COM> Date: Wed Nov 12 15:43:55 2025 +0300 translations: imp code, docs commit 5389ec67eddbe33a99c3c893cb5b63c131d34e84 Author: Eugene Burkov <E.Burkov@AdGuard.COM> Date: Wed Nov 12 14:14:08 2025 +0300 all: fix lint, imp code commitc2e1b8edeeAuthor: Eugene Burkov <E.Burkov@AdGuard.COM> Date: Mon Nov 10 18:03:23 2025 +0300 all: imp translations script commitc930dcff36Author: Eugene Burkov <E.Burkov@AdGuard.COM> Date: Mon Nov 10 17:52:44 2025 +0300 scripts: revert master commitc28e13b4e1Author: Elizaveta <e.egorova@adguard.com> Date: Wed Nov 5 16:43:13 2025 +0300 move project key const commitee977f5993Author: Elizaveta <e.egorova@adguard.com> Date: Wed Nov 5 16:25:34 2025 +0300 update download script commit244c1011b0Author: Elizaveta <e.egorova@adguard.com> Date: Sat Nov 1 15:59:04 2025 +0300 restore master toggle commit40418ea8cfAuthor: Elizaveta <e.egorova@adguard.com> Date: Sat Nov 1 14:59:57 2025 +0300 update saveToFile func commite4f3c677d4Author: Elizaveta <e.egorova@adguard.com> Date: Sat Nov 1 14:52:14 2025 +0300 go linter commit5359590953Author: Elizaveta <e.egorova@adguard.com> Date: Sat Nov 1 14:44:39 2025 +0300 remove en group tr from __locales commita91215dfadAuthor: Elizaveta <e.egorova@adguard.com> Date: Sat Nov 1 14:43:39 2025 +0300 add translations commitc65f80048dAuthor: Elizaveta <e.egorova@adguard.com> Date: Sat Nov 1 12:15:07 2025 +0300 fix ui commit893433bd8fAuthor: Elizaveta <e.egorova@adguard.com> Date: Fri Oct 31 17:22:44 2025 +0300 fix comment commit26148996c9Author: Elizaveta <e.egorova@adguard.com> Date: Fri Oct 31 16:49:46 2025 +0300 styles commit2785958252Author: Elizaveta <e.egorova@adguard.com> Date: Fri Oct 31 16:48:46 2025 +0300 linter ... and 6 more commits
433 lines
9.9 KiB
Go
433 lines
9.9 KiB
Go
// translations downloads translations, uploads translations, prints summary
|
|
// for translations, prints unused strings.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/AdguardTeam/AdGuardHome/internal/aghos"
|
|
"github.com/AdguardTeam/golibs/errors"
|
|
"github.com/AdguardTeam/golibs/logutil/slogutil"
|
|
"github.com/AdguardTeam/golibs/osutil"
|
|
"github.com/AdguardTeam/golibs/osutil/executil"
|
|
"github.com/c2h5oh/datasize"
|
|
)
|
|
|
|
// TODO(e.burkov): Remove the default as they should be set by configuration.
|
|
const (
|
|
twoskyConfFile = "./.twosky.json"
|
|
localesDirHome = "./client/src/__locales"
|
|
defaultBaseFile = "en.json"
|
|
srcDir = "./client/src"
|
|
twoskyURI = "https://twosky.int.agrd.dev/api/v1"
|
|
|
|
readLimit = 1 * datasize.MB
|
|
uploadTimeout = 20 * time.Second
|
|
)
|
|
|
|
// blockerLangCodes is the codes of languages which need to be fully translated.
|
|
var blockerLangCodes = []langCode{
|
|
"de",
|
|
"en",
|
|
"es",
|
|
"fr",
|
|
"it",
|
|
"ja",
|
|
"ko",
|
|
"pt-br",
|
|
"pt-pt",
|
|
"ru",
|
|
"zh-cn",
|
|
"zh-tw",
|
|
}
|
|
|
|
// langCode is a language code.
|
|
type langCode string
|
|
|
|
// languages is a map, where key is language code and value is display name.
|
|
type languages map[langCode]string
|
|
|
|
// textlabel is a text label of localization.
|
|
type textLabel string
|
|
|
|
// locales is a map, where key is text label and value is translation.
|
|
type locales map[textLabel]string
|
|
|
|
func main() {
|
|
ctx := context.Background()
|
|
l := slogutil.New(nil)
|
|
|
|
if len(os.Args) == 1 {
|
|
usage("need a command")
|
|
}
|
|
|
|
if os.Args[1] == "help" {
|
|
usage("")
|
|
}
|
|
|
|
homeConf, servicesConf, err := readTwoskyConfig()
|
|
errors.Check(err)
|
|
|
|
var cli *twoskyClient
|
|
|
|
switch os.Args[1] {
|
|
case "summary":
|
|
errors.Check(summary(homeConf.Languages))
|
|
case "download":
|
|
cli = errors.Must(newTwoskyClient(homeConf))
|
|
cli.download(ctx, l)
|
|
|
|
cli = errors.Must(newTwoskyClient(servicesConf))
|
|
cli.download(ctx, l)
|
|
case "unused":
|
|
errors.Check(unused(ctx, l, homeConf.LocalizableFiles[0]))
|
|
case "upload":
|
|
cli = errors.Must(newTwoskyClient(homeConf))
|
|
errors.Check(cli.upload())
|
|
case "auto-add":
|
|
errors.Check(autoAdd(ctx, l, homeConf.LocalizableFiles[0]))
|
|
default:
|
|
usage("unknown command")
|
|
}
|
|
}
|
|
|
|
// usage prints usage. If addStr is not empty print addStr and exit with code
|
|
// 1, otherwise exit with code 0.
|
|
func usage(addStr string) {
|
|
const usageStr = `Usage: go run main.go <command> [<args>]
|
|
Commands:
|
|
help
|
|
Print usage.
|
|
summary
|
|
Print summary.
|
|
download [-n <count>]
|
|
Download translations. count is a number of concurrent downloads.
|
|
unused
|
|
Print unused strings.
|
|
upload
|
|
Upload translations.
|
|
auto-add
|
|
Add locales with additions to the git and restore locales with
|
|
deletions.`
|
|
|
|
if addStr != "" {
|
|
fmt.Printf("%s\n%s\n", addStr, usageStr)
|
|
|
|
os.Exit(osutil.ExitCodeFailure)
|
|
}
|
|
|
|
fmt.Println(usageStr)
|
|
|
|
os.Exit(osutil.ExitCodeSuccess)
|
|
}
|
|
|
|
// validateLanguageStr validates languages codes that contain in the str and
|
|
// returns them or error.
|
|
func validateLanguageStr(str string, all languages) (langs []langCode, err error) {
|
|
codes := strings.Fields(str)
|
|
langs = make([]langCode, 0, len(codes))
|
|
|
|
for _, k := range codes {
|
|
lc := langCode(k)
|
|
_, ok := all[lc]
|
|
if !ok {
|
|
return nil, fmt.Errorf("validating languages: unexpected language code %q", k)
|
|
}
|
|
|
|
langs = append(langs, lc)
|
|
}
|
|
|
|
return langs, nil
|
|
}
|
|
|
|
// readLocales reads file with name fn and returns a map, where key is text
|
|
// label and value is localization.
|
|
func readLocales(fn string) (loc locales, err error) {
|
|
b, err := os.ReadFile(fn)
|
|
if err != nil {
|
|
// Don't wrap the error since it's informative enough as is.
|
|
return nil, err
|
|
}
|
|
|
|
loc = make(locales)
|
|
err = json.Unmarshal(b, &loc)
|
|
if err != nil {
|
|
err = fmt.Errorf("unmarshalling %q: %w", fn, err)
|
|
|
|
return nil, err
|
|
}
|
|
|
|
return loc, nil
|
|
}
|
|
|
|
// summary prints summary for translations.
|
|
//
|
|
// TODO(e.burkov): Consider making it a method of [twoskyClient] and
|
|
// calculating summary for all configurations.
|
|
func summary(langs languages) (err error) {
|
|
basePath := filepath.Join(localesDirHome, defaultBaseFile)
|
|
baseLoc, err := readLocales(basePath)
|
|
if err != nil {
|
|
return fmt.Errorf("summary: %w", err)
|
|
}
|
|
|
|
size := float64(len(baseLoc))
|
|
|
|
for _, lang := range slices.Sorted(maps.Keys(langs)) {
|
|
name := filepath.Join(localesDirHome, string(lang)+".json")
|
|
if name == basePath {
|
|
continue
|
|
}
|
|
|
|
var loc locales
|
|
loc, err = readLocales(name)
|
|
if err != nil {
|
|
return fmt.Errorf("summary: reading locales: %w", err)
|
|
}
|
|
|
|
f := float64(len(loc)) * 100 / size
|
|
|
|
blocker := ""
|
|
|
|
// N is small enough to not raise performance questions.
|
|
ok := slices.Contains(blockerLangCodes, lang)
|
|
if ok {
|
|
blocker = " (blocker)"
|
|
}
|
|
|
|
fmt.Printf("%s\t %6.2f %%%s\n", lang, f, blocker)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// unused prints unused text labels.
|
|
//
|
|
// TODO(e.burkov): Consider making it a method of [twoskyClient] and searching
|
|
// unused strings for all configurations.
|
|
func unused(ctx context.Context, l *slog.Logger, basePath string) (err error) {
|
|
defer func() { err = errors.Annotate(err, "unused: %w") }()
|
|
|
|
baseLoc, err := readLocales(basePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
locDir := filepath.Clean(localesDirHome)
|
|
js, err := findJS(ctx, l, locDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return findUnused(js, baseLoc)
|
|
}
|
|
|
|
// findJS returns list of JavaScript and JSON files or error.
|
|
func findJS(ctx context.Context, l *slog.Logger, locDir string) (fileNames []string, err error) {
|
|
walkFn := func(name string, _ os.FileInfo, err error) error {
|
|
if err != nil {
|
|
l.WarnContext(ctx, "accessing a path", slogutil.KeyError, err)
|
|
|
|
return nil
|
|
}
|
|
|
|
if strings.HasPrefix(name, locDir) {
|
|
return nil
|
|
}
|
|
|
|
ext := filepath.Ext(name)
|
|
if ext == ".js" || ext == ".json" {
|
|
fileNames = append(fileNames, name)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
err = filepath.Walk(srcDir, walkFn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("filepath walking %q: %w", srcDir, err)
|
|
}
|
|
|
|
return fileNames, nil
|
|
}
|
|
|
|
// findUnused prints unused text labels from fileNames.
|
|
func findUnused(fileNames []string, loc locales) (err error) {
|
|
knownUsed := []textLabel{
|
|
"blocking_mode_refused",
|
|
"blocking_mode_nxdomain",
|
|
"blocking_mode_custom_ip",
|
|
}
|
|
|
|
for _, v := range knownUsed {
|
|
delete(loc, v)
|
|
}
|
|
|
|
for _, fn := range fileNames {
|
|
var buf []byte
|
|
buf, err = os.ReadFile(fn)
|
|
if err != nil {
|
|
return fmt.Errorf("finding unused: %w", err)
|
|
}
|
|
|
|
for k := range loc {
|
|
if bytes.Contains(buf, []byte(k)) {
|
|
delete(loc, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, v := range slices.Sorted(maps.Keys(loc)) {
|
|
fmt.Println(v)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// autoAdd adds locales with additions to the git and restores locales with
|
|
// deletions.
|
|
func autoAdd(ctx context.Context, l *slog.Logger, basePath string) (err error) {
|
|
defer func() { err = errors.Annotate(err, "auto add: %w") }()
|
|
|
|
cmdCons := executil.SystemCommandConstructor{}
|
|
|
|
adds, dels, err := changedLocales(ctx, l, cmdCons)
|
|
if err != nil {
|
|
// Don't wrap the error since it's informative enough as is.
|
|
return err
|
|
}
|
|
|
|
if slices.Contains(dels, basePath) {
|
|
return errors.Error("base locale contains deletions")
|
|
}
|
|
|
|
err = handleAdds(ctx, l, cmdCons, adds)
|
|
if err != nil {
|
|
// Don't wrap the error since it's informative enough as is.
|
|
return nil
|
|
}
|
|
|
|
err = handleDels(ctx, l, cmdCons, dels)
|
|
if err != nil {
|
|
// Don't wrap the error since it's informative enough as is.
|
|
return nil
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// gitCmd is the shell command for Git.
|
|
const gitCmd = "git"
|
|
|
|
// handleAdds adds locales with additions to the git.
|
|
func handleAdds(
|
|
ctx context.Context,
|
|
l *slog.Logger,
|
|
cmdCons executil.CommandConstructor,
|
|
locales []string,
|
|
) (err error) {
|
|
if len(locales) == 0 {
|
|
return nil
|
|
}
|
|
|
|
gitArgs := append([]string{"add"}, locales...)
|
|
l.DebugContext(ctx, "executing", "cmd", gitCmd, "args", gitArgs)
|
|
|
|
code, out, err := aghos.RunCommand(ctx, cmdCons, gitCmd, gitArgs...)
|
|
|
|
if err != nil || code != 0 {
|
|
return fmt.Errorf("git add exited with code %d output %q: %w", code, out, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleDels restores locales with deletions.
|
|
func handleDels(
|
|
ctx context.Context,
|
|
l *slog.Logger,
|
|
cmdCons executil.CommandConstructor,
|
|
locales []string,
|
|
) (err error) {
|
|
if len(locales) == 0 {
|
|
return nil
|
|
}
|
|
|
|
gitArgs := append([]string{"restore"}, locales...)
|
|
l.DebugContext(ctx, "executing", "cmd", gitCmd, "args", gitArgs)
|
|
|
|
code, out, err := aghos.RunCommand(ctx, cmdCons, gitCmd, gitArgs...)
|
|
|
|
if err != nil || code != 0 {
|
|
return fmt.Errorf("git restore exited with code %d output %q: %w", code, out, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// changedLocales returns cleaned paths of locales with changes or error. adds
|
|
// is the list of locales with only additions. dels is the list of locales
|
|
// with only deletions.
|
|
func changedLocales(
|
|
ctx context.Context,
|
|
l *slog.Logger,
|
|
cmdCons executil.CommandConstructor,
|
|
) (adds, dels []string, err error) {
|
|
defer func() { err = errors.Annotate(err, "getting changes: %w") }()
|
|
|
|
gitArgs := []string{"diff", "--numstat", localesDirHome}
|
|
l.DebugContext(ctx, "executing", "cmd", gitCmd, "args", gitArgs)
|
|
|
|
// TODO(s.chzhen): Consider streaming the output if needed. Using
|
|
// [io.Pipe] here is unnecessary; it complicates lifecycle management
|
|
// because the output must be read concurrently, and the PipeWriter must be
|
|
// explicitly closed to signal EOF. Since this command's output is small, a
|
|
// bytes.Buffer via executil.Run is sufficient.
|
|
var out bytes.Buffer
|
|
err = executil.Run(ctx, cmdCons, &executil.CommandConfig{
|
|
Path: gitCmd,
|
|
Args: gitArgs,
|
|
Stdout: &out,
|
|
})
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("executing cmd: %w", err)
|
|
}
|
|
|
|
scanner := bufio.NewScanner(&out)
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 3 {
|
|
return nil, nil, fmt.Errorf("invalid input: %q", line)
|
|
}
|
|
|
|
path := fields[2]
|
|
|
|
if fields[0] == "0" {
|
|
dels = append(dels, path)
|
|
} else if fields[1] == "0" {
|
|
adds = append(adds, path)
|
|
}
|
|
}
|
|
|
|
err = scanner.Err()
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("scanning: %w", err)
|
|
}
|
|
|
|
return adds, dels, nil
|
|
}
|