Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ If you find a security vulnerability, please **do not** open a public issue. Ins
- **Tests:** If you add a feature, please add a corresponding test in the relevant `_test.go` file.
- **Documentation:** Update the `README.md` or `/docs` if you change application behaviours or procedures.

## Translations
Translations live in `internal/languages/translations/` as one JSON file per language, named after its ISO 639-1 code (e.g. `de.json`).

- To add a language, copy `en.json`, set `language_name` to the name of the language in that language itself, and translate the values. Keys must stay unchanged.
- Keep placeholders such as `{0}`, `_TOTAL_`, `_MAX_` and `{{filesize}}` intact — they are substituted at runtime.
- A few values contain HTML. Keep the tags and any `id` attributes; translate only the surrounding text.
- Partial translations are fine: missing or empty keys fall back to English, so no label will ever render blank.

`en.json` is the source of truth. When you add a new user-facing string, add the key there — the other languages will fall back to it until they are updated. See the [Languages section](https://gokapi.readthedocs.io/en/latest/advanced.html#languages) of the documentation for more detail.

## Submitting a PR
- Ensure your PR description follows our [PR Template](.github/PULL_REQUEST_TEMPLATE.md).
- Keep PRs focused. If you have two unrelated fixes, please open two PRs.
Expand Down
36 changes: 36 additions & 0 deletions docs/advanced.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ This page is a reference for users who have already completed the initial setup.
* **Tuning upload performance or RAM usage** — :ref:`chunksizes`
* **Deploying without running setup interactively** — :ref:`autodeployment`
* **Changing the look and feel** — :ref:`customising`
* **Changing the interface language or adding a translation** — :ref:`languages`

----

Expand Down Expand Up @@ -618,3 +619,38 @@ If you want to change the layout (e.g. add your company logo or add/disable cert
6. Restart the server. If the folders exist, the server will now add the local files.

Optional: If you require further changes or want to embedded the changes permanently, you can clone the source code and then modify the templates in ``internal/webserver/web/templates``. Afterwards run ``make`` to build a new binary with these changes.


.. _languages:

********************************
Languages
********************************

The web interface is available in several languages. No configuration is required — Gokapi picks the language for every request on its own.

How the language is selected
--------------------------------

Gokapi uses the first match from this list:

1. The language the visitor picked from the selector in the top-right corner of the page. The choice is stored in a cookie named ``gokapi_language`` and applies to that browser only.
2. The ``Accept-Language`` header sent by the browser. Regional variants are matched against the base language, so a browser requesting ``de-AT`` will be served the German translation.
3. English, if neither of the above matches an available translation.

Adding a translation
--------------------------------

Translations are plain JSON files in ``internal/languages/translations/``, one file per language, named after its `ISO 639-1 <https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes>`_ code — for example ``de.json`` for German. To add one:

1. Copy ``en.json`` to a new file named after the language code, e.g. ``fr.json``.
2. Set the key ``language_name`` to the name of the language written in that language itself (e.g. ``Français``). This is the text shown in the selector.
3. Translate the remaining values. Leave the keys unchanged.
4. Run ``make`` to build a new binary. The translation is embedded into the binary and appears in the selector automatically — no registration step is needed.

A few rules when translating:

* **Placeholders such as** ``{0}`` **and** ``{1}`` **must be kept.** They are replaced at runtime with a filename, a number, an error message and similar. ``"Unable to delete file: {0}"`` in French becomes ``"Impossible de supprimer le fichier : {0}"``.
* **A few values contain HTML** — links, ``<br>``, or ``<span>`` elements that are filled in by JavaScript. Keep the tags and especially any ``id`` attributes intact, and translate only the text around them.
* **The placeholders** ``_TOTAL_``, ``_MAX_``, ``{{filesize}}`` **and** ``{{maxFilesize}}`` belong to the table and upload components and must also be left as they are.
* **Incomplete translations are fine.** Any key that is missing or left empty falls back to the English text, so a partial translation will not produce blank labels. This also means an existing translation keeps working after Gokapi adds new strings.
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Key features:
* **CLI tool** — upload and download files directly from the command line
* **REST API** — full API for scripting and third-party integrations
* **Easy customisation** — change the look with plain CSS and JavaScript, no recompilation needed
* **Multiple languages** — the interface is translated and picks the right language for each visitor automatically


Contents
Expand Down
279 changes: 279 additions & 0 deletions internal/languages/Languages.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
// Package languages provides the translations for the Gokapi web interface.
//
// All translations are stored as flat JSON files in the translations folder and
// are embedded into the binary. English is always used as a fallback: if a
// translation is missing a key, the English string is displayed instead.
//
// Strings may contain the placeholders {0}, {1}, ... which are replaced by the
// arguments passed to Tf / Hf. A few strings intentionally contain HTML markup
// (e.g. links or spans that JavaScript writes into) - those are rendered with
// H / Hf and must keep their tags and ids when being translated.
package languages

import (
"embed"
"encoding/json"
"fmt"
"html/template"
"net/http"
"sort"
"strconv"
"strings"
)

// translationFiles contains all embedded translation files
//
//go:embed translations/*.json
var translationFiles embed.FS

// CookieName is the name of the cookie that stores the language selected by the user
const CookieName = "gokapi_language"

// DefaultCode is the language that is used if no other language matches the request
const DefaultCode = "en"

// keyLanguageName is the key that contains the native name of the language
const keyLanguageName = "language_name"

// Language contains the metadata of an available translation
type Language struct {
// Code is the ISO 639-1 code of the language, e.g. "de"
Code string
// Name is the name of the language in that language itself, e.g. "Deutsch"
Name string
}

// Translator contains all strings of a single language and is passed to the templates
type Translator struct {
// Code is the ISO 639-1 code of the language, e.g. "de"
Code string
// Name is the name of the language in that language itself, e.g. "Deutsch"
Name string

entries map[string]string
jsonData template.JS
}

var (
// translators contains all loaded languages, indexed by their language code
translators = make(map[string]Translator)
// available contains all loaded languages, sorted by their code
available []Language
// fallback contains the English strings, which are used if a key is missing
fallback map[string]string
)

func init() {
files, err := translationFiles.ReadDir("translations")
if err != nil {
panic(err)
}
rawEntries := make(map[string]map[string]string)
for _, file := range files {
if file.IsDir() || !strings.HasSuffix(file.Name(), ".json") {
continue
}
content, err := translationFiles.ReadFile("translations/" + file.Name())
if err != nil {
panic(err)
}
entries := make(map[string]string)
err = json.Unmarshal(content, &entries)
if err != nil {
panic("invalid translation file " + file.Name() + ": " + err.Error())
}
rawEntries[strings.TrimSuffix(file.Name(), ".json")] = entries
}

var ok bool
fallback, ok = rawEntries[DefaultCode]
if !ok {
panic("translation file for default language " + DefaultCode + " is missing")
}

for code, entries := range rawEntries {
// Merging with the English strings, so that an incomplete translation does
// not result in empty strings being sent to the browser
merged := make(map[string]string, len(fallback))
for key, value := range fallback {
merged[key] = value
}
for key, value := range entries {
if value != "" {
merged[key] = value
}
}
encoded, err := json.Marshal(merged)
if err != nil {
panic(err)
}
translators[code] = Translator{
Code: code,
Name: merged[keyLanguageName],
entries: merged,
jsonData: template.JS(encoded),
}
available = append(available, Language{Code: code, Name: merged[keyLanguageName]})
}
sort.Slice(available, func(i, j int) bool {
return available[i].Name < available[j].Name
})
}

// Get returns the Translator for the given language code. If the language is
// unknown, the default language is returned instead.
func Get(code string) Translator {
translator, ok := translators[normaliseCode(code)]
if !ok {
return Default()
}
return translator
}

// Default returns the Translator for the default language
func Default() Translator {
return translators[DefaultCode]
}

// IsAvailable returns true if a translation exists for the given language code
func IsAvailable(code string) bool {
_, ok := translators[normaliseCode(code)]
return ok
}

// GetAvailable returns all languages that Gokapi has been translated to, sorted by name
func GetAvailable() []Language {
result := make([]Language, len(available))
copy(result, available)
return result
}

// FromRequest returns the Translator that fits the request best. The language
// selected by the user (stored in a cookie) has priority, otherwise the
// Accept-Language header is used. If neither matches, the default language is
// returned.
func FromRequest(r *http.Request) Translator {
if r == nil {
return Default()
}
cookie, err := r.Cookie(CookieName)
if err == nil {
if translator, ok := translators[normaliseCode(cookie.Value)]; ok {
return translator
}
}
if translator, ok := fromAcceptLanguage(r.Header.Get("Accept-Language")); ok {
return translator
}
return Default()
}

// fromAcceptLanguage parses an Accept-Language header and returns the best
// matching Translator. The second return value is false if no language matched.
func fromAcceptLanguage(header string) (Translator, bool) {
type weightedLanguage struct {
code string
weight float64
}
var candidates []weightedLanguage
for _, entry := range strings.Split(header, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
code := entry
weight := 1.0
if index := strings.Index(entry, ";"); index != -1 {
code = strings.TrimSpace(entry[:index])
for _, parameter := range strings.Split(entry[index+1:], ";") {
parameter = strings.TrimSpace(parameter)
if !strings.HasPrefix(parameter, "q=") {
continue
}
parsed, err := strconv.ParseFloat(strings.TrimPrefix(parameter, "q="), 64)
if err == nil {
weight = parsed
}
}
}
if code == "" || weight <= 0 {
continue
}
candidates = append(candidates, weightedLanguage{code: code, weight: weight})
}
sort.SliceStable(candidates, func(i, j int) bool {
return candidates[i].weight > candidates[j].weight
})
for _, candidate := range candidates {
if translator, ok := translators[normaliseCode(candidate.code)]; ok {
return translator, true
}
}
return Default(), false
}

// normaliseCode converts a language tag like "de-DE" to the language code "de"
func normaliseCode(code string) string {
code = strings.ToLower(strings.TrimSpace(code))
if index := strings.IndexAny(code, "-_"); index != -1 {
code = code[:index]
}
return code
}

// T returns the translated string for the given key. If the key does not exist
// in this language, the English string is returned. If it does not exist at all,
// the key itself is returned, so that a missing translation is easy to spot.
func (t Translator) T(key string) string {
if value, ok := t.entries[key]; ok {
return value
}
if value, ok := fallback[key]; ok {
return value
}
return key
}

// Tf returns the translated string for the given key, with the placeholders
// {0}, {1}, ... replaced by the passed arguments
func (t Translator) Tf(key string, args ...any) string {
return replacePlaceholders(t.T(key), args)
}

// H returns the translated string for the given key as HTML. This must only be
// used for strings that intentionally contain markup, as the content is not
// escaped.
func (t Translator) H(key string) template.HTML {
return template.HTML(t.T(key))
}

// Hf returns the translated string for the given key as HTML, with the
// placeholders {0}, {1}, ... replaced by the passed arguments. This must only be
// used for strings that intentionally contain markup, as the content is not
// escaped.
func (t Translator) Hf(key string, args ...any) template.HTML {
return template.HTML(t.Tf(key, args...))
}

// Js returns all strings of this language as a JSON object, so that they can be
// embedded into a script tag and used by the JavaScript function t()
func (t Translator) Js() template.JS {
if t.jsonData == "" {
return Default().jsonData
}
return t.jsonData
}

// Available returns all languages that Gokapi has been translated to, so that a
// template can render the language selector
func (t Translator) Available() []Language {
return GetAvailable()
}

// replacePlaceholders replaces {0}, {1}, ... with the passed arguments
func replacePlaceholders(input string, args []any) string {
for i, arg := range args {
input = strings.ReplaceAll(input, "{"+strconv.Itoa(i)+"}", fmt.Sprint(arg))
}
return input
}
Loading