From 2aa1c129709b191f009d57d33798a7f7bcb6f560 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Fri, 18 Dec 2020 16:54:00 -0500 Subject: [PATCH] Extract ODM strings, moar translation work --- app/admin.py | 2 +- app/api/formulas.py | 35 ++++---- app/api/tasks.py | 21 ++--- .../commands/makemessages_djangojs.py | 29 +++++++ app/models/setting.py | 3 +- app/scripts/extract_odm_strings.py | 84 +++++++++++++++++++ app/templates/app/404.html | 7 +- app/templates/app/500.html | 6 +- app/templates/app/about.html | 4 +- app/views/app.py | 4 +- nodeodm/apps.py | 3 +- nodeodm/models.py | 28 ++++--- translate.sh | 1 + 13 files changed, 175 insertions(+), 52 deletions(-) create mode 100644 app/management/commands/makemessages_djangojs.py create mode 100644 app/scripts/extract_odm_strings.py diff --git a/app/admin.py b/app/admin.py index a641c949..f3baa173 100644 --- a/app/admin.py +++ b/app/admin.py @@ -61,7 +61,7 @@ class ThemeModelForm(forms.ModelForm): label=_("HTML (after header)"), required=False, widget=CodeMirrorEditor(options={'mode': 'xml', 'lineNumbers': True})) - html_after_body = forms.CharField(help_text=_("HTML that will be displayed after the </body> tag"), + html_after_body = forms.CharField(help_text=_("HTML that will be displayed after the body tag"), label=_("HTML (after body)"), required=False, widget=CodeMirrorEditor(options={'mode': 'xml', 'lineNumbers': True})) diff --git a/app/api/formulas.py b/app/api/formulas.py index 6198b3ad..8caf946a 100644 --- a/app/api/formulas.py +++ b/app/api/formulas.py @@ -4,78 +4,79 @@ import re from functools import lru_cache +from django.utils.translation import gettext_lazy as _ algos = { 'NDVI': { 'expr': '(N - R) / (N + R)', - 'help': 'Normalized Difference Vegetation Index shows the amount of green vegetation.' + 'help': _('Normalized Difference Vegetation Index shows the amount of green vegetation.') }, 'NDVI (Blue)': { 'expr': '(N - B) / (N + B)', - 'help': 'Normalized Difference Vegetation Index shows the amount of green vegetation.' + 'help': _('Normalized Difference Vegetation Index shows the amount of green vegetation.') }, 'ENDVI':{ 'expr': '((N + G) - (2 * B)) / ((N + G) + (2 * B))', - 'help': 'Enhanced Normalized Difference Vegetation Index is like NDVI, but uses Blue and Green bands instead of only Red to isolate plant health.' + 'help': _('Enhanced Normalized Difference Vegetation Index is like NDVI, but uses Blue and Green bands instead of only Red to isolate plant health.') }, 'VARI': { 'expr': '(G - R) / (G + R - B)', - 'help': 'Visual Atmospheric Resistance Index shows the areas of vegetation.', + 'help': _('Visual Atmospheric Resistance Index shows the areas of vegetation.'), 'range': (-1, 1) }, 'EXG': { 'expr': '(2 * G) - (R + B)', - 'help': 'Excess Green Index emphasizes the greenness of leafy crops such as potatoes.', + 'help': _('Excess Green Index emphasizes the greenness of leafy crops such as potatoes.',) }, 'BAI': { 'expr': '1.0 / (((0.1 - R) ** 2) + ((0.06 - N) ** 2))', - 'help': 'Burn Area Index hightlights burned land in the red to near-infrared spectrum.' + 'help': _('Burn Area Index hightlights burned land in the red to near-infrared spectrum.') }, 'GLI': { 'expr': '((G * 2) - R - B) / ((G * 2) + R + B)', - 'help': 'Green Leaf Index shows greens leaves and stems.', + 'help': _('Green Leaf Index shows greens leaves and stems.'), 'range': (-1, 1) }, 'GNDVI':{ 'expr': '(N - G) / (N + G)', - 'help': 'Green Normalized Difference Vegetation Index is similar to NDVI, but measures the green spectrum instead of red.' + 'help': _('Green Normalized Difference Vegetation Index is similar to NDVI, but measures the green spectrum instead of red.') }, 'GRVI':{ 'expr': 'N / G', - 'help': 'Green Ratio Vegetation Index is sensitive to photosynthetic rates in forests.' + 'help': _('Green Ratio Vegetation Index is sensitive to photosynthetic rates in forests.') }, 'SAVI':{ 'expr': '(1.5 * (N - R)) / (N + R + 0.5)', - 'help': 'Soil Adjusted Vegetation Index is similar to NDVI but attempts to remove the effects of soil areas using an adjustment factor (0.5).' + 'help': _('Soil Adjusted Vegetation Index is similar to NDVI but attempts to remove the effects of soil areas using an adjustment factor (0.5).') }, 'MNLI':{ 'expr': '((N ** 2 - R) * 1.5) / (N ** 2 + R + 0.5)', - 'help': 'Modified Non-Linear Index improves the Non-Linear Index algorithm to account for soil areas.' + 'help': _('Modified Non-Linear Index improves the Non-Linear Index algorithm to account for soil areas.') }, 'MSR': { 'expr': '((N / R) - 1) / (sqrt(N / R) + 1)', - 'help': 'Modified Simple Ratio is an improvement of the Simple Ratio (SR) index to be more sensitive to vegetation.' + 'help': _('Modified Simple Ratio is an improvement of the Simple Ratio (SR) index to be more sensitive to vegetation.') }, 'RDVI': { 'expr': '(N - R) / sqrt(N + R)', - 'help': 'Renormalized Difference Vegetation Index uses the difference between near-IR and red, plus NDVI to show areas of healthy vegetation.' + 'help': _('Renormalized Difference Vegetation Index uses the difference between near-IR and red, plus NDVI to show areas of healthy vegetation.') }, 'TDVI': { 'expr': '1.5 * ((N - R) / sqrt(N ** 2 + R + 0.5))', - 'help': 'Transformed Difference Vegetation Index highlights vegetation cover in urban environments.' + 'help': _('Transformed Difference Vegetation Index highlights vegetation cover in urban environments.') }, 'OSAVI': { 'expr': '(N - R) / (N + R + 0.16)', - 'help': 'Optimized Soil Adjusted Vegetation Index is based on SAVI, but tends to work better in areas with little vegetation where soil is visible.' + 'help': _('Optimized Soil Adjusted Vegetation Index is based on SAVI, but tends to work better in areas with little vegetation where soil is visible.') }, 'LAI': { 'expr': '3.618 * (2.5 * (N - R) / (N + 6*R - 7.5*B + 1)) * 0.118', - 'help': 'Leaf Area Index estimates foliage areas and predicts crop yields.', + 'help': _('Leaf Area Index estimates foliage areas and predicts crop yields.'), 'range': (-1, 1) }, 'EVI': { 'expr': '2.5 * (N - R) / (N + 6*R - 7.5*B + 1)', - 'help': 'Enhanced Vegetation Index is useful in areas where NDVI might saturate, by using blue wavelengths to correct soil signals.', + 'help': _('Enhanced Vegetation Index is useful in areas where NDVI might saturate, by using blue wavelengths to correct soil signals.'), 'range': (-1, 1) }, diff --git a/app/api/tasks.py b/app/api/tasks.py index 73886e62..cc2d6ec1 100644 --- a/app/api/tasks.py +++ b/app/api/tasks.py @@ -21,6 +21,7 @@ from nodeodm.models import ProcessingNode from worker import tasks as worker_tasks from .common import get_and_check_project from app.security import path_traversal_check +from django.utils.translation import gettext_lazy as _ def flatten_files(request_files): @@ -175,7 +176,7 @@ class TaskViewSet(viewsets.ViewSet): task.images_count = models.ImageUpload.objects.filter(task=task).count() if task.images_count < 2: - raise exceptions.ValidationError(detail="You need to upload at least 2 images before commit") + raise exceptions.ValidationError(detail=_("You need to upload at least 2 images before commit")) task.save() worker_tasks.process_task.delay(task.id) @@ -197,7 +198,7 @@ class TaskViewSet(viewsets.ViewSet): files = flatten_files(request.FILES) if len(files) == 0: - raise exceptions.ValidationError(detail="No files uploaded") + raise exceptions.ValidationError(detail=_("No files uploaded")) with transaction.atomic(): for image in files: @@ -220,7 +221,7 @@ class TaskViewSet(viewsets.ViewSet): files = flatten_files(request.FILES) if len(files) <= 1: - raise exceptions.ValidationError(detail="Cannot create task, you need at least 2 images") + raise exceptions.ValidationError(detail=_("Cannot create task, you need at least 2 images")) with transaction.atomic(): task = models.Task.objects.create(project=project, @@ -325,10 +326,10 @@ class TaskDownloads(TaskNestedView): try: asset_path = task.get_asset_download_path(asset) except FileNotFoundError: - raise exceptions.NotFound("Asset does not exist") + raise exceptions.NotFound(_("Asset does not exist")) if not os.path.exists(asset_path): - raise exceptions.NotFound("Asset does not exist") + raise exceptions.NotFound(_("Asset does not exist")) return download_file_response(request, asset_path, 'attachment') @@ -347,10 +348,10 @@ class TaskAssets(TaskNestedView): try: asset_path = path_traversal_check(task.assets_path(unsafe_asset_path), task.assets_path("")) except SuspiciousFileOperation: - raise exceptions.NotFound("Asset does not exist") + raise exceptions.NotFound(_("Asset does not exist")) if (not os.path.exists(asset_path)) or os.path.isdir(asset_path): - raise exceptions.NotFound("Asset does not exist") + raise exceptions.NotFound(_("Asset does not exist")) return download_file_response(request, asset_path, 'inline') @@ -366,13 +367,13 @@ class TaskAssetsImport(APIView): files = flatten_files(request.FILES) import_url = request.data.get('url', None) - task_name = request.data.get('name', 'Imported Task') + task_name = request.data.get('name', _('Imported Task')) if not import_url and len(files) != 1: - raise exceptions.ValidationError(detail="Cannot create task, you need to upload 1 file") + raise exceptions.ValidationError(detail=_("Cannot create task, you need to upload 1 file")) if import_url and len(files) > 0: - raise exceptions.ValidationError(detail="Cannot create task, either specify a URL or upload 1 file.") + raise exceptions.ValidationError(detail=_("Cannot create task, either specify a URL or upload 1 file.")) with transaction.atomic(): task = models.Task.objects.create(project=project, diff --git a/app/management/commands/makemessages_djangojs.py b/app/management/commands/makemessages_djangojs.py new file mode 100644 index 00000000..35217600 --- /dev/null +++ b/app/management/commands/makemessages_djangojs.py @@ -0,0 +1,29 @@ +from django.core.management.commands.makemessages import Command as MMCommand + +# https://medium.com/@hugosousa/hacking-djangos-makemessages-for-better-translations-matching-in-jsx-components-1174b57a13b1 +class Command(MMCommand): + """ + This is a wrapper for the makemessages command and + it is used to force makemessages call xgettext with the language + provided as input + The solution is really hacky and takes advantage of the fact + that in makemessages TranslatableFile process() + the options in command.xgettext_options are appended to the end + of the xgettext command. + """ + def add_arguments(self, parser): + parser.add_argument( + '--language', + '-lang', + default='Python', + dest='language', + help='Language to be used by xgettext' + ) + + super(Command, self).add_arguments(parser) + def handle(self, *args, **options): + language = options.get('language') + self.xgettext_options.append('--language={lang}'.format( + lang=language + )) + super(Command, self).handle(*args, **options) \ No newline at end of file diff --git a/app/models/setting.py b/app/models/setting.py index 17236200..ca3d3def 100644 --- a/app/models/setting.py +++ b/app/models/setting.py @@ -8,7 +8,6 @@ from django.db.models import signals from django.dispatch import receiver from imagekit.models import ImageSpecField from imagekit.processors import ResizeToFit -from django.utils.translation import gettext from django.utils.translation import gettext_lazy as _ from webodm import settings @@ -71,7 +70,7 @@ class Setting(models.Model): super(Setting, self).save(*args, **kwargs) def __str__(self): - return gettext("Application") + return str(_("Application")) class Meta: verbose_name = _("Settings") diff --git a/app/scripts/extract_odm_strings.py b/app/scripts/extract_odm_strings.py new file mode 100644 index 00000000..e07dabaa --- /dev/null +++ b/app/scripts/extract_odm_strings.py @@ -0,0 +1,84 @@ +#!/usr/bin/python3 + +import argparse, os, urllib.request, ast, sys +from io import StringIO + +parser = argparse.ArgumentParser(description='Extract ODM strings.') +parser.add_argument('input', type=str, + help='URL to ODM\'s config.py') +parser.add_argument('output', type=str, + help='Where to write resulting translation file') +args = parser.parse_args() + +url = args.input +outfile = args.output + +strings = [] +print("Fetching %s ..." % url) +res = urllib.request.urlopen(url) +config_file = res.read().decode('utf-8') +# config_file = open("test.py").read() + +options = {} +class ArgumentParserStub(argparse.ArgumentParser): + def add_argument(self, *args, **kwargs): + argparse.ArgumentParser.add_argument(self, *args, **kwargs) + options[args[0]] = {} + for name, value in kwargs.items(): + options[args[0]][str(name)] = str(value) + + def add_mutually_exclusive_group(self): + return ArgumentParserStub() + +# Voodoo! :) +# ( parse AST, extract "def config()" function, set module to only +# contain that function, execute module in current scope, +# run config function) +root = ast.parse(config_file) +new_body = [] +for stmt in root.body: + # Assignments + if hasattr(stmt, 'targets'): + new_body.append(stmt) + + # Functions + elif hasattr(stmt, 'name'): + new_body.append(stmt) + +root.body = new_body +exec(compile(root, filename="", mode="exec")) + + +# Misc variables needed to get config to run +__version__ = '?' +class context: + root_path = '' + num_cores = 4 +class io: + def path_or_json_string_to_dict(s): + return s +def path_or_json_string(s): + return s +class log: + def ODM_ERROR(s): + pass + +config(["--project-path", "/bogus", "name"], parser=ArgumentParserStub()) +for opt in options: + h = options[opt].get('help') + if h: + h = h.replace("\n", "") + strings.append(h) + +strings = list(set(strings)) +print("Found %s ODM strings" % len(strings)) +if len(strings) > 0: + with open(outfile, "w") as f: + f.write("// Auto-generated with extract_odm_strings.py, do not edit!\n\n") + + for s in strings: + f.write("_(\"%s\");\n" % s.replace("\"", "\\\"")) + + print("Wrote %s" % outfile) +else: + print("No strings found") \ No newline at end of file diff --git a/app/templates/app/404.html b/app/templates/app/404.html index 530268a0..7aa9671f 100644 --- a/app/templates/app/404.html +++ b/app/templates/app/404.html @@ -1,9 +1,10 @@ {% extends "app/base.html" %} -{% load settings %} +{% load settings i18n %} + {% block page-wrapper %}
-

404 Page Not Found

-
Are you sure the address is correct?
+

{% trans '404 Page Not Found' %}

+
{% trans 'Are you sure the address is correct?' %}
404
diff --git a/app/templates/app/500.html b/app/templates/app/500.html index eec99017..2fbfd25c 100644 --- a/app/templates/app/500.html +++ b/app/templates/app/500.html @@ -1,9 +1,9 @@ {% extends "app/base.html" %} -{% load settings %} +{% load settings i18n %} {% block page-wrapper %}
-

500 Internal Server Error

-
Something happened. The server logs contain more information.
+

{% trans '500 Internal Server Error' %}

+
{% trans 'Something happened. The server logs contain more information.' %}
500
diff --git a/app/templates/app/about.html b/app/templates/app/about.html index e109f4f6..344ca667 100644 --- a/app/templates/app/about.html +++ b/app/templates/app/about.html @@ -84,12 +84,14 @@
+{% load i18n %} +

WebODM {{ version }}

https://github.com/OpenDroneMap/WebODM
-
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. See the GNU Affero General Public License for more details.
+
{% blocktrans with link_start="" link_end='' %}This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. See {{link_start}}the GNU Affero General Public License{{link_end}} for more details.{% endblocktrans %}
{% endblock %} diff --git a/app/views/app.py b/app/views/app.py index 9138b9fb..112a0f2c 100644 --- a/app/views/app.py +++ b/app/views/app.py @@ -43,7 +43,7 @@ def dashboard(request): if Project.objects.count() == 0: Project.objects.create(owner=request.user, name=_("First Project")) - return render(request, 'app/dashboard.html', {'title': 'Dashboard', + return render(request, 'app/dashboard.html', {'title': _('Dashboard'), 'no_processingnodes': no_processingnodes, 'no_tasks': no_tasks }) @@ -144,7 +144,7 @@ def welcome(request): return render(request, 'app/welcome.html', { - 'title': 'Welcome', + 'title': _('Welcome'), 'firstuserform': fuf }) diff --git a/nodeodm/apps.py b/nodeodm/apps.py index 0b581827..83536403 100644 --- a/nodeodm/apps.py +++ b/nodeodm/apps.py @@ -1,7 +1,8 @@ from __future__ import unicode_literals from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ class NodeodmConfig(AppConfig): name = 'nodeodm' - verbose_name = 'Node Management' + verbose_name = _('Node Management') diff --git a/nodeodm/models.py b/nodeodm/models.py index d7c4324d..d8bd230a 100644 --- a/nodeodm/models.py +++ b/nodeodm/models.py @@ -18,19 +18,23 @@ from datetime import timedelta OFFLINE_MINUTES = 5 # Number of minutes a node hasn't been seen before it should be considered offline -# TODO + class ProcessingNode(models.Model): - hostname = models.CharField(max_length=255, help_text="Hostname or IP address where the node is located (can be an internal hostname as well). If you are using Docker, this is never 127.0.0.1 or localhost. Find the IP address of your host machine by running ifconfig on Linux or by checking your network settings.") - port = models.PositiveIntegerField(help_text="Port that connects to the node's API") - api_version = models.CharField(max_length=32, null=True, help_text="API version used by the node") - last_refreshed = models.DateTimeField(null=True, help_text="When was the information about this node last retrieved?") - queue_count = models.PositiveIntegerField(default=0, help_text="Number of tasks currently being processed by this node (as reported by the node itself)") - available_options = fields.JSONField(default=dict, help_text="Description of the options that can be used for processing") - token = models.CharField(max_length=1024, blank=True, default="", help_text="Token to use for authentication. If the node doesn't have authentication, you can leave this field blank.") - max_images = models.PositiveIntegerField(help_text="Maximum number of images accepted by this node.", blank=True, null=True) - engine_version = models.CharField(max_length=32, null=True, help_text="Engine version used by the node.") - label = models.CharField(max_length=255, default="", blank=True, help_text="Optional label for this node. When set, this label will be shown instead of the hostname:port name.") - engine = models.CharField(max_length=255, null=True, help_text="Engine used by the node.") + hostname = models.CharField(verbose_name=_("Hostname"), max_length=255, help_text=_("Hostname or IP address where the node is located (can be an internal hostname as well). If you are using Docker, this is never 127.0.0.1 or localhost. Find the IP address of your host machine by running ifconfig on Linux or by checking your network settings.")) + port = models.PositiveIntegerField(verbose_name=_("Port"), help_text=_("Port that connects to the node's API")) + api_version = models.CharField(verbose_name=_("API Version"), max_length=32, null=True, help_text=_("API version used by the node")) + last_refreshed = models.DateTimeField(verbose_name=_("Last Refreshed"), null=True, help_text=_("When was the information about this node last retrieved?")) + queue_count = models.PositiveIntegerField(verbose_name=_("Queue Count"), default=0, help_text=_("Number of tasks currently being processed by this node (as reported by the node itself)")) + available_options = fields.JSONField(verbose_name=_("Available Options"), default=dict, help_text=_("Description of the options that can be used for processing")) + token = models.CharField(verbose_name=_("Token"), max_length=1024, blank=True, default="", help_text=_("Token to use for authentication. If the node doesn't have authentication, you can leave this field blank.")) + max_images = models.PositiveIntegerField(verbose_name=_("Max Images"), help_text=_("Maximum number of images accepted by this node."), blank=True, null=True) + engine_version = models.CharField(verbose_name=_("Engine Version"), max_length=32, null=True, help_text=_("Engine version used by the node.")) + label = models.CharField(verbose_name=_("Label"), max_length=255, default="", blank=True, help_text=_("Optional label for this node. When set, this label will be shown instead of the hostname:port name.")) + engine = models.CharField(verbose_name=_("Engine"), max_length=255, null=True, help_text=_("Engine used by the node.")) + + class Meta: + verbose_name = _("Processing Node") + verbose_name_plural = _("Processing Nodes") def __str__(self): if self.label != "": diff --git a/translate.sh b/translate.sh index 4ad6258c..50633759 100755 --- a/translate.sh +++ b/translate.sh @@ -13,6 +13,7 @@ if [[ "$1" == "extract" ]]; then mkdir -p locale python3 app/scripts/extract_potree_strings.py app/static/app/js/vendor/potree/build/potree/resources/lang/en/translation.json app/static/app/js/translations/potree_autogenerated.js + python3 app/scripts/extract_odm_strings.py https://raw.githubusercontent.com/OpenDroneMap/ODM/master/opendm/config.py app/static/app/js/translations/odm_autogenerated.js django-admin makemessages --keep-pot $locale_param --ignore=build --ignore=app/templates/app/registration/* python manage.py makemessages_djangojs --keep-pot $locale_param -d djangojs --extension jsx --extension js --ignore=build --ignore app/static/app/js/vendor --ignore app/static/app/bundles --ignore node_modules --language Python