convert po string extraction to GUI app

This commit is contained in:
Damien Elmes 2020-07-29 17:47:06 +10:00
parent 440aa129d9
commit 97bf4da6e8
4 changed files with 433 additions and 167 deletions

View file

@ -1,167 +0,0 @@
#!/usr/bin/env python3
# -*- coding: UTF-8 -*-
import os
import json
import re
import sys
import polib
import shutil
from fluent.syntax import parse, serialize
from fluent.syntax.ast import Message, TextElement, Identifier, Pattern, Junk
# extract a translated string from strings.json and insert it into ftl
# eg:
# $ python extract-po-string.py strings.json /path/to/templates/media-check.ftl delete-unused "Delete Unused Media" ""
# $ python extract-po-string.py strings.json /path/to/templates/media-check.ftl delete-unused "%(a)s %(b)s" "%(a)s=$val1,%(b)s=$val2"
json_filename, ftl_filename, key, msgid_substring, repls = sys.argv[1:]
# split up replacements
replacements = []
for repl in repls.split(","):
if not repl:
continue
replacements.append(repl.split("="))
# add file as prefix to key
prefix = os.path.splitext(os.path.basename(ftl_filename))[0]
key = f"{prefix}-{key}"
strings = json.load(open(json_filename, "r"))
msgids = []
if msgid_substring in strings["en"]:
# is the ID an exact match?
msgids.append(msgid_substring)
else:
for id in strings["en"].keys():
if msgid_substring in id:
msgids.append(id)
msgid = None
if len(msgids) == 0:
print("no IDs matched")
sys.exit(1)
elif len(msgids) == 1:
msgid = msgids[0]
else:
for c, id in enumerate(msgids):
print(f"* {c}: {id}")
msgid = msgids[int(input("number to use? "))]
def transform_entry(entry):
if isinstance(entry, str):
return transform_string(entry)
else:
return [transform_string(e) for e in entry]
def transform_string(msg):
for (old, new) in replacements:
msg = msg.replace(old, f"{new}")
# strip leading/trailing whitespace
return msg.strip()
to_insert = []
for lang in strings.keys():
entry = strings[lang].get(msgid)
if not entry:
continue
entry = transform_entry(entry)
if entry:
print(f"{lang} had translation {entry}")
to_insert.append((lang, entry))
plurals = json.load(open("plurals.json"))
def plural_text(key, lang, translation):
lang = re.sub("(_|-).*", "", lang)
# extract the variable - if there's more than one, use the first one
var = re.findall(r"{(\$.*?)}", translation[0])
if not len(var) == 1:
print("multiple variables found, using first replacement")
var = replacements[0][1].replace("{", "").replace("}", "")
else:
var = var[0]
buf = f"{key} = {{ {var} ->\n"
# for each of the plural forms except the last
for idx, msg in enumerate(translation[:-1]):
plural_form = plurals[lang][idx]
buf += f" [{plural_form}] {msg}\n"
# add the catchall
msg = translation[-1]
buf += f" *[other] {msg}\n"
buf += " }\n"
return buf
# add a non-pluralized message. works via fluent.syntax, so can automatically
# indent, etc
def add_simple_message(fname, key, message):
orig = ""
if os.path.exists(fname):
with open(fname) as file:
orig = file.read()
obj = parse(orig)
for ent in obj.body:
if isinstance(ent, Junk):
raise Exception(f"file had junk! {fname} {ent}")
obj.body.append(Message(Identifier(key), Pattern([TextElement(message)])))
modified = serialize(obj, with_junk=True)
# escape leading dots
modified = re.sub(r"(?ms)^( +)\.", '\\1{"."}', modified)
# ensure the resulting serialized file is valid by parsing again
obj = parse(modified)
for ent in obj.body:
if isinstance(ent, Junk):
raise Exception(f"introduced junk! {fname} {ent}")
# it's ok, write it out
with open(fname, "w") as file:
file.write(modified)
def add_message(fname, key, translation):
# simple, non-plural form?
if isinstance(translation, str):
add_simple_message(fname, key, translation)
else:
text = plural_text(key, lang, translation)
open(fname, "a").write(text)
print()
input("proceed? ctrl+c to abort")
i18ndir = os.path.join(os.path.dirname(ftl_filename), "..")
# for each language's translation
for lang, translation in to_insert:
if lang == "en":
# template
ftl_path = ftl_filename
else:
# translation
ftl_path = ftl_filename.replace("templates", lang)
ftl_dir = os.path.dirname(ftl_path)
if not os.path.exists(ftl_dir):
os.mkdir(ftl_dir)
add_message(ftl_path, key, translation)
# copy file from repo into src
srcdir = os.path.join(i18ndir, "..", "..")
src_filename = os.path.join(srcdir, os.path.basename(ftl_filename))
shutil.copy(ftl_filename, src_filename)
print("done")

View file

@ -0,0 +1,238 @@
#!/usr/bin/env python3
# -*- coding: UTF-8 -*-
import os
import json
import re
import sys
import polib
import shutil
import sys
import subprocess
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from extract_po_string_diag import Ui_Dialog
from fluent.syntax import parse, serialize
from fluent.syntax.ast import Message, TextElement, Identifier, Pattern, Junk
# the templates folder inside the ftl repo
repo_templates_dir = sys.argv[1]
assert os.path.abspath(repo_templates_dir).endswith("templates")
strings = json.load(open("strings.json"))
plurals = json.load(open("plurals.json"))
# i18ndir = os.path.join(ftl_dir, "..")
def transform_entry(entry, replacements):
if isinstance(entry, str):
return transform_string(entry, replacements)
else:
return [transform_string(e, replacements) for e in entry]
def transform_string(msg, replacements):
try:
for (old, new) in replacements:
msg = msg.replace(old, f"{new}")
except ValueError:
pass
# strip leading/trailing whitespace
return msg.strip()
def plural_text(key, lang, translation):
lang = re.sub("(_|-).*", "", lang)
# extract the variable - if there's more than one, use the first one
var = re.findall(r"{(\$.*?)}", translation[0])
if not len(var) == 1:
print("multiple variables found, using first replacement")
var = replacements[0][1].replace("{", "").replace("}", "")
else:
var = var[0]
buf = f"{key} = {{ {var} ->\n"
# for each of the plural forms except the last
for idx, msg in enumerate(translation[:-1]):
plural_form = plurals[lang][idx]
buf += f" [{plural_form}] {msg}\n"
# add the catchall
msg = translation[-1]
buf += f" *[other] {msg}\n"
buf += " }\n"
return buf
def key_from_search(search):
return search.replace(" ", "-").replace("'", "")
# add a non-pluralized message. works via fluent.syntax, so can automatically
# indent, etc
def add_simple_message(fname, key, message):
orig = ""
if os.path.exists(fname):
with open(fname) as file:
orig = file.read()
obj = parse(orig)
for ent in obj.body:
if isinstance(ent, Junk):
raise Exception(f"file had junk! {fname} {ent}")
obj.body.append(Message(Identifier(key), Pattern([TextElement(message)])))
modified = serialize(obj, with_junk=True)
# escape leading dots
modified = re.sub(r"(?ms)^( +)\.", '\\1{"."}', modified)
# ensure the resulting serialized file is valid by parsing again
obj = parse(modified)
for ent in obj.body:
if isinstance(ent, Junk):
raise Exception(f"introduced junk! {fname} {ent}")
# it's ok, write it out
with open(fname, "w") as file:
file.write(modified)
def add_message(fname, key, translation):
# simple, non-plural form?
if isinstance(translation, str):
add_simple_message(fname, key, translation)
else:
text = plural_text(key, lang, translation)
open(fname, "a").write(text)
class Window(QDialog, Ui_Dialog):
def __init__(self):
QDialog.__init__(self)
self.setupUi(self)
self.matched_strings = []
self.files = sorted(os.listdir(repo_templates_dir))
self.filenames.addItems(self.files)
self.search.textChanged.connect(self.on_search)
self.replacements.textChanged.connect(self.update_preview)
self.key.textEdited.connect(self.update_preview)
self.filenames.currentIndexChanged.connect(self.update_preview)
self.searchMatches.currentItemChanged.connect(self.update_preview)
self.replacementsTemplateButton.clicked.connect(self.on_template)
self.addButton.clicked.connect(self.on_add)
def on_template(self):
self.replacements.setText("%d={ $value }")
# qt macos bug
self.replacements.repaint()
def on_search(self):
msgid_substring = self.search.text()
self.key.setText(key_from_search(msgid_substring))
msgids = []
exact_idx = None
for n, id in enumerate(strings["en"].keys()):
if msgid_substring.lower() in id.lower():
msgids.append(id)
# is the ID an exact match?
if msgid_substring in strings["en"]:
exact_idx = n
self.matched_strings = msgids
self.searchMatches.clear()
self.searchMatches.addItems(self.matched_strings)
if exact_idx is not None:
self.searchMatches.setCurrentRow(exact_idx)
elif self.matched_strings:
self.searchMatches.setCurrentRow(0)
self.update_preview()
def update_preview(self):
self.preview.clear()
if not self.matched_strings:
return
strings = self.get_adjusted_strings()
key = self.get_key()
self.preview.setPlainText(
f"Key: {key}\n\n"
+ "\n".join([f"{lang}: {value}" for (lang, value) in strings])
)
# returns list of (lang, entry)
def get_adjusted_strings(self):
msgid = self.matched_strings[self.searchMatches.currentRow()]
# split up replacements
replacements = []
for repl in self.replacements.text().split(","):
if not repl:
continue
replacements.append(repl.split("="))
to_insert = []
for lang in strings.keys():
entry = strings[lang].get(msgid)
if not entry:
continue
entry = transform_entry(entry, replacements)
if entry:
to_insert.append((lang, entry))
return to_insert
def get_key(self):
# add file as prefix to key
prefix = os.path.splitext(self.filenames.currentText())[0]
return f"{prefix}-{self.key.text()}"
def on_add(self):
to_insert = self.get_adjusted_strings()
key = self.get_key()
# for each language's translation
for lang, translation in to_insert:
ftl_path = self.filename_for_lang(lang)
add_message(ftl_path, key, translation)
if lang == "en":
# copy file from repo into src
srcdir = os.path.join(repo_templates_dir, "..", "..", "..")
src_filename = os.path.join(srcdir, os.path.basename(ftl_path))
shutil.copy(ftl_path, src_filename)
subprocess.check_call(
f"cd {repo_templates_dir} && git add .. && git commit -m 'add {key}'",
shell=True,
)
self.preview.setPlainText(f"Added {key}.")
self.preview.repaint()
def filename_for_lang(self, lang):
fname = self.filenames.currentText()
if lang == "en":
return os.path.join(repo_templates_dir, fname)
else:
ftl_dir = os.path.join(repo_templates_dir, "..", lang)
if not os.path.exists(ftl_dir):
os.mkdir(ftl_dir)
return os.path.join(ftl_dir, fname)
print("Remember to pull-i18n before making changes.")
if subprocess.check_output(f"git status --porcelain {repo_templates_dir}", shell=True):
print("Repo has uncommitted changes.")
sys.exit(1)
app = QApplication(sys.argv)
window = Window()
window.show()
sys.exit(app.exec_())

View file

@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
# Form implementation generated from reading ui file 'extract_po_string_diag.ui'
#
# Created by: PyQt5 UI code generator 5.15.0
#
# WARNING: Any manual changes made to this file will be lost when pyuic5 is
# run again. Do not edit this file unless you know what you are doing.
from PyQt5 import QtCore, QtGui, QtWidgets
class Ui_Dialog(object):
def setupUi(self, Dialog):
Dialog.setObjectName("Dialog")
Dialog.resize(879, 721)
self.verticalLayout = QtWidgets.QVBoxLayout(Dialog)
self.verticalLayout.setObjectName("verticalLayout")
self.gridLayout = QtWidgets.QGridLayout()
self.gridLayout.setObjectName("gridLayout")
self.label_2 = QtWidgets.QLabel(Dialog)
self.label_2.setObjectName("label_2")
self.gridLayout.addWidget(self.label_2, 2, 0, 1, 1)
self.filenames = QtWidgets.QComboBox(Dialog)
self.filenames.setObjectName("filenames")
self.gridLayout.addWidget(self.filenames, 1, 1, 1, 1)
self.label_4 = QtWidgets.QLabel(Dialog)
self.label_4.setObjectName("label_4")
self.gridLayout.addWidget(self.label_4, 0, 0, 1, 1)
self.replacementsTemplateButton = QtWidgets.QPushButton(Dialog)
self.replacementsTemplateButton.setObjectName("replacementsTemplateButton")
self.gridLayout.addWidget(self.replacementsTemplateButton, 4, 1, 1, 1)
self.label = QtWidgets.QLabel(Dialog)
self.label.setObjectName("label")
self.gridLayout.addWidget(self.label, 1, 0, 1, 1)
self.label_3 = QtWidgets.QLabel(Dialog)
self.label_3.setObjectName("label_3")
self.gridLayout.addWidget(self.label_3, 3, 0, 1, 1)
self.replacements = QtWidgets.QLineEdit(Dialog)
self.replacements.setObjectName("replacements")
self.gridLayout.addWidget(self.replacements, 3, 1, 1, 1)
self.key = QtWidgets.QLineEdit(Dialog)
self.key.setObjectName("key")
self.gridLayout.addWidget(self.key, 2, 1, 1, 1)
self.search = QtWidgets.QLineEdit(Dialog)
self.search.setObjectName("search")
self.gridLayout.addWidget(self.search, 0, 1, 1, 1)
self.verticalLayout.addLayout(self.gridLayout)
self.label_5 = QtWidgets.QLabel(Dialog)
self.label_5.setObjectName("label_5")
self.verticalLayout.addWidget(self.label_5)
self.searchMatches = QtWidgets.QListWidget(Dialog)
self.searchMatches.setObjectName("searchMatches")
self.verticalLayout.addWidget(self.searchMatches)
self.label_6 = QtWidgets.QLabel(Dialog)
self.label_6.setObjectName("label_6")
self.verticalLayout.addWidget(self.label_6)
self.preview = QtWidgets.QTextEdit(Dialog)
self.preview.setObjectName("preview")
self.verticalLayout.addWidget(self.preview)
self.addButton = QtWidgets.QPushButton(Dialog)
self.addButton.setObjectName("addButton")
self.verticalLayout.addWidget(self.addButton)
self.retranslateUi(Dialog)
QtCore.QMetaObject.connectSlotsByName(Dialog)
Dialog.setTabOrder(self.search, self.filenames)
Dialog.setTabOrder(self.filenames, self.key)
Dialog.setTabOrder(self.key, self.replacements)
Dialog.setTabOrder(self.replacements, self.replacementsTemplateButton)
Dialog.setTabOrder(self.replacementsTemplateButton, self.searchMatches)
Dialog.setTabOrder(self.searchMatches, self.preview)
Dialog.setTabOrder(self.preview, self.addButton)
def retranslateUi(self, Dialog):
_translate = QtCore.QCoreApplication.translate
Dialog.setWindowTitle(_translate("Dialog", "Dialog"))
self.label_2.setText(_translate("Dialog", "Fluent key suffix"))
self.label_4.setText(_translate("Dialog", "Search:"))
self.replacementsTemplateButton.setText(_translate("Dialog", "Template"))
self.label.setText(_translate("Dialog", "Target file"))
self.label_3.setText(_translate("Dialog", "Replacements"))
self.label_5.setText(_translate("Dialog", "Matches"))
self.label_6.setText(_translate("Dialog", "Preview"))
self.addButton.setText(_translate("Dialog", "Add"))

View file

@ -0,0 +1,109 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>879</width>
<height>721</height>
</rect>
</property>
<property name="windowTitle">
<string>Dialog</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QGridLayout" name="gridLayout">
<item row="2" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Fluent key suffix</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="filenames"/>
</item>
<item row="0" column="0">
<widget class="QLabel" name="label_4">
<property name="text">
<string>Search:</string>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QPushButton" name="replacementsTemplateButton">
<property name="text">
<string>Template</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Target file</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Replacements</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QLineEdit" name="replacements"/>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="key"/>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="search"/>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="label_5">
<property name="text">
<string>Matches</string>
</property>
</widget>
</item>
<item>
<widget class="QListWidget" name="searchMatches"/>
</item>
<item>
<widget class="QLabel" name="label_6">
<property name="text">
<string>Preview</string>
</property>
</widget>
</item>
<item>
<widget class="QTextEdit" name="preview"/>
</item>
<item>
<widget class="QPushButton" name="addButton">
<property name="text">
<string>Add</string>
</property>
</widget>
</item>
</layout>
</widget>
<tabstops>
<tabstop>search</tabstop>
<tabstop>filenames</tabstop>
<tabstop>key</tabstop>
<tabstop>replacements</tabstop>
<tabstop>replacementsTemplateButton</tabstop>
<tabstop>searchMatches</tabstop>
<tabstop>preview</tabstop>
<tabstop>addButton</tabstop>
</tabstops>
<resources/>
<connections/>
</ui>