Browse Source

go-i18n: working translations with catalog discovery.

Frederic G. MARAND 5 years ago
parent
commit
3c5e8dbf6e

+ 1 - 0
.gitignore

@@ -35,3 +35,4 @@ _testmain.go
 # Data and local files
 config.yml
 *.sql
+active.en.yaml

+ 49 - 0
cmd/kurzd/rootCmd.go

@@ -2,10 +2,18 @@ package main
 
 import (
 	"fmt"
+	"log"
 	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"time"
+
+	"code.osinet.fr/fgm/kurz/web/i18n"
 
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
+	"golang.org/x/text/language"
 )
 
 var cmd = &cobra.Command{
@@ -31,4 +39,45 @@ func initConfig() {
 func init() {
 	cobra.OnInitialize(initConfig)
 	verbose = *cmd.PersistentFlags().BoolP("verbose", "v", false, "Add -v for verbose operation")
+	setupI18n()
+}
+
+func setupI18n() {
+	cwd, err := os.Getwd()
+	if err != nil {
+		panic(err)
+	}
+	t0 := time.Now()
+	log.Printf("Finding message catalogs below %s\n", cwd)
+
+	tags := []string{language.English.String(), language.French.String()}
+
+	// Build the regex matching message file names.
+	re := regexp.MustCompile("messages\\.(?:" + strings.Join(tags, "|") + ")\\.yaml")
+
+	scanned := 0
+	catalogCount := 0
+	walkErr := filepath.Walk(cwd, func(path string, info os.FileInfo, err error) error {
+		scanned++
+		if err != nil {
+			panic(err)
+		}
+		if info.IsDir() {
+			return nil
+		}
+
+		if !re.MatchString(info.Name()) {
+			return nil
+		}
+
+		_, err = i18n.Bundle.LoadMessageFile(path)
+		catalogCount++
+		return err
+	})
+	if walkErr != nil {
+		panic(walkErr)
+	}
+	t1 := time.Now()
+	diff := t1.Sub(t0)
+	log.Printf("%d items scanned, %d catalogs loaded in %d msec\n", scanned, catalogCount, diff/time.Millisecond)
 }

+ 0 - 5
cmd/kurzd/serve.go

@@ -64,11 +64,6 @@ func ensureInfrastructure(db *sql.DB) *sql.DB {
 func serveHandler(_ *cobra.Command, args []string) {
 	db = ensureInfrastructure(db)
 	defer db.Close()
-	cwd, err := os.Getwd()
-	if err != nil {
-		panic(err)
-	}
-	log.Printf("Server initializing in %s\n", cwd)
 
 	// Set up globals from configuration, providing a few defaults.
 	siteBaseURL := viper.Get("web.siteBaseUrl").(string)

+ 0 - 13
domain/active.en.yaml

@@ -1,13 +0,0 @@
-error.none: No error
-error.target_invalid: Target invalid
-error.unimplemented: Not yet implemented
-short_not_created: Short URL not created for a new target
-short_not_found: Short URL not defined
-storage_read: Storage read error
-storage_unspecified: Storage unspecified error
-storage_write: Storage write error
-target_blocked: Target blocked
-target_censored: Target unavailable for legal reasons
-web.error.format:
-  description: The format for errors
-  one: '{.Kind}: {.Detail}'

+ 9 - 8
domain/errors.go

@@ -11,14 +11,14 @@ type ErrorKind = string
 
 var NoError = i18n.Message{ID: "error.none", Other: "No error"}
 var Unimplemented = i18n.Message{ID: "error.unimplemented", Other: "Not yet implemented"}
-var ShortNotCreated = i18n.Message{ID: "short_not_created", Other: "Short URL not created for a new target"}
+var ShortNotCreated = i18n.Message{ID: "error.short_not_created", Other: "Short URL not created for a new target"}
 var TargetInvalid = i18n.Message{ID: "error.target_invalid", Other: "Target invalid"}
-var ShortNotFound = i18n.Message{ID: "short_not_found", Other: "Short URL not defined"}
-var TargetBlocked = i18n.Message{ID: "target_blocked", Other: "Target blocked"}
-var TargetCensored = i18n.Message{ID: "target_censored", Other: "Target unavailable for legal reasons"}
-var StorageRead = i18n.Message{ID: "storage_read", Other: "Storage read error"}
-var StorageWrite = i18n.Message{ID: "storage_write", Other: "Storage write error"}
-var StorageUnspecified = i18n.Message{ID: "storage_unspecified", Other: "Storage unspecified error"}
+var ShortNotFound = i18n.Message{ID: "error.short_not_found", Other: "Short URL not defined"}
+var TargetBlocked = i18n.Message{ID: "error.target_blocked", Other: "Target blocked"}
+var TargetCensored = i18n.Message{ID: "error.target_censored", Other: "Target unavailable for legal reasons"}
+var StorageRead = i18n.Message{ID: "error.storage_read", Other: "Storage read error"}
+var StorageWrite = i18n.Message{ID: "error.storage_write", Other: "Storage write error"}
+var StorageUnspecified = i18n.Message{ID: "error.storage_unspecified", Other: "Storage unspecified error"}
 
 var ErrorMessages = map[ErrorKind]i18n.Message{
 	NoError.ID:            NoError,
@@ -27,6 +27,7 @@ var ErrorMessages = map[ErrorKind]i18n.Message{
 	ShortNotFound.ID:      ShortNotFound,
 	TargetBlocked.ID:      TargetBlocked,
 	TargetCensored.ID:     TargetCensored,
+	TargetInvalid.ID:      TargetInvalid,
 	StorageRead.ID:        StorageRead,
 	StorageWrite.ID:       StorageWrite,
 	StorageUnspecified.ID: StorageUnspecified,
@@ -77,7 +78,7 @@ func MakeError(l *i18n.Localizer, kind ErrorKind, detail string) Error {
 		})
 		message = l.MustLocalize(&i18n.LocalizeConfig{
 			DefaultMessage: &i18n.Message{
-				ID:          "web.error.format",
+				ID:          "error.error_format",
 				Description: "The format for errors",
 				One:         "{.Kind}: {.Detail}",
 				LeftDelim:   "{",

+ 5 - 4
domain/errors_test.go

@@ -12,10 +12,11 @@ import (
 TestMakeErrorHappy tests localization of a known error.
 */
 func TestMakeErrorHappy(t *testing.T) {
-	frNoError := i18n.Message{
-		ID:    NoError.ID,
-		Other: "Pas d'erreur",
-	}
+	// Do not use a i18n.Message literal to initialize, to prevent goi18n extraction.
+	var frNoError i18n.Message
+	frNoError.ID = NoError.ID
+	frNoError.Other = "Pas d'erreur"
+
 	bundle := &i18n.Bundle{DefaultLanguage: language.English}
 	bundle.AddMessages(language.English, &NoError)
 	bundle.AddMessages(language.French, &frNoError)

+ 16 - 0
domain/messages.en.yaml

@@ -0,0 +1,16 @@
+# FIXME re-enable that message once https://github.com/nicksnyder/go-i18n/issues/149 gets fixed
+Zerror.error_format:
+  description: The format for errors
+  one: '{.Kind}: {.Detail}'
+  leftDelim: '{'
+  rightDelim: '}'
+error.none: No error
+error.short_not_created: Short URL not created for a new target
+error.short_not_found: Short URL not defined
+error.storage_read: Storage read error
+error.storage_unspecified: Storage unspecified error
+error.storage_write: Storage write error
+error.target_blocked: Target blocked
+error.target_censored: Target unavailable for legal reasons
+error.target_invalid: Target invalid
+error.unimplemented: Not yet implemented

+ 16 - 0
domain/messages.fr.yaml

@@ -0,0 +1,16 @@
+# FIXME re-enable that message once https://github.com/nicksnyder/go-i18n/issues/149 gets fixed
+Zerror.error_format:
+  description: Le format des erreurs
+  one: '{.Kind}: {.Detail}'
+  leftDelim: '{'
+  rightDelim: '}'
+error.none: Aucune erreur
+error.short_not_created: URL court non créé pour une nouvelle cible
+error.short_not_found: URL court non défini
+error.storage_read: Erreur de lecture depuis le stockage
+error.storage_unspecified: Erreur de stockage non spécifique
+error.storage_write: Erreur d'écriture sur le stockage
+error.target_blocked: Cible bloquée
+error.target_censored: Cible censurée
+error.target_invalid: Cible invalide
+error.unimplemented: Pas encore réalisé

+ 0 - 13
web/api/api.go

@@ -29,8 +29,6 @@ package api
 
 import (
 	"github.com/gorilla/mux"
-	"github.com/nicksnyder/go-i18n/v2/i18n"
-	"net/http"
 	"net/url"
 )
 
@@ -79,14 +77,3 @@ func URLFromRoute(name string, args map[string]string) url.URL {
 	return url.URL{}
 }
 
-// getLocalizer is a helper method to return the localizer in a request context, or nil if none is found.
-func getLocalizer(r *http.Request) *i18n.Localizer {
-	iLocalizer := r.Context().Value("localizer")
-	var localizer *i18n.Localizer
-	if iLocalizer != nil {
-		localizer = iLocalizer.(*i18n.Localizer)
-	} else {
-		localizer = nil
-	}
-	return localizer
-}

+ 2 - 1
web/api/get.go

@@ -1,6 +1,7 @@
 package api
 
 import (
+	"code.osinet.fr/fgm/kurz/web/i18n"
 	"net/http"
 
 	"code.osinet.fr/fgm/kurz/domain"
@@ -15,7 +16,7 @@ func handleGetShort(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	target, err := domain.GetTargetURL(short, getLocalizer(r))
+	target, err := domain.GetTargetURL(short, i18n.Localizer(r))
 	// Happy path.
 	if err == nil {
 		w.Header().Set("Location", target)

+ 2 - 1
web/api/post.go

@@ -1,6 +1,7 @@
 package api
 
 import (
+	"code.osinet.fr/fgm/kurz/web/i18n"
 	"encoding/json"
 	"io/ioutil"
 	"net/http"
@@ -36,7 +37,7 @@ func handlePostTarget(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	shortString, isNew, err := domain.GetShortURL(target.Target, nil)
+	shortString, isNew, err := domain.GetShortURL(target.Target, i18n.Localizer(r))
 
 	w.Header().Set("Content-type", postContentType)
 

+ 5 - 8
web/i18n/i18n_test.go

@@ -1,25 +1,22 @@
 package i18n
 
 import (
+	"fmt"
 	"net/http"
 )
 
 func HelloHandlerFunc(w http.ResponseWriter, r *http.Request) {
-	p := Printer(r)
-
-	p.Fprintf(w, "Hello, world")
+	fmt.Fprintf(w, "Hello, world")
 }
 
 // helloHandler implements http.Handler.
 type helloHandler struct{}
 
 func (h helloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	p := Printer(r)
-
-	p.Fprintf(w, "Hello, world")
+	fmt.Fprintf(w, "Hello, world")
 }
 
 func ExampleWithPrinter() {
-	http.Handle("/h", WithPrinter(helloHandler{}))
-	http.Handle("/hf", WithPrinter(http.HandlerFunc(HelloHandlerFunc)))
+	http.Handle("/h", WithLocalizer(helloHandler{}))
+	http.Handle("/hf", WithLocalizer(http.HandlerFunc(HelloHandlerFunc)))
 }

+ 14 - 10
web/i18n/i18ner.go

@@ -3,11 +3,11 @@ package i18n
 import (
 	"context"
 	"errors"
+	"log"
 	"net/http"
 
 	"github.com/nicksnyder/go-i18n/v2/i18n"
 	"golang.org/x/text/language"
-	"golang.org/x/text/message"
 	"gopkg.in/yaml.v2"
 )
 
@@ -18,33 +18,37 @@ var Bundle = &i18n.Bundle{
 	},
 }
 
+const localizerKey = "localizer"
+
 /*
-WithPrinter() is a higher-order function injecting into the request context a
+WithLocalizer() is a higher-order function injecting into the request context a
 printer for the language best matching the Accept-Language header in the incoming
 request.
 
-The wrapped handler can use Printer() to get a message printer instance
+The wrapped handler can use Localizer() to get a message printer instance
 configured for the best available language for the request.
 */
-func WithPrinter(h http.Handler) http.Handler {
+func WithLocalizer(h http.Handler) http.Handler {
 	h2 := func(w http.ResponseWriter, r *http.Request) {
 		localizer := i18n.NewLocalizer(Bundle, r.Header.Get("Accept-Language"))
 
-		c := context.WithValue(r.Context(), "localizer", &localizer)
+		c := context.WithValue(r.Context(), localizerKey, localizer)
 		h.ServeHTTP(w, r.WithContext(c))
 	}
 	return http.HandlerFunc(h2)
 }
 
 /*
-Printer() returns a message printer configured for the language best matching the
+Localizer() returns a message printer configured for the language best matching the
 Accept-Language in the request, or panic if the handler invoking it was not
-wrapped by a WithPrinter() call.
+wrapped by a WithLocalizer() call.
 */
-func Printer(r *http.Request) *message.Printer {
-	p, ok := r.Context().Value("printer").(*message.Printer)
+func Localizer(r *http.Request) *i18n.Localizer {
+	ip := r.Context().Value(localizerKey)
+	p, ok := ip.(*i18n.Localizer)
 	if !ok {
-		panic(errors.New("trying to use i18n.Printer in a handler not wrapped with i18.WithPrinter"))
+		log.Println(errors.New("trying to use i18n.Localizer in a handler not wrapped with i18.WithLocalizer"))
+		// In that case, p will be nil, which is a valid localizer doing no localization.
 	}
 	return p
 }

+ 2 - 1
web/ui/get_short.go

@@ -1,6 +1,7 @@
 package ui
 
 import (
+	"code.osinet.fr/fgm/kurz/web/i18n"
 	"fmt"
 	"io"
 	"net/http"
@@ -96,7 +97,7 @@ func handleGetShort(w http.ResponseWriter, r *http.Request, router *mux.Router)
 		return
 	}
 
-	target, err := domain.GetTargetURL(short, nil)
+	target, err := domain.GetTargetURL(short, i18n.Localizer(r))
 	// Happy path.
 	if err == nil {
 		w.Header().Set("Location", target)

+ 2 - 0
web/ui/messages.en.yaml

@@ -0,0 +1,2 @@
+web.ui.empty.target:
+  other: empty target

+ 2 - 0
web/ui/messages.fr.yaml

@@ -0,0 +1,2 @@
+web.ui.empty.target:
+  other: cible vide

+ 14 - 4
web/ui/post_target.go

@@ -7,6 +7,9 @@ import (
 	"net/http"
 	"strings"
 
+	kurzi18n "code.osinet.fr/fgm/kurz/web/i18n"
+	i18n "github.com/nicksnyder/go-i18n/v2/i18n"
+
 	"github.com/gorilla/sessions"
 
 	"code.osinet.fr/fgm/kurz/domain"
@@ -27,7 +30,8 @@ func handlePostTarget(w http.ResponseWriter, r *http.Request, router *mux.Router
 	r.ParseForm()
 	defer r.Body.Close()
 	rawTarget := r.PostForm.Get(rootInputName)
-	target, err := validateTarget(rawTarget)
+	localizer := kurzi18n.Localizer(r)
+	target, err := validateTarget(rawTarget, localizer)
 	if err != nil {
 		sess.AddFlash(err.Error())
 		sess.Save(r, w)
@@ -41,7 +45,7 @@ func handlePostTarget(w http.ResponseWriter, r *http.Request, router *mux.Router
 		return
 	}
 
-	short, isNew, err := domain.GetShortURL(target, nil)
+	short, isNew, err := domain.GetShortURL(target, localizer)
 	if err != nil {
 		w.WriteHeader(http.StatusInternalServerError)
 		return
@@ -87,9 +91,15 @@ func handlePostTarget(w http.ResponseWriter, r *http.Request, router *mux.Router
 	io.Copy(w, strings.NewReader(sw.String()))
 }
 
-func validateTarget(raw string) (string, error) {
+func validateTarget(raw string, localizer *i18n.Localizer) (string, error) {
 	if raw == "" {
-		return "", domain.MakeError(nil, domain.TargetInvalid.ID, "empty target")
+		detail := localizer.MustLocalize(&i18n.LocalizeConfig{
+			DefaultMessage: &i18n.Message{
+				ID:  "web.ui.empty.target",
+				Other: "empty target",
+			},
+		})
+		return "", domain.MakeError(localizer, domain.TargetInvalid.ID, detail)
 	}
 	// BUG(fgm): needs much more validation, starting with XSS.
 	return raw, nil

+ 1 - 1
web/ui/templates/home.gohtml

@@ -23,7 +23,7 @@
             <div id="form">
                 <div>
                     <label accesskey="U" for="form_url" class="required">L&#039;URL à transformer :</label>
-                    <input type="url" id="form_url" name="{{ .InputName }}" zrequired="required" placeholder="http://www.osinet.fr" size="60" maxlength="240" />
+                    <input type="url" id="form_url" name="{{ .InputName }}" required="required" placeholder="http://www.osinet.fr" size="60" maxlength="240" />
                 </div>
             </div>
             <input type="submit" name="{{ .SubmitName }}" value="Envoyer" />

+ 6 - 5
web/ui/ui.go

@@ -8,6 +8,7 @@ a complete application.
 package ui
 
 import (
+	"code.osinet.fr/fgm/kurz/web/i18n"
 	"errors"
 	"fmt"
 	"html/template"
@@ -82,19 +83,19 @@ func setupAssetRoutes(configAssetsPath string, router *mux.Router) {
 func setupControllerRoutes(gmux *mux.Router) {
 	router = *gmux
 	// BUG(fgm): improve Accept header matchers once https://github.com/golang/go/issues/19307 is completed.
-	gmux.HandleFunc("/{short}", func(w http.ResponseWriter, r *http.Request) {
+	gmux.Handle("/{short}", i18n.WithLocalizer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		handleGetShort(w, r, gmux)
-	}).
+	}))).
 		Methods("GET", "HEAD").
 		Name(RouteGetShort)
-	gmux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+	gmux.Handle("/", i18n.WithLocalizer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		handlePostTarget(w, r, gmux)
-	}).
+	}))).
 		HeadersRegexp("Accept", HtmlTypeRegex).
 		Headers("Content-Type", HtmlFormType).
 		Methods("POST").
 		Name(RoutePostTarget)
-	gmux.HandleFunc("/", handleGetRoot).
+	gmux.Handle("/", i18n.WithLocalizer(http.HandlerFunc(handleGetRoot))).
 		Methods("GET", "HEAD").
 		Name(RouteGetRoot)
 }