From 0d8b74a9e07e805b973920c8cd91957fc0ca1ee7 Mon Sep 17 00:00:00 2001 From: derrod Date: Sat, 2 Oct 2021 21:10:25 +0200 Subject: [PATCH] [cli/core/utils/lfs] Add automatic alias generation --- legendary/cli.py | 10 ++++- legendary/core.py | 10 +++++ legendary/lfs/lgndry.py | 48 ++++++++++++++++++++ legendary/utils/aliasing.py | 89 +++++++++++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 legendary/utils/aliasing.py diff --git a/legendary/cli.py b/legendary/cli.py index 0524ce7..8a015cf 100644 --- a/legendary/cli.py +++ b/legendary/cli.py @@ -51,9 +51,15 @@ class LegendaryCLI: ql.start() return ql - def _resolve_aliases(self, app_name): + def _resolve_aliases(self, name): + # make sure aliases exist if not yet created + self.core.update_aliases(force=False) + name = name.strip() # resolve alias (if any) to real app name - return self.core.lgd.config.get('Legendary.aliases', app_name, fallback=app_name) + return self.core.lgd.config.get( + section='Legendary.aliases', option=name, + fallback=self.core.lgd.aliases.get(name.lower(), name) + ) def auth(self, args): if args.auth_delete: diff --git a/legendary/core.py b/legendary/core.py index c40d5a6..2a326e4 100644 --- a/legendary/core.py +++ b/legendary/core.py @@ -273,6 +273,11 @@ class LegendaryCore: # return data if available return sdl_data + def update_aliases(self, force=False): + _aliases_enabled = not self.lgd.config.getboolean('Legendary', 'disable_auto_aliasing', fallback=False) + if _aliases_enabled and (force or not self.lgd.aliases): + self.lgd.generate_aliases() + def get_assets(self, update_assets=False, platform_override=None) -> List[GameAsset]: # do not save and always fetch list when platform is overridden if platform_override: @@ -311,6 +316,7 @@ class LegendaryCore: force_refresh=False, skip_ue=True) -> (List[Game], Dict[str, List[Game]]): _ret = [] _dlc = defaultdict(list) + meta_updated = False for ga in self.get_assets(update_assets=update_assets, platform_override=platform_override): @@ -328,6 +334,7 @@ class LegendaryCore: app_title=eg_meta['title'], asset_info=ga, metadata=eg_meta) if not platform_override: + meta_updated = True self.lgd.set_game_meta(game.app_name, game) # replace asset info with the platform specific one if override is used @@ -340,6 +347,9 @@ class LegendaryCore: elif not any(i['path'] == 'mods' for i in game.metadata.get('categories', [])): _ret.append(game) + if not platform_override: + self.update_aliases(force=meta_updated) + return _ret, _dlc def get_non_asset_library_items(self, force_refresh=False, diff --git a/legendary/lfs/lgndry.py b/legendary/lfs/lgndry.py index 1eab6a1..fa678b9 100644 --- a/legendary/lfs/lgndry.py +++ b/legendary/lfs/lgndry.py @@ -4,10 +4,12 @@ import json import os import logging +from collections import defaultdict from pathlib import Path from time import time from legendary.models.game import * +from legendary.utils.aliasing import generate_aliases from legendary.utils.config import LGDConf from legendary.utils.env import is_windows_or_pyi from legendary.utils.lfs import clean_filename @@ -112,6 +114,17 @@ class LGDLFS: except Exception as e: self.log.debug(f'Loading game meta file "{gm_file}" failed: {e!r}') + # load auto-aliases if enabled + self.aliases = dict() + if not self.config.getboolean('Legendary', 'disable_auto_aliasing', fallback=False): + try: + _j = json.load(open(os.path.join(self.path, 'aliases.json'))) + for app_name, aliases in _j.items(): + for alias in aliases: + self.aliases[alias] = app_name + except Exception as e: + self.log.debug(f'Loading aliases failed with {e!r}') + @property def userdata(self): if self._user_data is not None: @@ -339,3 +352,38 @@ class LGDLFS: json.dump(dict(version=sdl_version, data=sdl_data), open(os.path.join(self.path, 'tmp', f'{app_name}.json'), 'w'), indent=2, sort_keys=True) + + def generate_aliases(self): + self.log.debug('Generating list of aliases...') + + aliases = set() + collisions = set() + alias_map = defaultdict(set) + + for app_name in self._game_metadata.keys(): + game = self.get_game_meta(app_name) + if game.is_dlc: + continue + game_folder = game.metadata.get('customAttributes', {}).get('FolderName', {}).get('value', None) + _aliases = generate_aliases(game.app_title, game_folder) + for alias in _aliases: + if alias not in aliases: + aliases.add(alias) + alias_map[game.app_name].add(alias) + else: + collisions.add(alias) + + # remove colliding aliases from map and add aliases to lookup table + for app_name, aliases in alias_map.items(): + alias_map[app_name] -= collisions + for alias in alias_map[app_name]: + self.aliases[alias] = app_name + + def serialise_sets(obj): + """Turn sets into sorted lists for storage""" + if isinstance(obj, set): + return sorted(obj) + return obj + + json.dump(alias_map, open(os.path.join(self.path, 'aliases.json'), 'w', newline='\n'), + indent=2, sort_keys=True, default=serialise_sets) diff --git a/legendary/utils/aliasing.py b/legendary/utils/aliasing.py new file mode 100644 index 0000000..6c3e214 --- /dev/null +++ b/legendary/utils/aliasing.py @@ -0,0 +1,89 @@ +from string import ascii_lowercase, digits + +# Aliases generated: +# - name lowercase (without TM etc.) +# - same, but without spaces +# - same, but roman numerals are replaced +# if name has >= 3 parts: +# - initials +# - initials, but roman numerals are intact +# - initials, but roman numerals are replaced with number +# if ':' in name: +# - run previous recursively with everything before ":" +# if single 'f' in long word: +# - split word (this is mainly for cases like Battlfront -> BF) + +allowed_characters = ascii_lowercase+digits +roman = { + # 'i': '1', + 'ii': '2', + 'iii': '3', + 'iv': '4', + 'v': '5', + 'vi': '6', + 'vii': '7', + 'viii': '8', + 'ix': '9', + 'x': '10', + 'xi': '11', + 'xii': '12', + 'xiii': '13', + 'xiv': '14', + 'xv': '15', + 'xvi': '16', + 'xvii': '17', + 'xviii': '18', + 'xix': '19', + 'xx': '20' +} + + +def _filter(input): + return ''.join(l for l in input if l in allowed_characters) + + +def generate_aliases(game_name, game_folder=None, split_words=True): + # normalise and split name, then filter for legal characters + game_parts = [_filter(p) for p in game_name.lower().split()] + # filter out empty parts + game_parts = [p for p in game_parts if p] + + _aliases = [ + ' '.join(game_parts), + ''.join(game_parts), + ''.join(roman.get(p, p) for p in game_parts), + ] + + # single word abbreviation + first_word = next(i for i in game_parts if i not in ('for', 'the')) + if len(first_word) > 1: + _aliases.append(first_word) + # remove subtitle from game + if ':' in game_name: + _aliases.extend(generate_aliases(game_name.partition(':')[0])) + # include folder name for alternative short forms + if game_folder: + _aliases.extend(generate_aliases(game_folder, split_words=False)) + # include initialisms + if len(game_parts) > 2: + _aliases.append(''.join(p[0] for p in game_parts)) + _aliases.append(''.join(p[0] if p not in roman else p for p in game_parts)) + _aliases.append(''.join(roman.get(p, p[0]) for p in game_parts)) + # Attempt to address cases like "Battlefront" being shortened to "BF" + if split_words: + new_game_parts = [] + for word in game_parts: + if len(word) >= 8 and word[3:-3].count('f') == 1: + word_middle = word[3:-3] + word_split = ' f'.join(word_middle.split('f')) + word = word[0:3] + word_split + word[-3:] + new_game_parts.extend(word.split()) + else: + new_game_parts.append(word) + + _aliases.append(''.join(p[0] for p in new_game_parts)) + _aliases.append(''.join(p[0] if p not in roman else p for p in new_game_parts)) + _aliases.append(''.join(roman.get(p, p[0]) for p in new_game_parts)) + + # return sorted unqiues + return sorted(set(_aliases))