Paarthurnax
A Golang translator
Utilisation
Init
Crée les fichiers nécessaire pour traquer les segments dans le temps
Normalize
Normalise les fichiers de traduction afin de réduire le bruit dans les reviews futurs
Translate
Traduit dans toutes les langues définit via DeepL
Init
$ paarthurnax init
2024/09/23 15:44:38 Generating state from disk...
2024/09/23 15:44:38 Processing config/locales/a_new_directory/fr.yml
2024/09/23 15:44:38 Processing config/locales/component/fr.yml
2024/09/23 15:44:38 Processing config/locales/fr.yml
2024/09/23 15:44:38 Persisting state...
- Charge les fichiers de traduction source
- Génere un Hash pour chaque clé basée sur son contenu
- Sauvegarde un fichier d'état .paarthurnax
Init
Quand?
Une seule fois a l'installation de Paarthurnax dans le projet
Prérequis?
Clean state (pas de traductions en attente)
Normalize
$ paarthurnax normalize
2024/09/23 15:59:11 Loading current state from disk...
2024/09/23 15:59:11 Normalizing translations...
2024/09/23 15:59:11 Processing config/locales/a_new_directory/fr.yml...
2024/09/23 15:59:11 Processing config/locales/component/fr.yml...
2024/09/23 15:59:11 Processing config/locales/fr.yml...
2024/09/23 15:59:11 Done!
- Charge les fichiers de destination de traduction
- Les réécrit directement avec le même système que lors de prochaines traductions
normalize
Quand?
A n'importe quel instant
Prérequis?
Aucun
translate
$ DEEPL_API_KEY=[...] paarthurnax translate
2024/09/23 16:00:41 Loading previous state from disk...
2024/09/23 16:00:41 Generating current state from disk...
2024/09/23 16:00:41 Reconciling states...
2024/09/23 16:00:41 Processing config/locales/a_new_directory/fr.yml
2024/09/23 16:00:41 Processing config/locales/component/fr.yml
2024/09/23 16:00:41 Processing config/locales/fr.yml
2024/09/23 16:00:41 Cleaning up removed files...
2024/09/23 16:00:41 Persisting new state...
- Charge les fichiers de traduction source
- Charge l'état précédent
- Traduit touts les segments modifiés avec DeepL
- Réécrit tous les fichiers de traduction cible modifiés
Init
Quand?
A chaque étape de traductio
Prérequis?
- Présence du fichier .paarthurnax
- Présence de la clé d'api DeepL FREE en variable d'env
Fonctionnement
// Variables
var i string
i := 42
// Fonctions
func (reciver File) Name(param string) (string, error) {
[...]
}
// Types
type File struct {
I int
}
Fonctionnement
type State struct {
Files []translationFile.TranslationFile
}
type TranslationFile struct {
Path string
SegmentsHashes map[string]string // Key: sha1
}
Chargement du state depuis les locales
Fonctionnement
Chargement du state depuis les locales
e := filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error {
if err == nil && d.Name() == "fr.yml" {
if verbose {
log.Println(fmt.Sprintf("Processing %s", path))
}
t, err := translation.Load(path)
if err != nil {
return errors.New(fmt.Sprintf("Failed to load translation: %s", err))
}
state.Files = append(state.Files, translationFile.TranslationFile{
Path: path,
SegmentsHashes: HashTranslation(t.FlattenedSegments()),
})
}
return err
})
if e != nil {
return nil, errors.New("Unable to process the filestructure: " + e.Error())
}
Fonctionnement
func Load(path string) (*TranslationFile, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, errors.New("Error reading file:" + err.Error())
}
var yamlData map[string]interface{}
err = yaml.Unmarshal(data, &yamlData)
if err != nil {
return nil, errors.New("Error unmarshaling YAML:" + err.Error())
}
if len(yamlData) != 1 {
return nil, errors.New("the provided YAML file is not a valid translation file")
}
locale := utils.MapKeys(yamlData)[0]
file := TranslationFile{Path: path, Locale: locale, Segments: yamlData[locale].(map[string]interface{})}
return &file, nil
}
Fonctionnement
Chargement du state depuis les locales
e := filepath.WalkDir(path, func(path string, d fs.DirEntry, err error) error {
if err == nil && d.Name() == "fr.yml" {
if verbose {
log.Println(fmt.Sprintf("Processing %s", path))
}
t, err := translation.Load(path)
if err != nil {
return errors.New(fmt.Sprintf("Failed to load translation: %s", err))
}
state.Files = append(state.Files, translationFile.TranslationFile{
Path: path,
SegmentsHashes: HashTranslation(t.FlattenedSegments()),
})
}
return err
})
if e != nil {
return nil, errors.New("Unable to process the filestructure: " + e.Error())
}
Fonctionnement
Chargement du state depuis les locales
func (translation *TranslationFile) FlattenedSegments() map[string]string {
return extractFlattenedSegments(translation.Segments)
}
func extractFlattenedSegments(m map[string]interface{}) map[string]string {
result := make(map[string]string)
for key, value := range m {
switch value.(type) {
case string:
result[key] = value.(string)
case map[string]interface{}:
for subKey, subValue := range extractFlattenedSegments(value.(map[string]interface{})) {
result[key+"."+subKey] = subValue
}
}
}
return result
}
Fonctionnement
Chargement du state depuis les locales
func HashTranslation(m map[string]string) map[string]string {
result := make(map[string]string)
for k, v := range m {
h := sha1.New()
h.Write([]byte(v))
result[k] = hex.EncodeToString(h.Sum(nil))
}
return result
}
Fonctionnement
Chargement du state depuis les locales
Fonctionnement
Chargement du state depuis les locales
[[Files]]
Path = 'config/locales/a_new_directory/fr.yml'
[Files.SegmentsHashes]
a_key = '2a6dcab3038a7c1d15b427378a3483952761df8c'
[[Files]]
Path = 'config/locales/component/fr.yml'
[Files.SegmentsHashes]
'a_super.deep.key' = 'e50fc81a49f34d67292cdfd88a6e1737be1677a6'
[[Files]]
Path = 'config/locales/fr.yml'
[Files.SegmentsHashes]
'a.deep.b' = '6dcd4ce23d88e2ee9568ba546c007c63d9131c1b'
first_level_key = '153d6addf72e832f95dd72ba7f891ccb47d6d4d2'
likes = '06cee428e9b518ed6181eb637a6a6175fa8d725f'
remote_status = 'ca4830751faf5e537a84eb297e53441a599c7681'
'second_level.plural_key.one' = '137eca41db3d9cb9591ad89ceb5e78379017b67f'
'second_level.plural_key.other' = 'a1ba1d821b7e573d90064bcfe2fd080ff625a0ad'
'second_level.plural_key.zero' = 'ddcc9ab409b7ba7733d96f07e981dda1102b21a6'
'second_level.second_level_key' = '6f9588ba7a1bb1d5a7c987cc9c1211fe81f7a15f'
Fonctionnement
Normalisation des traductions
log.Println("Loading current state from disk...")
nState, err := state.LoadFromDisk("config/locales", false)
if err != nil {
log.Fatal(err)
}
log.Println("Normalizing translations...")
for _, nFile := range nState.Files {
log.Println(fmt.Sprintf("Processing %s...", nFile.Path))
for _, locale := range translationGroup.DestLocales {
path := strings.Replace(nFile.Path, "fr.yml", locale+".yml", 1)
file, err := translation.LoadOrCreate(path)
if err != nil {
log.Fatal(fmt.Sprintf("%s: %s", path, err.Error()))
}
if err = file.Save(); err != nil {
log.Fatal(err)
}
}
}
Fonctionnement
Traduction
log.Println("Loading previous state from disk...")
pState, err := state.Load()
if err != nil {
log.Fatal(err)
}
log.Println("Generating current state from disk...")
nState, err := state.LoadFromDisk("config/locales", false)
if err != nil {
log.Fatal(err)
}
Fonctionnement
Traduction
log.Println("Reconciling states...")
for _, nFile := range nState.Files {
log.Println(fmt.Sprintf("Processing %s", nFile.Path))
changes := nFile.Changes(pState.GetFile(nFile.Path))
if len(changes) != 0 {
log.Println(fmt.Sprintf("Applying changes and translating %s...", nFile.Path))
group, err := translationGroup.New(nFile.Path)
if err != nil {
log.Fatal(err)
}
if err = group.Apply(changes); err != nil {
log.Fatal(err)
}
}
}
Fonctionnement
Traduction
const (
Added = 0
Removed = 1
Updated = 2
)
type Change struct {
Kind int8
Path string
}
func (file *TranslationFile) Changes(other *TranslationFile) []Change {
var changes []Change
for path, hash := range file.SegmentsHashes {
if other == nil { // When the other translation state does not exist (new source file)
changes = append(changes, Change{Kind: Added, Path: path})
} else {
oHash, ok := other.SegmentsHashes[path]
if !ok {
changes = append(changes, Change{Kind: Added, Path: path})
} else if hash != oHash {
changes = append(changes, Change{Kind: Updated, Path: path})
}
}
}
if other != nil {
for path, _ := range other.SegmentsHashes {
if _, ok := file.SegmentsHashes[path]; !ok {
changes = append(changes, Change{Kind: Removed, Path: path})
}
}
}
return changes
}
Fonctionnement
Traduction
log.Println("Reconciling states...")
for _, nFile := range nState.Files {
log.Println(fmt.Sprintf("Processing %s", nFile.Path))
changes := nFile.Changes(pState.GetFile(nFile.Path))
if len(changes) != 0 {
log.Println(fmt.Sprintf("Applying changes and translating %s...", nFile.Path))
group, err := translationGroup.New(nFile.Path)
if err != nil {
log.Fatal(err)
}
if err = group.Apply(changes); err != nil {
log.Fatal(err)
}
}
}
var DestLocales = [...]string{"en", "es", "it", "de", "hu", "pt", "pl", "ro", "uk"}
type TranslationGroup struct {
Path string
source *translation.TranslationFile
files []*translation.TranslationFile
}
Fonctionnement
Traduction
func (group *TranslationGroup) Apply(changes []translationFile.Change) error {
translator, err := deepl.New(os.Getenv("DEEPL_API_KEY"), "api-free.deepl.com")
if err != nil {
return errors.New("Unable to initialize the translation engine: " + err.Error())
}
for _, change := range changes {
keyParts := strings.Split(change.Path, ".")
if utils.Includes(keyParts[len(keyParts)-1], []string{"zero", "one", "other"}) {
if err := handlePluralSegment(keyParts[len(keyParts)-1], group, &change, translator); err != nil {
return err
}
} else {
if err := handleStandaloneSegment(group, &change, translator); err != nil {
return err
}
}
}
for _, file := range group.files {
if err := file.Save(); err != nil {
return errors.New("Failed to save updated translation file " + file.Path + ": " + err.Error())
}
}
return nil
}
Fonctionnement
Traduction
func handleStandaloneSegment(group *TranslationGroup, change *translationFile.Change, translator *deepl.Deepl) error {
for _, file := range group.files {
if change.Kind == translationFile.Added || change.Kind == translationFile.Updated {
value, err := group.source.GetSegmentValueAt(change.Path)
if err != nil {
return errors.New("Unable to get the value of the source segment " + change.Path + ": " + err.Error())
}
valueForTranslation, ctx, err := prepareForTranslation(value)
if err != nil {
return errors.New("Unable to prepare for translation " + change.Path + ": " + err.Error())
}
translation, err := translator.Translate(valueForTranslation, "fr", file.Locale)
if err != nil {
return errors.New("Unable to translate the value of the source segment " + change.Path + ": " + err.Error())
}
translation, err = revertTranslationPreparation(translation, ctx)
if err != nil {
return errors.New("Unable to revert translation preparation for " + change.Path + ": " + err.Error())
}
if err = checkVariableEquity(value, translation); err != nil {
return errors.New("The translation does not contain the required variables: " + err.Error())
}
if err = file.SetSegmentValueAt(change.Path, translation); err != nil {
return errors.New("Unable to set the value of the source segment " + change.Path + " in locale " + file.Locale + ": " + err.Error())
}
} else if change.Kind == translationFile.Removed {
if err := file.RemoveSegmentAt(change.Path); err != nil {
return errors.New("Unable to remove segment " + change.Path + " in locale " + file.Locale + ": " + err.Error())
}
}
}
return nil
}
Fonctionnement
Traduction
func prepareForTranslation(text string) (string, map[string]string, error) {
ctx := make(map[string]string)
r, err := regexp.Compile("%{(?P<variable>[a-zA-Z_ -]+)}")
if err != nil {
return "", nil, errors.New("Unable to prepare variable extraction: " + err.Error())
}
for _, variable := range r.FindAllStringSubmatch(text, -1) {
ctx[variable[1]] = "<span translate=\"no\">" + variable[1] + "</span>"
text = strings.Replace(text, "%{"+variable[1]+"}", ctx[variable[1]], -1)
}
return text, ctx, nil
}
Fonctionnement
Traduction
func handleStandaloneSegment(group *TranslationGroup, change *translationFile.Change, translator *deepl.Deepl) error {
for _, file := range group.files {
if change.Kind == translationFile.Added || change.Kind == translationFile.Updated {
value, err := group.source.GetSegmentValueAt(change.Path)
if err != nil {
return errors.New("Unable to get the value of the source segment " + change.Path + ": " + err.Error())
}
valueForTranslation, ctx, err := prepareForTranslation(value)
if err != nil {
return errors.New("Unable to prepare for translation " + change.Path + ": " + err.Error())
}
translation, err := translator.Translate(valueForTranslation, "fr", file.Locale)
if err != nil {
return errors.New("Unable to translate the value of the source segment " + change.Path + ": " + err.Error())
}
translation, err = revertTranslationPreparation(translation, ctx)
if err != nil {
return errors.New("Unable to revert translation preparation for " + change.Path + ": " + err.Error())
}
if err = checkVariableEquity(value, translation); err != nil {
return errors.New("The translation does not contain the required variables: " + err.Error())
}
if err = file.SetSegmentValueAt(change.Path, translation); err != nil {
return errors.New("Unable to set the value of the source segment " + change.Path + " in locale " + file.Locale + ": " + err.Error())
}
} else if change.Kind == translationFile.Removed {
if err := file.RemoveSegmentAt(change.Path); err != nil {
return errors.New("Unable to remove segment " + change.Path + " in locale " + file.Locale + ": " + err.Error())
}
}
}
return nil
}
Fonctionnement
Traduction
func revertTranslationPreparation(text string, ctx map[string]string) (string, error) {
for value, placeholder := range ctx {
text = strings.Replace(text, placeholder, "%{"+value+"}", -1)
}
return text, nil
}
Fonctionnement
Traduction
func handleStandaloneSegment(group *TranslationGroup, change *translationFile.Change, translator *deepl.Deepl) error {
for _, file := range group.files {
if change.Kind == translationFile.Added || change.Kind == translationFile.Updated {
value, err := group.source.GetSegmentValueAt(change.Path)
if err != nil {
return errors.New("Unable to get the value of the source segment " + change.Path + ": " + err.Error())
}
valueForTranslation, ctx, err := prepareForTranslation(value)
if err != nil {
return errors.New("Unable to prepare for translation " + change.Path + ": " + err.Error())
}
translation, err := translator.Translate(valueForTranslation, "fr", file.Locale)
if err != nil {
return errors.New("Unable to translate the value of the source segment " + change.Path + ": " + err.Error())
}
translation, err = revertTranslationPreparation(translation, ctx)
if err != nil {
return errors.New("Unable to revert translation preparation for " + change.Path + ": " + err.Error())
}
if err = checkVariableEquity(value, translation); err != nil {
return errors.New("The translation does not contain the required variables: " + err.Error())
}
if err = file.SetSegmentValueAt(change.Path, translation); err != nil {
return errors.New("Unable to set the value of the source segment " + change.Path + " in locale " + file.Locale + ": " + err.Error())
}
} else if change.Kind == translationFile.Removed {
if err := file.RemoveSegmentAt(change.Path); err != nil {
return errors.New("Unable to remove segment " + change.Path + " in locale " + file.Locale + ": " + err.Error())
}
}
}
return nil
}
Fonctionnement
Traduction
func checkVariableEquity(a string, b string) error {
r, err := regexp.Compile("(?P<variable>%{[a-zA-Z_ -]+})")
if err != nil {
return errors.New("Unable to prepare variable check: " + err.Error())
}
aResults := r.FindAllStringSubmatch(a, -1)
var bResults []string
for _, match := range r.FindAllStringSubmatch(b, -1) {
bResults = append(bResults, match[1])
}
for _, variable := range aResults {
if !utils.Includes(variable[1], bResults) {
return errors.New(fmt.Sprintf("'%s' is not present in '%s'", variable[1], b))
}
}
return nil
}
Fonctionnement
Traduction
func handleStandaloneSegment(group *TranslationGroup, change *translationFile.Change, translator *deepl.Deepl) error {
for _, file := range group.files {
if change.Kind == translationFile.Added || change.Kind == translationFile.Updated {
value, err := group.source.GetSegmentValueAt(change.Path)
if err != nil {
return errors.New("Unable to get the value of the source segment " + change.Path + ": " + err.Error())
}
valueForTranslation, ctx, err := prepareForTranslation(value)
if err != nil {
return errors.New("Unable to prepare for translation " + change.Path + ": " + err.Error())
}
translation, err := translator.Translate(valueForTranslation, "fr", file.Locale)
if err != nil {
return errors.New("Unable to translate the value of the source segment " + change.Path + ": " + err.Error())
}
translation, err = revertTranslationPreparation(translation, ctx)
if err != nil {
return errors.New("Unable to revert translation preparation for " + change.Path + ": " + err.Error())
}
if err = checkVariableEquity(value, translation); err != nil {
return errors.New("The translation does not contain the required variables: " + err.Error())
}
if err = file.SetSegmentValueAt(change.Path, translation); err != nil {
return errors.New("Unable to set the value of the source segment " + change.Path + " in locale " + file.Locale + ": " + err.Error())
}
} else if change.Kind == translationFile.Removed {
if err := file.RemoveSegmentAt(change.Path); err != nil {
return errors.New("Unable to remove segment " + change.Path + " in locale " + file.Locale + ": " + err.Error())
}
}
}
return nil
}
Fonctionnement
Traduction
func handlePluralSegment(part string, group *TranslationGroup, change *translationFile.Change, translator *deepl.Deepl) error {
for _, file := range group.files {
affectedKeys := determineAffectedKeysIn(part, file.Locale)
for _, definition := range affectedKeys {
pathParts := strings.Split(change.Path, ".")
localKey := strings.Join(append(pathParts[:len(pathParts)-1], definition.key), ".")
if change.Kind == translationFile.Added || change.Kind == translationFile.Updated {
value, err := group.source.GetSegmentValueAt(change.Path)
if err != nil {
return errors.New("Unable to get the value of the source segment " + change.Path + ": " + err.Error())
}
localValue := strings.ReplaceAll(value, "%{count}", strconv.Itoa(int(definition.tip)))
valueForTranslation, ctx, err := prepareForTranslation(localValue)
if err != nil {
return errors.New("Unable to prepare for translation " + change.Path + ": " + err.Error())
}
translation, err := translator.Translate(valueForTranslation, "fr", file.Locale)
if err != nil {
return errors.New("Unable to translate the value of the source segment " + change.Path + ": " + err.Error())
}
translation, err = revertTranslationPreparation(translation, ctx)
if err != nil {
return errors.New("Unable to revert translation preparation for " + change.Path + ": " + err.Error())
}
translation = strings.ReplaceAll(translation, strconv.Itoa(int(definition.tip)), "%{count}")
if err = checkVariableEquity(value, translation); err != nil {
return errors.New("The translation does not contain the required variables: " + err.Error())
}
if err = file.SetSegmentValueAt(localKey, translation); err != nil {
return errors.New("Unable to set the value of the source segment " + change.Path + " in locale " + file.Locale + ": " + err.Error())
}
} else if change.Kind == translationFile.Removed {
if err := file.RemoveSegmentAt(localKey); err != nil {
return errors.New("Unable to remove segment " + localKey + " in locale " + file.Locale + ": " + err.Error())
}
}
}
}
return nil
}
Fonctionnement
Traduction
func determineAffectedKeysIn(part string, locale string) []pluralDefinition {
keysDirections := map[string]map[string][]pluralDefinition{
"zero": {
"en": {
{
key: "zero",
tip: 0,
},
},
},
"one": {
"en": {
key: "one",
tip: 1
}
}
}
return keysDirections[part][locale]
}
Fonctionnement
Traduction
func handlePluralSegment(part string, group *TranslationGroup, change *translationFile.Change, translator *deepl.Deepl) error {
for _, file := range group.files {
affectedKeys := determineAffectedKeysIn(part, file.Locale)
for _, definition := range affectedKeys {
pathParts := strings.Split(change.Path, ".")
localKey := strings.Join(append(pathParts[:len(pathParts)-1], definition.key), ".")
if change.Kind == translationFile.Added || change.Kind == translationFile.Updated {
value, err := group.source.GetSegmentValueAt(change.Path)
if err != nil {
return errors.New("Unable to get the value of the source segment " + change.Path + ": " + err.Error())
}
localValue := strings.ReplaceAll(value, "%{count}", strconv.Itoa(int(definition.tip)))
valueForTranslation, ctx, err := prepareForTranslation(localValue)
if err != nil {
return errors.New("Unable to prepare for translation " + change.Path + ": " + err.Error())
}
translation, err := translator.Translate(valueForTranslation, "fr", file.Locale)
if err != nil {
return errors.New("Unable to translate the value of the source segment " + change.Path + ": " + err.Error())
}
translation, err = revertTranslationPreparation(translation, ctx)
if err != nil {
return errors.New("Unable to revert translation preparation for " + change.Path + ": " + err.Error())
}
translation = strings.ReplaceAll(translation, strconv.Itoa(int(definition.tip)), "%{count}")
if err = checkVariableEquity(value, translation); err != nil {
return errors.New("The translation does not contain the required variables: " + err.Error())
}
if err = file.SetSegmentValueAt(localKey, translation); err != nil {
return errors.New("Unable to set the value of the source segment " + change.Path + " in locale " + file.Locale + ": " + err.Error())
}
} else if change.Kind == translationFile.Removed {
if err := file.RemoveSegmentAt(localKey); err != nil {
return errors.New("Unable to remove segment " + localKey + " in locale " + file.Locale + ": " + err.Error())
}
}
}
}
return nil
}
Quoi retenir?
$ paarthurnax -h
A simple and quick translation tool for in place
translation of Rails project
Usage:
Paarthurnax [flags]
Paarthurnax [command]
Available Commands:
completion Generate the autocompletion script for the specified shell
help Help about any command
init Initialize the repository
normalize Normalize the repository
translate Translate the repository
Flags:
-h, --help help for Paarthurnax
Use "Paarthurnax [command] --help" for more information about a command.
Code source
Paarthurnax
By foret_a
Paarthurnax
- 18