429 lines
16 KiB
Python
429 lines
16 KiB
Python
# w.c.s. - web application for online forms
|
|
# Copyright (C) 2005-2022 Entr'ouvert
|
|
#
|
|
# This program is free software; you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation; either version 2 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program; if not, see <http://www.gnu.org/licenses/>.
|
|
|
|
import io
|
|
import xml.etree.ElementTree as ET
|
|
import zipfile
|
|
|
|
from quixote import get_publisher, get_request, get_response, redirect
|
|
from quixote.directory import Directory
|
|
from quixote.html import TemplateIO, htmltext
|
|
from quixote.http_request import parse_query
|
|
|
|
from wcs.blocks import BlockDef
|
|
from wcs.carddef import CardDef
|
|
from wcs.categories import Category
|
|
from wcs.formdef import FormDef, UpdateDigestAfterJob
|
|
from wcs.mail_templates import MailTemplate
|
|
from wcs.qommon import _, errors, get_cfg, misc, ods, template
|
|
from wcs.qommon.afterjobs import AfterJob
|
|
from wcs.qommon.backoffice.listing import pagination_links
|
|
from wcs.qommon.backoffice.menu import html_top
|
|
from wcs.qommon.form import CheckboxWidget, FileWidget, Form, RadiobuttonsWidget, TextWidget
|
|
from wcs.qommon.storage import Equal, FtsMatch, ILike, Or
|
|
from wcs.workflows import Workflow
|
|
|
|
|
|
class I18nDirectory(Directory):
|
|
do_not_call_in_templates = True
|
|
_q_exports = ['', 'scan', 'export', ('import', 'p_import')]
|
|
|
|
supported_languages = [
|
|
('en', _('English')),
|
|
('fr', _('French')),
|
|
('de', _('German')),
|
|
]
|
|
|
|
def get_enabled_languages(self):
|
|
enabled_languages = get_cfg('language', {}).get('languages') or []
|
|
return [x for x in self.supported_languages if x[0] in enabled_languages]
|
|
|
|
def get_supported_languages(self):
|
|
return [x for x in self.get_enabled_languages() if x[0] != get_cfg('language', {}).get('language')]
|
|
|
|
def get_selected_language(self):
|
|
return get_request().form.get('lang') or self.get_supported_languages()[0][0]
|
|
|
|
def _q_index(self):
|
|
from wcs.i18n import TranslatableMessage
|
|
|
|
if not get_publisher().has_i18n_enabled():
|
|
raise errors.TraversalError()
|
|
|
|
if TranslatableMessage.count() == 0:
|
|
return self.scan()
|
|
html_top('i18n', title=_('Multilinguism'))
|
|
get_response().breadcrumb.append(('i18n/', _('Multilinguism')))
|
|
|
|
criterias = []
|
|
criterias.append(Equal('translatable', not (bool(get_request().form.get('non_translatable')))))
|
|
if get_request().form.get('q'):
|
|
search_term = get_request().form.get('q')
|
|
criterias.append(Or([ILike('string', search_term), FtsMatch(search_term)]))
|
|
|
|
offset = misc.get_int_or_400(get_request().form.get('offset', 0))
|
|
limit = misc.get_int_or_400(get_request().form.get('limit', 20))
|
|
total_count = TranslatableMessage.count(criterias)
|
|
context = {
|
|
'has_sidebar': False,
|
|
'q': get_request().form.get('q'),
|
|
'view': self,
|
|
'selected_language': self.get_selected_language(),
|
|
'supported_languages': self.get_supported_languages(),
|
|
'pagination_links': pagination_links(offset, limit, total_count, load_js=False),
|
|
'messages': TranslatableMessage.select(criterias, offset=offset, limit=limit, order_by='string'),
|
|
'query': get_request().get_query(),
|
|
'non_translatable': get_request().form.get('non-translatable'),
|
|
}
|
|
|
|
get_response().add_javascript(['popup.js'])
|
|
return template.QommonTemplateResponse(
|
|
templates=['wcs/backoffice/i18n.html'], context=context, is_django_native=True
|
|
)
|
|
|
|
def scan(self):
|
|
job = get_response().add_after_job(
|
|
I18nScanAfterJob(
|
|
label=_('Scanning for translatable text'),
|
|
user_id=get_request().user.id,
|
|
return_url='/backoffice/i18n/',
|
|
)
|
|
)
|
|
job.store()
|
|
return redirect(job.get_processing_url())
|
|
|
|
def export(self):
|
|
if 'download' in get_request().form:
|
|
try:
|
|
job = AfterJob.get(get_request().form.get('download'))
|
|
except KeyError:
|
|
return redirect('.')
|
|
if not job.status == 'completed':
|
|
raise errors.TraversalError()
|
|
response = get_response()
|
|
response.set_content_type(job.content_type)
|
|
response.set_header('content-disposition', 'attachment; filename=%s' % job.file_name)
|
|
return job.file_content
|
|
|
|
form = Form()
|
|
form.add_hidden('query_string', get_request().get_query())
|
|
formats = [
|
|
('ods', _('OpenDocument (.ods)'), 'ods'),
|
|
('xliff', _('XLIFF'), 'xliff'),
|
|
]
|
|
form.add(
|
|
RadiobuttonsWidget,
|
|
'format',
|
|
options=formats,
|
|
value='ods',
|
|
required=True,
|
|
extra_css_class='widget-inline-radio',
|
|
)
|
|
form.add_submit('submit', _('Export'))
|
|
form.add_submit('cancel', _('Cancel'))
|
|
|
|
if form.get_widget('cancel').parse():
|
|
return redirect('./?' + (form.get_widget('query_string').parse() or ''))
|
|
|
|
if not form.is_submitted() or form.has_errors():
|
|
get_response().breadcrumb.append(('export', _('Export')))
|
|
r = TemplateIO(html=True)
|
|
r += htmltext('<h2>%s</h2>') % _('Export Options')
|
|
r += form.render()
|
|
return r.getvalue()
|
|
|
|
get_request().form = parse_query(form.get_widget('query_string').parse() or '', 'utf-8')
|
|
job = ExportAfterJob(
|
|
file_format=form.get_widget('format').parse(),
|
|
lang=self.get_selected_language(),
|
|
q=get_request().form.get('q'),
|
|
)
|
|
job.store()
|
|
get_response().add_after_job(job)
|
|
return redirect(job.get_processing_url())
|
|
|
|
def p_import(self):
|
|
form = Form(enctype='multipart/form-data', use_tokens=False)
|
|
form.add_hidden('query_string', get_request().get_query())
|
|
form.add(FileWidget, 'file', title=_('File'), required=True)
|
|
form.add_submit('submit', _('Submit'))
|
|
form.add_submit('cancel', _('Cancel'))
|
|
|
|
if form.get_widget('cancel').parse():
|
|
return redirect('./?' + (form.get_widget('query_string').parse() or ''))
|
|
|
|
if not form.is_submitted() or form.has_errors():
|
|
get_response().breadcrumb.append(('import', _('Import File')))
|
|
r = TemplateIO(html=True)
|
|
r += htmltext('<h2>%s</h2>') % _('Import File')
|
|
r += form.render()
|
|
return r.getvalue()
|
|
|
|
job = ImportAfterJob(
|
|
lang=self.get_selected_language(),
|
|
file_content=form.get_widget('file').parse().fp.read(),
|
|
return_url='/backoffice/i18n/?' + (form.get_widget('query_string').parse() or ''),
|
|
)
|
|
job.store()
|
|
get_response().add_after_job(job)
|
|
return redirect(job.get_processing_url())
|
|
|
|
def _q_lookup(self, component):
|
|
if component in [x[0] for x in self.get_enabled_languages()]:
|
|
return LanguageDirectory(component)
|
|
raise errors.TraversalError()
|
|
|
|
|
|
class LanguageDirectory(Directory):
|
|
def __init__(self, lang):
|
|
self.lang = lang
|
|
|
|
def _q_lookup(self, component):
|
|
from wcs.i18n import TranslatableMessage
|
|
|
|
try:
|
|
msg = TranslatableMessage.get(component)
|
|
except KeyError:
|
|
raise errors.TraversalError()
|
|
return MessageDirectory(self.lang, msg)
|
|
|
|
|
|
class MessageDirectory(Directory):
|
|
_q_exports = ['']
|
|
|
|
def __init__(self, lang, msg):
|
|
self.lang = lang
|
|
self.msg = msg
|
|
|
|
def _q_index(self):
|
|
form = Form(enctype='multipart/form-data', action='%s' % get_request().get_path_query())
|
|
rows = min(10, max(2, self.msg.string.count('\n')))
|
|
attr = 'string_%s' % self.lang
|
|
form.add(TextWidget, 'translation', value=getattr(self.msg, attr), rows=rows)
|
|
form.add(
|
|
CheckboxWidget,
|
|
'non_translatable',
|
|
title=_('Mark as non-translatable'),
|
|
value=not (self.msg.translatable),
|
|
)
|
|
form.add_submit('submit', _('Submit'))
|
|
form.add_submit('cancel', _('Cancel'))
|
|
if form.get_widget('cancel').parse():
|
|
return redirect('../../?' + get_request().get_query())
|
|
|
|
if form.is_submitted() and not form.has_errors():
|
|
setattr(self.msg, attr, form.get_widget('translation').parse())
|
|
self.msg.translatable = not (form.get_widget('non_translatable').parse())
|
|
self.msg.store()
|
|
update_digests()
|
|
return redirect('../../?' + get_request().get_query())
|
|
|
|
html_top('i18n', title=_('Multilinguism'))
|
|
context = {'html_form': form, 'msg': self.msg}
|
|
return template.QommonTemplateResponse(
|
|
templates=['wcs/backoffice/i18n-message.html'], context=context
|
|
)
|
|
|
|
|
|
class I18nScanAfterJob(AfterJob):
|
|
def done_action_url(self):
|
|
return self.kwargs['return_url']
|
|
|
|
def done_action_label(self):
|
|
return _('Go to multilinguism page')
|
|
|
|
def done_button_attributes(self):
|
|
return {'data-redirect-auto': 'true'}
|
|
|
|
def execute(self):
|
|
from wcs.i18n import TranslatableMessage
|
|
|
|
objects = []
|
|
for klass in (FormDef, CardDef, BlockDef, Workflow, MailTemplate, Category):
|
|
objects.extend(klass.select(ignore_errors=True, ignore_migration=True))
|
|
self.total_count = len(objects) * 2 # one for discovery, one for storing
|
|
self.store()
|
|
|
|
strings = {(x.context, x.string): x for x in TranslatableMessage.select()}
|
|
for string in strings.values():
|
|
string.locations = []
|
|
|
|
for obj in objects:
|
|
if obj is None:
|
|
self.increment_count()
|
|
continue
|
|
for location, context, string in obj.i18n_scan():
|
|
string = string.strip() if string else string
|
|
if not string:
|
|
continue
|
|
msg = strings.get((context, string))
|
|
if not msg:
|
|
msg = TranslatableMessage()
|
|
msg.context = context
|
|
msg.string = string
|
|
msg.locations = []
|
|
strings[(context, string)] = msg
|
|
msg.locations.append(location)
|
|
self.increment_count()
|
|
|
|
total_strings = len(strings)
|
|
for i, string in enumerate(strings.values()):
|
|
string.store()
|
|
self.increment_count(len(objects) / total_strings)
|
|
|
|
|
|
class ExportAfterJob(AfterJob):
|
|
label = _('Exporting translatable strings')
|
|
|
|
def __init__(self, file_format, lang, q):
|
|
super().__init__()
|
|
self.file_format = file_format
|
|
self.lang = lang
|
|
self.q = q
|
|
|
|
def execute(self):
|
|
from wcs.i18n import TranslatableMessage
|
|
|
|
criterias = []
|
|
if self.q:
|
|
criterias.append(Or([ILike('string', self.q), FtsMatch(self.q)]))
|
|
|
|
self.total_count = TranslatableMessage.count(criterias)
|
|
|
|
if self.file_format == 'ods':
|
|
workbook = ods.Workbook(encoding='utf-8')
|
|
ws = workbook.add_sheet('')
|
|
elif self.file_format == 'xliff':
|
|
ET.register_namespace('xliff', 'urn:oasis:names:tc:xliff:document:2.0')
|
|
root = ET.Element('{urn:oasis:names:tc:xliff:document:2.0}xliff')
|
|
root.attrib['version'] = '2.0'
|
|
root.attrib['srcLang'] = get_cfg('language', {}).get('language')
|
|
root.attrib['trgLang'] = self.lang
|
|
file_node = ET.SubElement(root, '{urn:oasis:names:tc:xliff:document:2.0}file')
|
|
file_node.attrib['id'] = 'f1'
|
|
unit_node = ET.SubElement(file_node, '{urn:oasis:names:tc:xliff:document:2.0}file')
|
|
unit_node.attrib['id'] = '1'
|
|
|
|
for i, message in enumerate(TranslatableMessage.select(criterias)):
|
|
source = message.string
|
|
target = message.translations().get(self.lang) or ''
|
|
if self.file_format == 'ods':
|
|
ws.write(i, 0, source)
|
|
ws.write(i, 1, target)
|
|
elif self.file_format == 'xliff':
|
|
segment = ET.SubElement(unit_node, '{urn:oasis:names:tc:xliff:document:2.0}segment')
|
|
ET.SubElement(segment, '{urn:oasis:names:tc:xliff:document:2.0}source').text = source
|
|
ET.SubElement(segment, '{urn:oasis:names:tc:xliff:document:2.0}target').text = target
|
|
self.increment_count()
|
|
|
|
output = io.BytesIO()
|
|
|
|
if self.file_format == 'ods':
|
|
workbook.save(output)
|
|
self.file_name = 'catalog.ods'
|
|
self.content_type = 'application/vnd.oasis.opendocument.spreadsheet'
|
|
elif self.file_format == 'xliff':
|
|
misc.indent_xml(root)
|
|
output.write(ET.tostring(root, 'utf-8'))
|
|
self.file_name = 'catalog.xliff'
|
|
self.content_type = 'text/xml'
|
|
|
|
self.file_content = output.getvalue()
|
|
self.store()
|
|
|
|
def done_action_url(self):
|
|
return '/backoffice/i18n/export?download=%s' % self.id
|
|
|
|
def done_action_label(self):
|
|
return _('Download Export')
|
|
|
|
|
|
class ImportAfterJob(AfterJob):
|
|
label = _('Importing translated strings')
|
|
|
|
def __init__(self, lang, file_content, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self.lang = lang
|
|
self.file_content = file_content
|
|
|
|
def add_string(self, source, target):
|
|
from wcs.i18n import TranslatableMessage
|
|
|
|
if not (source and target):
|
|
return
|
|
|
|
try:
|
|
msg = TranslatableMessage.select([Equal('string', source)])[0]
|
|
except IndexError:
|
|
msg = TranslatableMessage()
|
|
msg.string = source
|
|
|
|
setattr(msg, 'string_%s' % self.lang, target)
|
|
msg.store()
|
|
|
|
def execute(self):
|
|
if self.file_content.startswith(b'PK'): # assume ods
|
|
instream = io.BytesIO(self.file_content)
|
|
with zipfile.ZipFile(instream, mode='r') as zin, zin.open('content.xml') as content_xml:
|
|
doc = ET.parse(content_xml)
|
|
rows = doc.findall('.//{urn:oasis:names:tc:opendocument:xmlns:table:1.0}table-row')
|
|
self.total_count = len(rows)
|
|
for row in rows:
|
|
self.increment_count()
|
|
cells = row.findall('{urn:oasis:names:tc:opendocument:xmlns:table:1.0}table-cell')[:2]
|
|
if len(cells) != 2:
|
|
continue
|
|
source = cells[0].find('{urn:oasis:names:tc:opendocument:xmlns:text:1.0}p').text
|
|
target = cells[1].find('{urn:oasis:names:tc:opendocument:xmlns:text:1.0}p').text
|
|
self.add_string(source, target)
|
|
update_digests()
|
|
elif b'urn:oasis:names:tc:xliff:document' in self.file_content[:1000]:
|
|
doc = ET.parse(io.BytesIO(self.file_content))
|
|
segments = doc.findall('.//{urn:oasis:names:tc:xliff:document:2.0}segment')
|
|
self.total_count = len(segments)
|
|
for segment in segments:
|
|
self.increment_count()
|
|
source = segment.find('{urn:oasis:names:tc:xliff:document:2.0}source').text
|
|
target = segment.find('{urn:oasis:names:tc:xliff:document:2.0}target').text
|
|
self.add_string(source, target)
|
|
update_digests()
|
|
else:
|
|
self.status = 'failed'
|
|
self.failure_label = str(_('Unknown file format'))
|
|
|
|
def done_action_url(self):
|
|
return self.kwargs['return_url']
|
|
|
|
def done_action_label(self):
|
|
return _('Go to multilinguism page')
|
|
|
|
def done_button_attributes(self):
|
|
return {'data-redirect-auto': 'true'}
|
|
|
|
|
|
def update_digests():
|
|
# for all carddefs, check if |translate in digest templates, and rebuild if necessary.
|
|
carddefs = []
|
|
for carddef in CardDef.select():
|
|
for template in (carddef.digest_templates or {}).values():
|
|
if template and '|translate' in template:
|
|
carddefs.append(carddef)
|
|
break
|
|
if carddefs and get_response():
|
|
get_response().add_after_job(UpdateDigestAfterJob(formdefs=carddefs))
|
|
elif carddefs:
|
|
UpdateDigestAfterJob(formdefs=carddefs).execute()
|