translation_file.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. # Copyright (c) 2026 Riverbank Computing Limited <info@riverbankcomputing.com>
  2. #
  3. # This file is part of PyQt6.
  4. #
  5. # This file may be used under the terms of the GNU General Public License
  6. # version 3.0 as published by the Free Software Foundation and appearing in
  7. # the file LICENSE included in the packaging of this file. Please review the
  8. # following information to ensure the GNU General Public License version 3.0
  9. # requirements will be met: http://www.gnu.org/copyleft/gpl.html.
  10. #
  11. # If you do not wish to use this file under the terms of the GPL version 3.0
  12. # then you may purchase a commercial license. For more information contact
  13. # info@riverbankcomputing.com.
  14. #
  15. # This file is provided AS IS with NO WARRANTY OF ANY KIND, INCLUDING THE
  16. # WARRANTY OF DESIGN, MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
  17. import os
  18. from xml.etree import ElementTree
  19. from .user import User, UserException
  20. class TranslationFile(User):
  21. """ Encapsulate a translation file. """
  22. def __init__(self, ts_file, no_obsolete, no_summary, **kwargs):
  23. """ Initialise the translation file. """
  24. super().__init__(**kwargs)
  25. if os.path.isfile(ts_file):
  26. self.progress("Reading {0}...".format(ts_file))
  27. try:
  28. self._root = ElementTree.parse(ts_file).getroot()
  29. except Exception as e:
  30. raise UserException(
  31. "{}: {}: {}".format(ts_file,
  32. "invalid translation file", str(e)))
  33. else:
  34. self._root = ElementTree.fromstring(_EMPTY_TS)
  35. self._ts_file = ts_file
  36. self._no_obsolete = no_obsolete
  37. self._no_summary = no_summary
  38. self._updated_contexts = {}
  39. # Create a dict of contexts keyed by the context name and having the
  40. # list of message elements as the value.
  41. self._contexts = {}
  42. # Also create a dict of existing translations so that they can be
  43. # re-used.
  44. self._translations = {}
  45. context_els = []
  46. for context_el in self._root:
  47. if context_el.tag != 'context':
  48. continue
  49. context_els.append(context_el)
  50. name = ''
  51. message_els = []
  52. for el in context_el:
  53. if el.tag == 'name':
  54. name = el.text
  55. elif el.tag == 'message':
  56. message_els.append(el)
  57. if name:
  58. self._contexts[name] = message_els
  59. for message_el in message_els:
  60. source_el = message_el.find('source')
  61. if source_el is None or not source_el.text:
  62. continue
  63. translation_el = message_el.find('translation')
  64. if translation_el is None or not translation_el.text:
  65. continue
  66. self._translations[source_el.text] = translation_el.text
  67. # Remove the context elements but keep everything else in the root
  68. # (probably set by Linguist).
  69. for context_el in context_els:
  70. self._root.remove(context_el)
  71. # Clear the summary statistics.
  72. self._nr_new = 0
  73. self._nr_new_duplicates = 0
  74. self._nr_new_using_existing_translation = 0
  75. self._nr_existing = 0
  76. self._nr_kept_obsolete = 0
  77. self._nr_discarded_obsolete = 0
  78. self._nr_discarded_untranslated = 0
  79. # Remember all new messages so we can make the summary less confusing
  80. # than it otherwise might be.
  81. self._new_message_els = []
  82. def update(self, source):
  83. """ Update the translation file from a SourceFile object. """
  84. self.progress(
  85. "Updating {0} from {1}...".format(self._ts_file,
  86. source.filename))
  87. for context in source.contexts:
  88. # Get the messages that we already know about for this context.
  89. try:
  90. message_els = self._contexts[context.name]
  91. except KeyError:
  92. message_els = []
  93. # Get the messages that have already been updated.
  94. updated_message_els = self._get_updated_message_els(context.name)
  95. for message in context.messages:
  96. message_el = self._find_message(message, message_els)
  97. if message_el is not None:
  98. # Move the message to the updated list.
  99. message_els.remove(message_el)
  100. self._add_message_el(message_el, updated_message_els)
  101. else:
  102. # See if this is a new message. If not then we just have
  103. # another location for an existing message.
  104. message_el = self._find_message(message,
  105. updated_message_els)
  106. if message_el is None:
  107. message_el = self._make_message_el(message)
  108. updated_message_els.append(message_el)
  109. self.progress(
  110. "Added new message '{0}'".format(
  111. self.pretty(message.source)))
  112. self._nr_new += 1
  113. else:
  114. self.progress(
  115. "Updated message '{0}'".format(
  116. self.pretty(message.source)))
  117. # Go through any translations making sure they are not
  118. # 'vanished' which might happen if we have restored a
  119. # previously obsolete message.
  120. for translation_el in message_el.findall('translation'):
  121. if translation_el.get('type') == 'vanished':
  122. if translation_el.text:
  123. del translation_el.attrib['type']
  124. else:
  125. translation_el.set('type', 'unfinished')
  126. # Don't count another copy of a new message as an existing
  127. # one.
  128. if message_el in self._new_message_els:
  129. self._nr_new_duplicates += 1
  130. else:
  131. self._nr_existing += 1
  132. message_el.insert(0, self._make_location_el(message))
  133. def write(self):
  134. """ Write the translation file back to the filesystem. """
  135. # If we are keeping obsolete messages then add them to the updated
  136. # message elements list.
  137. for name, message_els in self._contexts.items():
  138. updated_message_els = None
  139. for message_el in message_els:
  140. source = self.pretty(message_el.find('source').text)
  141. translation_el = message_el.find('translation')
  142. if translation_el is not None and translation_el.text:
  143. if self._no_obsolete:
  144. self.progress(
  145. "Discarded obsolete message '{0}'".format(
  146. source))
  147. self._nr_discarded_obsolete += 1
  148. else:
  149. translation_el.set('type', 'vanished')
  150. if updated_message_els is None:
  151. updated_message_els = self._get_updated_message_els(
  152. name)
  153. self._add_message_el(message_el, updated_message_els)
  154. self.progress(
  155. "Kept obsolete message '{0}'".format(source))
  156. self._nr_kept_obsolete += 1
  157. else:
  158. self.progress(
  159. "Discarded untranslated message '{0}'".format(
  160. source))
  161. self._nr_discarded_untranslated += 1
  162. # Created the sorted context elements.
  163. for name in sorted(self._updated_contexts.keys()):
  164. context_el = ElementTree.Element('context')
  165. name_el = ElementTree.Element('name')
  166. name_el.text = name
  167. context_el.append(name_el)
  168. context_el.extend(self._updated_contexts[name])
  169. self._root.append(context_el)
  170. self.progress("Writing {0}...".format(self._ts_file))
  171. # Replicate the indentation used by Qt Linguist. Note that there are
  172. # still differences in the way elements are closed.
  173. for el in self._root:
  174. ElementTree.indent(el, space=' ')
  175. with open(self._ts_file, 'w', encoding='utf-8', newline='\n') as f:
  176. f.write('<?xml version="1.0" encoding="utf-8"?>\n')
  177. f.write('<!DOCTYPE TS>\n')
  178. ElementTree.ElementTree(self._root).write(f, encoding='unicode')
  179. f.write('\n')
  180. if not self._no_summary:
  181. self._summary()
  182. @staticmethod
  183. def _add_message_el(message_el, updated_message_els):
  184. """ Add a message element to a list of updated message elements. """
  185. # Remove all the location elements.
  186. for location_el in message_el.findall('location'):
  187. message_el.remove(location_el)
  188. # Add the message to the updated list.
  189. updated_message_els.append(message_el)
  190. @classmethod
  191. def _find_message(cls, message, message_els):
  192. """ Return the message element for a message from a list. """
  193. for message_el in message_els:
  194. source = ''
  195. comment = ''
  196. extra_comment = ''
  197. extras = []
  198. # Extract the data from the element.
  199. for el in message_el:
  200. if el.tag == 'source':
  201. source = el.text
  202. elif el.tag == 'comment':
  203. comment = el.text
  204. elif el.tag == 'extracomment':
  205. extra_comment = el.text
  206. elif el.tag.startswith('extra-'):
  207. extras.append([el.tag[6:], el.text])
  208. # Compare with the message.
  209. if source != message.source:
  210. continue
  211. if comment != message.comment:
  212. continue
  213. if extra_comment != cls._get_message_extra_comments(message):
  214. continue
  215. if extras != message.embedded_comments.extras:
  216. continue
  217. return message_el
  218. return None
  219. @staticmethod
  220. def _get_message_extra_comments(message):
  221. """ Return a message's extra comments as they appear in a .ts file. """
  222. return ' '.join(message.embedded_comments.extra_comments)
  223. def _get_updated_message_els(self, name):
  224. """ Return the list of updated message elements for a context. """
  225. try:
  226. updated_message_els = self._updated_contexts[name]
  227. except KeyError:
  228. updated_message_els = []
  229. self._updated_contexts[name] = updated_message_els
  230. return updated_message_els
  231. def _make_location_el(self, message):
  232. """ Return a 'location' element. """
  233. return ElementTree.Element('location',
  234. filename=os.path.relpath(message.filename,
  235. start=os.path.dirname(os.path.abspath(self._ts_file))),
  236. line=str(message.line_nr))
  237. def _make_message_el(self, message):
  238. """ Return a 'message' element. """
  239. attrs = {}
  240. if message.embedded_comments.message_id:
  241. attrs['id'] = message.embedded_comments.message_id
  242. if message.numerus:
  243. attrs['numerus'] = 'yes'
  244. message_el = ElementTree.Element('message', attrs)
  245. source_el = ElementTree.Element('source')
  246. source_el.text = message.source
  247. message_el.append(source_el)
  248. if message.comment:
  249. comment_el = ElementTree.Element('comment')
  250. comment_el.text = message.comment
  251. message_el.append(comment_el)
  252. if message.embedded_comments.extra_comments:
  253. extracomment_el = ElementTree.Element('extracomment')
  254. extracomment_el.text = self._get_message_extra_comments(message)
  255. message_el.append(extracomment_el)
  256. translation_el = ElementTree.Element('translation',
  257. type='unfinished')
  258. # Try and find another message with the same source and use its
  259. # translation if it has one.
  260. translation = self._translations.get(message.source)
  261. if translation:
  262. translation_el.text = translation
  263. self.progress(
  264. "Reused existing translation for '{0}'".format(
  265. self.pretty(message.source)))
  266. self._nr_new_using_existing_translation += 1
  267. if message.numerus:
  268. translation_el.append(ElementTree.Element(
  269. 'numerusform'))
  270. message_el.append(translation_el)
  271. for field, value in message.embedded_comments.extras:
  272. el = ElementTree.Element('extra-' + field)
  273. el.text = value
  274. message_el.append(el)
  275. self._new_message_els.append(message_el)
  276. return message_el
  277. def _summary(self):
  278. """ Display the summary of changes to the user. """
  279. summary_lines = []
  280. # Display a line of the summary and the heading if not already done.
  281. def summary(line):
  282. nonlocal summary_lines
  283. if not summary_lines:
  284. summary_lines.append(
  285. "Summary of changes to {ts}:".format(ts=self._ts_file))
  286. summary_lines.append(" " + line)
  287. if self._nr_new:
  288. if self._nr_new_duplicates:
  289. summary("{0} new messages were added (and {1} duplicates)".format(
  290. self._nr_new, self._nr_new_duplicates))
  291. else:
  292. summary("{0} new messages were added".format(self._nr_new))
  293. if self._nr_new_using_existing_translation:
  294. summary("{0} messages reused existing translations".format(
  295. self._nr_new_using_existing_translation))
  296. if self._nr_existing:
  297. summary("{0} existing messages were found".format(
  298. self._nr_existing))
  299. if self._nr_kept_obsolete:
  300. summary("{0} obsolete messages were kept".format(
  301. self._nr_kept_obsolete))
  302. if self._nr_discarded_obsolete:
  303. summary("{0} obsolete messages were discarded".format(
  304. self._nr_discarded_obsolete))
  305. if self._nr_discarded_untranslated:
  306. summary("{0} untranslated messages were discarded".format(
  307. self._nr_discarded_untranslated))
  308. if not summary_lines:
  309. summary_lines.append("{ts} was unchanged".format(ts=self._ts_file))
  310. print(os.linesep.join(summary_lines))
  311. # The XML of an empty .ts file. This is what a current lupdate will create
  312. # with an empty C++ source file.
  313. _EMPTY_TS = '''<TS version="2.1">
  314. </TS>
  315. '''