diff --git a/Makefile b/Makefile index ec92333..1d3b02f 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ po/POTFILES.js.in: mkdir -p $(dir $@) find src/ -name '*.js' -o -name '*.jsx' -o -name '*.es6' > $@ -po/$(PACKAGE_NAME).pot: po/POTFILES.js.in +po/$(PACKAGE_NAME).js.pot: po/POTFILES.js.in xgettext --default-domain=cockpit --output=$@ --language=C --keyword= \ --keyword=_:1,1t --keyword=_:1c,2,1t --keyword=C_:1c,2 \ --keyword=N_ --keyword=NC_:1c,2 \ @@ -26,6 +26,19 @@ po/$(PACKAGE_NAME).pot: po/POTFILES.js.in --keyword=gettextCatalog.getString:1,3c --keyword=gettextCatalog.getPlural:2,3,4c \ --from-code=UTF-8 --files-from=$^ +po/POTFILES.html.in: + mkdir -p $(dir $@) + find src -name '*.html' > $@ + +po/$(PACKAGE_NAME).html.pot: po/POTFILES.html.in + po/html2po -f $^ -o $@ + +po/$(PACKAGE_NAME).manifest.pot: + po/manifest2po src/manifest.json -o $@ + +po/$(PACKAGE_NAME).pot: po/$(PACKAGE_NAME).html.pot po/$(PACKAGE_NAME).js.pot po/$(PACKAGE_NAME).manifest.pot + msgcat --sort-output --output-file=$@ $^ + # Update translations against current PO template update-po: po/$(PACKAGE_NAME).pot for lang in $(LINGUAS); do \ diff --git a/package.json b/package.json index 8563e43..42731a8 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "eslint": "^3.0.0", "eslint-loader": "~1.6.1", "eslint-plugin-react": "~6.9.0", + "htmlparser": "^1.7.7", "jed": "^1.1.1", "jshint": "~2.9.1", "jshint-loader": "~0.8.3", diff --git a/po/de.po b/po/de.po index a37688a..6f2a1f8 100644 --- a/po/de.po +++ b/po/de.po @@ -4,7 +4,7 @@ msgid "" msgstr "" "Project-Id-Version: starter-kit 1.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2018-06-19 10:54+0200\n" +"POT-Creation-Date: 2018-06-19 17:07+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -13,6 +13,14 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +#: src/index.html:20 +msgid "Cockpit Starter Kit" +msgstr "Cockpit Bausatz" + #: src/app.jsx:40 msgid "Running on $0" msgstr "Läuft auf $0" + +#: src/manifest.json +msgid "Starter Kit" +msgstr "Bausatz" diff --git a/po/html2po b/po/html2po new file mode 100755 index 0000000..2c92897 --- /dev/null +++ b/po/html2po @@ -0,0 +1,264 @@ +#!/usr/bin/env node + +/* + * Extracts translatable strings from HTML files in the following forms: + * + * String + * String + * String + * + * + * Supports the following Glade compatible forms: + * + * String + * String + * + * Supports the following angular-gettext compatible forms: + * + * String + * Singular + * + * Note that some of the use of the translated may not support all the strings + * depending on the code actually using these strings to translate the HTML. + */ + + +function fatal(message, code) { + console.log((filename || "html2po") + ": " + message); + process.exit(code || 1); +} + +function usage() { + console.log("usage: html2po input output"); + process.exit(2); +} + +var fs, htmlparser, path, stdio; + +try { + fs = require('fs'); + path = require('path'); + htmlparser = require('htmlparser'); + stdio = require('stdio'); +} catch (ex) { + fatal(ex.message, 127); /* missing looks for this */ +} + +var opts = stdio.getopt({ + directory: { key: "d", args: 1, description: "Base directory for input files" }, + output: { key: "o", args: 1, description: "Output file" }, + from: { key: "f", args: 1, description: "File containing list of input files" }, +}); + +if (!opts.from && opts.args.length < 1) { + usage(); +} + +var input = opts.args; +var entries = { }; + +/* Filename being parsed and offset of line number */ +var filename = null; +var offsets = 0; + +/* The HTML parser we're using */ +var handler = new htmlparser.DefaultHandler(function(error, dom) { + if (error) + fatal(error); + else + walk(dom); +}); + +prepare(); + +/* Decide what input files to process */ +function prepare() { + if (opts.from) { + fs.readFile(opts.from, { encoding: "utf-8"}, function(err, data) { + if (err) + fatal(err.message); + input = data.split("\n").filter(function(value) { + return !!value; + }).concat(input); + step(); + }); + } else { + step(); + } +} + +/* Now process each file in turn */ +function step() { + filename = input.shift(); + if (filename === undefined) { + finish(); + return; + } + + /* Qualify the filename if necessary */ + var full = filename; + if (opts.directory) + full = path.join(opts.directory, filename); + + fs.readFile(full, { encoding: "utf-8"}, function(err, data) { + if (err) + fatal(err.message); + + var parser = new htmlparser.Parser(handler, { includeLocation: true }); + parser.parseComplete(data); + step(); + }); +} + +/* Process an array of nodes */ +function walk(children) { + if (!children) + return; + + children.forEach(function(child) { + var line = (child.location || { }).line || 0; + var offset = line - 1; + + /* Scripts get their text processed as HTML */ + if (child.type == 'script' && child.children) { + var parser = new htmlparser.Parser(handler, { includeLocation: true }); + + /* Make note of how far into the outer HTML file we are */ + offsets += offset; + + child.children.forEach(function(node) { + parser.parseChunk(node.raw); + }); + parser.done(); + + offsets -= offset; + + /* Tags get extracted as usual */ + } else if (child.type == 'tag') { + tag(child); + } + }); +} + +/* Process a single loaded tag */ +function tag(node) { + + var tasks, line, entry; + var attrs = node.attribs || { }; + var nest = true; + + /* Extract translate strings */ + if ("translate" in attrs || "translatable" in attrs) { + tasks = (attrs["translate"] || attrs["translatable"] || "yes").split(" "); + + /* Calculate the line location taking into account nested parsing */ + line = (node.location || { })["line"] || 0; + line += offsets; + + entry = { + msgctxt: attrs['translate-context'] || attrs['context'], + msgid_plural: attrs['translate-plural'], + locations: [ filename + ":" + line ] + }; + + /* For each thing listed */ + tasks.forEach(function(task) { + var copy = Object.assign({}, entry); + + /* The element text itself */ + if (task == "yes" || task == "translate") { + copy.msgid = extract(node.children); + nest = false; + + /* An attribute */ + } else if (task) { + copy.msgid = attrs[task]; + } + + if (copy.msgid) + push(copy); + }); + } + + /* Walk through all the children */ + if (nest) + walk(node.children); +} + +/* Push an entry onto the list */ +function push(entry) { + var key = entry.msgid + "\0" + entry.msgid_plural + "\0" + entry.msgctxt; + var prev = entries[key]; + if (prev) { + prev.locations = prev.locations.concat(entry.locations); + } else { + entries[key] = entry; + } +} + +/* Extract the given text */ +function extract(children) { + if (!children) + return null; + + var i, len, node, str = []; + children.forEach(function(node) { + if (node.type == 'tag' && node.children) + str.push(extract(node.children)) + else if (node.type == 'text' && node.data) + str.push(node.data); + }); + + return str.join(""); +} + +/* Escape a string for inclusion in po file */ +function escape(string) { + var bs = string.split('\\').join('\\\\').split('"').join('\\"'); + return bs.split("\n").map(function(line) { + return '"' + line + '"'; + }).join("\n"); +} + +/* Finish by writing out the strings */ +function finish() { + var result = [ + 'msgid ""', + 'msgstr ""', + '"Project-Id-Version: PACKAGE_VERSION\\n"', + '"MIME-Version: 1.0\\n"', + '"Content-Type: text/plain; charset=UTF-8\\n"', + '"Content-Transfer-Encoding: 8bit\\n"', + '"X-Generator: Cockpit html2po\\n"', + '', + ]; + + var msgid, entry; + for (msgid in entries) { + entry = entries[msgid]; + result.push('#: ' + entry.locations.join(" ")); + if (entry.msgctxt) + result.push('msgctxt ' + escape(entry.msgctxt)); + result.push('msgid ' + escape(entry.msgid)); + if (entry.msgid_plural) { + result.push('msgid_plural ' + escape(entry.msgid_plural)); + result.push('msgstr[0] ""'); + result.push('msgstr[1] ""'); + } else { + result.push('msgstr ""'); + } + result.push(''); + } + + var data = result.join('\n'); + if (!opts.output) { + process.stdout.write(data); + process.exit(0); + } else { + fs.writeFile(opts.output, data, function(err) { + if (err) + fatal(err.message); + process.exit(0); + }); + } +} diff --git a/po/manifest2po b/po/manifest2po new file mode 100755 index 0000000..4d26db3 --- /dev/null +++ b/po/manifest2po @@ -0,0 +1,161 @@ +#!/usr/bin/env node + +/* + * Extracts translatable strings from manifest.json files. + * + */ + +function fatal(message, code) { + console.log((filename || "manifest2po") + ": " + message); + process.exit(code || 1); +} + +function usage() { + console.log("usage: manifest2po [-o output] input..."); + process.exit(2); +} + +var fs, path, stdio; + +try { + fs = require('fs'); + path = require('path'); + stdio = require('stdio'); +} catch (ex) { + fatal(ex.message, 127); /* missing looks for this */ +} + +var opts = stdio.getopt({ + directory: { key: "d", args: 1, description: "Base directory for input files" }, + output: { key: "o", args: 1, description: "Output file" }, + from: { key: "f", args: 1, description: "File containing list of input files" }, +}); + +if (!opts.from && opts.args.length < 1) { + usage(); +} + +var input = opts.args; +var entries = { }; + +/* Filename being parsed */ +var filename = null; + +prepare(); + +/* Decide what input files to process */ +function prepare() { + if (opts.from) { + fs.readFile(opts.from, { encoding: "utf-8"}, function(err, data) { + if (err) + fatal(err.message); + input = data.split("\n").filter(function(value) { + return !!value; + }).concat(input); + step(); + }); + } else { + step(); + } +} + +/* Now process each file in turn */ +function step() { + filename = input.shift(); + if (filename === undefined) { + finish(); + return; + } + + if (path.basename(filename) != "manifest.json") + return step(); + + fs.readFile(filename, { encoding: "utf-8"}, function(err, data) { + if (err) + fatal(err.message); + + process_manifest(JSON.parse(data)); + + return step(); + }); +} + +function process_manifest(manifest) { + if (manifest.menu) + process_menu(manifest.menu); + if (manifest.tools) + process_menu(manifest.tools); +} + +function process_menu(menu) { + for (var m in menu) { + if (menu[m].label) { + push({ + msgid: menu[m].label, + locations: [ filename ] + }); + } + } +} + +/* Push an entry onto the list */ +function push(entry) { + var key = entry.msgid + "\0" + entry.msgid_plural + "\0" + entry.msgctxt; + var prev = entries[key]; + if (prev) { + prev.locations = prev.locations.concat(entry.locations); + } else { + entries[key] = entry; + } +} + +/* Escape a string for inclusion in po file */ +function escape(string) { + var bs = string.split('\\').join('\\\\').split('"').join('\\"'); + return bs.split("\n").map(function(line) { + return '"' + line + '"'; + }).join("\n"); +} + +/* Finish by writing out the strings */ +function finish() { + var result = [ + 'msgid ""', + 'msgstr ""', + '"Project-Id-Version: PACKAGE_VERSION\\n"', + '"MIME-Version: 1.0\\n"', + '"Content-Type: text/plain; charset=UTF-8\\n"', + '"Content-Transfer-Encoding: 8bit\\n"', + '"X-Generator: Cockpit manifest2po\\n"', + '', + ]; + + var msgid, entry; + for (msgid in entries) { + entry = entries[msgid]; + result.push('#: ' + entry.locations.join(" ")); + if (entry.msgctxt) + result.push('msgctxt ' + escape(entry.msgctxt)); + result.push('msgid ' + escape(entry.msgid)); + if (entry.msgid_plural) { + result.push('msgid_plural ' + escape(entry.msgid_plural)); + result.push('msgstr[0] ""'); + result.push('msgstr[1] ""'); + } else { + result.push('msgstr ""'); + } + result.push(''); + } + + var data = result.join('\n'); + if (!opts.output) { + process.stdout.write(data); + process.exit(0); + } else { + fs.writeFile(opts.output, data, function(err) { + if (err) + fatal(err.message); + process.exit(0); + }); + } +} diff --git a/test/check-application b/test/check-application index 5efe8b0..befa153 100755 --- a/test/check-application +++ b/test/check-application @@ -40,10 +40,12 @@ class TestApplication(testlib.MachineCase): # HACK: work around language switching in Chrome not working in current session (Cockpit issue #8160) b.reload(ignore_cache=True) b.wait_present("#content") + # menu label (from manifest) should be translated + b.wait_text("#host-apps a[href='/starter-kit']", "Bausatz") b.go("/starter-kit") b.enter_page("/starter-kit") - + # page label (from js) should be translated b.wait_in_text(".container-fluid span", "Läuft auf") if __name__ == '__main__':