From 3f551e9fdbe017069d67aa4e6fed756767dc6e3d Mon Sep 17 00:00:00 2001 From: AuraMindNest Date: Thu, 7 May 2026 14:30:14 -0600 Subject: [PATCH 01/17] #37 : Add some tests for Asciidoc and Quickbook format. --- weblate/formats/tests/test_convert.py | 115 +++++++++++++++++++++++++- weblate/trans/tests/data/cs.qbk | 9 ++ weblate/trans/tests/data/cs2.qbk | 9 ++ weblate/utils/tests/test_quickbook.py | 95 +++++++++++++++++++++ 4 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 weblate/trans/tests/data/cs.qbk create mode 100644 weblate/trans/tests/data/cs2.qbk create mode 100644 weblate/utils/tests/test_quickbook.py diff --git a/weblate/formats/tests/test_convert.py b/weblate/formats/tests/test_convert.py index d5edfc32c42f..99d71db6f011 100644 --- a/weblate/formats/tests/test_convert.py +++ b/weblate/formats/tests/test_convert.py @@ -10,8 +10,8 @@ from typing import ClassVar from weblate.checks.tests.test_checks import MockUnit +from weblate.formats.asciidoc import AsciiDocFormat from weblate.formats.convert import ( - AsciiDocFormat, HTMLFormat, IDMLFormat, MarkdownFormat, @@ -20,6 +20,7 @@ WindowsRCFormat, WXLFormat, ) +from weblate.formats.quickbook import QuickBookFormat from weblate.formats.helpers import NamedBytesIO from weblate.formats.tests.test_formats import BaseFormatTest from weblate.trans.tests.utils import get_test_file @@ -31,6 +32,8 @@ MARKDOWN_FILE = get_test_file("cs.md") MARKDOWN_FILE_TRANSLATED = get_test_file("cs2.md") ASCIIDOC_FILE = get_test_file("cs.adoc") +QUICKBOOK_FILE = get_test_file("cs.qbk") +QUICKBOOK_FILE_TRANSLATED = get_test_file("cs2.qbk") OPENDOCUMENT_FILE = get_test_file("cs.odt") TEST_RC = get_test_file("cs-CZ.rc") TEST_TXT = get_test_file("cs.txt") @@ -322,7 +325,7 @@ class PlainTextFormatTest(ConvertFormatTest): class AsciiDocFormatTest(ConvertFormatTest): format_class = AsciiDocFormat FILE = ASCIIDOC_FILE - MIME = "text/x-asciidoc" + MIME = "text/asciidoc" EXT = "adoc" COUNT = 5 MASK = "*/translations.adoc" @@ -389,6 +392,114 @@ def test_existing_units(self) -> None: ) +class QuickBookFormatTest(ConvertFormatTest): + format_class = QuickBookFormat + FILE = QUICKBOOK_FILE + MIME = "text/x-quickbook" + EXT = "qbk" + COUNT = 5 + MASK = "*/translations.qbk" + EXPECTED_PATH = "cs_CZ/translations.qbk" + FIND = "Orangutan has five bananas." + FIND_MATCH = "Orangutan has five bananas." + MATCH = b"[h1" + NEW_UNIT_MATCH = None + BASE = QUICKBOOK_FILE + EXPECTED_FLAGS = "" + EDIT_OFFSET = 1 + + def test_convert(self) -> None: + """Round-trip two [heading …] blocks (QuickBook pairs translations by segment count).""" + self.maxDiff = None + template = None + translation = None + try: + with NamedTemporaryFile( + encoding="utf-8", delete=False, mode="w+", suffix=".qbk" + ) as template_f: + template_f.write( + "[heading Hello]\n\n[heading Bye]\n", + ) + with NamedTemporaryFile( + encoding="utf-8", delete=False, mode="w+", suffix=".qbk" + ) as translation_f: + translation_f.write( + "[heading Ahoj]\n\n[heading Bye]\n", + ) + + template = template_f + translation = translation_f + + storage = self.format_class( + translation.name, + template_store=self.format_class( + template.name, + is_template=True, + ), + existing_units=self.CONVERT_EXISTING, + ) + + self.assertEqual(len(storage.content_units), 2) + unit1, unit2 = storage.content_units + self.assertEqual(unit1.source, "Hello") + self.assertEqual(unit1.target, "Ahoj") + self.assertEqual(unit2.source, "Bye") + self.assertEqual(unit2.target, "Bye") + + unit2.set_target("Nazdar") + unit2.set_state(STATE_TRANSLATED) + storage.save() + + self.assertEqual( + Path(translation.name).read_text(encoding="utf-8"), + "[heading Ahoj]\n\n[heading Nazdar]\n", + ) + finally: + if template: + os.unlink(template.name) + if translation: + os.unlink(translation.name) + + def test_existing_units(self) -> None: + testdata = Path(self.FILE).read_bytes() + testfile = os.path.join(self.tempdir, os.path.basename(self.FILE)) + Path(testfile).write_bytes(testdata) + + storage = self.format_class( + testfile, + template_store=self.format_class(testfile, is_template=True), + existing_units=[ + MockUnit( + source="Orangutan has five bananas.", + target="Orangutan má pět banánů.", + ) + ], + ) + storage.save() + + newdata = Path(testfile).read_text(encoding="utf-8") + self.assertEqual( + newdata, + """[article QuickBook] + +[h1 Ahoj světe!] + +Orangutan má pět banánů. + +Try Weblate at [@https://demo.weblate.org/ weblate.org]! + +Thank you for using Weblate. +""", + ) + + def test_import_existing(self) -> None: + storage = self.parse_file( + QUICKBOOK_FILE_TRANSLATED, + QUICKBOOK_FILE, + ) + self.assertEqual(storage.all_units[4].target, "Díky za používání Weblate.") + + class WXLFormatTest(ConvertFormatTest): format_class = WXLFormat FILE = TEST_WXL diff --git a/weblate/trans/tests/data/cs.qbk b/weblate/trans/tests/data/cs.qbk new file mode 100644 index 000000000000..723d12c93000 --- /dev/null +++ b/weblate/trans/tests/data/cs.qbk @@ -0,0 +1,9 @@ +[article QuickBook] + +[h1 Ahoj světe!] + +Orangutan has five bananas. + +Try Weblate at [@https://demo.weblate.org/ weblate.org]! + +Thank you for using Weblate. diff --git a/weblate/trans/tests/data/cs2.qbk b/weblate/trans/tests/data/cs2.qbk new file mode 100644 index 000000000000..1c4c4fce1bb9 --- /dev/null +++ b/weblate/trans/tests/data/cs2.qbk @@ -0,0 +1,9 @@ +[article QuickBook] + +[h1 Ahoj světe!] + +Orangutan má pět banánů. + +Zkus Weblate na [@https://demo.weblate.org/ weblate.org]! + +Díky za používání Weblate. diff --git a/weblate/utils/tests/test_quickbook.py b/weblate/utils/tests/test_quickbook.py new file mode 100644 index 000000000000..c5da5d7147fd --- /dev/null +++ b/weblate/utils/tests/test_quickbook.py @@ -0,0 +1,95 @@ +# Copyright © Boost Organization +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +import unittest + +from weblate.utils.quickbook import ( + _find_bracket_end, + _parse_bracket_keyword, + _parse_qbk, + po_to_qbk, + qbk_to_po, +) + + +class QuickBookParserTest(unittest.TestCase): + def test_find_bracket_end_nested(self) -> None: + text = "[outer [inner] tail]" + end = _find_bracket_end(text, 0) + self.assertEqual(end, len(text) - 1) + + def test_find_bracket_end_triple_quote(self) -> None: + text = "['''[not closed]''']" + end = _find_bracket_end(text, 0) + self.assertEqual(end, len(text) - 1) + + def test_parse_bracket_keyword_section_with_id(self) -> None: + block = "[section:myid Title here]" + kw, off = _parse_bracket_keyword(block) + self.assertEqual(kw, "section") + self.assertEqual(block[off:-1].lstrip(), "Title here") + + def test_skip_include_and_parse_heading(self) -> None: + qbk = "[include other.qbk]\n\n[h1 Title]\n" + segs = _parse_qbk(qbk) + self.assertEqual(len(segs), 1) + self.assertEqual(segs[0].msgid, "Title") + self.assertEqual(segs[0].seg_type, "heading") + + def test_paragraph_soft_wrap_joined(self) -> None: + qbk = "One line\ncontinued here.\n" + segs = _parse_qbk(qbk) + self.assertEqual(len(segs), 1) + self.assertEqual(segs[0].msgid, "One line continued here.") + + def test_indented_code_block_skipped(self) -> None: + qbk = "Prose line.\n code not extracted\nMore prose.\n" + segs = _parse_qbk(qbk) + self.assertEqual(len(segs), 2) + self.assertEqual(segs[0].msgid, "Prose line.") + self.assertEqual(segs[1].msgid, "More prose.") + + def test_section_title_and_body(self) -> None: + qbk = "[section:anchor Title line\nBody text here.]\n" + segs = _parse_qbk(qbk) + titles = [s for s in segs if s.seg_type == "section-title"] + paras = [s for s in segs if s.seg_type == "paragraph"] + self.assertEqual(len(titles), 1) + self.assertEqual(titles[0].msgid, "Title line") + self.assertEqual(len(paras), 1) + self.assertEqual(paras[0].msgid, "Body text here.") + + def test_inline_bracket_on_wrapped_line(self) -> None: + qbk = "Start text\n[@https://example.com/ link]\nend text.\n" + segs = _parse_qbk(qbk) + self.assertEqual(len(segs), 1) + self.assertIn("Start text", segs[0].msgid) + self.assertIn("[@https://example.com/ link]", segs[0].msgid) + + +class QuickBookConversionTest(unittest.TestCase): + def test_qbk_to_po_locations(self) -> None: + qbk = "[h1 Hi]\n\nHello.\n" + store = qbk_to_po(qbk, "doc.qbk") + units = [u for u in store.units if not u.isheader()] + self.assertEqual(len(units), 2) + self.assertIn("doc.qbk:1", units[0].getlocations()) + self.assertIn("doc.qbk:3", units[1].getlocations()) + + def test_po_to_qbk_applies_translation(self) -> None: + template = "[heading English]\n" + store = qbk_to_po(template, "t.qbk") + for u in store.units: + if not u.isheader() and u.source == "English": + u.target = "Česky" + out = po_to_qbk(template, store, "t.qbk") + self.assertEqual(out, "[heading Česky]\n") + + def test_po_to_qbk_fallback_untranslated(self) -> None: + template = "[heading Only]\n" + store = qbk_to_po(template, "t.qbk") + out = po_to_qbk(template, store, "t.qbk") + self.assertEqual(out, template) From 893db0ce9f00afd994085abfdf6d1eab10de6808 Mon Sep 17 00:00:00 2001 From: AuraMindNest <242653549+AuraMindNest@users.noreply.github.com> Date: Thu, 7 May 2026 20:47:04 +0000 Subject: [PATCH 02/17] docs: Documentation snippets update --- weblate/formats/tests/test_convert.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/weblate/formats/tests/test_convert.py b/weblate/formats/tests/test_convert.py index 99d71db6f011..cdb580401465 100644 --- a/weblate/formats/tests/test_convert.py +++ b/weblate/formats/tests/test_convert.py @@ -20,8 +20,8 @@ WindowsRCFormat, WXLFormat, ) -from weblate.formats.quickbook import QuickBookFormat from weblate.formats.helpers import NamedBytesIO +from weblate.formats.quickbook import QuickBookFormat from weblate.formats.tests.test_formats import BaseFormatTest from weblate.trans.tests.utils import get_test_file from weblate.utils.state import STATE_TRANSLATED From 0753770509929a4120624565bf6789e3e02f1f40 Mon Sep 17 00:00:00 2001 From: AuraMindNest Date: Thu, 7 May 2026 17:15:57 -0600 Subject: [PATCH 03/17] Update due to coderabbitai review. --- weblate/utils/tests/test_quickbook.py | 165 +++++++++++++------------- 1 file changed, 85 insertions(+), 80 deletions(-) diff --git a/weblate/utils/tests/test_quickbook.py b/weblate/utils/tests/test_quickbook.py index c5da5d7147fd..b6add67dc4f2 100644 --- a/weblate/utils/tests/test_quickbook.py +++ b/weblate/utils/tests/test_quickbook.py @@ -4,8 +4,6 @@ from __future__ import annotations -import unittest - from weblate.utils.quickbook import ( _find_bracket_end, _parse_bracket_keyword, @@ -15,81 +13,88 @@ ) -class QuickBookParserTest(unittest.TestCase): - def test_find_bracket_end_nested(self) -> None: - text = "[outer [inner] tail]" - end = _find_bracket_end(text, 0) - self.assertEqual(end, len(text) - 1) - - def test_find_bracket_end_triple_quote(self) -> None: - text = "['''[not closed]''']" - end = _find_bracket_end(text, 0) - self.assertEqual(end, len(text) - 1) - - def test_parse_bracket_keyword_section_with_id(self) -> None: - block = "[section:myid Title here]" - kw, off = _parse_bracket_keyword(block) - self.assertEqual(kw, "section") - self.assertEqual(block[off:-1].lstrip(), "Title here") - - def test_skip_include_and_parse_heading(self) -> None: - qbk = "[include other.qbk]\n\n[h1 Title]\n" - segs = _parse_qbk(qbk) - self.assertEqual(len(segs), 1) - self.assertEqual(segs[0].msgid, "Title") - self.assertEqual(segs[0].seg_type, "heading") - - def test_paragraph_soft_wrap_joined(self) -> None: - qbk = "One line\ncontinued here.\n" - segs = _parse_qbk(qbk) - self.assertEqual(len(segs), 1) - self.assertEqual(segs[0].msgid, "One line continued here.") - - def test_indented_code_block_skipped(self) -> None: - qbk = "Prose line.\n code not extracted\nMore prose.\n" - segs = _parse_qbk(qbk) - self.assertEqual(len(segs), 2) - self.assertEqual(segs[0].msgid, "Prose line.") - self.assertEqual(segs[1].msgid, "More prose.") - - def test_section_title_and_body(self) -> None: - qbk = "[section:anchor Title line\nBody text here.]\n" - segs = _parse_qbk(qbk) - titles = [s for s in segs if s.seg_type == "section-title"] - paras = [s for s in segs if s.seg_type == "paragraph"] - self.assertEqual(len(titles), 1) - self.assertEqual(titles[0].msgid, "Title line") - self.assertEqual(len(paras), 1) - self.assertEqual(paras[0].msgid, "Body text here.") - - def test_inline_bracket_on_wrapped_line(self) -> None: - qbk = "Start text\n[@https://example.com/ link]\nend text.\n" - segs = _parse_qbk(qbk) - self.assertEqual(len(segs), 1) - self.assertIn("Start text", segs[0].msgid) - self.assertIn("[@https://example.com/ link]", segs[0].msgid) - - -class QuickBookConversionTest(unittest.TestCase): - def test_qbk_to_po_locations(self) -> None: - qbk = "[h1 Hi]\n\nHello.\n" - store = qbk_to_po(qbk, "doc.qbk") - units = [u for u in store.units if not u.isheader()] - self.assertEqual(len(units), 2) - self.assertIn("doc.qbk:1", units[0].getlocations()) - self.assertIn("doc.qbk:3", units[1].getlocations()) - - def test_po_to_qbk_applies_translation(self) -> None: - template = "[heading English]\n" - store = qbk_to_po(template, "t.qbk") - for u in store.units: - if not u.isheader() and u.source == "English": - u.target = "Česky" - out = po_to_qbk(template, store, "t.qbk") - self.assertEqual(out, "[heading Česky]\n") - - def test_po_to_qbk_fallback_untranslated(self) -> None: - template = "[heading Only]\n" - store = qbk_to_po(template, "t.qbk") - out = po_to_qbk(template, store, "t.qbk") - self.assertEqual(out, template) +def test_find_bracket_end_nested() -> None: + text = "[outer [inner] tail]" + end = _find_bracket_end(text, 0) + assert end == len(text) - 1 + + +def test_find_bracket_end_triple_quote() -> None: + text = "['''[not closed]''']" + end = _find_bracket_end(text, 0) + assert end == len(text) - 1 + + +def test_parse_bracket_keyword_section_with_id() -> None: + block = "[section:myid Title here]" + kw, off = _parse_bracket_keyword(block) + assert kw == "section" + assert block[off:-1].lstrip() == "Title here" + + +def test_skip_include_and_parse_heading() -> None: + qbk = "[include other.qbk]\n\n[h1 Title]\n" + segs = _parse_qbk(qbk) + assert len(segs) == 1 + assert segs[0].msgid == "Title" + assert segs[0].seg_type == "heading" + + +def test_paragraph_soft_wrap_joined() -> None: + qbk = "One line\ncontinued here.\n" + segs = _parse_qbk(qbk) + assert len(segs) == 1 + assert segs[0].msgid == "One line continued here." + + +def test_indented_code_block_skipped() -> None: + qbk = "Prose line.\n code not extracted\nMore prose.\n" + segs = _parse_qbk(qbk) + assert len(segs) == 2 + assert segs[0].msgid == "Prose line." + assert segs[1].msgid == "More prose." + + +def test_section_title_and_body() -> None: + qbk = "[section:anchor Title line\nBody text here.]\n" + segs = _parse_qbk(qbk) + titles = [s for s in segs if s.seg_type == "section-title"] + paras = [s for s in segs if s.seg_type == "paragraph"] + assert len(titles) == 1 + assert titles[0].msgid == "Title line" + assert len(paras) == 1 + assert paras[0].msgid == "Body text here." + + +def test_inline_bracket_on_wrapped_line() -> None: + qbk = "Start text\n[@https://example.com/ link]\nend text.\n" + segs = _parse_qbk(qbk) + assert len(segs) == 1 + assert "Start text" in segs[0].msgid + assert "[@https://example.com/ link]" in segs[0].msgid + + +def test_qbk_to_po_locations() -> None: + qbk = "[h1 Hi]\n\nHello.\n" + store = qbk_to_po(qbk, "doc.qbk") + units = [u for u in store.units if not u.isheader()] + assert len(units) == 2 + assert "doc.qbk:1" in units[0].getlocations() + assert "doc.qbk:3" in units[1].getlocations() + + +def test_po_to_qbk_applies_translation() -> None: + template = "[heading English]\n" + store = qbk_to_po(template, "t.qbk") + for u in store.units: + if not u.isheader() and u.source == "English": + u.target = "Česky" + out = po_to_qbk(template, store, "t.qbk") + assert out == "[heading Česky]\n" + + +def test_po_to_qbk_fallback_untranslated() -> None: + template = "[heading Only]\n" + store = qbk_to_po(template, "t.qbk") + out = po_to_qbk(template, store, "t.qbk") + assert out == template From 3c451e96c3c08e8f93be90428216e382743888cc Mon Sep 17 00:00:00 2001 From: AuraMindNest Date: Thu, 7 May 2026 17:18:19 -0600 Subject: [PATCH 04/17] Update for code coverage test fail. --- weblate/formats/tests/test_convert.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/weblate/formats/tests/test_convert.py b/weblate/formats/tests/test_convert.py index cdb580401465..f8ee45959e21 100644 --- a/weblate/formats/tests/test_convert.py +++ b/weblate/formats/tests/test_convert.py @@ -10,8 +10,8 @@ from typing import ClassVar from weblate.checks.tests.test_checks import MockUnit -from weblate.formats.asciidoc import AsciiDocFormat from weblate.formats.convert import ( + AsciiDocFormat, HTMLFormat, IDMLFormat, MarkdownFormat, @@ -20,8 +20,8 @@ WindowsRCFormat, WXLFormat, ) -from weblate.formats.helpers import NamedBytesIO from weblate.formats.quickbook import QuickBookFormat +from weblate.formats.helpers import NamedBytesIO from weblate.formats.tests.test_formats import BaseFormatTest from weblate.trans.tests.utils import get_test_file from weblate.utils.state import STATE_TRANSLATED @@ -325,7 +325,7 @@ class PlainTextFormatTest(ConvertFormatTest): class AsciiDocFormatTest(ConvertFormatTest): format_class = AsciiDocFormat FILE = ASCIIDOC_FILE - MIME = "text/asciidoc" + MIME = "text/x-asciidoc" EXT = "adoc" COUNT = 5 MASK = "*/translations.adoc" From 279c42272812a6a20aa8fac75e9b5291f8585326 Mon Sep 17 00:00:00 2001 From: AuraMindNest <242653549+AuraMindNest@users.noreply.github.com> Date: Thu, 7 May 2026 23:29:34 +0000 Subject: [PATCH 05/17] docs: Documentation snippets update --- weblate/formats/tests/test_convert.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/weblate/formats/tests/test_convert.py b/weblate/formats/tests/test_convert.py index f8ee45959e21..cad0f219e8a9 100644 --- a/weblate/formats/tests/test_convert.py +++ b/weblate/formats/tests/test_convert.py @@ -20,8 +20,8 @@ WindowsRCFormat, WXLFormat, ) -from weblate.formats.quickbook import QuickBookFormat from weblate.formats.helpers import NamedBytesIO +from weblate.formats.quickbook import QuickBookFormat from weblate.formats.tests.test_formats import BaseFormatTest from weblate.trans.tests.utils import get_test_file from weblate.utils.state import STATE_TRANSLATED From ef7fce4d39458d1276e277d6a1921f225069f3c4 Mon Sep 17 00:00:00 2001 From: AuraMindNest Date: Thu, 7 May 2026 23:50:52 -0600 Subject: [PATCH 06/17] Fix Pre-commit fail. --- weblate/utils/tests/test_quickbook.py | 163 ++++++++++++-------------- 1 file changed, 78 insertions(+), 85 deletions(-) diff --git a/weblate/utils/tests/test_quickbook.py b/weblate/utils/tests/test_quickbook.py index b6add67dc4f2..850b0b2f7cf5 100644 --- a/weblate/utils/tests/test_quickbook.py +++ b/weblate/utils/tests/test_quickbook.py @@ -4,6 +4,8 @@ from __future__ import annotations +from unittest import TestCase + from weblate.utils.quickbook import ( _find_bracket_end, _parse_bracket_keyword, @@ -13,88 +15,79 @@ ) -def test_find_bracket_end_nested() -> None: - text = "[outer [inner] tail]" - end = _find_bracket_end(text, 0) - assert end == len(text) - 1 - - -def test_find_bracket_end_triple_quote() -> None: - text = "['''[not closed]''']" - end = _find_bracket_end(text, 0) - assert end == len(text) - 1 - - -def test_parse_bracket_keyword_section_with_id() -> None: - block = "[section:myid Title here]" - kw, off = _parse_bracket_keyword(block) - assert kw == "section" - assert block[off:-1].lstrip() == "Title here" - - -def test_skip_include_and_parse_heading() -> None: - qbk = "[include other.qbk]\n\n[h1 Title]\n" - segs = _parse_qbk(qbk) - assert len(segs) == 1 - assert segs[0].msgid == "Title" - assert segs[0].seg_type == "heading" - - -def test_paragraph_soft_wrap_joined() -> None: - qbk = "One line\ncontinued here.\n" - segs = _parse_qbk(qbk) - assert len(segs) == 1 - assert segs[0].msgid == "One line continued here." - - -def test_indented_code_block_skipped() -> None: - qbk = "Prose line.\n code not extracted\nMore prose.\n" - segs = _parse_qbk(qbk) - assert len(segs) == 2 - assert segs[0].msgid == "Prose line." - assert segs[1].msgid == "More prose." - - -def test_section_title_and_body() -> None: - qbk = "[section:anchor Title line\nBody text here.]\n" - segs = _parse_qbk(qbk) - titles = [s for s in segs if s.seg_type == "section-title"] - paras = [s for s in segs if s.seg_type == "paragraph"] - assert len(titles) == 1 - assert titles[0].msgid == "Title line" - assert len(paras) == 1 - assert paras[0].msgid == "Body text here." - - -def test_inline_bracket_on_wrapped_line() -> None: - qbk = "Start text\n[@https://example.com/ link]\nend text.\n" - segs = _parse_qbk(qbk) - assert len(segs) == 1 - assert "Start text" in segs[0].msgid - assert "[@https://example.com/ link]" in segs[0].msgid - - -def test_qbk_to_po_locations() -> None: - qbk = "[h1 Hi]\n\nHello.\n" - store = qbk_to_po(qbk, "doc.qbk") - units = [u for u in store.units if not u.isheader()] - assert len(units) == 2 - assert "doc.qbk:1" in units[0].getlocations() - assert "doc.qbk:3" in units[1].getlocations() - - -def test_po_to_qbk_applies_translation() -> None: - template = "[heading English]\n" - store = qbk_to_po(template, "t.qbk") - for u in store.units: - if not u.isheader() and u.source == "English": - u.target = "Česky" - out = po_to_qbk(template, store, "t.qbk") - assert out == "[heading Česky]\n" - - -def test_po_to_qbk_fallback_untranslated() -> None: - template = "[heading Only]\n" - store = qbk_to_po(template, "t.qbk") - out = po_to_qbk(template, store, "t.qbk") - assert out == template +class QuickBookUtilsTest(TestCase): + def test_find_bracket_end_nested(self) -> None: + text = "[outer [inner] tail]" + end = _find_bracket_end(text, 0) + self.assertEqual(end, len(text) - 1) + + def test_find_bracket_end_triple_quote(self) -> None: + text = "['''[not closed]''']" + end = _find_bracket_end(text, 0) + self.assertEqual(end, len(text) - 1) + + def test_parse_bracket_keyword_section_with_id(self) -> None: + block = "[section:myid Title here]" + kw, off = _parse_bracket_keyword(block) + self.assertEqual(kw, "section") + self.assertEqual(block[off:-1].lstrip(), "Title here") + + def test_skip_include_and_parse_heading(self) -> None: + qbk = "[include other.qbk]\n\n[h1 Title]\n" + segs = _parse_qbk(qbk) + self.assertEqual(len(segs), 1) + self.assertEqual(segs[0].msgid, "Title") + self.assertEqual(segs[0].seg_type, "heading") + + def test_paragraph_soft_wrap_joined(self) -> None: + qbk = "One line\ncontinued here.\n" + segs = _parse_qbk(qbk) + self.assertEqual(len(segs), 1) + self.assertEqual(segs[0].msgid, "One line continued here.") + + def test_indented_code_block_skipped(self) -> None: + qbk = "Prose line.\n code not extracted\nMore prose.\n" + segs = _parse_qbk(qbk) + self.assertEqual(len(segs), 2) + self.assertEqual(segs[0].msgid, "Prose line.") + self.assertEqual(segs[1].msgid, "More prose.") + + def test_section_title_and_body(self) -> None: + qbk = "[section:anchor Title line\nBody text here.]\n" + segs = _parse_qbk(qbk) + titles = [s for s in segs if s.seg_type == "section-title"] + paras = [s for s in segs if s.seg_type == "paragraph"] + self.assertEqual(len(titles), 1) + self.assertEqual(titles[0].msgid, "Title line") + self.assertEqual(len(paras), 1) + self.assertEqual(paras[0].msgid, "Body text here.") + + def test_inline_bracket_on_wrapped_line(self) -> None: + qbk = "Start text\n[@https://example.com/ link]\nend text.\n" + segs = _parse_qbk(qbk) + self.assertEqual(len(segs), 1) + self.assertIn("Start text", segs[0].msgid) + self.assertIn("[@https://example.com/ link]", segs[0].msgid) + + def test_qbk_to_po_locations(self) -> None: + qbk = "[h1 Hi]\n\nHello.\n" + store = qbk_to_po(qbk, "doc.qbk") + units = [u for u in store.units if not u.isheader()] + self.assertEqual(len(units), 2) + self.assertIn("doc.qbk:1", units[0].getlocations()) + self.assertIn("doc.qbk:3", units[1].getlocations()) + + def test_po_to_qbk_applies_translation(self) -> None: + template = "[heading English]\n" + store = qbk_to_po(template, "t.qbk") + for u in store.units: + if not u.isheader() and u.source == "English": + u.target = "Česky" + out = po_to_qbk(template, store, "t.qbk") + self.assertEqual(out, "[heading Česky]\n") + + def test_po_to_qbk_fallback_untranslated(self) -> None: + template = "[heading Only]\n" + store = qbk_to_po(template, "t.qbk") + out = po_to_qbk(template, store, "t.qbk") + self.assertEqual(out, template) From 5158b95ca6867905caa2c96feb9344672c907e17 Mon Sep 17 00:00:00 2001 From: AuraMindNest Date: Fri, 8 May 2026 06:10:22 -0600 Subject: [PATCH 07/17] Fix CI fail. --- weblate/formats/tests/test_convert.py | 7 ++++--- weblate/utils/quickbook.py | 25 ++++++++++++++++--------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/weblate/formats/tests/test_convert.py b/weblate/formats/tests/test_convert.py index cad0f219e8a9..793bd61327a9 100644 --- a/weblate/formats/tests/test_convert.py +++ b/weblate/formats/tests/test_convert.py @@ -397,7 +397,7 @@ class QuickBookFormatTest(ConvertFormatTest): FILE = QUICKBOOK_FILE MIME = "text/x-quickbook" EXT = "qbk" - COUNT = 5 + COUNT = 4 MASK = "*/translations.qbk" EXPECTED_PATH = "cs_CZ/translations.qbk" FIND = "Orangutan has five bananas." @@ -405,7 +405,8 @@ class QuickBookFormatTest(ConvertFormatTest): MATCH = b"[h1" NEW_UNIT_MATCH = None BASE = QUICKBOOK_FILE - EXPECTED_FLAGS = "" + # Heading segments use PO ``no-wrap`` (single-line titles); prose units have no flags. + EXPECTED_FLAGS: ClassVar[list[str]] = ["no-wrap", "", "", ""] EDIT_OFFSET = 1 def test_convert(self) -> None: @@ -497,7 +498,7 @@ def test_import_existing(self) -> None: QUICKBOOK_FILE_TRANSLATED, QUICKBOOK_FILE, ) - self.assertEqual(storage.all_units[4].target, "Díky za používání Weblate.") + self.assertEqual(storage.all_units[3].target, "Díky za používání Weblate.") class WXLFormatTest(ConvertFormatTest): diff --git a/weblate/utils/quickbook.py b/weblate/utils/quickbook.py index 31d0abeb5a85..62b802e96072 100644 --- a/weblate/utils/quickbook.py +++ b/weblate/utils/quickbook.py @@ -49,6 +49,8 @@ from translate.storage.pypo import pofile +from weblate.utils.state import STATE_FUZZY + # Text that consists only of QuickBook macro references and punctuation is not # translatable prose; it is a rendered identifier placeholder. _QBK_MACRO_ONLY_RE = re.compile(r"^(?:__\w+__[\s,;.]*)+$") @@ -814,9 +816,10 @@ def qbk_to_po(content: str, filename: str, existing_units: Any = None) -> pofile *filename* is used in PO location comments (``#: filename:lineno``). *existing_units* is an optional iterable of Weblate ``Unit`` objects whose - existing translations are merged into the result (same pattern as - ``AsciiDocFormat._merge_translations``). Units not found via file - extraction but present in the database are added so no translation is lost. + translations are merged into the result (same pattern as + ``AsciiDocFormat._merge_translations`` plus applying targets to extracted + strings that match). Matching uses ``(source, context)``. Units present in + the database but missing from extraction are added so no translation is lost. """ store = pofile() store.updateheader(add=True, x_accelerator_marker=None, x_previous_msgid=None) @@ -844,8 +847,8 @@ def qbk_to_po(content: str, filename: str, existing_units: Any = None) -> pofile unit.settypecomment("no-wrap", True) unit_by_msgid[seg.msgid] = unit - # Merge translations that exist in the Weblate database but may have been - # missed by the file extractor (e.g. removed blocks, formatting changes). + # Merge translations from the Weblate database: apply targets to extracted + # units that match (source, context), and add units the extractor missed. if existing_units: store_index: dict[tuple[str, str], Any] = { (u.source, u.getcontext()): u for u in store.units if not u.isheader() @@ -856,16 +859,20 @@ def qbk_to_po(content: str, filename: str, existing_units: Any = None) -> pofile continue src = sources[0] ctx = ex_unit.context or "" - if (src, ctx) not in store_index: + key = (src, ctx) + if key in store_index: + po_unit = store_index[key] + po_unit.target = ex_unit.target + if ex_unit.state == STATE_FUZZY: + po_unit.markfuzzy(True) + else: new_unit = store.addsourceunit(src) if ctx: new_unit.setcontext(ctx) new_unit.target = ex_unit.target - from weblate.utils.state import STATE_FUZZY - if ex_unit.state == STATE_FUZZY: new_unit.markfuzzy(True) - store_index[src, ctx] = new_unit + store_index[key] = new_unit return store From acc5dbe0ba0908fded8297cf40bdebfa87b0daee Mon Sep 17 00:00:00 2001 From: AuraMindNest Date: Fri, 8 May 2026 08:52:07 -0600 Subject: [PATCH 08/17] Fix CI fail. --- weblate/formats/asciidoc.py | 30 +++++++++++++-------------- weblate/formats/tests/test_convert.py | 5 +++-- weblate/utils/quickbook.py | 2 +- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/weblate/formats/asciidoc.py b/weblate/formats/asciidoc.py index d64889f6685a..ef270cd83103 100644 --- a/weblate/formats/asciidoc.py +++ b/weblate/formats/asciidoc.py @@ -16,6 +16,7 @@ from weblate.formats.convert import ConvertFormat from weblate.utils.errors import report_error +from weblate.utils.state import STATE_FUZZY class AsciiDocFormat(ConvertFormat): @@ -27,13 +28,15 @@ class AsciiDocFormat(ConvertFormat): format_id = "asciidoc" monolingual = True - def _merge_translations(self, store, template_store): + def _merge_translations(self, store, _template_store): """ - Add missing translation units from database to the store. + Merge Weblate database translations into the PO store from po4a-gettextize. - Only adds units that don't exist in the store. Does not merge/overwrite - existing units in the store. This ensures all database translations are - preserved even if po4a-gettextize didn't extract them. + For each ``existing_units`` entry, match on ``(source, context)``. If a unit + with that key is already in the store (typical after gettextize), overwrite + ``target`` so database edits win—gettextize often sets ``msgstr`` from the + on-disk file only, which is wrong when the template and translation paths are + the same file. If no unit matches, add one so nothing from the DB is lost. """ # Create index of units already in store (by source + context) for quick lookup store_units_index = {} @@ -41,10 +44,9 @@ def _merge_translations(self, store, template_store): if unit.isheader(): continue # Use source + context as key for matching - key = (unit.source, unit.getcontext()) + key = (unit.source, unit.getcontext() or "") store_units_index[key] = unit - # Add missing units from database that are not in the store for existing_unit in self.existing_units: sources = existing_unit.get_source_plurals() if not sources: @@ -52,21 +54,19 @@ def _merge_translations(self, store, template_store): source = sources[0] # Use first source for matching context = existing_unit.context or "" - # Check if this unit exists in store key = (source, context) - if key not in store_units_index: - # Unit is missing from store, add it with its translation from database + if key in store_units_index: + thepo = store_units_index[key] + thepo.target = existing_unit.target + if existing_unit.state == STATE_FUZZY: + thepo.markfuzzy(True) + else: thepo = store.addsourceunit(source) if context: thepo.setcontext(context) - # Set the translation from database thepo.target = existing_unit.target - # Set fuzzy flag if unit is STATE_FUZZY - from weblate.utils.state import STATE_FUZZY - if existing_unit.state == STATE_FUZZY: thepo.markfuzzy(True) - # Update index store_units_index[key] = thepo return store diff --git a/weblate/formats/tests/test_convert.py b/weblate/formats/tests/test_convert.py index 793bd61327a9..cd0096a54130 100644 --- a/weblate/formats/tests/test_convert.py +++ b/weblate/formats/tests/test_convert.py @@ -397,7 +397,8 @@ class QuickBookFormatTest(ConvertFormatTest): FILE = QUICKBOOK_FILE MIME = "text/x-quickbook" EXT = "qbk" - COUNT = 4 + # PO header unit plus four translatable strings (see ``template_units``). + COUNT = 5 MASK = "*/translations.qbk" EXPECTED_PATH = "cs_CZ/translations.qbk" FIND = "Orangutan has five bananas." @@ -498,7 +499,7 @@ def test_import_existing(self) -> None: QUICKBOOK_FILE_TRANSLATED, QUICKBOOK_FILE, ) - self.assertEqual(storage.all_units[3].target, "Díky za používání Weblate.") + self.assertEqual(storage.all_units[4].target, "Díky za používání Weblate.") class WXLFormatTest(ConvertFormatTest): diff --git a/weblate/utils/quickbook.py b/weblate/utils/quickbook.py index 62b802e96072..b83b2293bf03 100644 --- a/weblate/utils/quickbook.py +++ b/weblate/utils/quickbook.py @@ -851,7 +851,7 @@ def qbk_to_po(content: str, filename: str, existing_units: Any = None) -> pofile # units that match (source, context), and add units the extractor missed. if existing_units: store_index: dict[tuple[str, str], Any] = { - (u.source, u.getcontext()): u for u in store.units if not u.isheader() + (u.source, u.getcontext() or ""): u for u in store.units if not u.isheader() } for ex_unit in existing_units: sources = ex_unit.get_source_plurals() From 5e77ff96f474506382d85163205ef3a7f66c265c Mon Sep 17 00:00:00 2001 From: AuraMindNest Date: Fri, 8 May 2026 09:03:00 -0600 Subject: [PATCH 09/17] Fix mypy failures. --- weblate/formats/tests/test_convert.py | 63 ++++++++++++++++----------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/weblate/formats/tests/test_convert.py b/weblate/formats/tests/test_convert.py index cd0096a54130..d3e165830112 100644 --- a/weblate/formats/tests/test_convert.py +++ b/weblate/formats/tests/test_convert.py @@ -7,7 +7,9 @@ import os from pathlib import Path from tempfile import NamedTemporaryFile -from typing import ClassVar +from typing import ClassVar, cast + +from translate.storage.pypo import pofile as POStore from weblate.checks.tests.test_checks import MockUnit from weblate.formats.convert import ( @@ -23,6 +25,7 @@ from weblate.formats.helpers import NamedBytesIO from weblate.formats.quickbook import QuickBookFormat from weblate.formats.tests.test_formats import BaseFormatTest +from weblate.trans.models import Unit from weblate.trans.tests.utils import get_test_file from weblate.utils.state import STATE_TRANSLATED @@ -72,7 +75,7 @@ def test_convert(self) -> None: storage = self.format_class( translation.name, template_store=self.format_class(template.name, is_template=True), - existing_units=self.CONVERT_EXISTING, + existing_units=cast(list[Unit], self.CONVERT_EXISTING), ) # Ensure it is parsed correctly @@ -170,12 +173,15 @@ def test_existing_units(self) -> None: storage = self.format_class( testfile, template_store=self.format_class(testfile, is_template=True), - existing_units=[ - MockUnit( - source="Orangutan has five bananas.", - target="Orangutan má pět banánů.", - ) - ], + existing_units=cast( + list[Unit], + [ + MockUnit( + source="Orangutan has five bananas.", + target="Orangutan má pět banánů.", + ) + ], + ), ) # Save test file @@ -250,12 +256,13 @@ class IDMLFormatTest(ConvertFormatTest): EDIT_OFFSET = 1 def extract_document(self, content: bytes): - pofile = self.format_class(NamedBytesIO("test.idml", content)).convertfile( + po_store = self.format_class(NamedBytesIO("test.idml", content)).convertfile( NamedBytesIO("test.idml", content), None ) + assert isinstance(po_store, POStore) # Avoid (changing) timestamp in the PO header - pofile.updateheader(pot_creation_date="") - return bytes(pofile).decode() + po_store.updateheader(pot_creation_date="") + return bytes(po_store).decode() def assert_same(self, newdata, testdata) -> None: self.assertEqual( @@ -365,12 +372,15 @@ def test_existing_units(self) -> None: storage = self.format_class( testfile, template_store=self.format_class(testfile, is_template=True), - existing_units=[ - MockUnit( - source="Orangutan has five bananas.", - target="Orangutan má pět banánů.", - ) - ], + existing_units=cast( + list[Unit], + [ + MockUnit( + source="Orangutan has five bananas.", + target="Orangutan má pět banánů.", + ) + ], + ), ) # Save test file @@ -407,7 +417,7 @@ class QuickBookFormatTest(ConvertFormatTest): NEW_UNIT_MATCH = None BASE = QUICKBOOK_FILE # Heading segments use PO ``no-wrap`` (single-line titles); prose units have no flags. - EXPECTED_FLAGS: ClassVar[list[str]] = ["no-wrap", "", "", ""] + EXPECTED_FLAGS: ClassVar[str | list[str]] = ["no-wrap", "", "", ""] EDIT_OFFSET = 1 def test_convert(self) -> None: @@ -438,7 +448,7 @@ def test_convert(self) -> None: template.name, is_template=True, ), - existing_units=self.CONVERT_EXISTING, + existing_units=cast(list[Unit], self.CONVERT_EXISTING), ) self.assertEqual(len(storage.content_units), 2) @@ -470,12 +480,15 @@ def test_existing_units(self) -> None: storage = self.format_class( testfile, template_store=self.format_class(testfile, is_template=True), - existing_units=[ - MockUnit( - source="Orangutan has five bananas.", - target="Orangutan má pět banánů.", - ) - ], + existing_units=cast( + list[Unit], + [ + MockUnit( + source="Orangutan has five bananas.", + target="Orangutan má pět banánů.", + ) + ], + ), ) storage.save() From 2748ae44b4a4e1eb35fbf9ebffa28d5537716b19 Mon Sep 17 00:00:00 2001 From: AuraMindNest Date: Fri, 8 May 2026 09:09:16 -0600 Subject: [PATCH 10/17] Fix coderabbitai review. --- weblate/formats/asciidoc.py | 8 +++--- weblate/formats/tests/test_convert.py | 4 +-- weblate/utils/quickbook.py | 35 +++++++++++++++++++++------ 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/weblate/formats/asciidoc.py b/weblate/formats/asciidoc.py index ef270cd83103..23c4f875d09a 100644 --- a/weblate/formats/asciidoc.py +++ b/weblate/formats/asciidoc.py @@ -16,7 +16,7 @@ from weblate.formats.convert import ConvertFormat from weblate.utils.errors import report_error -from weblate.utils.state import STATE_FUZZY +from weblate.utils.state import FUZZY_STATES class AsciiDocFormat(ConvertFormat): @@ -58,15 +58,13 @@ def _merge_translations(self, store, _template_store): if key in store_units_index: thepo = store_units_index[key] thepo.target = existing_unit.target - if existing_unit.state == STATE_FUZZY: - thepo.markfuzzy(True) + thepo.markfuzzy(existing_unit.state in FUZZY_STATES) else: thepo = store.addsourceunit(source) if context: thepo.setcontext(context) thepo.target = existing_unit.target - if existing_unit.state == STATE_FUZZY: - thepo.markfuzzy(True) + thepo.markfuzzy(existing_unit.state in FUZZY_STATES) store_units_index[key] = thepo return store diff --git a/weblate/formats/tests/test_convert.py b/weblate/formats/tests/test_convert.py index d3e165830112..15f88aa656ae 100644 --- a/weblate/formats/tests/test_convert.py +++ b/weblate/formats/tests/test_convert.py @@ -9,7 +9,7 @@ from tempfile import NamedTemporaryFile from typing import ClassVar, cast -from translate.storage.pypo import pofile as POStore +from translate.storage.pypo import pofile from weblate.checks.tests.test_checks import MockUnit from weblate.formats.convert import ( @@ -259,7 +259,7 @@ def extract_document(self, content: bytes): po_store = self.format_class(NamedBytesIO("test.idml", content)).convertfile( NamedBytesIO("test.idml", content), None ) - assert isinstance(po_store, POStore) + self.assertIsInstance(po_store, pofile) # Avoid (changing) timestamp in the PO header po_store.updateheader(pot_creation_date="") return bytes(po_store).decode() diff --git a/weblate/utils/quickbook.py b/weblate/utils/quickbook.py index b83b2293bf03..7bc434d97ef2 100644 --- a/weblate/utils/quickbook.py +++ b/weblate/utils/quickbook.py @@ -49,7 +49,23 @@ from translate.storage.pypo import pofile -from weblate.utils.state import STATE_FUZZY +from weblate.utils.state import FUZZY_STATES + +# Developer note written by :func:`qbk_to_po` for segment context (not msgctxt). +_QBK_TYPE_NOTE_PREFIX = "type: " + + +def _qbk_po_unit_merge_context(unit: Any) -> str: + """Context for merging ``existing_units``: msgctxt, else ``type:`` developer note.""" + ctx = unit.getcontext() or "" + if ctx: + return ctx + raw = unit.getnotes("developer") or "" + for line in str(raw).split("\n"): + line = line.strip() + if line.startswith(_QBK_TYPE_NOTE_PREFIX): + return line[len(_QBK_TYPE_NOTE_PREFIX) :].strip() + return "" # Text that consists only of QuickBook macro references and punctuation is not # translatable prose; it is a rendered identifier placeholder. @@ -818,8 +834,11 @@ def qbk_to_po(content: str, filename: str, existing_units: Any = None) -> pofile *existing_units* is an optional iterable of Weblate ``Unit`` objects whose translations are merged into the result (same pattern as ``AsciiDocFormat._merge_translations`` plus applying targets to extracted - strings that match). Matching uses ``(source, context)``. Units present in - the database but missing from extraction are added so no translation is lost. + strings that match). Matching uses ``(source, context)``; for units produced + by extraction, *context* is taken from msgctxt or, if empty, from the + developer note ``type: …`` (see segment ``context`` in :func:`_parse_qbk`). + Units present in the database but missing from extraction are added so no + translation is lost. """ store = pofile() store.updateheader(add=True, x_accelerator_marker=None, x_previous_msgid=None) @@ -851,7 +870,9 @@ def qbk_to_po(content: str, filename: str, existing_units: Any = None) -> pofile # units that match (source, context), and add units the extractor missed. if existing_units: store_index: dict[tuple[str, str], Any] = { - (u.source, u.getcontext() or ""): u for u in store.units if not u.isheader() + (u.source, _qbk_po_unit_merge_context(u)): u + for u in store.units + if not u.isheader() } for ex_unit in existing_units: sources = ex_unit.get_source_plurals() @@ -863,15 +884,13 @@ def qbk_to_po(content: str, filename: str, existing_units: Any = None) -> pofile if key in store_index: po_unit = store_index[key] po_unit.target = ex_unit.target - if ex_unit.state == STATE_FUZZY: - po_unit.markfuzzy(True) + po_unit.markfuzzy(ex_unit.state in FUZZY_STATES) else: new_unit = store.addsourceunit(src) if ctx: new_unit.setcontext(ctx) new_unit.target = ex_unit.target - if ex_unit.state == STATE_FUZZY: - new_unit.markfuzzy(True) + new_unit.markfuzzy(ex_unit.state in FUZZY_STATES) store_index[key] = new_unit return store From f3f4220ee5202af24a37a83e575541df28e388e7 Mon Sep 17 00:00:00 2001 From: AuraMindNest <242653549+AuraMindNest@users.noreply.github.com> Date: Fri, 8 May 2026 15:41:10 +0000 Subject: [PATCH 11/17] docs: Documentation snippets update --- weblate/formats/tests/test_convert.py | 16 +++++++++------- weblate/utils/quickbook.py | 1 + 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/weblate/formats/tests/test_convert.py b/weblate/formats/tests/test_convert.py index 15f88aa656ae..cd2ce9f55796 100644 --- a/weblate/formats/tests/test_convert.py +++ b/weblate/formats/tests/test_convert.py @@ -7,7 +7,7 @@ import os from pathlib import Path from tempfile import NamedTemporaryFile -from typing import ClassVar, cast +from typing import TYPE_CHECKING, ClassVar, cast from translate.storage.pypo import pofile @@ -25,10 +25,12 @@ from weblate.formats.helpers import NamedBytesIO from weblate.formats.quickbook import QuickBookFormat from weblate.formats.tests.test_formats import BaseFormatTest -from weblate.trans.models import Unit from weblate.trans.tests.utils import get_test_file from weblate.utils.state import STATE_TRANSLATED +if TYPE_CHECKING: + from weblate.trans.models import Unit + IDML_FILE = get_test_file("en.idml") HTML_FILE = get_test_file("cs.html") HTML_FILE_TRANSLATED = get_test_file("cs2.html") @@ -75,7 +77,7 @@ def test_convert(self) -> None: storage = self.format_class( translation.name, template_store=self.format_class(template.name, is_template=True), - existing_units=cast(list[Unit], self.CONVERT_EXISTING), + existing_units=cast("list[Unit]", self.CONVERT_EXISTING), ) # Ensure it is parsed correctly @@ -174,7 +176,7 @@ def test_existing_units(self) -> None: testfile, template_store=self.format_class(testfile, is_template=True), existing_units=cast( - list[Unit], + "list[Unit]", [ MockUnit( source="Orangutan has five bananas.", @@ -373,7 +375,7 @@ def test_existing_units(self) -> None: testfile, template_store=self.format_class(testfile, is_template=True), existing_units=cast( - list[Unit], + "list[Unit]", [ MockUnit( source="Orangutan has five bananas.", @@ -448,7 +450,7 @@ def test_convert(self) -> None: template.name, is_template=True, ), - existing_units=cast(list[Unit], self.CONVERT_EXISTING), + existing_units=cast("list[Unit]", self.CONVERT_EXISTING), ) self.assertEqual(len(storage.content_units), 2) @@ -481,7 +483,7 @@ def test_existing_units(self) -> None: testfile, template_store=self.format_class(testfile, is_template=True), existing_units=cast( - list[Unit], + "list[Unit]", [ MockUnit( source="Orangutan has five bananas.", diff --git a/weblate/utils/quickbook.py b/weblate/utils/quickbook.py index 7bc434d97ef2..5f773e8d729b 100644 --- a/weblate/utils/quickbook.py +++ b/weblate/utils/quickbook.py @@ -67,6 +67,7 @@ def _qbk_po_unit_merge_context(unit: Any) -> str: return line[len(_QBK_TYPE_NOTE_PREFIX) :].strip() return "" + # Text that consists only of QuickBook macro references and punctuation is not # translatable prose; it is a rendered identifier placeholder. _QBK_MACRO_ONLY_RE = re.compile(r"^(?:__\w+__[\s,;.]*)+$") From 6c39b4eb74e0be9627e884dafafe18bc3a422a22 Mon Sep 17 00:00:00 2001 From: AuraMindNest Date: Fri, 8 May 2026 09:41:22 -0600 Subject: [PATCH 12/17] Fix CI fail --- weblate/formats/quickbook.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/weblate/formats/quickbook.py b/weblate/formats/quickbook.py index a60832212ded..95be18c36be0 100644 --- a/weblate/formats/quickbook.py +++ b/weblate/formats/quickbook.py @@ -89,8 +89,16 @@ def convertfile( if storefile_path == template_path: # Loading the source-language file: set target = source on every unit # so Weblate stores a non-empty translation for the source language. + # When ``existing_units`` merged DB strings (same template + translation + # path, gettextize-style), do not overwrite those targets—only fill empty + # targets so unmatched segments still show the source text. for unit in store.units: - if not unit.isheader(): + if unit.isheader(): + continue + if self.existing_units: + if not unit.target: + unit.target = unit.source + else: unit.target = unit.source # Loading a translated .qbk file: parse it and pair its segments # positionally with the template segments to populate msgstr values. From 7182fd7aa68fcf9bff6e12643e8e99a0cfc85ee2 Mon Sep 17 00:00:00 2001 From: AuraMindNest Date: Fri, 8 May 2026 09:50:38 -0600 Subject: [PATCH 13/17] Fix pre-commit fail. --- weblate/formats/quickbook.py | 133 +++++++++++++++++++---------------- 1 file changed, 71 insertions(+), 62 deletions(-) diff --git a/weblate/formats/quickbook.py b/weblate/formats/quickbook.py index 95be18c36be0..1633c8f17a9b 100644 --- a/weblate/formats/quickbook.py +++ b/weblate/formats/quickbook.py @@ -28,6 +28,72 @@ from weblate.formats.base import TranslationFormat +def _empty_quickbook_po() -> pofile: + empty = pofile() + empty.updateheader(add=True, x_accelerator_marker=None, x_previous_msgid=None) + return empty + + +def _resolve_template_path( + storefile: IO[bytes], + template_store: TranslationFormat | None, +) -> str | None: + template_path: str | None = None + if template_store is not None and hasattr(template_store, "storefile"): + tf = template_store.storefile + if hasattr(tf, "name"): + template_path = tf.name + elif isinstance(tf, str): + template_path = tf + if template_path is None: + template_path = getattr(storefile, "name", None) + return template_path + + +def _fill_targets_same_template_file( + store: TranslationStore, + existing_units: object | None, +) -> None: + # Same template + translation path (gettextize-style): source-language fill, + # but preserve targets merged from ``existing_units`` where present. + for unit in store.units: + if unit.isheader(): + continue + if existing_units: + if not unit.target: + unit.target = unit.source + else: + unit.target = unit.source + + +def _merge_positional_translated_qbk( + store: TranslationStore, + storefile_path: str, +) -> None: + """Pair translated .qbk segments positionally with template segments.""" + try: + translated_content = Path(storefile_path).read_text(encoding="utf-8") + translated_store = qbk_to_po( + translated_content, Path(storefile_path).name + ) + trans_units = [u for u in translated_store.units if not u.isheader()] + tmpl_units = [u for u in store.units if not u.isheader()] + if len(tmpl_units) != len(trans_units): + report_error( + "QuickBook: refusing positional import: segment count mismatch " + f"(file={storefile_path!s}, name={Path(storefile_path).name!s}, " + f"template_units={len(tmpl_units)}, translated_units={len(trans_units)})" + ) + return + for tmpl_unit, trans_unit in zip(tmpl_units, trans_units, strict=True): + if trans_unit.source: + tmpl_unit.target = trans_unit.source + except Exception as exc: + report_error( + f"QuickBook: cannot read translated file {storefile_path}: {exc}" + ) + + class QuickBookFormat(ConvertFormat): """ QuickBook (.qbk) documentation file format with built-in PO converter. @@ -51,86 +117,29 @@ def convertfile( template_store: TranslationFormat | None, ) -> TranslationStore: """Extract translatable strings from a .qbk file, returning a ``pofile``.""" - # Resolve the template (source-language) .qbk file path. - template_path: str | None = None - if template_store is not None and hasattr(template_store, "storefile"): - tf = template_store.storefile - if hasattr(tf, "name"): - template_path = tf.name - elif isinstance(tf, str): - template_path = tf - - if template_path is None: - # Fall back: use storefile path as the template. - template_path = getattr(storefile, "name", None) - + template_path = _resolve_template_path(storefile, template_store) if template_path is None: report_error("QuickBook: cannot determine template file path") - empty = pofile() - empty.updateheader( - add=True, x_accelerator_marker=None, x_previous_msgid=None - ) - return empty + return _empty_quickbook_po() try: content = Path(template_path).read_text(encoding="utf-8") except Exception as exc: report_error(f"QuickBook: cannot read template {template_path}: {exc}") - empty = pofile() - empty.updateheader( - add=True, x_accelerator_marker=None, x_previous_msgid=None - ) - return empty + return _empty_quickbook_po() filename = Path(template_path).name store = qbk_to_po(content, filename, self.existing_units) storefile_path: str | None = getattr(storefile, "name", None) if storefile_path == template_path: - # Loading the source-language file: set target = source on every unit - # so Weblate stores a non-empty translation for the source language. - # When ``existing_units`` merged DB strings (same template + translation - # path, gettextize-style), do not overwrite those targets—only fill empty - # targets so unmatched segments still show the source text. - for unit in store.units: - if unit.isheader(): - continue - if self.existing_units: - if not unit.target: - unit.target = unit.source - else: - unit.target = unit.source - # Loading a translated .qbk file: parse it and pair its segments - # positionally with the template segments to populate msgstr values. - # This mirrors what po4a-gettextize does when given both -m and -l. + _fill_targets_same_template_file(store, self.existing_units) elif storefile_path is None: report_error( "QuickBook: cannot load translated .qbk without a filesystem path" ) else: - try: - translated_content = Path(storefile_path).read_text(encoding="utf-8") - translated_store = qbk_to_po( - translated_content, Path(storefile_path).name - ) - trans_units = [u for u in translated_store.units if not u.isheader()] - tmpl_units = [u for u in store.units if not u.isheader()] - if len(tmpl_units) != len(trans_units): - report_error( - "QuickBook: refusing positional import: segment count mismatch " - f"(file={storefile_path!s}, name={Path(storefile_path).name!s}, " - f"template_units={len(tmpl_units)}, translated_units={len(trans_units)})" - ) - else: - for tmpl_unit, trans_unit in zip( - tmpl_units, trans_units, strict=True - ): - if trans_unit.source: - tmpl_unit.target = trans_unit.source - except Exception as exc: - report_error( - f"QuickBook: cannot read translated file {storefile_path}: {exc}" - ) + _merge_positional_translated_qbk(store, storefile_path) return store From b5d7744bea05ccd7fb9587ecfa04ce408f1974dc Mon Sep 17 00:00:00 2001 From: AuraMindNest Date: Fri, 8 May 2026 09:54:00 -0600 Subject: [PATCH 14/17] Fix pre-commit fail. --- weblate/formats/quickbook.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/weblate/formats/quickbook.py b/weblate/formats/quickbook.py index 1633c8f17a9b..d76eb07a3074 100644 --- a/weblate/formats/quickbook.py +++ b/weblate/formats/quickbook.py @@ -73,9 +73,7 @@ def _merge_positional_translated_qbk( """Pair translated .qbk segments positionally with template segments.""" try: translated_content = Path(storefile_path).read_text(encoding="utf-8") - translated_store = qbk_to_po( - translated_content, Path(storefile_path).name - ) + translated_store = qbk_to_po(translated_content, Path(storefile_path).name) trans_units = [u for u in translated_store.units if not u.isheader()] tmpl_units = [u for u in store.units if not u.isheader()] if len(tmpl_units) != len(trans_units): @@ -89,9 +87,7 @@ def _merge_positional_translated_qbk( if trans_unit.source: tmpl_unit.target = trans_unit.source except Exception as exc: - report_error( - f"QuickBook: cannot read translated file {storefile_path}: {exc}" - ) + report_error(f"QuickBook: cannot read translated file {storefile_path}: {exc}") class QuickBookFormat(ConvertFormat): From bba762cc5fdad8303c2177b1e2509f0a793ee3cf Mon Sep 17 00:00:00 2001 From: AuraMindNest Date: Fri, 8 May 2026 11:40:24 -0600 Subject: [PATCH 15/17] Fix ci fail. --- ci/apt-install | 1 + weblate/formats/tests/test_convert.py | 123 ++++++++++++++++++++++++-- 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/ci/apt-install b/ci/apt-install index aef2e43615a2..974e8044c9e1 100755 --- a/ci/apt-install +++ b/ci/apt-install @@ -24,6 +24,7 @@ apt-get update # Install dependencies apt-get install -y \ gettext \ + po4a \ git \ git-svn \ gnupg \ diff --git a/weblate/formats/tests/test_convert.py b/weblate/formats/tests/test_convert.py index cd2ce9f55796..8587bfd04e16 100644 --- a/weblate/formats/tests/test_convert.py +++ b/weblate/formats/tests/test_convert.py @@ -5,6 +5,7 @@ """File format specific behavior.""" import os +import shutil from pathlib import Path from tempfile import NamedTemporaryFile from typing import TYPE_CHECKING, ClassVar, cast @@ -12,8 +13,9 @@ from translate.storage.pypo import pofile from weblate.checks.tests.test_checks import MockUnit +from weblate.formats.asciidoc import AsciiDocFormat as AsciiDocPo4aFormat from weblate.formats.convert import ( - AsciiDocFormat, + AsciiDocFormat as AsciiDocToolkitFormat, HTMLFormat, IDMLFormat, MarkdownFormat, @@ -47,7 +49,7 @@ class ConvertFormatTest(BaseFormatTest): NEW_UNIT_MATCH = None - EXPECTED_FLAGS = "" + EXPECTED_FLAGS: ClassVar[str | list[str]] = "" MONOLINGUAL = True CONVERT_TEMPLATE = "" @@ -263,8 +265,9 @@ def extract_document(self, content: bytes): ) self.assertIsInstance(po_store, pofile) # Avoid (changing) timestamp in the PO header - po_store.updateheader(pot_creation_date="") - return bytes(po_store).decode() + po_parsed = cast(pofile, po_store) + po_parsed.updateheader(pot_creation_date="") + return bytes(po_parsed).decode() def assert_same(self, newdata, testdata) -> None: self.assertEqual( @@ -331,8 +334,10 @@ class PlainTextFormatTest(ConvertFormatTest): CONVERT_EXPECTED = "Ahoj\n\nNazdar" -class AsciiDocFormatTest(ConvertFormatTest): - format_class = AsciiDocFormat +class AsciiDocToolkitFormatTest(ConvertFormatTest): + """AsciiDoc via translate-toolkit (``AsciiDocFile`` / ``AsciiDocTranslator``).""" + + format_class = AsciiDocToolkitFormat FILE = ASCIIDOC_FILE MIME = "text/x-asciidoc" EXT = "adoc" @@ -403,6 +408,112 @@ def test_existing_units(self) -> None: """, ) + def test_import_existing(self) -> None: + """Localized AsciiDoc aligned with the English template imports Czech targets.""" + translated = """== Ahoj světe! + +Orangutan má pět banánů. + +Zkus Weblate na https://demo.weblate.org/[weblate.org]! + +_Díky za používání Weblate._ +""" + with NamedTemporaryFile( + mode="w", + encoding="utf-8", + delete=False, + suffix=".adoc", + ) as translated_file: + translated_file.write(translated) + translated_path = translated_file.name + try: + storage = self.parse_file(translated_path, ASCIIDOC_FILE) + finally: + os.unlink(translated_path) + thank_units = [ + u + for u in storage.all_units + if "Thank you for using Weblate" in u.source + ] + self.assertEqual(len(thank_units), 1) + self.assertEqual( + thank_units[0].target, + "_Díky za používání Weblate._", + ) + + +class AsciiDocPo4aFormatTest(AsciiDocToolkitFormatTest): + """AsciiDoc via po4a (handler registered in ``WEBLATE_FORMATS``).""" + + format_class = AsciiDocPo4aFormat + MIME = "text/asciidoc" + # po4a-gettextize marks title segments like translate-toolkit QuickBook headings. + EXPECTED_FLAGS = "no-wrap" + + def setUp(self) -> None: + super().setUp() + if shutil.which("po4a-gettextize") is None or shutil.which( + "po4a-translate" + ) is None: + self.skipTest( + "po4a-gettextize / po4a-translate not found; install the po4a package" + ) + + def test_convert(self) -> None: + """Round-trip like :meth:`ConvertFormatTest.test_convert`. + + ``po4a-gettextize`` requires the localized file to expose the same segments as + the template (see po4a ``Original has more strings than the translation``). + The translate-toolkit test uses a stub translation missing the second segment; + here both segments are present so gettextize can align them. + """ + self.maxDiff = None + if not self.CONVERT_TEMPLATE: + self.skipTest( + f"Test template not provided for {self.format_class.format_id}" + ) + translation = template = None + try: + with NamedTemporaryFile( + encoding="utf-8", delete=False, mode="w+", suffix=".adoc" + ) as template_f: + template_f.write(self.CONVERT_TEMPLATE) + with NamedTemporaryFile( + encoding="utf-8", delete=False, mode="w+", suffix=".adoc" + ) as translation_f: + translation_f.write("== Ahoj\n\nBye\n") + + template = template_f + translation = translation_f + + storage = self.format_class( + translation.name, + template_store=self.format_class(template.name, is_template=True), + existing_units=cast("list[Unit]", self.CONVERT_EXISTING), + ) + + self.assertEqual(len(storage.content_units), 2) + unit1, unit2 = storage.content_units + self.assertEqual(unit1.source, "Hello") + self.assertEqual(unit1.target, "Ahoj") + self.assertEqual(unit2.source, "Bye") + self.assertEqual(unit2.target, "Bye") + + unit2.set_target("Nazdar") + unit2.set_state(STATE_TRANSLATED) + + storage.save() + + self.assertEqual( + Path(translation.name).read_text(encoding="utf-8"), + self.CONVERT_EXPECTED, + ) + finally: + if template: + os.unlink(template.name) + if translation: + os.unlink(translation.name) + class QuickBookFormatTest(ConvertFormatTest): format_class = QuickBookFormat From f1f661ed1f0ca2b5de3a67e33c9f6cf5583eedeb Mon Sep 17 00:00:00 2001 From: AuraMindNest <242653549+AuraMindNest@users.noreply.github.com> Date: Fri, 8 May 2026 17:46:30 +0000 Subject: [PATCH 16/17] docs: Documentation snippets update --- weblate/formats/tests/test_convert.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/weblate/formats/tests/test_convert.py b/weblate/formats/tests/test_convert.py index 8587bfd04e16..aa188f0c8d04 100644 --- a/weblate/formats/tests/test_convert.py +++ b/weblate/formats/tests/test_convert.py @@ -16,6 +16,8 @@ from weblate.formats.asciidoc import AsciiDocFormat as AsciiDocPo4aFormat from weblate.formats.convert import ( AsciiDocFormat as AsciiDocToolkitFormat, +) +from weblate.formats.convert import ( HTMLFormat, IDMLFormat, MarkdownFormat, @@ -265,7 +267,7 @@ def extract_document(self, content: bytes): ) self.assertIsInstance(po_store, pofile) # Avoid (changing) timestamp in the PO header - po_parsed = cast(pofile, po_store) + po_parsed = cast("pofile", po_store) po_parsed.updateheader(pot_creation_date="") return bytes(po_parsed).decode() @@ -431,9 +433,7 @@ def test_import_existing(self) -> None: finally: os.unlink(translated_path) thank_units = [ - u - for u in storage.all_units - if "Thank you for using Weblate" in u.source + u for u in storage.all_units if "Thank you for using Weblate" in u.source ] self.assertEqual(len(thank_units), 1) self.assertEqual( @@ -452,15 +452,17 @@ class AsciiDocPo4aFormatTest(AsciiDocToolkitFormatTest): def setUp(self) -> None: super().setUp() - if shutil.which("po4a-gettextize") is None or shutil.which( - "po4a-translate" - ) is None: + if ( + shutil.which("po4a-gettextize") is None + or shutil.which("po4a-translate") is None + ): self.skipTest( "po4a-gettextize / po4a-translate not found; install the po4a package" ) def test_convert(self) -> None: - """Round-trip like :meth:`ConvertFormatTest.test_convert`. + """ + Round-trip like :meth:`ConvertFormatTest.test_convert`. ``po4a-gettextize`` requires the localized file to expose the same segments as the template (see po4a ``Original has more strings than the translation``). From 5c3c9056039fffa77d8052921fd24a280dbe5d08 Mon Sep 17 00:00:00 2001 From: AuraMindNest Date: Fri, 8 May 2026 12:02:50 -0600 Subject: [PATCH 17/17] Run CI again.