Sync telesrv commits 49f9bab and 04d9563 into the public mirror. Exclude private docs and runtime key material per sync rules.
236 lines
6.4 KiB
Go
236 lines
6.4 KiB
Go
package langpack
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"telesrv/internal/domain"
|
||
)
|
||
|
||
type seedCandidate struct {
|
||
path string
|
||
meta domain.LangPack
|
||
}
|
||
|
||
// SeedDirectory 将导出的 .strings 文件清单原子对账到 LangPackStore。
|
||
// root 可直接指向 data/langpack,也可指向包含 .strings 的具体平台目录。
|
||
func (s *Service) SeedDirectory(ctx context.Context, root string) (int, error) {
|
||
if s == nil || s.packs == nil || root == "" {
|
||
return 0, nil
|
||
}
|
||
dir := filepath.Clean(root)
|
||
if _, err := os.Stat(dir); err != nil {
|
||
if errors.Is(err, os.ErrNotExist) {
|
||
return 0, nil
|
||
}
|
||
return 0, fmt.Errorf("stat langpack seed dir: %w", err)
|
||
}
|
||
|
||
candidates, scopes, err := scanSeedCandidates(dir)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("scan langpack seed dir: %w", err)
|
||
}
|
||
keys := make([]string, 0, len(candidates))
|
||
for key := range candidates {
|
||
keys = append(keys, key)
|
||
}
|
||
sort.Strings(keys)
|
||
|
||
seed := domain.LangPackSeed{
|
||
Catalog: seedCatalogID(dir),
|
||
Scopes: scopes,
|
||
Packs: make([]domain.LangPackSeedEntry, 0, len(keys)),
|
||
}
|
||
previous, err := s.packs.GetSeedCatalog(ctx, seed.Catalog)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("get previous langpack seed catalog: %w", err)
|
||
}
|
||
previousByKey := make(map[string]domain.LangPackSeedCatalogEntry, len(previous.Packs))
|
||
for _, entry := range previous.Packs {
|
||
previousByKey[entry.LangPack+"\x00"+entry.LangCode] = entry
|
||
}
|
||
for _, key := range keys {
|
||
candidate := candidates[key]
|
||
sourceHash, err := fileSHA256(candidate.path)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("hash langpack source %q: %w", candidate.path, err)
|
||
}
|
||
if old, ok := previousByKey[key]; ok &&
|
||
old.Version == candidate.meta.Version &&
|
||
old.SourceHash == sourceHash &&
|
||
old.ContentHash != "" && old.StringsCount > 0 {
|
||
seed.Packs = append(seed.Packs, domain.LangPackSeedEntry{
|
||
Pack: candidate.meta,
|
||
SourceHash: sourceHash,
|
||
ContentHash: old.ContentHash,
|
||
StringsCount: old.StringsCount,
|
||
ContentLoaded: false,
|
||
})
|
||
continue
|
||
}
|
||
pack, err := ParseTDesktopFile(candidate.path)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
pack, err = prepareSeedPack(pack)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("prepare langpack %q: %w", candidate.path, err)
|
||
}
|
||
hash, err := langPackContentHash(pack)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("hash langpack %q: %w", candidate.path, err)
|
||
}
|
||
seed.Packs = append(seed.Packs, domain.LangPackSeedEntry{
|
||
Pack: pack,
|
||
SourceHash: sourceHash,
|
||
ContentHash: hash,
|
||
StringsCount: len(pack.Strings),
|
||
ContentLoaded: true,
|
||
})
|
||
}
|
||
|
||
seeded, err := s.packs.ReconcileSeed(ctx, seed)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("reconcile langpack seed: %w", err)
|
||
}
|
||
s.flushCaches()
|
||
return seeded, nil
|
||
}
|
||
|
||
func fileSHA256(path string) (string, error) {
|
||
file, err := os.Open(path)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer file.Close()
|
||
hash := sha256.New()
|
||
if _, err := io.Copy(hash, file); err != nil {
|
||
return "", err
|
||
}
|
||
return hex.EncodeToString(hash.Sum(nil)), nil
|
||
}
|
||
|
||
func scanSeedCandidates(root string) (map[string]seedCandidate, []string, error) {
|
||
candidates := make(map[string]seedCandidate)
|
||
scopeSet := make(map[string]struct{})
|
||
hasChildDirs := false
|
||
hasFiles := false
|
||
|
||
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rel, err := filepath.Rel(root, path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if entry.IsDir() {
|
||
if rel != "." && filepath.Dir(rel) == "." {
|
||
hasChildDirs = true
|
||
scope := normalizePack(entry.Name())
|
||
if langPackNameRE.MatchString(scope) {
|
||
scopeSet[scope] = struct{}{}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
if !strings.EqualFold(filepath.Ext(entry.Name()), ".strings") {
|
||
return nil
|
||
}
|
||
hasFiles = true
|
||
meta, err := packFromFilename(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if relDir := filepath.Dir(rel); relDir != "." {
|
||
firstDir := strings.Split(relDir, string(filepath.Separator))[0]
|
||
scope := normalizePack(firstDir)
|
||
if !langPackNameRE.MatchString(scope) || scope != meta.LangPack {
|
||
return fmt.Errorf("langpack file %q is under pack directory %q", path, firstDir)
|
||
}
|
||
}
|
||
scopeSet[meta.LangPack] = struct{}{}
|
||
key := meta.LangPack + "\x00" + meta.LangCode
|
||
if previous, ok := candidates[key]; ok {
|
||
switch {
|
||
case meta.Version < previous.meta.Version:
|
||
return nil
|
||
case meta.Version == previous.meta.Version:
|
||
return fmt.Errorf("duplicate langpack version %s/%s v%d in %q and %q", meta.LangPack, meta.LangCode, meta.Version, previous.path, path)
|
||
}
|
||
}
|
||
candidates[key] = seedCandidate{path: path, meta: meta}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
if !hasChildDirs && !hasFiles {
|
||
scope := normalizePack(filepath.Base(root))
|
||
if langPackNameRE.MatchString(scope) {
|
||
scopeSet[scope] = struct{}{}
|
||
}
|
||
}
|
||
|
||
scopes := make([]string, 0, len(scopeSet))
|
||
for scope := range scopeSet {
|
||
scopes = append(scopes, scope)
|
||
}
|
||
sort.Strings(scopes)
|
||
return candidates, scopes, nil
|
||
}
|
||
|
||
func prepareSeedPack(pack domain.LangPack) (domain.LangPack, error) {
|
||
pack.LangPack = normalizePack(pack.LangPack)
|
||
pack.LangCode = normalizeCode(pack.LangCode)
|
||
pack.FromVersion = 0
|
||
if len(pack.Strings) == 0 {
|
||
return domain.LangPack{}, errors.New("language file contains no strings")
|
||
}
|
||
deduplicated := make([]domain.LangPackString, 0, len(pack.Strings))
|
||
indexes := make(map[string]int, len(pack.Strings))
|
||
for _, item := range pack.Strings {
|
||
if item.Key == "" || utf8.RuneCountInString(item.Key) > 128 {
|
||
return domain.LangPack{}, fmt.Errorf("invalid string key %q", item.Key)
|
||
}
|
||
if index, exists := indexes[item.Key]; exists {
|
||
deduplicated[index] = item
|
||
continue
|
||
}
|
||
indexes[item.Key] = len(deduplicated)
|
||
deduplicated = append(deduplicated, item)
|
||
}
|
||
pack.Strings = deduplicated
|
||
sort.Slice(pack.Strings, func(i, j int) bool {
|
||
return pack.Strings[i].Key < pack.Strings[j].Key
|
||
})
|
||
return pack, nil
|
||
}
|
||
|
||
func langPackContentHash(pack domain.LangPack) (string, error) {
|
||
encoded, err := json.Marshal(pack)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
sum := sha256.Sum256(encoded)
|
||
return hex.EncodeToString(sum[:]), nil
|
||
}
|
||
|
||
func seedCatalogID(root string) string {
|
||
base := normalizePack(filepath.Base(root))
|
||
if langPackNameRE.MatchString(base) {
|
||
return base
|
||
}
|
||
sum := sha256.Sum256([]byte(filepath.Clean(root)))
|
||
return "path-" + hex.EncodeToString(sum[:8])
|
||
}
|