Add translation system (#104)

This commit is contained in:
Thomas Miceli
2023-09-22 17:26:09 +02:00
committed by GitHub
parent 61e274e56d
commit a5ea522e45
27 changed files with 774 additions and 205 deletions

125
internal/i18n/locale.go Normal file
View File

@ -0,0 +1,125 @@
package i18n
import (
"fmt"
"github.com/thomiceli/opengist/internal/i18n/locales"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"golang.org/x/text/language/display"
"gopkg.in/yaml.v3"
"html/template"
"io"
"io/fs"
"path/filepath"
"strings"
)
var title = cases.Title(language.English)
var Locales = NewLocaleStore()
type LocaleStore struct {
Locales map[string]*Locale
}
type Locale struct {
Code string
Name string
Messages map[string]string
}
// NewLocaleStore creates a new LocaleStore
func NewLocaleStore() *LocaleStore {
return &LocaleStore{
Locales: make(map[string]*Locale),
}
}
// loadLocaleFromYAML loads a single Locale from a given YAML file
func (store *LocaleStore) loadLocaleFromYAML(localeCode, path string) error {
a, err := locales.Files.Open(path)
if err != nil {
return err
}
data, err := io.ReadAll(a)
if err != nil {
return err
}
tag, err := language.Parse(localeCode)
if err != nil {
return err
}
name := display.Self.Name(tag)
if tag == language.AmericanEnglish {
name = "English"
}
locale := &Locale{
Code: localeCode,
Name: title.String(name),
Messages: make(map[string]string),
}
err = yaml.Unmarshal(data, &locale.Messages)
if err != nil {
return err
}
store.Locales[localeCode] = locale
return nil
}
func (store *LocaleStore) LoadAll() error {
return fs.WalkDir(locales.Files, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
localeKey := strings.TrimSuffix(path, filepath.Ext(path))
err := store.loadLocaleFromYAML(localeKey, path)
if err != nil {
return err
}
}
return nil
})
}
func (store *LocaleStore) GetLocale(lang string) (*Locale, error) {
_, ok := store.Locales[lang]
if !ok {
return nil, fmt.Errorf("locale %s not found", lang)
}
return store.Locales[lang], nil
}
func (store *LocaleStore) HasLocale(lang string) bool {
_, ok := store.Locales[lang]
return ok
}
func (store *LocaleStore) MatchTag(langs []language.Tag) string {
for _, lang := range langs {
if store.HasLocale(lang.String()) {
return lang.String()
}
}
return "en-US"
}
func (l *Locale) Tr(key string, args ...any) template.HTML {
message := l.Messages[key]
if message == "" {
return Locales.Locales["en-US"].Tr(key, args...)
}
if len(args) == 0 {
return template.HTML(message)
}
return template.HTML(fmt.Sprintf(message, args...))
}

View File

@ -0,0 +1,177 @@
gist.public: Public
gist.unlisted: Unlisted
gist.private: Private
gist.header.like: Like
gist.header.unlike: Unlike
gist.header.fork: Fork
gist.header.edit: Edit
gist.header.delete: Delete
gist.header.forked-from: Forked from
gist.header.last-active: Last active
gist.header.select-tab: Select a tab
gist.header.code: Code
gist.header.revisions: Revisions
gist.header.revision: Revision
gist.header.clone-http: Clone via %s
gist.header.clone-http-help: Clone with Git using HTTP basic authentication.
gist.header.clone-ssh: Clone via SSH
gist.header.clone-ssh-help: Clone with Git using an SSH key.
gist.header.share: Share
gist.header.share-help: Copy shareable link for this gist.
gist.header.download-zip: Download ZIP
gist.raw: Raw
gist.file-truncated: This file has been truncated.
gist.watch-full-file: View the full file.
gist.file-not-valid: This file is not a valid CSV file.
gist.no-content: No content
gist.new.new_gist: New gist
gist.new.title: Title
gist.new.description: Description
gist.new.filename-with-extension: Filename with extension
gist.new.indent-mode: Indent mode
gist.new.indent-mode-space: Space
gist.new.indent-mode-tab: Tab
gist.new.indent-size: Indent size
gist.new.wrap-mode: Wrap mode
gist.new.wrap-mode-no: No wrap
gist.new.wrap-mode-soft: Soft wrap
gist.new.add-file: Add file
gist.new.create-public-button: Create public gist
gist.new.create-unlisted-button: Create unlisted gist
gist.new.create-private-button: Create private gist
gist.edit.editing: Editing
gist.edit.change-visibility: Make
gist.edit.delete: Delete
gist.edit.cancel: Cancel
gist.edit.save: Save
gist.list.joined: Joined
gist.list.all: All gists
gist.list.search-results: Search results
gist.list.sort: Sort
gist.list.sort-by-created: created
gist.list.sort-by-updated: updated
gist.list.order-by-asc: Least recently
gist.list.order-by-desc: Recently
gist.list.select-tab: Select a tab
gist.list.liked: Liked
gist.list.likes: likes
gist.list.forked: Forked
gist.list.forked-from: Forked from
gist.list.forks: forks
gist.list.files: files
gist.list.last-active: Last active
gist.list.no-gists: No gists
gist.forks: Forks
gist.forks.view: View fork
gist.forks.no: No public forks
gist.likes: Likes
gist.likes.no: No likes yet
gist.revisions: Revisions
gist.revision.revised: revised this gist
gist.revision.go-to-revision: Go to revision
gist.revision.file-created: file created
gist.revision.file-deleted: file deleted
gist.revision.file-renamed: renamed to
gist.revision.diff-truncated: Diff truncated because it's too large to be shown
gist.revision.file-renamed-no-changes: File renamed without changes
gist.revision.empty-file: Empty file
gist.revision.no-changes: No changes
gist.revision.no-revisions: No revisions to show
settings: Settings
settings.email: Email
settings.email-help: Used for commits and Gravatar
settings.email-set: Set email
settings.link-accounts: Link accounts
settings.link-github-account: Link GitHub account
settings.link-gitea-account: Link Gitea account
settings.unlink-github-account: Unlink GitHub account
settings.unlink-gitea-account: Unlink Gitea account
settings.delete-account: Delete account
settings.delete-account-confirm: Are you sure you want to delete your account ?
settings.add-ssh-key: Add SSH key
settings.add-ssh-key-help: Used only to pull/push gists using Git via SSH
settings.add-ssh-key-title: Title
settings.add-ssh-key-content: Key
settings.delete-ssh-key: Delete
settings.delete-ssh-key-confirm: Confirm deletion of SSH key
settings.ssh-key-added-at: Added
settings.ssh-key-never-used: Never used
settings.ssh-key-last-used: Last used
auth.signup-disabled: Administrator has disabled signing up
auth.login: Login
auth.signup: Register
auth.new-account: New account
auth.username: Username
auth.password: Password
auth.register-instead: Register instead
auth.login-instead: Login instead
auth.github-oauth: Continue with GitHub account
auth.gitea-oauth: Continue with Gitea account
error: Error
header.menu.all: All
header.menu.new: New
header.menu.search: Search
header.menu.my-gists: My gists
header.menu.liked: Liked
header.menu.admin: Admin
header.menu.settings: Settings
header.menu.logout: Logout
header.menu.register: Register
header.menu.login: Login
header.menu.light: Light
header.menu.dark: Dark
header.menu.system: System
footer.powered-by: Powered by %s
pagination.older: Older
pagination.newer: Newer
pagination.previous: Previous
pagination.next: Next
admin.admin_panel: Admin panel
admin.general: General
admin.users: Users
admin.gists: Gists
admin.configuration: Configuration
admin.versions: Versions
admin.ssh_keys: SSH keys
admin.stats: Stats
admin.actions: Actions
admin.actions.sync-fs: Synchronize gists from filesystem
admin.actions.sync-db: Synchronize gists from database
admin.actions.git-gc: Garbage collect git repositories
admin.id: ID
admin.user: User
admin.delete: Delete
admin.created_at: Created
admin.config-link: This configuration can be %s by a YAML config file and/or environment variables.
admin.config-link-overriden: overridden
admin.disable-signup: Disable signup
admin.disable-signup_help: Forbid the creation of new accounts.
admin.require-login: Require login
admin.require-login_help: Enforce users to be logged in to see gists.
admin.disable-login: Disable login form
admin.disable-login_help: Forbid logging in via the login form to force using OAuth providers instead.
admin.disable-gravatar: Disable Gravatar
admin.disable-gravatar_help: Disable the usage of Gravatar as an avatar provider.
admin.users.delete_confirm: Do you want to delete this user ?
admin.gists.title: Title
admin.gists.private: Private ?
admin.gists.nb-files: Nb. files
admin.gists.nb-likes: Nb. likes
admin.gists.delete_confirm: Do you want to delete this gist ?

View File

@ -0,0 +1,177 @@
gist.public: Public
gist.unlisted: Non répertorié
gist.private: Privé
gist.header.like: J'aime
gist.header.unlike: Je n'aime plus
gist.header.fork: Fork
gist.header.edit: Éditer
gist.header.delete: Supprimer
gist.header.forked-from: Forké de
gist.header.last-active: Dernière activité
gist.header.select-tab: Sélectionner un onglet
gist.header.code: Code
gist.header.revisions: Révisions
gist.header.revision: Révision
gist.header.clone-http: Cloner via %s
gist.header.clone-http-help: Cloner avec Git en utilisant l'authentification HTTP basic.
gist.header.clone-ssh: Cloner via SSH
gist.header.clone-ssh-help: Cloner avec Git en utilisant une clé SSH.
gist.header.share: Partager
gist.header.share-help: Copier le lien partageable de ce gist.
gist.header.download-zip: Télécharger en ZIP
gist.raw: Brut
gist.file-truncated: Ce fichier a été tronqué.
gist.watch-full-file: Voir le fichier complet.
gist.file-not-valid: Ce fichier n'est pas un fichier CSV valide.
gist.no-content: Pas de contenu
gist.new.new_gist: Nouveau gist
gist.new.title: Titre
gist.new.description: Description
gist.new.filename-with-extension: Nom de fichier avec extension
gist.new.indent-mode: Mode d'indentation
gist.new.indent-mode-space: Espace
gist.new.indent-mode-tab: Tabulation
gist.new.indent-size: Taille d'indentation
gist.new.wrap-mode: Mode d'enroulement
gist.new.wrap-mode-no: Sans enroulement
gist.new.wrap-mode-soft: Enroulement doux
gist.new.add-file: Ajouter un fichier
gist.new.create-public-button: Créer un gist public
gist.new.create-unlisted-button: Créer un gist non repertorié
gist.new.create-private-button: Créer un gist privé
gist.edit.editing: Édition de
gist.edit.change-visibility: Rendre
gist.edit.delete: Supprimer
gist.edit.cancel: Annuler
gist.edit.save: Sauvegarder
gist.list.joined: Inscrit
gist.list.all: Tous les gists
gist.list.search-results: Résultats de recherche
gist.list.sort: Trier
gist.list.sort-by-created: créé
gist.list.sort-by-updated: mis à jour
gist.list.order-by-asc: Le moins récemment
gist.list.order-by-desc: Récemment
gist.list.select-tab: Sélectionner un onglet
gist.list.liked: Aimé
gist.list.likes: j'aimes
gist.list.forked: Forké
gist.list.forked-from: Forké de
gist.list.forks: forks
gist.list.files: fichiers
gist.list.last-active: Dernière activité
gist.list.no-gists: Aucun gist
gist.forks: Forks
gist.forks.view: Voir le fork
gist.forks.no: Pas de forks publics
gist.likes: J'aime
gist.likes.no: Aucun j'aime pour le moment
gist.revisions: Révisions
gist.revision.revised: a révisé ce gist
gist.revision.go-to-revision: Aller à la révision
gist.revision.file-created: fichier créé
gist.revision.file-deleted: fichier supprimé
gist.revision.file-renamed: renommé en
gist.revision.diff-truncated: Révision tronquée car trop volumineuse pour être affichée
gist.revision.file-renamed-no-changes: Fichier renommé sans modifications
gist.revision.empty-file: Fichier vide
gist.revision.no-changes: Aucun changement
gist.revision.no-revisions: Aucune révision à afficher
settings: Paramètres
settings.email: Email
settings.email-help: Utilisé pour les commits et Gravatar
settings.email-set: Définir l'email
settings.link-accounts: Lier les comptes
settings.link-github-account: Lier le compte GitHub
settings.link-gitea-account: Lier le compte Gitea
settings.unlink-github-account: Détacher le compte GitHub
settings.unlink-gitea-account: Détacher le compte Gitea
settings.delete-account: Supprimer le compte
settings.delete-account-confirm: Êtes-vous sûr de vouloir supprimer votre compte ?
settings.add-ssh-key: Ajouter une clé SSH
settings.add-ssh-key-help: Utilisé uniquement pour pull/push des gists avec Git via SSH
settings.add-ssh-key-title: Titre
settings.add-ssh-key-content: Clé
settings.delete-ssh-key: Supprimer
settings.delete-ssh-key-confirm: Confirmer la suppression de la clé SSH
settings.ssh-key-added-at: Ajouté
settings.ssh-key-never-used: Jamais utilisé
settings.ssh-key-last-used: Dernière utilisation
auth.signup-disabled: L'administrateur a désactivé l'inscription
auth.login: Connexion
auth.signup: Inscription
auth.new-account: Nouveau compte
auth.username: Nom d'utilisateur
auth.password: Mot de passe
auth.register-instead: Je préfère m'inscrire
auth.login-instead: Je préfère me connecter
auth.github-oauth: Continuer avec un compte GitHub
auth.gitea-oauth: Continuer avec un compte Gitea
error: Erreur
header.menu.all: Tous
header.menu.new: Nouveau
header.menu.search: Recherche
header.menu.my-gists: Mes gists
header.menu.liked: Aimés
header.menu.admin: Admin
header.menu.settings: Paramètres
header.menu.logout: Déconnexion
header.menu.register: Inscription
header.menu.login: Connexion
header.menu.light: Clair
header.menu.dark: Sombre
header.menu.system: Système
footer.powered-by: Propulsé par %s
pagination.older: Plus ancien
pagination.newer: Plus récent
pagination.previous: Précédent
pagination.next: Suivant
admin.admin_panel: Panneau d'administration
admin.general: Général
admin.users: Utilisateurs
admin.gists: Gists
admin.configuration: Configuration
admin.versions: Versions
admin.ssh_keys: Clés SSH
admin.stats: Statistiques
admin.actions: Actions
admin.actions.sync-fs: Synchroniser les gists depuis le système de fichiers
admin.actions.sync-db: Synchroniser les gists depuis la base de données
admin.actions.git-gc: Nettoyage des dépôts git
admin.id: ID
admin.user: Utilisateur
admin.delete: Supprimer
admin.created_at: Créé
admin.config-link: Cette configuration peut être %s par un fichier de configuration YAML et/ou des variables d'environnement.
admin.config-link-overriden: remplacée
admin.disable-signup: Désactiver l'inscription
admin.disable-signup_help: Interdire la création de nouveaux comptes.
admin.require-login: Exiger la connexion
admin.require-login_help: Obliger les utilisateurs à être connectés pour voir les gists.
admin.disable-login: Désactiver le formulaire de connexion
admin.disable-login_help: Interdire la connexion via le formulaire de connexion pour forcer l'utilisation des fournisseurs OAuth à la place.
admin.disable-gravatar: Désactiver Gravatar
admin.disable-gravatar_help: Désactiver l'utilisation de Gravatar comme fournisseur d'avatar.
admin.users.delete_confirm: Voulez-vous supprimer cet utilisateur ?
admin.gists.title: Titre
admin.gists.private: Privé ?
admin.gists.nb-files: Nb. de fichiers
admin.gists.nb-likes: Nb. de j'aime
admin.gists.delete_confirm: Voulez-vous supprimer ce gist ?

View File

@ -0,0 +1,6 @@
package locales
import "embed"
//go:embed *.yml
var Files embed.FS

View File

@ -27,7 +27,7 @@ import (
var title = cases.Title(language.English)
func register(ctx echo.Context) error {
setData(ctx, "title", "New account")
setData(ctx, "title", tr(ctx, "auth.new-account"))
setData(ctx, "htmlTitle", "New account")
setData(ctx, "disableForm", getData(ctx, "DisableLoginForm"))
return html(ctx, "auth_form.html")
@ -87,7 +87,7 @@ func processRegister(ctx echo.Context) error {
}
func login(ctx echo.Context) error {
setData(ctx, "title", "Login")
setData(ctx, "title", tr(ctx, "auth.login"))
setData(ctx, "htmlTitle", "Login")
setData(ctx, "disableForm", getData(ctx, "DisableLoginForm"))
return html(ctx, "auth_form.html")

View File

@ -121,19 +121,21 @@ func allGists(ctx echo.Context) error {
pageInt := getPage(ctx)
sort := "created"
sortText := tr(ctx, "gist.list.sort-by-created")
order := "desc"
orderText := "Recently"
orderText := tr(ctx, "gist.list.order-by-desc")
if ctx.QueryParam("sort") == "updated" {
sort = "updated"
sortText = tr(ctx, "gist.list.sort-by-updated")
}
if ctx.QueryParam("order") == "asc" {
order = "asc"
orderText = "Least recently"
orderText = tr(ctx, "gist.list.order-by-asc")
}
setData(ctx, "sort", sort)
setData(ctx, "sort", sortText)
setData(ctx, "order", orderText)
var gists []*db.Gist

View File

@ -12,8 +12,11 @@ import (
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/i18n"
"github.com/thomiceli/opengist/public"
"github.com/thomiceli/opengist/templates"
"golang.org/x/text/language"
htmlpkg "html"
"html/template"
"io"
"net/http"
@ -105,6 +108,13 @@ var fm = template.FuncMap{
}
return s
},
"unescape": htmlpkg.UnescapeString,
"join": func(s ...string) string {
return strings.Join(s, "")
},
"toStr": func(i interface{}) string {
return fmt.Sprint(i)
},
}
type Template struct {
@ -129,7 +139,12 @@ func NewServer(isDev bool) *Server {
e.HideBanner = true
e.HidePort = true
if err := i18n.Locales.LoadAll(); err != nil {
log.Fatal().Err(err).Msg("Failed to load locales")
}
e.Use(dataInit)
e.Use(locale)
e.Pre(middleware.MethodOverrideWithConfig(middleware.MethodOverrideConfig{
Getter: middleware.MethodFromForm("_method"),
}))
@ -297,6 +312,50 @@ func dataInit(next echo.HandlerFunc) echo.HandlerFunc {
}
}
func locale(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
// Check URL arguments
lang := ctx.Request().URL.Query().Get("lang")
changeLang := lang != ""
// Then check cookies
if len(lang) == 0 {
cookie, _ := ctx.Request().Cookie("lang")
if cookie != nil {
lang = cookie.Value
}
}
// Check again in case someone changes the supported language list.
if lang != "" && !i18n.Locales.HasLocale(lang) {
lang = ""
changeLang = false
}
//3.Then check from 'Accept-Language' header.
if len(lang) == 0 {
tags, _, _ := language.ParseAcceptLanguage(ctx.Request().Header.Get("Accept-Language"))
lang = i18n.Locales.MatchTag(tags)
}
if changeLang {
ctx.SetCookie(&http.Cookie{Name: "lang", Value: lang, Path: "/", MaxAge: 1<<31 - 1})
}
localeUsed, err := i18n.Locales.GetLocale(lang)
if err != nil {
return errorRes(500, "Cannot get locale", err)
}
setData(ctx, "localeName", localeUsed.Name)
setData(ctx, "locale", localeUsed)
setData(ctx, "allLocales", i18n.Locales.Locales)
return next(ctx)
}
}
func sessionInit(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
sess := getSession(ctx)

View File

@ -12,6 +12,7 @@ import (
"github.com/labstack/echo/v4"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/i18n"
"golang.org/x/crypto/argon2"
"html/template"
"net/http"
@ -212,11 +213,11 @@ func paginate[T any](ctx echo.Context, data []*T, pageInt int, perPage int, temp
switch labels {
case 1:
setData(ctx, "prevLabel", "Previous")
setData(ctx, "nextLabel", "Next")
setData(ctx, "prevLabel", tr(ctx, "pagination.previous"))
setData(ctx, "nextLabel", tr(ctx, "pagination.next"))
case 2:
setData(ctx, "prevLabel", "Newer")
setData(ctx, "nextLabel", "Older")
setData(ctx, "prevLabel", tr(ctx, "pagination.newer"))
setData(ctx, "nextLabel", tr(ctx, "pagination.older"))
}
setData(ctx, "urlPage", urlPage)
@ -224,6 +225,11 @@ func paginate[T any](ctx echo.Context, data []*T, pageInt int, perPage int, temp
return nil
}
func tr(ctx echo.Context, key string) template.HTML {
l := getData(ctx, "locale").(*i18n.Locale)
return l.Tr(key)
}
type Argon2ID struct {
format string
version int