Management dialog for language packs

Allows managing language packs of core OctoPrint and plugins.
This commit is contained in:
Gina Häußge 2015-06-02 13:39:10 +02:00
parent 5904d01bff
commit 52dd2ad7ac
5 changed files with 336 additions and 3 deletions

View file

@ -331,7 +331,8 @@ class Server():
self._tornado_app = Application(server_routes)
max_body_sizes = [
("POST", r"/api/files/([^/]*)", s.getInt(["server", "uploads", "maxSize"]))
("POST", r"/api/files/([^/]*)", s.getInt(["server", "uploads", "maxSize"])),
("POST", r"/api/languages", 5 * 1024 * 1024)
]
# allow plugins to extend allowed maximum body sizes

View file

@ -37,6 +37,7 @@ from . import users as api_users
from . import log as api_logs
from . import slicing as api_slicing
from . import printer_profiles as api_printer_profiles
from . import languages as api_languages
VERSION = "0.1"

View file

@ -0,0 +1,147 @@
# coding=utf-8
from __future__ import absolute_import
__author__ = "Gina Häußge <osd@foosel.net>"
__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html'
__copyright__ = "Copyright (C) 2015 The OctoPrint Project - Released under terms of the AGPLv3 License"
import os
import tarfile
import zipfile
from collections import defaultdict
from flask import request, jsonify, make_response
from octoprint.settings import settings
from octoprint.server import admin_permission
from octoprint.server.api import api
from octoprint.server.util.flask import restricted_access
from octoprint.plugin import plugin_manager
from flask.ext.babel import Locale
@api.route("/languages", methods=["GET"])
@restricted_access
@admin_permission.require(403)
def getInstalledLanguagePacks():
translation_folder = settings().getBaseFolder("translations")
if not os.path.exists(translation_folder):
return jsonify(language_packs=dict(_core=[]))
core_packs = []
plugin_packs = defaultdict(lambda: dict(identifier=None, display=None, languages=[]))
for folder in os.listdir(translation_folder):
path = os.path.join(translation_folder, folder)
if not os.path.isdir(path):
continue
def load_meta(path, locale):
meta = dict()
meta_path = os.path.join(path, "meta.yaml")
if os.path.isfile(meta_path):
import yaml
try:
with open(meta_path) as f:
meta = yaml.safe_load(f)
except:
pass
else:
import datetime
if "last_update" in meta and isinstance(meta["last_update"], datetime.datetime):
meta["last_update"] = (meta["last_update"] - datetime.datetime(1970,1,1)).total_seconds()
l = Locale.parse(locale)
meta["locale"] = locale
meta["locale_display"] = l.display_name
meta["locale_english"] = l.english_name
return meta
if folder == "_plugins":
for plugin_folder in os.listdir(path):
plugin_path = os.path.join(path, plugin_folder)
if not os.path.isdir(plugin_path):
continue
if not plugin_folder in plugin_manager().plugins:
continue
plugin_info = plugin_manager().plugins[plugin_folder]
plugin_packs[plugin_folder]["identifier"] = plugin_folder
plugin_packs[plugin_folder]["display"] = plugin_info.name
for language_folder in os.listdir(plugin_path):
plugin_packs[plugin_folder]["languages"].append(load_meta(os.path.join(plugin_path, language_folder), language_folder))
else:
core_packs.append(load_meta(os.path.join(translation_folder, folder), folder))
result = dict(_core=dict(identifier="_core", display="Core", languages=core_packs))
result.update(plugin_packs)
return jsonify(language_packs=result)
@api.route("/languages", methods=["POST"])
@restricted_access
@admin_permission.require(403)
def uploadLanguagePack():
input_name = "file"
input_upload_path = input_name + "." + settings().get(["server", "uploads", "pathSuffix"])
if not input_upload_path in request.values:
return make_response("No file included", 400)
upload_path = request.values[input_upload_path]
target_path = settings().getBaseFolder("translations")
if tarfile.is_tarfile(upload_path):
_unpack_uploaded_tarball(upload_path, target_path)
elif zipfile.is_zipfile(upload_path):
_unpack_uploaded_zipfile(upload_path, target_path)
else:
return make_response("Neither zip file nor tarball included", 400)
return getInstalledLanguagePacks()
@api.route("/languages/<string:locale>/<string:pack>", methods=["DELETE"])
@restricted_access
@admin_permission.require(403)
def deleteInstalledLanguagePack(locale, pack):
if pack == "_core":
target_path = os.path.join(settings().getBaseFolder("translations"), locale)
else:
target_path = os.path.join(settings().getBaseFolder("translations"), "_plugins", pack, locale)
if os.path.isdir(target_path):
import shutil
shutil.rmtree(target_path)
return getInstalledLanguagePacks()
def _unpack_uploaded_zipfile(path, target):
with zipfile.ZipFile(path, "r") as zip:
# sanity check
map(_validate_archive_name, zip.namelist())
# unpack everything
zip.extractall(target)
def _unpack_uploaded_tarball(path, target):
with tarfile.open(path, "r") as tar:
# sanity check
map(_validate_archive_name, tar.getmembers())
# unpack everything
tar.extractall(target)
def _validate_archive_name(name):
if name.startswith("/") or ".." in name:
raise InvalidLanguagePack("Provided language pack contains invalid name {name}".format(**locals()))
class InvalidLanguagePack(BaseException):
pass

View file

@ -16,6 +16,37 @@ $(function() {
self.appearance_defaultLanguage = ko.observable();
self.settingsDialog = undefined;
self.translationManagerDialog = undefined;
self.translationUploadElement = $("#settings_appearance_managelanguagesdialog_upload");
self.translationUploadButton = $("#settings_appearance_managelanguagesdialog_upload_start");
self.translationUploadFilename = ko.observable();
self.invalidTranslationArchive = ko.computed(function() {
var name = self.translationUploadFilename();
return name !== undefined && !(_.endsWith(name.toLocaleLowerCase(), ".zip") || _.endsWith(name.toLocaleLowerCase(), ".tar.gz") || _.endsWith(name.toLocaleLowerCase(), ".tgz") || _.endsWith(name.toLocaleLowerCase(), ".tar"));
});
self.enableTranslationUpload = ko.computed(function() {
var name = self.translationUploadFilename();
return name !== undefined && name.trim() != "" && !self.invalidTranslationArchive();
});
self.translations = new ItemListHelper(
"settings.translations",
{
"locale": function (a, b) {
// sorts ascending
if (a["locale"].toLocaleLowerCase() < b["locale"].toLocaleLowerCase()) return -1;
if (a["locale"].toLocaleLowerCase() > b["locale"].toLocaleLowerCase()) return 1;
return 0;
}
},
{
},
"locale",
[],
[],
0
);
self.appearance_available_colors = ko.observable([
{key: "default", name: gettext("default")},
@ -108,7 +139,7 @@ $(function() {
self.scripts_gcode_afterPrintPaused = ko.observable(undefined);
self.scripts_gcode_beforePrintResumed = ko.observable(undefined);
self.scripts_gcode_afterPrinterConnected = ko.observable(undefined);
self.temperature_profiles = ko.observableArray(undefined);
self.temperature_cutoff = ko.observable(undefined);
@ -140,11 +171,43 @@ $(function() {
self.onStartup = function() {
self.settingsDialog = $('#settings_dialog');
self.translationManagerDialog = $('#settings_appearance_managelanguagesdialog');
self.translationUploadElement = $("#settings_appearance_managelanguagesdialog_upload");
self.translationUploadButton = $("#settings_appearance_managelanguagesdialog_upload_start");
self.translationUploadElement.fileupload({
dataType: "json",
maxNumberOfFiles: 1,
autoUpload: false,
add: function(e, data) {
if (data.files.length == 0) {
return false;
}
self.translationUploadFilename(data.files[0].name);
self.translationUploadButton.unbind("click");
self.translationUploadButton.bind("click", function() {
data.submit();
return false;
});
},
done: function(e, data) {
self.translationUploadButton.unbind("click");
self.translationUploadFilename(undefined);
self.fromTranslationResponse(data.result);
},
fail: function(e, data) {
self.translationUploadButton.unbind("click");
self.translationUploadFilename(undefined);
}
});
};
self.onAllBound = function(allViewModels) {
self.settingsDialog.on('show', function(event) {
if (event.target.id == "settings_dialog") {
self.requestTranslationData();
_.each(allViewModels, function(viewModel) {
if (viewModel.hasOwnProperty("onSettingsShown")) {
viewModel.onSettingsShown();
@ -182,6 +245,11 @@ $(function() {
return false;
};
self.showTranslationManager = function() {
self.translationManagerDialog.modal();
return false;
};
self.requestData = function(callback) {
$.ajax({
url: API_BASEURL + "settings",
@ -194,6 +262,71 @@ $(function() {
});
};
self.requestTranslationData = function(callback) {
$.ajax({
url: API_BASEURL + "languages",
type: "GET",
dataType: "json",
success: function(response) {
self.fromTranslationResponse(response);
if (callback) callback();
}
})
};
self.fromTranslationResponse = function(response) {
var translationsByLocale = {};
_.each(response.language_packs, function(item, key) {
_.each(item.languages, function(pack) {
var locale = pack.locale;
if (!_.has(translationsByLocale, locale)) {
translationsByLocale[locale] = {
locale: locale,
display: pack.locale_display,
english: pack.locale_english,
packs: []
};
}
translationsByLocale[locale]["packs"].push({
identifier: key,
display: item.display,
pack: pack
});
});
});
var translations = [];
_.each(translationsByLocale, function(item) {
item["packs"].sort(function(a, b) {
if (a.identifier == "_core") return -1;
if (b.identifier == "_core") return 1;
if (a.display < b.display) return -1;
if (a.display > b.display) return 1;
return 0;
});
translations.push(item);
});
self.translations.updateItems(translations);
};
self.languagePackDisplay = function(item) {
return item.display + ((item.english != undefined) ? ' (' + item.english + ')' : '');
};
self.deleteLanguagePack = function(locale, pack) {
$.ajax({
url: API_BASEURL + "languages/" + locale + "/" + pack,
type: "DELETE",
dataType: "json",
success: function(response) {
self.fromTranslationResponse(response);
}
})
};
self.fromResponse = function(response) {
if (self.settings === undefined) {
self.settings = ko.mapping.fromJS(response);
@ -263,7 +396,7 @@ $(function() {
self.scripts_gcode_afterPrintPaused(response.scripts.gcode.afterPrintPaused);
self.scripts_gcode_beforePrintResumed(response.scripts.gcode.beforePrintResumed);
self.scripts_gcode_afterPrinterConnected(response.scripts.gcode.afterPrinterConnected);
self.temperature_profiles(response.temperature.profiles);
self.temperature_cutoff(response.temperature.cutoff);

View file

@ -19,6 +19,12 @@
</label>
</div>
</div>
<div class="control-group" title="">
<label class="control-label" for="settings-appearanceLanguages">{{ _('Language Packs') }}</label>
<div class="controls">
<button class="btn" data-bind="click: showTranslationManager">{{ _('Manage...') }}</button>
</div>
</div>
<div class="control-group" title="">
<label class="control-label" for="settings-appearanceDefaultLanguage">{{ _('Default Language') }}</label>
<div class="controls">
@ -31,3 +37,48 @@
</div>
</div>
</form>
<div id="settings_appearance_managelanguagesdialog" class="modal hide fade">
<div class="modal-header">
<a href="#" class="close" data-dismiss="modal" aria-hidden="true">&times;</a>
<h3>{{ _('Manage Language Packs...') }}</h3>
</div>
<div class="modal-body">
<div id="settings_appearance_managelanguagesdialog_list" data-bind="slimScrolledForeach: translations.paginatedItems" style="height: 300px">
<div class="entry">
<strong><a href="#" onclick="$(this).children('i').toggleClass('icon-caret-right icon-caret-down').parent().parent().next().slideToggle('fast')"><i class="icon-caret-down"></i> <span data-bind="text: $root.languagePackDisplay($data)"></span></a></strong>
<div class="packs">
<!-- ko foreach: $data.packs -->
<div class="row-fluid">
<div class="span8 offset1">
<strong data-bind="text: display"></strong><br />
<small data-bind="visible: pack.last_update" class="muted">{{ _('Last update:') }} <span data-bind="text: formatDate($data.pack.last_update)"></span></small>
</div>
<div class="span3">
<button class="btn btn-block btn-small" data-bind="click: function() {$root.deleteLanguagePack($data.pack.locale, $data.identifier)}"><i class="icon-trash"></i> {{ _('Delete') }}</button>
</div>
</div>
<!-- /ko -->
</div>
</div>
</div>
<form class="form-inline">
<div class="control-group row-fluid" data-bind="css: {error: invalidTranslationArchive}">
<div class="input-prepend span9">
<span class="btn fileinput-button">
<span>{{ _('Browse...') }}</span>
<input id="settings_appearance_managelanguagesdialog_upload" type="file" name="file" data-url="{{ url_for("api.uploadLanguagePack") }}">
</span>
<span class="add-on add-on-limited text-left" data-bind="text: translationUploadFilename, attr: {title: translationUploadFilename}"></span>
</div>
<button id="settings_appearance_managelanguagesdialog_upload_start" class="btn btn-primary span3" data-bind="enable: enableTranslationUpload, css: {disabled: !enableTranslationUpload()}, click: function(){}">{{ _('Upload') }}</button>
</div>
<span class="help-block" data-bind="visible: invalidTranslationArchive">{{ _('This does not look like a valid language pack. Valid language packs should be either zip files or tarballs and have the extension ".zip", ".tar.gz", ".tgz" or ".tar"') }}</span>
</form>
</div>
<div class="modal-footer">
<button class="btn" data-dismiss="modal" aria-hidden="true">{{ _('Close') }}</button>
</div>
</div>