#!/usr/bin/env python3 # SPDX-FileCopyrightText: Michal Siedlaczek # SPDX-FileCopyrightText: Florian Bruhin (The Compiler) # # SPDX-License-Identifier: GPL-3.0-or-later """A script installing Hunspell dictionaries. Use: python -m scripts.dictcli [-h] {list,update,remove-old,install} ... """ import argparse import base64 import json import os import sys import re import urllib.request import dataclasses from typing import Optional sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) from qutebrowser.browser.webengine import spell from qutebrowser.config import configdata from qutebrowser.utils import standarddir API_URL = 'https://chromium.googlesource.com/chromium/deps/hunspell_dictionaries.git/+/main/' class InvalidLanguageError(Exception): """Raised when requesting invalid languages.""" def __init__(self, invalid_langs): msg = 'invalid languages: {}'.format(', '.join(invalid_langs)) super().__init__(msg) @dataclasses.dataclass class Language: """Dictionary language specs.""" code: str name: str remote_filename: str local_filename: Optional[str] = None def __post_init__(self): if self.local_filename is None: self.local_filename = spell.local_filename(self.code) @property def remote_version(self): """Resolve the version of the local dictionary.""" return spell.version(self.remote_filename) @property def local_version(self): """Resolve the version of the local dictionary.""" local_filename = self.local_filename if local_filename is None: return None return spell.version(local_filename) def get_argparser(): """Get the argparse parser.""" desc = 'Install and manage Hunspell dictionaries for QtWebEngine.' parser = argparse.ArgumentParser(prog='dictcli', description=desc) subparsers = parser.add_subparsers(help='Command', dest='cmd') subparsers.required = True subparsers.add_parser('list', help='Display the list of available languages.') subparsers.add_parser('update', help='Update dictionaries') subparsers.add_parser('remove-old', help='Remove old versions of dictionaries.') install_parser = subparsers.add_parser('install', help='Install dictionaries') install_parser.add_argument('language', nargs='*', help="A list of languages to install.") return parser def version_str(version): return '.'.join(str(n) for n in version) def print_list(languages): """Print the list of available languages.""" pat = '{:<7}{:<26}{:<8}{:<5}' print(pat.format('Code', 'Name', 'Version', 'Installed')) for lang in languages: remote_version = version_str(lang.remote_version) local_version = '-' if lang.local_version is not None: local_version = version_str(lang.local_version) if lang.local_version < lang.remote_version: local_version += ' - update available!' print(pat.format(lang.code, lang.name, remote_version, local_version)) def valid_languages(): """Return a mapping from valid language codes to their names.""" option = configdata.DATA['spellcheck.languages'] return option.typ.valtype.valid_values.descriptions def parse_entry(entry): """Parse an entry from the remote API.""" dict_re = re.compile(r""" (?P(?P[a-z]{2}(-[A-Z]{2})?).*\.bdic) """, re.VERBOSE) match = dict_re.fullmatch(entry['name']) if match is not None: return match.group('code'), match.group('filename') else: return None def language_list_from_api(): """Return a JSON with a list of available languages from Google API.""" listurl = API_URL + '?format=JSON' with urllib.request.urlopen(listurl) as response: # A special 5-byte prefix must be stripped from the response content # See: https://github.com/google/gitiles/issues/22 # https://github.com/google/gitiles/issues/82 json_content = response.read()[5:] entries = json.loads(json_content.decode('utf-8'))['entries'] parsed_entries = [parse_entry(entry) for entry in entries] return [entry for entry in parsed_entries if entry is not None] def latest_yet(code2file, code, filename): """Determine whether the latest version so far.""" if code not in code2file: return True return spell.version(code2file[code]) < spell.version(filename) def available_languages(): """Return a list of Language objects of all available languages.""" lang_map = valid_languages() api_list = language_list_from_api() code2file = {} for code, filename in api_list: if latest_yet(code2file, code, filename): code2file[code] = filename return [ Language(code, name, code2file[code]) for code, name in lang_map.items() if code in code2file ] def download_dictionary(url, dest): """Download a decoded dictionary file.""" with urllib.request.urlopen(url) as response: decoded = base64.decodebytes(response.read()) with open(dest, 'bw') as dict_file: dict_file.write(decoded) def filter_languages(languages, selected): """Filter a list of languages based on an inclusion list. Args: languages: a list of languages to filter selected: a list of keys to select """ filtered_languages = [] for language in languages: if language.code in selected: filtered_languages.append(language) selected.remove(language.code) if selected: raise InvalidLanguageError(selected) return filtered_languages def install_lang(lang): """Install a single lang given by the argument.""" lang_url = API_URL + lang.remote_filename + '?format=TEXT' if not os.path.isdir(spell.dictionary_dir()): msg = '{} does not exist, creating the directory' print(msg.format(spell.dictionary_dir())) os.makedirs(spell.dictionary_dir()) print('Downloading {}'.format(lang_url)) dest = os.path.join(spell.dictionary_dir(), lang.remote_filename) download_dictionary(lang_url, dest) print('Installed to {}.'.format(dest)) def install(languages): """Install languages.""" for lang in languages: print('Installing {}: {}'.format(lang.code, lang.name)) install_lang(lang) def update(languages): """Update the given languages.""" installed = [lang for lang in languages if lang.local_version is not None] for lang in installed: if lang.local_version < lang.remote_version: print('Upgrading {} from {} to {}'.format( lang.code, version_str(lang.local_version), version_str(lang.remote_version))) install_lang(lang) def remove_old(languages): """Remove old versions of languages.""" installed = [lang for lang in languages if lang.local_version is not None] for lang in installed: local_files = spell.local_files(lang.code) for old_file in local_files[1:]: os.remove(os.path.join(spell.dictionary_dir(), old_file)) def main(): if configdata.DATA is None: configdata.init() standarddir.init(None) parser = get_argparser() argv = sys.argv[1:] args = parser.parse_args(argv) languages = available_languages() if args.cmd == 'list': print_list(languages) elif args.cmd == 'update': update(languages) elif args.cmd == 'remove-old': remove_old(languages) elif not args.language: sys.exit('You must provide a list of languages to install.') else: try: install(filter_languages(languages, args.language)) except InvalidLanguageError as e: print(e) if __name__ == '__main__': main()