manage_languages.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. # Written for Python 3.6+
  2. # Older versions don't retain insertion order of regular dicts
  3. import argparse
  4. import cmd
  5. import json
  6. import os
  7. import re
  8. from pprint import pprint
  9. INDENT = 2
  10. PRIMARY_LANGUAGE = 'en-US.json'
  11. PRIMARY_FALLBACK_PREFIX = '🇺🇸' # This is invisible in-game, terminal emulators might render it
  12. LANGUAGE_FOLDER = 'src/main/resources/languages/'
  13. LANGUAGE_FILENAMES = sorted(os.listdir(LANGUAGE_FOLDER), key=lambda x: 'AAA' if x == PRIMARY_LANGUAGE else x)
  14. SOURCE_FOLDER = 'src/'
  15. SOURCE_EXTENSIONS = ('java')
  16. def ppprint(data):
  17. pprint(data, width=130, sort_dicts=False, compact=True)
  18. class JsonHelpers:
  19. @staticmethod
  20. def load(filename: str) -> dict:
  21. with open(filename, 'r', encoding='utf-8') as file:
  22. return json.load(file)
  23. @staticmethod
  24. def save(filename: str, data: dict) -> None:
  25. with open(filename, 'w', encoding='utf-8', newline='\n') as file:
  26. json.dump(data, file, ensure_ascii=False, indent=INDENT)
  27. file.write('\n') # json.dump doesn't terminate last line
  28. @staticmethod
  29. def flatten(data: dict, prefix='') -> dict:
  30. output = {}
  31. for key, value in data.items():
  32. if isinstance(value, dict):
  33. for k,v in JsonHelpers.flatten(value, f'{prefix}{key}.').items():
  34. output[k] = v
  35. else:
  36. output[f'{prefix}{key}'] = value
  37. return output
  38. @staticmethod
  39. def unflatten(data: dict) -> dict:
  40. output = {}
  41. def add_key(k: list, value, d: dict):
  42. if len(k) == 1:
  43. d[k[0]] = value
  44. else:
  45. d[k[0]] = d.get(k[0], {})
  46. add_key(k[1:], value, d[k[0]])
  47. for key, value in data.items():
  48. add_key(key.split('.'), value, output)
  49. return output
  50. @staticmethod
  51. def pprint_keys(keys, indent=4) -> str:
  52. # Only strip down to one level
  53. padding = ' ' * indent
  54. roots = {}
  55. for key in keys:
  56. root, _, k = key.rpartition('.')
  57. roots[root] = roots.get(root, [])
  58. roots[root].append(k)
  59. lines = []
  60. for root, ks in roots.items():
  61. if len(ks) > 1:
  62. lines.append(f'{padding}{root}.[{", ".join(ks)}]')
  63. else:
  64. lines.append(f'{padding}{root}.{ks[0]}')
  65. return ',\n'.join(lines)
  66. @staticmethod
  67. def deep_clone_and_fill(d1: dict, d2: dict, fallback_prefix=PRIMARY_FALLBACK_PREFIX) -> dict:
  68. out = {}
  69. for key, value in d1.items():
  70. if isinstance(value, dict):
  71. out[key] = JsonHelpers.deep_clone_and_fill(value, d2.get(key, {}), fallback_prefix)
  72. else:
  73. v2 = d2.get(key, value)
  74. if type(value) == str and v2 == value:
  75. out[key] = fallback_prefix + value
  76. else:
  77. out[key] = v2
  78. return out
  79. class LanguageManager:
  80. TRANSLATION_KEY = re.compile(r'[Tt]ranslate.*"(\w+\.[\w\.]+)"')
  81. POTENTIAL_KEY = re.compile(r'"(\w+\.[\w\.]+)"')
  82. COMMAND_LABEL = re.compile(r'@Command\s*\([\W\w]*?label\s*=\s*"(\w+)"', re.MULTILINE) # [\W\w] is a cheeky way to match everything including \n
  83. def __init__(self):
  84. self.load_jsons()
  85. def load_jsons(self):
  86. self.language_jsons = [JsonHelpers.load(LANGUAGE_FOLDER + filename) for filename in LANGUAGE_FILENAMES]
  87. self.flattened_jsons = [JsonHelpers.flatten(j) for j in self.language_jsons]
  88. self.update_keys()
  89. def update_keys(self):
  90. self.key_sets = [set(j.keys()) for j in self.flattened_jsons]
  91. self.common_keys = set.intersection(*self.key_sets)
  92. self.all_keys = set.union(*self.key_sets)
  93. self.used_keys = self.find_all_used_keys(self.all_keys)
  94. self.missing_keys = self.used_keys - self.common_keys
  95. self.unused_keys = self.all_keys - self.used_keys
  96. def find_all_used_keys(self, expected_keys=[]) -> set:
  97. # Note that this will only find string literals passed to the translate() or sendTranslatedMessage() methods!
  98. # String variables passed to them can be checked against expected_keys
  99. used = set()
  100. potential = set()
  101. for root, dirs, files in os.walk(SOURCE_FOLDER):
  102. for file in files:
  103. if file.rpartition('.')[-1] in SOURCE_EXTENSIONS:
  104. filename = os.path.join(root, file)
  105. with open(filename, 'r', encoding='utf-8') as f:
  106. data = f.read() # Loads in entire file at once
  107. for k in self.TRANSLATION_KEY.findall(data):
  108. used.add(k)
  109. for k in self.POTENTIAL_KEY.findall(data):
  110. potential.add(k)
  111. for label in self.COMMAND_LABEL.findall(data):
  112. used.add(f'commands.{label}.description')
  113. return used | (potential & expected_keys)
  114. def _lint_report_language(self, lang: str, keys: set, flattened: dict, primary_language_flattened: dict) -> None:
  115. missing = self.used_keys - keys
  116. unused = keys - self.used_keys
  117. identical_keys = set() if (lang == PRIMARY_LANGUAGE) else {key for key in keys if primary_language_flattened.get(key, None) == flattened.get(key)}
  118. placeholder_keys = {key for key in keys if flattened.get(key).startswith(PRIMARY_FALLBACK_PREFIX)}
  119. p1 = f'Language {lang} has {len(missing)} missing keys and {len(unused)} unused keys.'
  120. p2 = 'This is the primary language.' if (lang == PRIMARY_LANGUAGE) else f'{len(identical_keys)} match {PRIMARY_LANGUAGE}, {len(placeholder_keys)} have the placeholder mark.'
  121. print(f'{p1} {p2}')
  122. lint_categories = {
  123. 'Missing': missing,
  124. 'Unused': unused,
  125. f'Matches {PRIMARY_LANGUAGE}': identical_keys,
  126. 'Placeholder': placeholder_keys,
  127. }
  128. for name, category in lint_categories.items():
  129. if len(category) > 0:
  130. print(name + ':')
  131. print(JsonHelpers.pprint_keys(sorted(category)))
  132. def lint_report(self) -> None:
  133. print(f'There are {len(self.missing_keys)} translation keys in use that are missing from one or more language files.')
  134. print(f'There are {len(self.unused_keys)} translation keys in language files that are not used.')
  135. primary_language_flattened = self.flattened_jsons[LANGUAGE_FILENAMES.index(PRIMARY_LANGUAGE)]
  136. for lang, keys, flattened in zip(LANGUAGE_FILENAMES, self.key_sets, self.flattened_jsons):
  137. print('')
  138. self._lint_report_language(lang, keys, flattened, primary_language_flattened)
  139. def rename_keys(self, key_remappings: dict) -> None:
  140. # Unfortunately we can't rename keys in-place preserving insertion order, so we have to make new dicts
  141. for i in range(len(self.flattened_jsons)):
  142. self.flattened_jsons[i] = {key_remappings.get(k,k):v for k,v in self.flattened_jsons[i].items()}
  143. def update_secondary_languages(self):
  144. # Push en_US fallback
  145. primary_language_json = self.language_jsons[LANGUAGE_FILENAMES.index(PRIMARY_LANGUAGE)]
  146. for filename, lang in zip(LANGUAGE_FILENAMES, self.language_jsons):
  147. if filename != PRIMARY_LANGUAGE:
  148. js = JsonHelpers.deep_clone_and_fill(primary_language_json, lang)
  149. JsonHelpers.save(LANGUAGE_FOLDER + filename, js)
  150. def update_all_languages_from_flattened(self):
  151. for filename, flat in zip(LANGUAGE_FILENAMES, self.flattened_jsons):
  152. JsonHelpers.save(LANGUAGE_FOLDER + filename, JsonHelpers.unflatten(flat))
  153. def save_flattened_languages(self, prefix='flat_'):
  154. for filename, flat in zip(LANGUAGE_FILENAMES, self.flattened_jsons):
  155. JsonHelpers.save(prefix + filename, flat)
  156. class InteractiveRename(cmd.Cmd):
  157. intro = 'Welcome to the interactive rename shell. Type help or ? to list commands.\n'
  158. prompt = '(rename) '
  159. file = None
  160. def __init__(self, language_manager: LanguageManager) -> None:
  161. super().__init__()
  162. self.language_manager = language_manager
  163. self.flat_keys = [key for key in language_manager.flattened_jsons[LANGUAGE_FILENAMES.index(PRIMARY_LANGUAGE)].keys()]
  164. self.mappings = {}
  165. def do_add(self, arg):
  166. '''
  167. Prepare to rename an existing translation key. Will not actually rename anything until you confirm all your pending changes with 'rename'.
  168. e.g. a single string: add commands.execution.argument_error commands.generic.invalid.argument
  169. e.g. a group: add commands.enter_dungeon commands.new_enter_dungeon
  170. '''
  171. args = arg.split()
  172. if len(args) < 2:
  173. self.do_help('add')
  174. return
  175. old, new = args[:2]
  176. if old in self.flat_keys:
  177. self.mappings[old] = new
  178. else:
  179. # Check if we are renaming a higher level
  180. if not old.endswith('.'):
  181. old = old + '.'
  182. results = [key for key in self.flat_keys if key.startswith(old)]
  183. if len(results) > 0:
  184. if not new.endswith('.'):
  185. new = new + '.'
  186. new_mappings = {key: key.replace(old, new) for key in results}
  187. # Ask for confirmation
  188. print('Will add the following mappings:')
  189. ppprint(new_mappings)
  190. print('Add these mappings? [y/N]')
  191. if self.prompt_yn():
  192. for k,v in new_mappings.items():
  193. self.mappings[k] = v
  194. else:
  195. print('No translation keys matched!')
  196. def complete_add(self, text: str, line: str, begidx: int, endidx: int) -> list:
  197. if text == '':
  198. return [k for k in {key.partition('.')[0] for key in self.flat_keys}]
  199. results = [key for key in self.flat_keys if key.startswith(text)]
  200. if len(results) > 40:
  201. # Collapse categories
  202. if text[-1] != '.':
  203. text = text + '.'
  204. level = text.count('.') + 1
  205. new_results = {'.'.join(key.split('.')[:level]) for key in results}
  206. return list(new_results)
  207. return results
  208. def do_remove(self, arg):
  209. '''
  210. Remove a pending rename mapping. Takes the old name of the key, not the new one.
  211. e.g. a single key: remove commands.execution.argument_error
  212. e.g. a group: remove commands.enter_dungeon
  213. '''
  214. old = arg.split()[0]
  215. if old in self.mappings:
  216. self.mappings.pop(old)
  217. else:
  218. # Check if we are renaming a higher level
  219. if not old.endswith('.'):
  220. old = old + '.'
  221. results = [key for key in self.mappings if key.startswith(old)]
  222. if len(results) > 0:
  223. # Ask for confirmation
  224. print('Will remove the following pending mappings:')
  225. print(JsonHelpers.pprint_keys(results))
  226. print('Delete these mappings? [y/N]')
  227. if self.prompt_yn():
  228. for key in results:
  229. self.mappings.pop(key)
  230. else:
  231. print('No pending rename mappings matched!')
  232. def complete_remove(self, text: str, line: str, begidx: int, endidx: int) -> list:
  233. return [key for key in self.mappings if key.startswith(text)]
  234. def do_rename(self, _arg):
  235. 'Applies pending renames and overwrites language jsons.'
  236. # Ask for confirmation
  237. print('Will perform the following mappings:')
  238. ppprint(self.mappings)
  239. print('Perform and save these rename mappings? [y/N]')
  240. if self.prompt_yn():
  241. self.language_manager.rename_keys(self.mappings)
  242. self.language_manager.update_all_languages_from_flattened()
  243. print('Renamed keys, closing')
  244. return True
  245. else:
  246. print('Do you instead wish to quit without saving? [yes/N]')
  247. if self.prompt_yn(True):
  248. print('Left rename shell without renaming')
  249. return True
  250. def prompt_yn(self, strict_yes=False):
  251. if strict_yes:
  252. return input('(yes/N) ').lower() == 'yes'
  253. return input('(y/N) ').lower()[0] == 'y'
  254. def main(args: argparse.Namespace):
  255. # print(args)
  256. language_manager = LanguageManager()
  257. errors = None
  258. if args.lint_report:
  259. language_manager.lint_report()
  260. missing = language_manager.used_keys - language_manager.key_sets[LANGUAGE_FILENAMES.index(PRIMARY_LANGUAGE)]
  261. if len(missing) > 0:
  262. errors = f'[ERROR] {len(missing)} keys missing from primary language json!\n{JsonHelpers.pprint_keys(missing)}'
  263. if prefix := args.save_flattened:
  264. language_manager.save_flattened_languages(prefix)
  265. if args.update:
  266. print('Updating secondary languages')
  267. language_manager.update_secondary_languages()
  268. if args.interactive_rename:
  269. language_manager.load_jsons() # Previous actions may have changed them on-disk
  270. try:
  271. InteractiveRename(language_manager).cmdloop()
  272. except KeyboardInterrupt:
  273. print('Left rename shell without renaming')
  274. if errors:
  275. print(errors)
  276. exit(1)
  277. if __name__ == "__main__":
  278. parser = argparse.ArgumentParser(description="Manage Grasscutter's language json files.")
  279. parser.add_argument('-u', '--update', action='store_true',
  280. help=f'Update secondary language files to conform to the layout of the primary language file ({PRIMARY_LANGUAGE}) and contain any new keys from it.')
  281. parser.add_argument('-l', '--lint-report', action='store_true',
  282. help='Prints a lint report, listing unused, missing, and untranslated keys among all language jsons.')
  283. parser.add_argument('-f', '--save-flattened', const='./flat_', metavar='prefix', nargs='?',
  284. help='Save copies of all the language jsons in a flattened key form.')
  285. parser.add_argument('-i', '--interactive-rename', action='store_true',
  286. help='Enter interactive rename mode, in which you can specify keys in flattened form to be renamed.')
  287. args = parser.parse_args()
  288. main(args)