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
36 changes: 36 additions & 0 deletions .weblate
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
; Weblate component discovery config for MistServer.
; See https://docs.weblate.org/en/latest/admin/addons.html#component-discovery
;
; Two independent components: the LSP admin panel (translated client-side,
; in the browser) and the C++ backend (translated server-side, so the JSON
; API itself can return already-translated text to standalone/other-frontend
; callers). They don't share a catalog -- see plan_localization_v2.md for
; why -- so each gets its own template/filemask/source strings. To add
; either in Weblate: either point the "Component discovery" addon at this
; file, or create the component by hand in the Weblate admin using the same
; settings.
[mistserver-lsp]
name = MistServer LSP
slug = mistserver-lsp
project = mistserver
file_format = po
filemask = lsp/lang/*.po
template = lsp/lang/mistserver-lsp.pot
new_lang = add
; msgids are the English source strings (gettext keyword extraction from
; lsp/mist.js) - there is no separate "source_language" catalog file to
; point at.
source_language = en

[mistserver-backend]
name = MistServer Backend
slug = mistserver-backend
project = mistserver
file_format = po
filemask = src/lang/*.po
template = src/lang/mistserver-backend.pot
new_lang = add
; msgids are the English source strings (gettext keyword extraction from
; src/input|output|process capa["friendly"|"desc"|...] literals wrapped in
; tr(), plus src/controller/controller_capabilities.cpp).
source_language = en
35 changes: 35 additions & 0 deletions generated/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,38 @@ endforeach


server_html = custom_target('embed_server.html', output: 'server.html.h', input: gen_html, command: [sourcery, '@INPUT@', 'server_html', '@OUTPUT@'])

# Translation catalogs, embedded as a lookup table so the controller can serve
# /translations/<code>.json without a per-language code change.
gen_lang_header = files('../scripts/gen_lang_header.py')
lang_catalogs = custom_target('lang_catalogs',
output: 'lang_catalogs.h',
input: lang_json_tgts,
command: [py_installation, gen_lang_header, '@OUTPUT@'] + lang_specs + ['--', '@INPUT@'])

# Backend (C++) translation catalogs. Separate from the LSP ones above: these
# are consumed by the controller itself (to translate API responses, since
# the API is also used standalone / by other front-ends), never served to a
# browser. --prefix keeps the generated types/variables from colliding with
# the frontend ones if both headers are ever included in the same file;
# --no-index skips the code -> native-name index JSON the frontend uses for
# its language picker, since nothing here ever lists backend languages.
backend_languages = [
{'code': 'de-DE', 'name': 'Deutsch'},
]

backend_lang_json_tgts = []
backend_lang_specs = []

foreach l : backend_languages
backend_lang_json_tgts += custom_target('backend_lang_json_' + l.get('code'),
input: '../src/lang/' + l.get('code') + '.po',
output: 'backend_' + l.get('code') + '.json',
command: [py_installation, po2json, '@INPUT@', '@OUTPUT@'])
backend_lang_specs += l.get('code') + '=' + l.get('name')
endforeach

backend_lang_catalogs = custom_target('backend_lang_catalogs',
output: 'backend_lang_catalogs.h',
input: backend_lang_json_tgts,
command: [py_installation, gen_lang_header, '@OUTPUT@', '--prefix', 'Backend', '--no-index'] + backend_lang_specs + ['--', '@INPUT@'])
1 change: 1 addition & 0 deletions lib/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ headers = [
'url.h',
'urireader.h',
'flac.h',
'tr.h',
]

if have_srt
Expand Down
17 changes: 17 additions & 0 deletions lib/tr.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#pragma once
#include <string>

/// Marks a backend (C++) string literal as translatable, for
/// `xgettext --keyword=tr` extraction -- nothing more.
///
/// This is *not* the same thing as the request-time translation lookup
/// (Controller::tr(msgid, lang) in controller_i18n.h). Calls to this tr()
/// happen at capability-introspection time (e.g. `capa["friendly"] = tr(
/// "MP4 over HTTP");` in a plugin's init()), which runs once, long before
/// any request -- and therefore any requested language -- exists. Its
/// result is cached and reused for every future API request in every
/// language, so it can only ever be an identity function here. The actual
/// per-request translation happens later, in the controller, by looking up
/// the (still-English) msgid this function returned.
inline const char *tr(const char *msgid) { return msgid; }
inline const std::string &tr(const std::string &msgid) { return msgid; }
6 changes: 3 additions & 3 deletions lsp/footer.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@
<div class=filler>
<header>
<div class="texture"></div>
<h1>Management Interface</h1>
<h1 data-tr="Management Interface">Management Interface</h1>
<aside id=status>
<span id='connection' class='red'>Disconnected</span>
<span id='connection' class='red' data-tr="Disconnected" data-tr-once>Disconnected</span>
<span id='user_and_host'></span>
<span id='message'></span>
</aside>
</header>
<main>
Loading..
<span data-tr="Loading.." data-tr-once>Loading..</span>
<noscript>Please enable JavaScript.</noscript>
</main>
</div>
Expand Down
2 changes: 1 addition & 1 deletion lsp/generateLSP.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

echo "Minimizing LSP.."

terser -mo minified.js -- plugins/md5.js plugins/cattablesort.js mist.js
terser -mo minified.js -- plugins/md5.js plugins/cattablesort.js i18n.js mist.js

if [ $? -eq 0 ]; then
echo "Done."
Expand Down
198 changes: 198 additions & 0 deletions lsp/i18n.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/* MistServer LSP internationalization runtime.
*
* Source-string-as-key (gettext style): the English string is the msgid, so a
* missing catalog entry simply falls back to the source string and English
* needs no catalog at all.
*
* This file is concatenated *before* mist.js, so it must not touch UI/mist.
* mist.js hooks it up as UI.lang.
*/

var MistLang = {
//Currently active language code, "en" means "no catalog, use the msgids".
current: "en",
//msgid -> msgstr (string, or array of plural forms)
catalog: {},
//code -> native language name, filled in by loadIndex(). English is implicit.
available: {"en": "English"},
//Set to true once the index has been fetched (successfully or not)
indexLoaded: false,
//Callbacks to run whenever the active language changes
onchange: [],

/* Where to look for catalogs. When the panel is served by MistController the
* first location applies; the second one is the dev layout (lsp/index.html
* next to lsp/lang/). */
paths: ["translations/","lang/"],

//gettext-style plural selector. Only the ones we ship are listed; anything
//else falls back to the English/Germanic rule.
pluralRules: {
"en": function(n){ return n != 1 ? 1 : 0; },
"de": function(n){ return n != 1 ? 1 : 0; }
},

isEnglish: function(code){
return (!code) || (code == "en") || (code.slice(0,3) == "en-");
},

/* Look up a msgid. Returns the msgid itself when there is no translation.
* Non-strings are passed through untouched so this is safe to use as a
* blanket pass-through on values that may be jQuery objects or numbers. */
lookup: function(msgid){
if (typeof msgid != "string") { return msgid; }
var hit = this.catalog[msgid];
if (hit === undefined) { return msgid; }
if (hit instanceof Array) { hit = hit[0]; }
return (hit === "" || hit === undefined ? msgid : hit);
},

lookupPlural: function(singular,plural,n){
var hit = this.catalog[singular];
if (hit instanceof Array) {
var rule = this.pluralRules[this.current] || this.pluralRules[this.current.split("-")[0]] || this.pluralRules["en"];
var idx = rule(n);
if ((idx < hit.length) && (hit[idx] !== "")) { return hit[idx]; }
}
else if ((typeof hit == "string") && (hit !== "") && (n == 1)) {
return hit;
}
return (n == 1 ? singular : plural);
},

/* printf-ish interpolation. Supports %s (sequential), %1$s (positional) and
* %% for a literal percent sign. Anything else is left alone. */
format: function(str,args){
if ((typeof str != "string") || !args || !args.length) { return str; }
var next = 0;
return str.replace(/%(?:(\d+)\$)?([%s])/g,function(match,pos,kind){
if (kind == "%") { return "%"; }
var val = (pos ? args[Number(pos)-1] : args[next++]);
return (val === undefined || val === null ? "" : String(val));
});
},

/* Fetch <path><code>.json, trying each configured path in order. */
fetchCatalog: function(code,callback){
var paths = this.paths.slice(0);
function attempt(){
if (!paths.length) { callback(false); return; }
var url = paths.shift()+encodeURIComponent(code)+".json";
var xhr = new XMLHttpRequest();
xhr.open("GET",url,true);
xhr.onreadystatechange = function(){
if (xhr.readyState != 4) { return; }
if ((xhr.status >= 200) && (xhr.status < 300)) {
var parsed = null;
try { parsed = JSON.parse(xhr.responseText); } catch (e) { parsed = null; }
if (parsed) { callback(parsed); return; }
}
attempt();
};
try { xhr.send(); } catch (e) { attempt(); }
}
attempt();
},

/* Load the list of catalogs the server has available. */
loadIndex: function(callback){
var me = this;
if (this.indexLoaded) { if (callback) { callback(this.available); } return; }
this.fetchCatalog("index",function(d){
me.indexLoaded = true;
if (d && (typeof d == "object")) {
for (var code in d) { me.available[code] = d[code]; }
}
if (callback) { callback(me.available); }
});
},

/* Switch to the given language, fetching its catalog if needed.
* English never fetches anything. */
setLanguage: function(code,callback){
var me = this;
function done(){
document.documentElement.setAttribute("lang",me.current);
for (var i in me.onchange) { me.onchange[i](me.current); }
if (callback) { callback(me.current); }
}
if (this.isEnglish(code)) {
this.current = "en";
this.catalog = {};
done();
return;
}
if (code == this.current) { done(); return; }
this.fetchCatalog(code,function(d){
if (d) {
me.current = code;
me.catalog = d;
}
else {
//Catalog unavailable: stay on / fall back to English rather than
//pretending we're translated.
me.current = "en";
me.catalog = {};
}
done();
});
},

/* Pick a language for a first-time visitor from the browser preferences.
* We match on the full code including region (de-DE), then on the bare
* language subtag (de -> de-DE) so a "de-AT" browser still gets German. */
detect: function(){
var prefs = [];
if (navigator.languages && navigator.languages.length) {
prefs = Array.prototype.slice.call(navigator.languages);
}
else if (navigator.language) { prefs = [navigator.language]; }
for (var i in prefs) {
var want = prefs[i];
for (var code in this.available) {
if (code.toLowerCase() == want.toLowerCase()) { return code; }
}
var base = want.split("-")[0].toLowerCase();
for (var code in this.available) {
if (code.split("-")[0].toLowerCase() == base) { return code; }
}
}
return "en";
}
};

/* Translate a single string.
*
* Exposed as a global (and on window) rather than a local so that terser /
* closure-compiler cannot mangle the name away. The string *arguments* are
* always preserved by minifiers, which is what xgettext extracts.
*
* tr("Save")
* tr("Failed (%s)", err)
* tr("%1$s of %2$s", done, total)
*/
function tr(msgid) {
var out = MistLang.lookup(msgid);
if (arguments.length > 1) {
return MistLang.format(out,Array.prototype.slice.call(arguments,1));
}
return out;
}

/* ngettext-style plural form.
*
* trn("%s stream","%s streams",n,n)
*/
function trn(singular,plural,n) {
var out = MistLang.lookupPlural(singular,plural,n);
if (arguments.length > 3) {
return MistLang.format(out,Array.prototype.slice.call(arguments,3));
}
return MistLang.format(out,[n]);
}

if (typeof window != "undefined") {
window.MistLang = MistLang;
window.tr = tr;
window.trn = trn;
}
Loading