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/asciidoc.py b/weblate/formats/asciidoc.py index d64889f6685a..23c4f875d09a 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 FUZZY_STATES 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,17 @@ 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 + thepo.markfuzzy(existing_unit.state in FUZZY_STATES) + 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 + thepo.markfuzzy(existing_unit.state in FUZZY_STATES) store_units_index[key] = thepo return store diff --git a/weblate/formats/quickbook.py b/weblate/formats/quickbook.py index a60832212ded..d76eb07a3074 100644 --- a/weblate/formats/quickbook.py +++ b/weblate/formats/quickbook.py @@ -28,6 +28,68 @@ 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,78 +113,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. - for unit in store.units: - if not unit.isheader(): - 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 diff --git a/weblate/formats/tests/test_convert.py b/weblate/formats/tests/test_convert.py index d5edfc32c42f..aa188f0c8d04 100644 --- a/weblate/formats/tests/test_convert.py +++ b/weblate/formats/tests/test_convert.py @@ -5,13 +5,19 @@ """File format specific behavior.""" import os +import shutil from pathlib import Path from tempfile import NamedTemporaryFile -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar, cast + +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 as AsciiDocToolkitFormat, +) from weblate.formats.convert import ( - AsciiDocFormat, HTMLFormat, IDMLFormat, MarkdownFormat, @@ -21,16 +27,22 @@ WXLFormat, ) 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 +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") 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") @@ -39,7 +51,7 @@ class ConvertFormatTest(BaseFormatTest): NEW_UNIT_MATCH = None - EXPECTED_FLAGS = "" + EXPECTED_FLAGS: ClassVar[str | list[str]] = "" MONOLINGUAL = True CONVERT_TEMPLATE = "" @@ -69,7 +81,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 @@ -167,12 +179,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 @@ -247,12 +262,14 @@ 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 ) + self.assertIsInstance(po_store, pofile) # Avoid (changing) timestamp in the PO header - pofile.updateheader(pot_creation_date="") - return bytes(pofile).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( @@ -319,8 +336,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" @@ -362,12 +381,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 @@ -388,6 +410,225 @@ 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 + FILE = QUICKBOOK_FILE + MIME = "text/x-quickbook" + EXT = "qbk" + # 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." + FIND_MATCH = "Orangutan has five bananas." + MATCH = b"[h1" + NEW_UNIT_MATCH = None + BASE = QUICKBOOK_FILE + # Heading segments use PO ``no-wrap`` (single-line titles); prose units have no flags. + EXPECTED_FLAGS: ClassVar[str | list[str]] = ["no-wrap", "", "", ""] + 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=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"), + "[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=cast( + "list[Unit]", + [ + 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 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/quickbook.py b/weblate/utils/quickbook.py index 31d0abeb5a85..5f773e8d729b 100644 --- a/weblate/utils/quickbook.py +++ b/weblate/utils/quickbook.py @@ -49,6 +49,25 @@ from translate.storage.pypo import pofile +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. _QBK_MACRO_ONLY_RE = re.compile(r"^(?:__\w+__[\s,;.]*)+$") @@ -814,9 +833,13 @@ 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)``; 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) @@ -844,11 +867,13 @@ 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() + (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() @@ -856,16 +881,18 @@ 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 + 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 - from weblate.utils.state import STATE_FUZZY - - if ex_unit.state == STATE_FUZZY: - new_unit.markfuzzy(True) - store_index[src, ctx] = new_unit + new_unit.markfuzzy(ex_unit.state in FUZZY_STATES) + store_index[key] = new_unit return store diff --git a/weblate/utils/tests/test_quickbook.py b/weblate/utils/tests/test_quickbook.py new file mode 100644 index 000000000000..850b0b2f7cf5 --- /dev/null +++ b/weblate/utils/tests/test_quickbook.py @@ -0,0 +1,93 @@ +# Copyright © Boost Organization +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +from unittest import TestCase + +from weblate.utils.quickbook import ( + _find_bracket_end, + _parse_bracket_keyword, + _parse_qbk, + po_to_qbk, + qbk_to_po, +) + + +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)