Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC
This commit is contained in:
3
translation_helper/wizards/__init__.py
Normal file
3
translation_helper/wizards/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import translation_helper_wizard
|
||||
from . import remote_translation_sync_wizard
|
||||
from . import local_translation_sync_wizard
|
||||
217
translation_helper/wizards/local_translation_sync_wizard.py
Normal file
217
translation_helper/wizards/local_translation_sync_wizard.py
Normal file
@@ -0,0 +1,217 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
"""
|
||||
Wizard: Загрузка перевода из локального хранилища.
|
||||
|
||||
Scans the module's own ``translations/<lang>/*.po`` folder, lists each found
|
||||
file, lets the user drop unwanted rows, and applies the remaining PO files to
|
||||
the current database using the same ``translation.remote.service.apply_po``
|
||||
pipeline as the remote wizard. Dropping a row only excludes it from this run —
|
||||
the file on disk is never deleted.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
import polib
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.modules.module import get_module_path
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LocalTranslationSyncLine(models.TransientModel):
|
||||
_name = "translation.local.sync.line"
|
||||
_description = "Строка локальной загрузки перевода"
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
"translation.local.sync.wizard",
|
||||
string="Визард",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
)
|
||||
module_name = fields.Char(
|
||||
string="Модуль",
|
||||
readonly=True,
|
||||
)
|
||||
file_path = fields.Char(
|
||||
string="Путь",
|
||||
readonly=True,
|
||||
)
|
||||
file_date = fields.Datetime(
|
||||
string="Дата файла",
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
|
||||
class LocalTranslationSyncWizard(models.TransientModel):
|
||||
_name = "translation.local.sync.wizard"
|
||||
_description = "Загрузка перевода из локального хранилища"
|
||||
|
||||
language = fields.Many2one(
|
||||
"res.lang",
|
||||
string="Язык",
|
||||
required=True,
|
||||
default=lambda self: self.env["res.lang"].search(
|
||||
[("code", "=", "ru_RU")], limit=1
|
||||
),
|
||||
)
|
||||
line_ids = fields.One2many(
|
||||
"translation.local.sync.line",
|
||||
"wizard_id",
|
||||
string="Файлы переводов",
|
||||
)
|
||||
overwrite = fields.Boolean(
|
||||
string="Перезаписать существующие переводы",
|
||||
default=True,
|
||||
help="Заменять уже имеющиеся переводы значениями из локальных PO-файлов",
|
||||
)
|
||||
state = fields.Selection(
|
||||
[("form", "Форма"), ("done", "Готово")],
|
||||
default="form",
|
||||
readonly=True,
|
||||
)
|
||||
result_message = fields.Text(
|
||||
string="Результат",
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Scan helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def _scan_local_po(self, lang):
|
||||
"""Scan ``translations/<folder>/*.po`` for *lang* and return a list of
|
||||
``(0, 0, vals)`` command tuples for the one2many ``line_ids``.
|
||||
|
||||
The on-disk folder name is derived from the language's ``iso_code``
|
||||
(falling back to the first segment of its ``code``), matching the
|
||||
layout written by this module (e.g. ``ru`` for ``ru_RU``).
|
||||
"""
|
||||
if not lang:
|
||||
return []
|
||||
base = get_module_path("translation_helper", display_warning=False)
|
||||
if not base:
|
||||
return []
|
||||
folder = lang.iso_code or (lang.code or "").split("_")[0]
|
||||
if not folder:
|
||||
return []
|
||||
po_dir = os.path.join(base, "translations", folder)
|
||||
if not os.path.isdir(po_dir):
|
||||
return []
|
||||
|
||||
commands = []
|
||||
for fname in sorted(os.listdir(po_dir)):
|
||||
if not fname.endswith(".po"):
|
||||
continue
|
||||
full = os.path.join(po_dir, fname)
|
||||
if not os.path.isfile(full):
|
||||
continue
|
||||
mtime = datetime.fromtimestamp(os.path.getmtime(full))
|
||||
commands.append((0, 0, {
|
||||
"module_name": fname[:-3],
|
||||
"file_path": full,
|
||||
"file_date": fields.Datetime.to_string(mtime),
|
||||
}))
|
||||
return commands
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
defaults = super().default_get(fields_list)
|
||||
res_lang = self.env["res.lang"].browse(defaults.get("language"))
|
||||
if not res_lang:
|
||||
res_lang = self.env["res.lang"].search(
|
||||
[("code", "=", "ru_RU")], limit=1
|
||||
)
|
||||
defaults["line_ids"] = self._scan_local_po(res_lang)
|
||||
defaults.setdefault("language", res_lang.id)
|
||||
return defaults
|
||||
|
||||
@api.onchange("language")
|
||||
def _onchange_language(self):
|
||||
"""Rebuild the file list when the selected language changes."""
|
||||
if self.language:
|
||||
self.line_ids = [(5, 0, 0)] + self._scan_local_po(self.language)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Actions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def action_rescan(self):
|
||||
"""Server-side rescan of the local folder for the chosen language."""
|
||||
self.ensure_one()
|
||||
self.line_ids = [(5, 0, 0)] + self._scan_local_po(self.language)
|
||||
return self._reopen()
|
||||
|
||||
def action_sync(self):
|
||||
"""Read each remaining PO file and apply it via the shared service."""
|
||||
self.ensure_one()
|
||||
if not self.line_ids:
|
||||
raise UserError(_("Нет файлов для загрузки."))
|
||||
|
||||
service = self.env["translation.remote.service"]
|
||||
lang_code = self.language.code
|
||||
|
||||
modules = []
|
||||
total_entries = 0
|
||||
translated_entries = 0
|
||||
errors = []
|
||||
|
||||
for line in self.line_ids:
|
||||
try:
|
||||
with open(line.file_path, "rb") as fh:
|
||||
po_bytes = fh.read()
|
||||
service.apply_po(po_bytes, lang_code, overwrite=self.overwrite)
|
||||
|
||||
po = polib.pofile(po_bytes.decode("utf-8", errors="replace"))
|
||||
total_entries += len(po)
|
||||
translated_entries += sum(1 for e in po if e.msgstr and e.msgid)
|
||||
modules.append(line.module_name)
|
||||
except Exception as exc: # noqa: BLE001 - isolate per-file failures
|
||||
_logger.exception(
|
||||
"Local translation import failed for %s", line.file_path
|
||||
)
|
||||
errors.append(_("%(module)s: %(err)s",
|
||||
module=line.module_name, err=exc))
|
||||
|
||||
msg = _(
|
||||
"Загрузка завершена!\n\n"
|
||||
"Файлов обработано: %(files)d\n"
|
||||
"Модули: %(modules)s\n"
|
||||
"Язык: %(lang)s\n"
|
||||
"Переводов в файлах: %(total)d\n"
|
||||
"Из них переведено: %(translated)d\n"
|
||||
"Перезапись: %(overwrite)s",
|
||||
files=len(modules),
|
||||
modules=", ".join(modules) or _("нет"),
|
||||
lang=self.language.name,
|
||||
total=total_entries,
|
||||
translated=translated_entries,
|
||||
overwrite=_("да") if self.overwrite else _("нет"),
|
||||
)
|
||||
if errors:
|
||||
msg += "\n\n" + _("Ошибки:") + "\n" + "\n".join(errors)
|
||||
|
||||
self.write({"result_message": msg, "state": "done"})
|
||||
_logger.info(
|
||||
"Local translation sync completed for %s (lang=%s)",
|
||||
modules, lang_code,
|
||||
)
|
||||
return self._reopen()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _reopen(self):
|
||||
"""Return action that re-opens this wizard record."""
|
||||
return {
|
||||
"name": _("Загрузка перевода из локального хранилища"),
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": self._name,
|
||||
"res_id": self.id,
|
||||
"view_mode": "form",
|
||||
"target": "new",
|
||||
}
|
||||
215
translation_helper/wizards/remote_translation_sync_wizard.py
Normal file
215
translation_helper/wizards/remote_translation_sync_wizard.py
Normal file
@@ -0,0 +1,215 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
"""
|
||||
Wizard: Загрузка перевода из удалённой базы.
|
||||
|
||||
Connects to a remote Odoo 19 via XML-RPC, runs the standard PO export on
|
||||
the remote side, downloads the resulting PO, and imports it locally using
|
||||
the standard TranslationImporter pipeline.
|
||||
"""
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RemoteTranslationSyncWizard(models.TransientModel):
|
||||
_name = "translation.remote.sync.wizard"
|
||||
_description = "Загрузка перевода из удалённой базы"
|
||||
|
||||
# ---- Connection ----
|
||||
remote_url = fields.Char(
|
||||
string="URL удалённого Odoo",
|
||||
required=True,
|
||||
help="Адрес удалённого сервера (например, http://192.168.1.10:8069)",
|
||||
)
|
||||
remote_db = fields.Char(
|
||||
string="База данных",
|
||||
required=True,
|
||||
help="Имя базы на удалённом сервере",
|
||||
)
|
||||
remote_user = fields.Char(
|
||||
string="Логин",
|
||||
required=True,
|
||||
default="admin",
|
||||
)
|
||||
remote_password = fields.Char(
|
||||
string="Пароль",
|
||||
required=True,
|
||||
)
|
||||
|
||||
# ---- What to sync ----
|
||||
modules = fields.Many2many(
|
||||
"ir.module.module",
|
||||
string="Модули",
|
||||
required=True,
|
||||
domain=[("state", "=", "installed")],
|
||||
help="Модули, переводы которых нужно загрузить",
|
||||
)
|
||||
language = fields.Many2one(
|
||||
"res.lang",
|
||||
string="Язык",
|
||||
required=True,
|
||||
default=lambda self: self.env["res.lang"].search(
|
||||
[("code", "=", "ru_RU")], limit=1
|
||||
),
|
||||
)
|
||||
|
||||
# ---- Options ----
|
||||
overwrite = fields.Boolean(
|
||||
string="Перезаписать существующие переводы",
|
||||
default=True,
|
||||
help="Заменять уже имеющиеся переводы значениями из удалённой базы",
|
||||
)
|
||||
save_credentials = fields.Boolean(
|
||||
string="Запомнить учётные данные",
|
||||
default=True,
|
||||
help="Сохранить URL, базу, логин и пароль для повторного использования",
|
||||
)
|
||||
|
||||
# ---- State / results ----
|
||||
state = fields.Selection(
|
||||
[("form", "Форма"), ("preview", "Просмотр"), ("done", "Готово")],
|
||||
default="form",
|
||||
readonly=True,
|
||||
)
|
||||
preview_lines = fields.Text(
|
||||
string="Просмотр",
|
||||
readonly=True,
|
||||
)
|
||||
result_message = fields.Text(
|
||||
string="Результат",
|
||||
readonly=True,
|
||||
)
|
||||
# hidden binary to cache PO between preview ↔ sync
|
||||
_po_cache = fields.Binary(attachment=False)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Defaults from saved credentials
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
defaults = super().default_get(fields_list)
|
||||
ICP = self.env["ir.config_parameter"].sudo()
|
||||
defaults.setdefault("remote_url", ICP.get_param("translation_helper.remote_url", ""))
|
||||
defaults.setdefault("remote_db", ICP.get_param("translation_helper.remote_db", ""))
|
||||
defaults.setdefault("remote_user", ICP.get_param("translation_helper.remote_user", "admin"))
|
||||
defaults.setdefault("remote_password", ICP.get_param("translation_helper.remote_password", ""))
|
||||
return defaults
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Actions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def action_preview(self):
|
||||
"""Fetch PO from remote, show preview, cache for later import."""
|
||||
self.ensure_one()
|
||||
service = self.env["translation.remote.service"]
|
||||
|
||||
po_bytes, _filename = service.fetch_po_from_remote(
|
||||
remote_url=self.remote_url,
|
||||
remote_db=self.remote_db,
|
||||
remote_user=self.remote_user,
|
||||
remote_password=self.remote_password,
|
||||
module_names=self.modules.mapped("name"),
|
||||
lang_code=self.language.code,
|
||||
)
|
||||
|
||||
preview_text = service.preview_po(po_bytes)
|
||||
|
||||
self.write({
|
||||
"preview_lines": preview_text,
|
||||
"state": "preview",
|
||||
"_po_cache": base64.b64encode(po_bytes),
|
||||
})
|
||||
return self._reopen()
|
||||
|
||||
def action_sync(self):
|
||||
"""Import PO into local database."""
|
||||
self.ensure_one()
|
||||
service = self.env["translation.remote.service"]
|
||||
|
||||
# Use cached PO if available (from preview), otherwise re-fetch
|
||||
if self._po_cache:
|
||||
po_bytes = base64.b64decode(self._po_cache)
|
||||
else:
|
||||
po_bytes, _filename = service.fetch_po_from_remote(
|
||||
remote_url=self.remote_url,
|
||||
remote_db=self.remote_db,
|
||||
remote_user=self.remote_user,
|
||||
remote_password=self.remote_password,
|
||||
module_names=self.modules.mapped("name"),
|
||||
lang_code=self.language.code,
|
||||
)
|
||||
|
||||
# Apply
|
||||
service.apply_po(po_bytes, self.language.code, overwrite=self.overwrite)
|
||||
|
||||
# Count translated entries for the message
|
||||
import polib
|
||||
po = polib.pofile(po_bytes.decode("utf-8", errors="replace"))
|
||||
translated_count = sum(1 for e in po if e.msgstr and e.msgid)
|
||||
|
||||
msg = _(
|
||||
"Загрузка завершена!\n\n"
|
||||
"Модули: %(modules)s\n"
|
||||
"Язык: %(lang)s\n"
|
||||
"Переводов в файле: %(total)d\n"
|
||||
"Из них переведено: %(translated)d\n"
|
||||
"Перезапись: %(overwrite)s",
|
||||
modules=", ".join(self.modules.mapped("name")),
|
||||
lang=self.language.name,
|
||||
total=len(po),
|
||||
translated=translated_count,
|
||||
overwrite=_("да") if self.overwrite else _("нет"),
|
||||
)
|
||||
|
||||
# Save credentials
|
||||
if self.save_credentials:
|
||||
self._save_credentials()
|
||||
msg += "\n\n" + _("Учётные данные сохранены.")
|
||||
|
||||
self.write({
|
||||
"result_message": msg,
|
||||
"state": "done",
|
||||
"_po_cache": False,
|
||||
})
|
||||
_logger.info("Remote translation sync completed for %s", self.modules.mapped("name"))
|
||||
return self._reopen()
|
||||
|
||||
def action_open_local_sync(self):
|
||||
"""Open the local-storage translation wizard."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
"name": _("Загрузка перевода из локального хранилища"),
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "translation.local.sync.wizard",
|
||||
"view_mode": "form",
|
||||
"target": "new",
|
||||
"context": {"default_language": self.language.id},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _save_credentials(self):
|
||||
ICP = self.env["ir.config_parameter"].sudo()
|
||||
ICP.set_param("translation_helper.remote_url", self.remote_url or "")
|
||||
ICP.set_param("translation_helper.remote_db", self.remote_db or "")
|
||||
ICP.set_param("translation_helper.remote_user", self.remote_user or "admin")
|
||||
ICP.set_param("translation_helper.remote_password", self.remote_password or "")
|
||||
|
||||
def _reopen(self):
|
||||
"""Return action that re-opens this wizard record."""
|
||||
return {
|
||||
"name": _("Загрузка перевода из удалённой базы"),
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": self._name,
|
||||
"res_id": self.id,
|
||||
"view_mode": "form",
|
||||
"target": "new",
|
||||
}
|
||||
139
translation_helper/wizards/translation_helper_wizard.py
Normal file
139
translation_helper/wizards/translation_helper_wizard.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import polib
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo.modules.module import get_module_path
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
dir_path = os.path.dirname(os.path.realpath(__file__))
|
||||
dir_path = os.path.join(dir_path, "..")
|
||||
|
||||
|
||||
class TranslationHelperWizard(models.TransientModel):
|
||||
_name = "translation.helper.wizard"
|
||||
_description = "Translation Helper Wizard"
|
||||
|
||||
weblate_link = fields.Char(
|
||||
string="Weblate Link",
|
||||
help="Link to current term on Weblate server",
|
||||
)
|
||||
|
||||
term_value = fields.Char(
|
||||
string="Term",
|
||||
help="Term value to translate",
|
||||
)
|
||||
|
||||
translation_value = fields.Char(
|
||||
string="Translation",
|
||||
help="Translation for term value",
|
||||
)
|
||||
|
||||
language_id = fields.Many2one(
|
||||
string="Language",
|
||||
help="The language for which the translation will be applied",
|
||||
comodel_name="res.lang",
|
||||
)
|
||||
|
||||
modules = fields.Char(
|
||||
string="In Apps",
|
||||
help="List of modules in which the translation term is defined",
|
||||
)
|
||||
|
||||
metadata = fields.Json(
|
||||
string="Metadata",
|
||||
help="Metadata",
|
||||
)
|
||||
|
||||
def save_translation(self):
|
||||
for module_name in self.modules.split(", "):
|
||||
self.update_term_translation_in_module(
|
||||
module_name=module_name,
|
||||
term_to_translate=self.term_value,
|
||||
translation_value=self.translation_value,
|
||||
lang=self.language_id.iso_code,
|
||||
)
|
||||
model_name = self.metadata.get("resModel")
|
||||
field_name = self.metadata.get("field", {}).get("name")
|
||||
translation_field_data = self.metadata.get("translation_field_data", {})
|
||||
translation_field_data[self.language_id.code] = self.translation_value
|
||||
query = """
|
||||
UPDATE ir_model_fields SET field_description = %s
|
||||
WHERE model = %s AND name = %s
|
||||
"""
|
||||
self.env.cr.execute(
|
||||
query, [json.dumps(translation_field_data), model_name, field_name]
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "ir.actions.client",
|
||||
"tag": "translation_helper.response_from_wizard",
|
||||
"name": "Translate info",
|
||||
"params": {
|
||||
"model_name": model_name,
|
||||
"field_name": field_name,
|
||||
"translation_field_data": translation_field_data,
|
||||
},
|
||||
}
|
||||
|
||||
def update_term_translation_in_module(
|
||||
self, module_name, term_to_translate, translation_value, lang
|
||||
):
|
||||
# Always write to local translations/{lang}/ directory inside translation_helper module
|
||||
local_po_dir = os.path.join(dir_path, "translations", lang.strip())
|
||||
local_po_file = os.path.join(local_po_dir, f"{module_name}.po")
|
||||
|
||||
if os.path.exists(local_po_file):
|
||||
# Local override already exists — update it
|
||||
source_pofile = polib.pofile(local_po_file)
|
||||
else:
|
||||
# Try to find the system .po file to use as a base
|
||||
source_pofile = None
|
||||
module_path = get_module_path(module_name, display_warning=False)
|
||||
if module_path:
|
||||
for subdir in ("i18n", "i18n_extra"):
|
||||
candidate = os.path.join(module_path, subdir, f"{lang}.po")
|
||||
if os.path.exists(candidate):
|
||||
source_pofile = polib.pofile(candidate)
|
||||
break
|
||||
|
||||
if source_pofile is None:
|
||||
_logger.warning(
|
||||
"translation_helper: no .po file found for module %r, lang %r",
|
||||
module_name,
|
||||
lang,
|
||||
)
|
||||
return
|
||||
|
||||
# Ensure local directory exists
|
||||
os.makedirs(local_po_dir, exist_ok=True)
|
||||
|
||||
# Build new pofile with updated translation
|
||||
new_pofile = polib.POFile()
|
||||
new_pofile.metadata = source_pofile.metadata
|
||||
module_prefix = f"module: {module_name}"
|
||||
for old_entry in source_pofile:
|
||||
msgstr = old_entry.msgstr
|
||||
if old_entry.msgid == term_to_translate:
|
||||
msgstr = translation_value
|
||||
# Odoo 19 translate.py requires "module: {name}" in every entry's comment
|
||||
comment = old_entry.comment
|
||||
if comment and "module:" not in comment:
|
||||
comment = f"module: {module_name}\n{comment}"
|
||||
elif not comment:
|
||||
comment = f"module: {module_name}"
|
||||
new_entry = polib.POEntry(
|
||||
comment=comment,
|
||||
occurrences=old_entry.occurrences,
|
||||
flags=old_entry.flags,
|
||||
msgid=old_entry.msgid,
|
||||
msgstr=msgstr,
|
||||
)
|
||||
new_pofile.append(new_entry)
|
||||
|
||||
# Always save to local path (never to system directories)
|
||||
new_pofile.save(local_po_file)
|
||||
23
translation_helper/wizards/translation_helper_wizard.xml
Normal file
23
translation_helper/wizards/translation_helper_wizard.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="translation_helper_wizard_form" model="ir.ui.view">
|
||||
<field name="name">translation.helper.wizard.form</field>
|
||||
<field name="model">translation.helper.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="weblate_link" readonly="1" widget="url" options="{'website_path': True}" />
|
||||
<field name="term_value" readonly="1" />
|
||||
<field name="translation_value" />
|
||||
<field name="language_id" readonly="1" />
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="save_translation" string="Apply" class="oe_highlight" type="object" data-hotkey="q" />
|
||||
<button special="cancel" data-hotkey="x" string="Cancel" />
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user