diff --git a/src/octoprint/server/__init__.py b/src/octoprint/server/__init__.py index 43fd5247..16e6971a 100644 --- a/src/octoprint/server/__init__.py +++ b/src/octoprint/server/__init__.py @@ -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 diff --git a/src/octoprint/server/api/__init__.py b/src/octoprint/server/api/__init__.py index 056319bb..d50bb922 100644 --- a/src/octoprint/server/api/__init__.py +++ b/src/octoprint/server/api/__init__.py @@ -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" diff --git a/src/octoprint/server/api/languages.py b/src/octoprint/server/api/languages.py new file mode 100644 index 00000000..a3690f74 --- /dev/null +++ b/src/octoprint/server/api/languages.py @@ -0,0 +1,147 @@ +# coding=utf-8 +from __future__ import absolute_import + +__author__ = "Gina Häußge " +__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//", 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 diff --git a/src/octoprint/static/js/app/viewmodels/settings.js b/src/octoprint/static/js/app/viewmodels/settings.js index 7ea41078..2c243653 100644 --- a/src/octoprint/static/js/app/viewmodels/settings.js +++ b/src/octoprint/static/js/app/viewmodels/settings.js @@ -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); diff --git a/src/octoprint/templates/dialogs/settings/appearance.jinja2 b/src/octoprint/templates/dialogs/settings/appearance.jinja2 index 5744d729..7975a4e4 100644 --- a/src/octoprint/templates/dialogs/settings/appearance.jinja2 +++ b/src/octoprint/templates/dialogs/settings/appearance.jinja2 @@ -19,6 +19,12 @@ +
+ +
+ +
+
@@ -31,3 +37,48 @@
+ +