├── 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://www.reddit.com/r/Notion/comments/jiy1sb/notion_games_list/?utm_source=share&utm_medium=web2x&context=3) [](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 | 
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 |
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 | [](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 |
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 |
--------------------------------------------------------------------------------