├── ngl ├── __init__.py ├── games │ ├── __init__.py │ ├── base.py │ └── steam.py ├── core │ └── __init__.py ├── models │ ├── base.py │ └── steam.py ├── errors.py ├── utils.py └── client.py ├── _config.yml ├── requirements.txt ├── .gitmodules ├── LICENSE ├── .github └── workflows │ └── release.yaml ├── .gitignore ├── main.py └── README.md /ngl/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ngl/games/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=2.3.0 2 | notion>=0.0.25 3 | termcolor>=1.1.0 4 | colorama>=0.4.3 5 | urllib3<2.0.0 -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "ngl/api/steam"] 2 | path = ngl/api/steam 3 | url = https://github.com/smiley/steamapi.git 4 | -------------------------------------------------------------------------------- /ngl/core/__init__.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | def is_valid_link(url): 5 | try: 6 | r = requests.get(url, timeout=3) 7 | except requests.Timeout: 8 | return False 9 | return r.ok 10 | -------------------------------------------------------------------------------- /ngl/models/base.py: -------------------------------------------------------------------------------- 1 | import typing as tp 2 | 3 | 4 | class BaseModel: 5 | 6 | @classmethod 7 | def load(cls, d: tp.Optional[dict]): 8 | if d is None: 9 | return None 10 | return cls(**d) 11 | -------------------------------------------------------------------------------- /ngl/errors.py: -------------------------------------------------------------------------------- 1 | from .utils import color 2 | 3 | 4 | class ServiceError(Exception): 5 | """ Common exception """ 6 | code = 420 7 | 8 | def __init__(self, msg=None, error=None, code=420): 9 | self.code = code 10 | self.msg = getattr(error, "msg", str(error)) if msg is None and error else msg 11 | self.error = self.__class__.__name__ if error is None else error.__class__.__name__ 12 | super().__init__(self.msg) 13 | 14 | def __str__(self): 15 | msg = " "*100 + "\r" + color.r(self.error) 16 | 17 | if self.msg: 18 | msg += color.r(f": {self.msg}") 19 | 20 | return msg 21 | 22 | def __repr__(self): 23 | return self.__str__() 24 | 25 | 26 | class ApiError(ServiceError): 27 | code = 422 28 | 29 | 30 | class SteamApiError(ApiError): 31 | code = 501 32 | 33 | 34 | class SteamStoreApiError(SteamApiError): 35 | code = 481 36 | 37 | 38 | class SteamApiNotFoundError(SteamApiError): 39 | code = 404 40 | 41 | 42 | class NotionApiError(ApiError): 43 | code = 502 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 solesensei 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ngl/games/base.py: -------------------------------------------------------------------------------- 1 | import typing as tp 2 | 3 | from abc import abstractmethod, ABCMeta 4 | 5 | TGameID = tp.Union[int, str] # unique game identifier in library 6 | 7 | 8 | class GameInfo: 9 | 10 | def __init__( 11 | self, 12 | id: TGameID, 13 | name: str, 14 | platforms: tp.List[str], 15 | release_date: tp.Optional[str] = None, 16 | playtime: tp.Optional[str] = None, 17 | playtime_minutes: tp.Optional[int] = None, 18 | logo_uri: tp.Optional[str] = None, 19 | bg_uri: tp.Optional[str] = None, 20 | icon_uri: tp.Optional[str] = None, 21 | free: bool = False 22 | ): 23 | self.id = id 24 | self.name = name 25 | self.platforms = platforms 26 | self.release_date = release_date if release_date else None 27 | self.playtime = playtime if playtime else None 28 | self.playtime_minutes = playtime_minutes if playtime_minutes else 0 29 | self.logo_uri = logo_uri if logo_uri else None 30 | self.bg_uri = bg_uri if bg_uri else None 31 | self.icon_uri = icon_uri if icon_uri else None 32 | self.free = free 33 | 34 | def to_dict(self): 35 | return self.__dict__ 36 | 37 | class GamesLibrary(metaclass=ABCMeta): 38 | 39 | @abstractmethod 40 | def get_games_list(self) -> tp.List[TGameID]: 41 | """ Get game ids from library """ 42 | raise NotImplementedError 43 | 44 | @abstractmethod 45 | def get_game_info(self, game_id: TGameID) -> GameInfo: 46 | """ Get game info by id """ 47 | raise NotImplementedError 48 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # This github action workflow builds python executable for different platforms 2 | 3 | name: Build Tools 4 | 5 | on: 6 | release: 7 | types: [published] 8 | 9 | jobs: 10 | build: 11 | name: Build 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, macos-latest, windows-latest] 16 | python-version: [3.10.11] 17 | steps: 18 | - name: Checkout Repo 19 | uses: actions/checkout@v3 20 | with: 21 | submodules: true 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | cache: pip 27 | - name: Install dependencies 28 | run: | 29 | pip install -r requirements.txt -U 30 | pip install pyinstaller -U 31 | - name: Build 32 | run: | 33 | pyinstaller --console --hidden-import=pkg_resources.py2_warn --onefile main.py -n "main-${{ matrix.os }}" 34 | sleep 1 # Allow pyinstaller to finish writing to stdout 35 | ls -l dist 36 | - name: Make Executable 37 | if: matrix.os != 'windows-latest' 38 | run: | 39 | chmod +x "dist/main-${{ matrix.os }}" 40 | - name: Upload Release Asset (Unix) 41 | if: matrix.os != 'windows-latest' 42 | uses: actions/upload-release-asset@v1 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | with: 46 | upload_url: ${{ github.event.release.upload_url }} 47 | asset_path: dist/main-${{ matrix.os }} 48 | asset_name: main-${{ matrix.os }} 49 | asset_content_type: application/octet-stream 50 | - name: Upload Release Asset (Windows) 51 | if: matrix.os == 'windows-latest' 52 | uses: actions/upload-release-asset@v1 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | with: 56 | upload_url: ${{ github.event.release.upload_url }} 57 | asset_path: dist/main-${{ matrix.os }}.exe 58 | asset_name: main-${{ matrix.os }}.exe 59 | asset_content_type: application/octet-stream 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | game_info_cache.json 131 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import sys 4 | 5 | from ngl.client import NotionGameList 6 | from ngl.errors import ServiceError 7 | from ngl.games.steam import SteamGamesLibrary 8 | from ngl.utils import echo, color, soft_exit 9 | 10 | 11 | # ----------- Variables ----------- 12 | NOTION_TOKEN = os.getenv("NOTION_TOKEN") # Notion cookies 'token_v2' 13 | STEAM_TOKEN = os.getenv("STEAM_TOKEN") # https://steamcommunity.com/dev/apikey 14 | STEAM_USER = os.getenv("STEAM_USER") # http://steamcommunity.com/id/{STEAM_USER} 15 | DEBUG = os.getenv("DEBUG", "0") == "1" 16 | # --------------------------------- 17 | 18 | try: 19 | parser = argparse.ArgumentParser() 20 | parser.add_argument("--steam-user", help="Steam user id. http://steamcommunity.com/id/{STEAM_USER}") 21 | parser.add_argument("--store-bg-cover", help="Use steam store background as a game cover", action="store_true") 22 | parser.add_argument("--skip-non-steam", help="Do not import games that are no longer on Steam store", action="store_true") 23 | parser.add_argument("--use-only-library", help="Do not use steam store to fetch game info, fetch everything from library", action="store_true") 24 | parser.add_argument("--skip-free-steam", help="Do not import free2play games", action="store_true") 25 | parser.add_argument("--steam-no-cache", help="Do not use cached fetched games", action="store_true") 26 | args = parser.parse_args() 27 | 28 | assert not (args.skip_non_steam and args.use_only_library), "You can't use --skip-non-steam and --use-only-library together" 29 | 30 | STEAM_USER = args.steam_user or STEAM_USER 31 | 32 | echo.y("Logging into Notion...") 33 | ngl = NotionGameList.login(token_v2=NOTION_TOKEN) 34 | echo.g("Logged into Notion!") 35 | echo.y("Logging into Steam...") 36 | steam = SteamGamesLibrary.login(api_key=STEAM_TOKEN, user_id=STEAM_USER) 37 | echo.g("Logged into Steam!") 38 | 39 | echo.y("Getting Steam library games...") 40 | game_list = sorted( 41 | [ 42 | steam.get_game_info(id_) for id_ in steam.get_games_list( 43 | skip_non_steam=args.skip_non_steam, 44 | skip_free_games=args.skip_free_steam, 45 | library_only=args.use_only_library, 46 | no_cache=args.steam_no_cache, 47 | ) 48 | ], key=lambda x: x.name, 49 | ) 50 | if not game_list: 51 | raise ServiceError(msg="no steam games found") 52 | 53 | echo.m(" " * 100 + f"\rGot {len(game_list)} games!") 54 | 55 | echo.y("Creating Notion template page...") 56 | game_page = ngl.create_game_page() 57 | echo.g("Created!") 58 | echo.y("Importing steam library games to Notion...") 59 | errors = ngl.import_game_list(game_list, game_page, use_bg_as_cover=args.store_bg_cover) 60 | imported = len(game_list) - len(errors) 61 | 62 | if imported == 0: 63 | raise ServiceError(msg="no games were imported to Notion") 64 | 65 | if errors: 66 | echo.r("Not imported games: ") 67 | for error in sorted(errors, key=lambda x: x.name): 68 | echo.r(f"- {error.name}") 69 | echo.g(f"Imported: {imported}/{len(game_list)}\n") 70 | 71 | except ServiceError as err: 72 | echo(err) 73 | if DEBUG: 74 | raise err 75 | soft_exit(1) 76 | except (Exception, KeyboardInterrupt) as err: 77 | echo(f"\n{err.__class__.__name__}: {err}", file=sys.stderr) 78 | if DEBUG: 79 | raise err 80 | soft_exit(1) 81 | 82 | echo.m("Completed!") 83 | soft_exit(0) 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notion Game List ![](https://img.shields.io/badge/version-0.1.1-blue) ![](https://app.travis-ci.com/solesensei/notion-game-list.svg?branch=master) [![discuss](https://img.shields.io/reddit/subreddit-subscribers/notion?label=Discuss%20r%2Fnotion-games-list&style=social)](https://www.reddit.com/r/Notion/comments/jiy1sb/notion_games_list/?utm_source=share&utm_medium=web2x&context=3) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 2 | 3 | 4 | All your games inside [Notion](https://www.notion.so/solesensei/Notion-Game-List-generated-0d0d39993755415bb8812563a2781d84). 5 | 6 | ![notion-x-steam](https://user-images.githubusercontent.com/24857057/87418150-eb088280-c5d9-11ea-87b1-ab77979a1b25.png) 7 | 8 | ## Requirements 9 | 10 | Python 3.6+ 11 | 12 | ```bash 13 | # Clone with submodules 14 | git clone --recurse-submodules git@github.com:solesensei/notion-game-list.git 15 | 16 | # Create virtual environment 17 | python -m venv notion-game-list-venv && source notion-game-list-venv/bin/activate 18 | 19 | # Install requirements 20 | pip install -r requirements.txt -U 21 | ``` 22 | 23 | ## How it works 24 | 25 | The tool uses 2 Web API clients for Steam and Notion. 26 | 27 | ### Steam 28 | 29 | I used [Web SteamAPI client](https://github.com/smiley/steamapi) written by [@smiley](https://github.com/smiley). 30 | 31 | **Authentification:** 32 | - [Get APIKey](https://steamcommunity.com/dev/apikey) 33 | - Add to environment variable `STEAM_TOKEN` (optional) 34 | - Add your `steamcommunity.com/id/{user_id}` to `STEAM_USER` (optional) 35 | 36 | ### Notion 37 | 38 | For notion i used [notion-py client](https://github.com/jamalex/notion-py) written by [@jamalex](https://github.com/jamalex). 39 | 40 | **Authentification:** 41 | 42 | - Login to [notion.so](https://notion.so) with your regular email and password 43 | - Open browser cookies and copy `token_v2` 44 | click to open 45 | 46 | - Pass `token_v2` to system environment variable `NOTION_TOKEN` (optional) 47 | 48 | ## Usage 49 | 50 | Check [releases](https://github.com/solesensei/notion-game-list/releases/latest) and get binary tool for os you run, or you can use pure python. 51 | 52 | ```bash 53 | python main.py -h # help 54 | 55 | python main.py --steam-user solesensei # run for steam user_id = solesensei 56 | 57 | python main.py --store-bg-cover --skip-non-steam # use store steam background as cover and skip games that are no longer in store 58 | 59 | python main.py --skip-free-steam # import all games except of free2play 60 | 61 | python main.py --steam-no-cache # do not use game_info_cache.json, you can also remove the file 62 | ``` 63 | 64 | [![notion-example](https://user-images.githubusercontent.com/24857057/87416955-21450280-c5d8-11ea-976e-3242bc61ec49.png)](https://www.notion.so/solesensei/Notion-Game-List-generated-0d0d39993755415bb8812563a2781d84) 65 | 66 | _[result here](https://www.notion.so/solesensei/Notion-Game-List-generated-0d0d39993755415bb8812563a2781d84)_ 67 | 68 | _feel free to contribute and create issues_ 69 | 70 | Buy Me A Coffee 71 | 72 | ## Plans 73 | 74 | - rewrite on official [notion api](https://developers.notion.com/) 75 | - connect to existing page 76 | - update existing page values, do not recreate databases 77 | - add options for setting status 78 | - add options for importing specific games 79 | - options for disabling/enabling icons 80 | - parse recent games 81 | - login to notion with password 82 | - add proxy for unlimited requests to Steam Store Web Api (limit: 200 games per 5 minutes) 83 | - ~add release date~ done in v0.0.3 84 | - ~load game covers with better resolution (game DB, steamstore?)~ done in v0.0.2 85 | -------------------------------------------------------------------------------- /ngl/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | import time 5 | from functools import wraps 6 | 7 | from termcolor import colored 8 | 9 | if sys.platform == "win32": 10 | import colorama 11 | os.system('color') 12 | colorama.init() 13 | 14 | 15 | class ColorText: 16 | 17 | @staticmethod 18 | def r(msg): 19 | """ Returns red message """ 20 | return colored(msg, "red") 21 | 22 | @staticmethod 23 | def g(msg): 24 | """ Returns green message """ 25 | return colored(msg, "green") 26 | 27 | @staticmethod 28 | def y(msg): 29 | """ Returns yellow message """ 30 | return colored(msg, "yellow") 31 | 32 | @staticmethod 33 | def c(msg): 34 | """ Returns cyan message """ 35 | return colored(msg, "cyan") 36 | 37 | @staticmethod 38 | def m(msg): 39 | """ Returns magenta message """ 40 | return colored(msg, "magenta") 41 | 42 | 43 | class Echo: 44 | 45 | @staticmethod 46 | def _colored(msg, color): 47 | return colored(msg, color) 48 | 49 | @staticmethod 50 | def _color_print(msg, color, **kwargs): 51 | print(Echo._colored(msg, color=color), **kwargs) 52 | sys.stdout.flush() 53 | 54 | @staticmethod 55 | def r(msg, **kwargs): 56 | """ Print red color message """ 57 | Echo._color_print(msg, "red", **kwargs) 58 | 59 | @staticmethod 60 | def g(msg, **kwargs): 61 | """ Print green color message """ 62 | Echo._color_print(msg, "green", **kwargs) 63 | 64 | @staticmethod 65 | def y(msg, **kwargs): 66 | """ Print yellow color message """ 67 | Echo._color_print(msg, "yellow", **kwargs) 68 | 69 | @staticmethod 70 | def c(msg, **kwargs): 71 | """ Print cyan color message """ 72 | Echo._color_print(msg, "cyan", **kwargs) 73 | 74 | @staticmethod 75 | def m(msg, **kwargs): 76 | """ Print magenta color message """ 77 | Echo._color_print(msg, "magenta", **kwargs) 78 | 79 | def __call__(self, *args, **kwargs): 80 | print(*args, **kwargs) 81 | sys.stdout.flush() 82 | 83 | 84 | echo = Echo() 85 | color = ColorText() 86 | 87 | 88 | def soft_exit(exit_code): 89 | if sys.platform == "win32": 90 | input(color.y("\nEnter any key to exit")) 91 | sys.exit(exit_code) 92 | 93 | 94 | def load_from_file(filename): 95 | if not os.path.exists(filename): 96 | return {} 97 | with open(filename, 'r') as f: 98 | return json.load(f) 99 | 100 | 101 | def dump_to_file(d, filename): 102 | with open(filename, 'w') as f: 103 | json.dump(d, f) 104 | 105 | 106 | def retry(exceptions, on_code=None, retry_num=3, initial_wait=0.5, backoff=2, raise_on_error=True, debug_msg=None, debug=False): 107 | def decorator(f): 108 | @wraps(f) 109 | def wrapper(*args, **kwargs): 110 | _tries, _delay = retry_num + 1, initial_wait 111 | while _tries > 1: 112 | try: 113 | return f(*args, **kwargs) 114 | except exceptions as e: 115 | if on_code is not None and on_code != e.code: 116 | if raise_on_error: 117 | raise 118 | return None 119 | _tries -= 1 120 | if _tries == 1: 121 | if raise_on_error: 122 | raise 123 | return None 124 | _delay *= backoff 125 | if debug: 126 | print_args = args if args else "" 127 | msg = str( 128 | f"Function: {f.__name__} args: {print_args}, kwargs: {kwargs}\n" 129 | f"Exception: {e}\n" 130 | ) if debug_msg is None else color.m(debug_msg) 131 | echo.m("\n" + msg) 132 | for s in range(_delay, 1, -1): 133 | echo.m(" " * 20 + f"\rWait for {s} seconds!", end="\r") 134 | time.sleep(1) 135 | else: 136 | time.sleep(_delay) 137 | return wrapper 138 | return decorator 139 | -------------------------------------------------------------------------------- /ngl/games/steam.py: -------------------------------------------------------------------------------- 1 | import re 2 | import typing as tp 3 | 4 | import requests 5 | 6 | from ngl.api.steam import steamapi 7 | from ngl.core import is_valid_link 8 | from ngl.errors import SteamApiError, SteamApiNotFoundError, SteamStoreApiError 9 | from ngl.models.steam import SteamStoreApp 10 | from ngl.utils import color, echo, retry, dump_to_file, load_from_file 11 | 12 | from .base import GameInfo, GamesLibrary, TGameID 13 | 14 | 15 | TSteamUserID = tp.Union[str, int] 16 | TSteamApiKey = str 17 | 18 | PLATFORM = "steam" 19 | 20 | 21 | class SteamStoreApi: 22 | API_HOST = "https://store.steampowered.com/api/appdetails?appids={}" 23 | 24 | def __init__(self): 25 | self.session = requests.Session() 26 | self._cache = {} 27 | 28 | @retry(SteamStoreApiError, retry_num=2, initial_wait=90, backoff=1, raise_on_error=False, debug_msg="Limit StoreSteamAPI requests exceeded", debug=True) 29 | def get_game_info(self, game_id: TGameID) -> tp.Optional[SteamStoreApp]: 30 | game_id = str(game_id) 31 | if game_id in self._cache: 32 | return self._cache[game_id] 33 | try: 34 | r = self.session.get(self.API_HOST.format(game_id), timeout=3) 35 | if not r.ok: 36 | raise SteamStoreApiError(f"can't get {r.url}, code: {r.status_code}, text: {r.text}") 37 | 38 | response_body = r.json()[str(game_id)] 39 | if not response_body["success"]: 40 | raise SteamApiNotFoundError(f"Game {game_id} unsuccessfull request") 41 | 42 | self._cache[game_id] = SteamStoreApp.load(response_body["data"]) 43 | return self._cache[game_id] 44 | except (SteamApiNotFoundError, SteamStoreApiError): 45 | raise 46 | except Exception as e: 47 | raise SteamApiError(error=e) 48 | 49 | 50 | class SteamGamesLibrary(GamesLibrary): 51 | IMAGE_HOST = "http://media.steampowered.com/steamcommunity/public/images/apps/" 52 | CACHE_GAME_FILE = "game_info_cache.json" 53 | 54 | def __init__(self, api_key: TSteamApiKey, user_id: TSteamUserID): 55 | self.api = self._get_api(api_key) 56 | self.store = SteamStoreApi() 57 | self.user = self._get_user(user_id) 58 | self._games = {} 59 | self._store_skipped = [] 60 | 61 | def _get_api(self, api_key: TSteamApiKey): 62 | try: 63 | return steamapi.core.APIConnection(api_key=api_key, validate_key=True) 64 | except Exception as e: 65 | raise SteamApiError(error=e) 66 | 67 | def _get_user(self, user_id: TSteamUserID): 68 | try: 69 | return steamapi.user.SteamUser(user_id) if isinstance(user_id, int) else steamapi.user.SteamUser(userurl=user_id) 70 | except steamapi.errors.UserNotFoundError: 71 | raise SteamApiError(msg=f"User {user_id} not found") 72 | except Exception as e: 73 | raise SteamApiError(error=e) 74 | 75 | @classmethod 76 | def login(cls, api_key: tp.Optional[TSteamApiKey] = None, user_id: tp.Optional[TSteamUserID] = None): 77 | # TODO: parse library from profile url ? 78 | if api_key is None: 79 | echo(color.y("Get steam token from: ") + "https://steamcommunity.com/dev/apikey") 80 | api_key = input(color.c("Token: ")).strip() 81 | if user_id is None: 82 | echo.y("Pass steam user profile id.") 83 | user_id = input(color.c("User: http://steamcommunity.com/id/")).strip() 84 | user_id = re.sub(r"^https?:\/\/steamcommunity\.com\/id\/", "", user_id) 85 | return cls(api_key=api_key, user_id=user_id) 86 | 87 | def _image_link(self, game_id: TGameID, img_hash: str): 88 | return self.IMAGE_HOST + f"{game_id}/{img_hash}.jpg" 89 | 90 | def _get_bg_image(self, game_id: TGameID) -> tp.Optional[str]: 91 | _bg_img = "https://steamcdn-a.akamaihd.net/steam/apps/{game_id}/{bg}.jpg" 92 | bg_link = _bg_img.format(game_id=game_id, bg="page.bg") 93 | if is_valid_link(bg_link): 94 | return bg_link 95 | bg_link = _bg_img.format(game_id=game_id, bg="page_bg_generated") 96 | if is_valid_link(bg_link): 97 | return bg_link 98 | return None 99 | 100 | @staticmethod 101 | def _playtime_format(playtime_in_minutes: int) -> str: 102 | if playtime_in_minutes == 0: 103 | return "never" 104 | if playtime_in_minutes < 120: 105 | return f"{playtime_in_minutes} minutes" 106 | return f"{playtime_in_minutes // 60} hours" 107 | 108 | def _cache_game(self, game_info: GameInfo): 109 | g = load_from_file(self.CACHE_GAME_FILE) 110 | g[str(game_info.id)] = game_info.to_dict() 111 | dump_to_file(g, self.CACHE_GAME_FILE) 112 | 113 | def _load_cached_games(self, skip_free_games: bool = False): 114 | g = load_from_file(self.CACHE_GAME_FILE) 115 | for id_, game_dict in g.items(): 116 | game_info = GameInfo(**game_dict) 117 | if skip_free_games and game_info.free: 118 | continue 119 | self._games[id_] = game_info 120 | 121 | def _fetch_library_games(self, skip_non_steam: bool = False, skip_free_games: bool = False, library_only: bool = False, no_cache: bool = False, force: bool = False): 122 | if force or not self._games: 123 | if not no_cache: 124 | self._load_cached_games(skip_free_games=skip_free_games) 125 | try: 126 | number_of_games = len(self.user.games) 127 | for i, g in enumerate(sorted(self.user.games, key=lambda x: x.name)): 128 | game_id = str(g.id) 129 | if game_id in self._games: 130 | continue 131 | echo.c(" " * 100 + f"\rFetching [{i}/{number_of_games}]: {g.name}", end="\r") 132 | steam_game = None 133 | if not library_only: 134 | # Fetch game info from steam store 135 | try: 136 | steam_game = self.store.get_game_info(game_id) 137 | except SteamApiNotFoundError: 138 | pass 139 | 140 | if steam_game is None and skip_non_steam: 141 | echo.m(f"Game {g.name} id:{game_id} not found in Steam store, skip it") 142 | self._store_skipped.append(game_id) 143 | continue 144 | 145 | if steam_game is None: 146 | echo.r(f"Game {g.name} id:{game_id} not found in Steam store, fetching details from library") 147 | 148 | logo_uri = None 149 | if steam_game is not None and steam_game.header_image: 150 | logo_uri = steam_game.header_image 151 | elif getattr(g, "img_logo_url", None): 152 | logo_uri = self._image_link(game_id, g.img_logo_url) 153 | 154 | game_info = GameInfo( 155 | id=game_id, 156 | name=g.name, 157 | platforms=[PLATFORM], 158 | release_date=steam_game.release_date.date if steam_game is not None else None, 159 | playtime=self._playtime_format(g.playtime_forever) if getattr(g, "playtime_forever", None) is not None else None, 160 | playtime_minutes=g.playtime_forever if getattr(g, "playtime_forever", None) is not None else None, 161 | logo_uri=logo_uri, 162 | bg_uri=self._get_bg_image(game_id), 163 | icon_uri=self._image_link(game_id, g.img_icon_url) if getattr(g, "img_icon_url", None) is not None else None, 164 | free=steam_game.is_free if steam_game is not None else None, 165 | ) 166 | if steam_game is not None: 167 | self._cache_game(game_info) 168 | if skip_free_games and game_info.free: 169 | continue 170 | self._games[game_id] = game_info 171 | except Exception as e: 172 | raise SteamApiError(error=e) 173 | 174 | def get_games_list(self, **kwargs) -> tp.List[TGameID]: 175 | """ Get game ids from library """ 176 | self._fetch_library_games(**kwargs) 177 | return list(self._games) 178 | 179 | def get_game_info(self, game_id: TGameID, **kwargs) -> GameInfo: 180 | """ Get game info by id """ 181 | self._fetch_library_games(**kwargs) 182 | 183 | if game_id not in self._games: 184 | raise SteamApiError(msg=f"Game with id {game_id} not found") 185 | 186 | return self._games[game_id] 187 | -------------------------------------------------------------------------------- /ngl/models/steam.py: -------------------------------------------------------------------------------- 1 | import typing as tp 2 | 3 | from ngl.models.base import BaseModel 4 | 5 | 6 | class SteamStoreAppPriceOverview(BaseModel): 7 | 8 | def __init__( 9 | self, 10 | currency: str, 11 | initial: int, 12 | final: int, 13 | discount_percent: int, 14 | initial_formatted: str, 15 | final_formatted: str, 16 | **kwargs 17 | ): 18 | self.currency = currency 19 | self.initial = initial 20 | self.final = final 21 | self.discount_percent = discount_percent 22 | self.initial_formatted = initial_formatted 23 | self.final_formatted = final_formatted 24 | 25 | 26 | class SteamStoreAppPackageGroupSub(BaseModel): 27 | 28 | def __init__( 29 | self, 30 | packageid: int, 31 | percent_savings_text: str, 32 | percent_savings: int, 33 | option_text: str, 34 | option_description: str, 35 | can_get_free_license: str, 36 | is_free_license: bool, 37 | price_in_cents_with_discount: int, 38 | **kwargs 39 | ): 40 | self.packageid = packageid 41 | self.percent_savings_text = percent_savings_text 42 | self.percent_savings = percent_savings 43 | self.option_text = option_text 44 | self.option_description = option_description 45 | self.can_get_free_license = can_get_free_license 46 | self.is_free_license = is_free_license 47 | self.price_in_cents_with_discount = price_in_cents_with_discount 48 | 49 | 50 | class SteamStoreAppPackageGroup(BaseModel): 51 | 52 | def __init__( 53 | self, 54 | name: str, 55 | title: str, 56 | description: str, 57 | selection_text: str, 58 | save_text: str, 59 | display_type: int, 60 | is_recurring_subscription: str, 61 | subs: tp.List[SteamStoreAppPackageGroupSub] 62 | ): 63 | self.name = name 64 | self.title = title 65 | self.description = description 66 | self.selection_text = selection_text 67 | self.save_text = save_text 68 | self.display_type = display_type 69 | self.is_recurring_subscription = is_recurring_subscription 70 | self.subs = subs 71 | 72 | @classmethod 73 | def load(cls, d: tp.Optional[dict]): 74 | if d is None: 75 | return None 76 | d["subs"] = [SteamStoreAppPackageGroupSub.load(t) for t in d["subs"]] 77 | return cls(**d) 78 | 79 | 80 | class SteamStoreAppCategory(BaseModel): 81 | 82 | def __init__(self, id: int, description: str, **kwargs): 83 | self.id = id 84 | self.description = description 85 | 86 | 87 | class SteamStoreAppGenre(BaseModel): 88 | 89 | def __init__(self, id: str, description: str, **kwargs): 90 | self.id = id 91 | self.description = description 92 | 93 | 94 | class SteamStoreAppScreenshot(BaseModel): 95 | 96 | def __init__(self, id: int, path_thumbnail: str, path_full: str, **kwargs): 97 | self.id = id 98 | self.path_thumbnail = path_thumbnail 99 | self.path_full = path_full 100 | 101 | 102 | class SteamStoreAppMovie(BaseModel): 103 | 104 | def __init__( 105 | self, 106 | id: int, 107 | name: str, 108 | thumbnail: str, 109 | webm: tp.Dict[str, str], 110 | mp4: tp.Dict[str, str], 111 | highlight: bool, 112 | **kwargs 113 | ): 114 | self.id = id 115 | self.name = name 116 | self.thumbnail = thumbnail 117 | self.webm = webm 118 | self.mp4 = mp4 119 | self.highlight = highlight 120 | 121 | 122 | class SteamStoreAppMetacriticScore(BaseModel): 123 | 124 | def __init__(self, score: int, url: tp.Optional[str] = None, **kwargs): 125 | self.score = score 126 | self.url = url 127 | 128 | 129 | class SteamStoreAppAchievementHighlighted(BaseModel): 130 | 131 | def __init__(self, name: str, path: str, **kwargs): 132 | self.name = name 133 | self.path = path 134 | 135 | 136 | class SteamStoreAppAchievements(BaseModel): 137 | 138 | def __init__(self, total: int, highlighted: tp.List[SteamStoreAppAchievementHighlighted], **kwargs): 139 | self.total = total 140 | self.highlighted = highlighted 141 | 142 | @classmethod 143 | def load(cls, d: tp.Optional[dict]): 144 | if d is None: 145 | return None 146 | d["highlighted"] = [SteamStoreAppAchievementHighlighted.load(t) for t in d.get("highlighted", [])] 147 | return cls(**d) 148 | 149 | 150 | class SteamStoreAppReleaseDate(BaseModel): 151 | 152 | def __init__(self, coming_soon: bool, date: str, **kwargs): 153 | self.coming_soon = coming_soon 154 | self.date = date if date else None 155 | 156 | 157 | class SteamStoreAppSupportInfo(BaseModel): 158 | 159 | def __init__(self, url: str, email: str, **kwargs): 160 | self.url = url 161 | self.email = email 162 | 163 | 164 | class SteamStoreAppContentDescriptors(BaseModel): 165 | 166 | def __init__(self, ids: tp.List[int], notes: str, **kwargs): 167 | self.ids = ids 168 | self.notes = notes 169 | 170 | 171 | class SteamStoreApp(BaseModel): 172 | 173 | def __init__( 174 | self, 175 | type: str, 176 | name: str, 177 | steam_appid: int, 178 | required_age: tp.Union[str, int], 179 | is_free: bool, 180 | detailed_description: str, 181 | about_the_game: str, 182 | short_description: str, 183 | header_image: str, 184 | publishers: tp.List[str], 185 | platforms: tp.Dict[str, bool], 186 | release_date: SteamStoreAppReleaseDate, 187 | support_info: SteamStoreAppSupportInfo, 188 | package_groups: tp.List[SteamStoreAppPackageGroup], 189 | background: str, 190 | content_descriptors: tp.Optional[SteamStoreAppContentDescriptors] = None, 191 | screenshots: tp.Optional[tp.List[SteamStoreAppScreenshot]] = None, 192 | categories: tp.Optional[tp.List[SteamStoreAppCategory]] = None, 193 | developers: tp.Optional[tp.List[str]] = None, 194 | supported_languages: tp.Optional[str] = None, 195 | recommendations: tp.Optional[tp.Dict[str, int]] = None, 196 | genres: tp.Optional[tp.List[SteamStoreAppGenre]] = None, 197 | packages: tp.Optional[tp.List[int]] = None, 198 | achievements: tp.Optional[SteamStoreAppAchievements] = None, 199 | metacritic: tp.Optional[SteamStoreAppMetacriticScore] = None, 200 | movies: tp.Optional[tp.List[SteamStoreAppMovie]] = None, 201 | price_overview: tp.Optional[SteamStoreAppPriceOverview] = None, 202 | reviews: tp.Optional[str] = None, 203 | legal_notice: tp.Optional[str] = None, 204 | demos: tp.Optional[tp.List[dict]] = None, 205 | dlc: tp.Optional[tp.List[int]] = None, 206 | website: tp.Optional[str] = None, 207 | pc_requirements: tp.Dict[str, str] = None, 208 | mac_requirements: tp.Dict[str, str] = None, 209 | linux_requirements: tp.Dict[str, str] = None, 210 | **kwargs, 211 | ): 212 | self.type = type 213 | self.name = name 214 | self.steam_appid = steam_appid 215 | self.required_age = required_age 216 | self.is_free = is_free 217 | self.detailed_description = detailed_description 218 | self.about_the_game = about_the_game 219 | self.short_description = short_description 220 | self.supported_languages = supported_languages 221 | self.header_image = header_image 222 | self.developers = developers 223 | self.publishers = publishers 224 | self.packages = packages 225 | self.platforms = platforms 226 | self.metacritic = metacritic 227 | self.categories = categories 228 | self.genres = genres 229 | self.screenshots = screenshots 230 | self.movies = movies 231 | self.recommendations = recommendations 232 | self.achievements = achievements 233 | self.release_date = release_date 234 | self.support_info = support_info 235 | self.package_groups = package_groups 236 | self.background = background 237 | self.content_descriptors = content_descriptors 238 | self.price_overview = price_overview 239 | self.reviews = reviews 240 | self.legal_notice = legal_notice 241 | self.demos = demos 242 | self.dlc = dlc 243 | self.website = website 244 | self.pc_requirements = pc_requirements 245 | self.mac_requirements = mac_requirements 246 | self.linux_requirements = linux_requirements 247 | 248 | @classmethod 249 | def load(cls, d: tp.Optional[dict]): 250 | if d is None: 251 | return None 252 | d["release_date"] = SteamStoreAppReleaseDate.load(d["release_date"]) 253 | d["support_info"] = SteamStoreAppSupportInfo.load(d["support_info"]) 254 | d["package_groups"] = [SteamStoreAppPackageGroup.load(t) for t in d["package_groups"]] 255 | d["content_descriptors"] = SteamStoreAppContentDescriptors.load(d.get("content_descriptors")) 256 | d["screenshots"] = [SteamStoreAppScreenshot.load(t) for t in d.get("screenshots", [])] 257 | d["categories"] = [SteamStoreAppCategory.load(t) for t in d.get("categories", [])] 258 | d["genres"] = [SteamStoreAppGenre.load(t) for t in d.get("genres", [])] 259 | d["achievements"] = SteamStoreAppAchievements.load(d.get("achievements")) 260 | d["metacritic"] = SteamStoreAppMetacriticScore.load(d.get("metacritic")) 261 | d["movies"] = [SteamStoreAppMovie.load(t) for t in d.get("movies", [])] 262 | d["price_overview"] = SteamStoreAppPriceOverview.load(d.get("price_overview")) 263 | return cls(**d) 264 | -------------------------------------------------------------------------------- /ngl/client.py: -------------------------------------------------------------------------------- 1 | import typing as tp 2 | from datetime import datetime 3 | 4 | from notion.block import CollectionViewPageBlock, DividerBlock, CalloutBlock 5 | from notion.client import NotionClient 6 | from notion.collection import Collection, CollectionRowBlock 7 | from notion.operations import build_operation 8 | 9 | from ngl.errors import ServiceError 10 | from ngl.games.base import GameInfo 11 | 12 | from .utils import echo, color 13 | 14 | 15 | class NotionGameList: 16 | PAGE_COVER = "https://images.unsplash.com/photo-1559984430-c12e199879b6?ixlib=rb-1.2.1&q=85&fm=jpg&crop=entropy&cs=srgb&ixid=eyJhcHBfaWQiOjYzOTIxfQ" 17 | PAGE_ICON = "🎮" 18 | 19 | def __init__(self, token_v2): 20 | self.client = NotionClient(token_v2=token_v2) 21 | self._gl_icon = "👾" 22 | 23 | @classmethod 24 | def login(cls, token_v2=None): 25 | # TODO: add log in by email/password 26 | if token_v2 is None: 27 | echo(color.y("Log in to Notion: ") + "https://notion.so/login") 28 | echo("Get 'token_v2' from browser cookies") 29 | token_v2 = input(color.c("Token: ")).strip() 30 | return cls(token_v2=token_v2) 31 | 32 | def create_game_page(self, title: str = "Notion Game List", description: str = "My game list"): 33 | page = self.client.current_space.add_page(title + " [generated]") 34 | callout = page.children.add_new(CalloutBlock) 35 | callout.title = "All your games inside Notion\n\n**Github:** [https://github.com/solesensei/notion-game-list](https://github.com/solesensei/notion-game-list)" 36 | page.children.add_new(DividerBlock) 37 | v = page.children.add_new(CollectionViewPageBlock) 38 | v.collection = self.client.get_collection(self.client.create_record("collection", parent=v, schema=self._game_list_schema())) 39 | v.title = title 40 | v.description = description 41 | table = v.views.add_new(view_type="table") 42 | gallery = v.views.add_new(view_type="gallery") 43 | calendar = v.views.add_new(view_type="calendar") 44 | board = v.views.add_new(view_type="board") 45 | table.name = "List" 46 | gallery.name = "Gallery" 47 | calendar.name = "Calendar" 48 | board.name = "Board" 49 | with self.client.as_atomic_transaction(): 50 | # Main Page: callout icon 💡 51 | self.client.submit_transaction( 52 | build_operation(callout.id, path=["format", "page_icon"], command="set", args="💡", table="block") 53 | ) 54 | # Main Page: callout background 55 | self.client.submit_transaction( 56 | build_operation(callout.id, path=["format"], command="update", args=dict(block_color="brown_background"), table="block") 57 | ) 58 | # Main Page: set cover image 59 | self.client.submit_transaction( 60 | build_operation(page.id, path=["format", "page_cover"], command="set", args=self.PAGE_COVER, table="block") 61 | ) 62 | # Main Page: set icon 🎮 63 | self.client.submit_transaction( 64 | build_operation(page.id, path=["format", "page_icon"], command="set", args=self.PAGE_ICON, table="block") 65 | ) 66 | with self.client.as_atomic_transaction(): 67 | # Game List Page: set icon 👾 68 | self.client.submit_transaction( 69 | build_operation(v.collection.id, path=["icon"], command="set", args=self._gl_icon, table="collection") 70 | ) 71 | # Table: format columns 72 | self.client.submit_transaction( 73 | build_operation(table.id, path=[], command="update", args=self._properites_format(), table="collection_view") 74 | ) 75 | # Board: group by status 76 | self.client.submit_transaction( 77 | build_operation(board.id, path=[], command="update", args=dict(query2={"group_by": "status"}), table="collection_view") 78 | ) 79 | # Gallery: cover image 80 | self.client.submit_transaction( 81 | build_operation(gallery.id, path=[], command="update", args=self._gallery_format(), table="collection_view") 82 | ) 83 | return v 84 | 85 | def connect_page(self, url): 86 | pass 87 | 88 | def _add_row(self, collection: Collection, **row_data) -> CollectionRowBlock: 89 | return collection.add_row(**row_data) 90 | 91 | @staticmethod 92 | def _parse_date(game: GameInfo): 93 | date_str = game.release_date 94 | if not date_str: 95 | return None 96 | check_date_formats = (r"%d %b, %Y", r"%b %d, %Y", r"%b %Y", r"%d %b %Y", r"%b %d %Y", r"%Y") 97 | for fmt in check_date_formats: 98 | try: 99 | return datetime.strptime(date_str, fmt).date() 100 | except ValueError: 101 | pass 102 | echo.r( 103 | "\nGame '{}:{}' | Release Date: '{}' does not match any of formats '{}' | skip".format( 104 | game.name, game.id, date_str, "', '".join(check_date_formats) 105 | ) 106 | ) 107 | return None 108 | 109 | def add_game(self, game: GameInfo, game_page: CollectionViewPageBlock, use_bg_as_cover: bool = False) -> bool: 110 | row_data = {"title": game.name, "platforms": game.platforms, "release_date": self._parse_date(game), "notes": f"Playtime: {game.playtime}", "playtime": game.playtime_minutes} 111 | row = self._add_row(game_page.collection, **row_data) 112 | row.icon = game.icon_uri or self._gl_icon 113 | with self.client.as_atomic_transaction(): 114 | # Game cover image 115 | cover_img_uri = game.bg_uri or game.logo_uri if use_bg_as_cover else game.logo_uri 116 | if cover_img_uri: 117 | self.client.submit_transaction( 118 | build_operation(row.id, path=["format", "page_cover"], command="set", args=cover_img_uri, table="block") 119 | ) 120 | else: 121 | echo.y(f"Game '{game.name}:{game.id}' does not have cover image") 122 | return True 123 | 124 | def import_game_list(self, game_list: tp.List[GameInfo], game_page: CollectionViewPageBlock, **kwargs) -> tp.List[GameInfo]: 125 | errors = [] 126 | for i, game in enumerate(game_list, start=1): 127 | echo.c(f"Imported: {i}/{len(game_list)}", end="\r") 128 | if not self.add_game(game, game_page, **kwargs): 129 | errors.append(game) 130 | return errors 131 | 132 | @staticmethod 133 | def _gallery_format(): 134 | return { 135 | "format": { 136 | "gallery_cover": { 137 | "type": "page_cover" 138 | }, 139 | "gallery_properties": [ 140 | { 141 | "property": "title", 142 | "visible": True 143 | }, 144 | { 145 | "property": "notes", 146 | "visible": False 147 | }, 148 | { 149 | "property": "platforms", 150 | "visible": True 151 | }, 152 | { 153 | "property": "score", 154 | "visible": False 155 | }, 156 | { 157 | "property": "status", 158 | "visible": True 159 | }, 160 | { 161 | "property": "time", 162 | "visible": False 163 | } 164 | ] 165 | } 166 | } 167 | 168 | @staticmethod 169 | def _game_list_schema(): 170 | return { 171 | "title": {"name": "Title", "type": "title"}, 172 | "status": { 173 | "name": "Status", 174 | "type": "select", 175 | "options": [ 176 | { 177 | "color": "green", 178 | "value": "Completed" 179 | }, 180 | { 181 | "color": "yellow", 182 | "value": "Playing" 183 | }, 184 | { 185 | "color": "blue", 186 | "value": "Planned" 187 | }, 188 | { 189 | "color": "gray", 190 | "value": "Stalled" 191 | }, 192 | { 193 | "color": "red", 194 | "value": "Dropped" 195 | }, 196 | ] 197 | }, 198 | "score": { 199 | "name": "Score", 200 | "type": "select", 201 | "options": [ 202 | { 203 | "color": "green", 204 | "value": "10", 205 | }, 206 | { 207 | "color": "orange", 208 | "value": "9", 209 | }, 210 | { 211 | "color": "yellow", 212 | "value": "8", 213 | }, 214 | { 215 | "color": "blue", 216 | "value": "7", 217 | }, 218 | { 219 | "color": "purple", 220 | "value": "6", 221 | }, 222 | { 223 | "color": "brown", 224 | "value": "5", 225 | }, 226 | { 227 | "color": "pink", 228 | "value": "4", 229 | }, 230 | { 231 | "color": "orange", 232 | "value": "3", 233 | }, 234 | { 235 | "color": "gray", 236 | "value": "2", 237 | }, 238 | { 239 | "color": "red", 240 | "value": "1", 241 | } 242 | ], 243 | }, 244 | "platforms": { 245 | "name": "Platforms", 246 | "type": "multi_select", 247 | "options": [ 248 | { 249 | "color": "gray", 250 | "value": "Steam", 251 | }, 252 | { 253 | "color": "default", 254 | "value": "PC", 255 | }, 256 | { 257 | "color": "red", 258 | "value": "Switch", 259 | }, 260 | { 261 | "color": "blue", 262 | "value": "PlayStation", 263 | }, 264 | { 265 | "color": "green", 266 | "value": "Xbox", 267 | }, 268 | ], 269 | }, 270 | "notes": {"name": "Notes", "type": "text"}, 271 | "time": {"name": "Time", "type": "date"}, 272 | "release_date": {"name": "Release Date", "type": "date"}, 273 | "playtime": {"name": "playtime", "type": "number"}, 274 | } 275 | 276 | @staticmethod 277 | def _properites_format(): 278 | return { 279 | "format": { 280 | "table_properties": [ 281 | { 282 | "property": "title", 283 | "visible": True, 284 | "width": 280 285 | }, 286 | { 287 | "property": "status", 288 | "visible": True, 289 | "width": 100 290 | }, 291 | { 292 | "property": "score", 293 | "visible": True, 294 | "width": 100 295 | }, 296 | { 297 | "property": "platforms", 298 | "visible": True, 299 | "width": 200 300 | }, 301 | { 302 | "property": "time", 303 | "visible": True, 304 | "width": 200 305 | }, 306 | { 307 | "property": "release_date", 308 | "visible": True, 309 | "width": 200 310 | }, 311 | { 312 | "property": "notes", 313 | "visible": True, 314 | "width": 200 315 | }, 316 | { 317 | "property": "playtime", 318 | "visible": False, 319 | "width": 100, 320 | } 321 | ] 322 | } 323 | } 324 | --------------------------------------------------------------------------------