commit 5b665f55eb0ff3f22b1dd137470e11c353ff284f Author: Daniel Snider Date: Wed May 25 01:44:45 2022 -0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..750294c --- /dev/null +++ b/.gitignore @@ -0,0 +1,162 @@ +cache +data.json +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/components.js b/components.js new file mode 100644 index 0000000..2135b8b --- /dev/null +++ b/components.js @@ -0,0 +1,70 @@ +import { h, render } from 'https://unpkg.com/preact?module'; + +function players(range) { + if (range[0] === range[1]) { + if (range[0] === 1) { + return "1 player"; + } + return `${range[0]} players`; + } + return `${range[0]} to ${range[1]} players`; +} + +function time(minutes) { + const hours = Math.floor(minutes / 60); + minutes -= hours * 60; + const out = []; + if (hours === 1) { + out.push(`1 hour`); + } else if (hours > 1){ + out.push(`${hours} hours`); + } + + if (minutes === 1) { + out.push(`1 minute`); + } else if (minutes > 1){ + out.push(`${minutes} minutes`); + } + + return out.join(" "); +} + +function weight_to_percent(weight) { + return Math.round(((weight - 1) / 4) * 100); +} + +export function boardgame(game) { + return h('div', { class: 'boardgame' }, [ + h('h2', { class: 'title' }, game.title), + h('img', {src: `./cache/${game.id}.jpg`}), + h('div', { class: 'weight stat' }, `${weight_to_percent(game.weight)}%`), + h('div', { class: 'time stat' }, time(game.time)), + h('div', { class: 'players stat' }, players(game.player_range)), + h('div', { class: 'description' }, game.description.slice(0, 400) + "..."), + h('div', { class: 'mechanics' }, game.mechanics.slice(0,3).join(" • ")) + ]); +} + +function boardgames(games) { + return h('div', { class: "boardgames" }, games.map(boardgame)); +} + +function nav_list() { + return h('ul', null, [ + h('li', null, h('a', { href: "?all" }, "All")), + h('li', null, h('a', { href: "?two-player" }, "Two Player")), + h('li', null, h('a', { href: "?multi" }, "Multiplayer")) + ]); +} + +export function app(games, nav) { + if (nav === null) { + return nav_list(); + } else if (nav === "all") { + return boardgames(games); + } else if (nav === "two-player") { + return boardgames(games.filter(g => g.player_range[0] === 2 && g.player_range[1] === 2)); + } else if (nav === "multi") { + return boardgames(games.filter(g => g.player_range[0] !== 2 || g.player_range[1] !== 2)); + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..0b8ba25 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + Boardgame Menu + + + +
+ + + diff --git a/input.json b/input.json new file mode 100644 index 0000000..fcf2c47 --- /dev/null +++ b/input.json @@ -0,0 +1,246 @@ +[ + { + "title": "7 Wonders Duel", + "id": 173346 + }, + { + "title": "Advanced Squad Leader", + "id": 243 + }, + { + "title": "Agricola", + "id": 31260 + }, + { + "title": "Arkham Horror The Card Game", + "id": 205637 + }, + { + "title": "Ascension", + "id": 69789 + }, + { + "title": "Azul", + "id": 230802 + }, + { + "title": "Bärenpark", + "id": 219513 + }, + { + "title": "Battleline", + "id": 760 + }, + { + "title": "Battletech", + "id": 296694, + "families": ["Strategy"] + }, + { + "title": "Betrayal at the House on the Hill", + "id": 10547 + }, + { + "title": "Castle Panic", + "id": 43443 + }, + { + "title": "Caylus", + "id": 18602 + }, + { + "title": "Codenames", + "id": 178900 + }, + { + "title": "Commands & Colors Ancients", + "id": 14105 + }, + { + "title": "Coup", + "id": 131357 + }, + { + "title": "Dominion", + "id": 36218 + }, + { + "title": "Flash Point", + "id": 100901 + }, + { + "title": "Gloomhaven Jaws of the Lion", + "id": 291457 + }, + { + "title": "Haunted Mansion", + "id": 309129, + "families": ["Family"] + }, + { + "title": "Haunted Mansion Clue", + "id": 5467, + "families": ["Family"] + }, + { + "title": "Haunted Mansion Life", + "id": 80028, + "families": ["Family"] + }, + { + "title": "Hive", + "id": 2655 + }, + { + "title": "Jaipur", + "id": 54043 + }, + { + "title": "Kanagawa", + "id": 200147 + }, + { + "title": "Keyforge", + "id": 257501 + }, + { + "title": "King of Tokyo", + "id": 70323 + }, + { + "title": "Koi-Koi", + "id": 11865 + }, + { + "title": "Lanterns", + "id": 160851 + }, + { + "title": "Love Letter", + "id": 129622 + }, + { + "title": "Machi Koro", + "id": 143884 + }, + { + "title": "Mage Knight Board Game", + "id": 96848 + }, + { + "title": "Magic Maze", + "id": 209778 + }, + { + "title": "Mr Jack", + "id": 21763 + }, + { + "title": "Mysterium", + "id": 181304 + }, + { + "title": "Mysterium Park", + "id": 301767 + }, + { + "title": "Netrunner", + "id": 124742 + }, + { + "title": "One Night Ultimate Werewolf", + "id": 147949 + }, + { + "title": "Pandemic", + "id": 30549 + }, + { + "title": "Patchwork", + "id": 163412 + }, + { + "title": "Pathfinder Adventure Card Game", + "id": 133038 + }, + { + "title": "Power Grid", + "id": 2651 + }, + { + "title": "Puerto Rico", + "id": 3076 + }, + { + "title": "Ramen Fury", + "id": 265524 + }, + { + "title": "Red Cathedral", + "id": 227224 + }, + { + "title": "Ricochet Robots", + "id": 51 + }, + { + "title": "Rummikub", + "id": 811 + }, + { + "title": "Settlers of Catan", + "id": 13 + }, + { + "title": "Shadows over Camelot", + "id": 15062 + }, + { + "title": "Star Realms", + "id": 147020 + }, + { + "title": "Tak", + "id": 197405 + }, + { + "title": "Through the Ages", + "id": 25613 + }, + { + "title": "Ticket to Ride Europe", + "id": 14996 + }, + { + "title": "Timeline", + "id": 85256 + }, + { + "title": "Tokaido", + "id": 123540 + }, + { + "title": "Twilight Struggle", + "id": 12333 + }, + { + "title": "Unmatched", + "id": 274637 + }, + { + "title": "Uno", + "id": 2223 + }, + { + "title": "Upwords", + "id": 1515 + }, + { + "title": "Villainous", + "id": 256382 + }, + { + "title": "Wingspan", + "id": 266192 + } +] diff --git a/main.js b/main.js new file mode 100644 index 0000000..d2215ae --- /dev/null +++ b/main.js @@ -0,0 +1,16 @@ +import { h, render } from 'https://unpkg.com/preact?module'; +import * as components from './components.js'; + +(async function() { + + const boardgames = (await (await fetch("./data.json")).json()); + boardgames.sort((a, b) => a.rank - b.rank); + + let nav = window.location.search.substr(1); + if (nav === "") { + nav = null; + } + + render(components.app(boardgames, nav), document.body); + +})().then(); diff --git a/scripts/fetch.py b/scripts/fetch.py new file mode 100644 index 0000000..2ca32dd --- /dev/null +++ b/scripts/fetch.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 + +from pathlib import Path +from util import DiskCache +import html +import json +import re +import urllib.request +import xml.etree.ElementTree as ET + +INPUT_PATH = Path(__file__).parent.parent.joinpath("input.json").resolve() +OUTPUT_PATH = Path(__file__).parent.parent.joinpath("data.json").resolve() +CACHE_PATH = Path(__file__).parent.parent.joinpath("cache").resolve() + +class BoardGame: + def __init__(self, id, name, year, player_range, time, rank, families, weight, description, mechanics, thumbnail_url, image_url): + self.id = id + self.name = name + self.year = year + self.player_range = player_range + self.time = time + self.rank = rank + self.families = families + self.weight = weight + self.description = description + self.mechanics = mechanics + self.thumbnail_url = thumbnail_url + self.image_url = image_url + + @classmethod + def from_bgg_xml(cls, xml_doc): + families = [e.attrib['friendlyname'].split()[0] for e in xml_doc.findall('.//rank[@type="family"]')] + mechanics = [e.attrib['value'] for e in xml_doc.findall('.//link[@type="boardgamemechanic"]')] + return cls( + name=re.split(r'[–-]', xml_doc.find(".//name").attrib['value'])[0].strip(), + id=int(xml_doc.find(".//item").attrib['id']), + year=int(xml_doc.find('.//yearpublished').attrib['value']), + player_range=( + int(xml_doc.find('.//minplayers').attrib['value']), + int(xml_doc.find('.//maxplayers').attrib['value']), + ), + time=int(xml_doc.find('.//playingtime').attrib['value']), + rank=int(xml_doc.find('.//rank[@type="subtype"]').attrib['value']), + families=families, + weight=float(xml_doc.find('.//averageweight').attrib['value']), + description=html.unescape(xml_doc.find('.//description').text.strip()), + mechanics=mechanics, + thumbnail_url=xml_doc.find('.//thumbnail').text.strip(), + image_url=xml_doc.find('.//image').text.strip() + ) + + def serialize(self): + return { + 'id': self.id, + 'title': self.name, + 'year': self.year, + 'player_range': self.player_range, + 'time': self.time, + 'rank': self.rank, + 'families': self.families, + 'description': self.description, + 'mechanics': self.mechanics, + 'weight': self.weight + } + +def fetch_boardgames(data, cache): + boardgames = [] + + for item in data: + key = f"{item['title']}.xml" + if key not in cache: + url = f"https://api.geekdo.com/xmlapi2/thing?id={item['id']}&stats=1" + with urllib.request.urlopen(url) as response: + cache[key] = response.read() + boardgame = BoardGame.from_bgg_xml(ET.fromstring(cache[key])) + if 'families' in item: + boardgame.families = item['families'] + boardgames.append(boardgame) + return boardgames + +def cache_images(boardgames, cache): + for boardgame in boardgames: + key = f"{boardgame.id}_thumbnail.jpg" + if key not in cache: + with urllib.request.urlopen(boardgame.thumbnail_url) as response: + cache[key] = response.read() + key = f"{boardgame.id}.jpg" + if key not in cache: + with urllib.request.urlopen(boardgame.image_url) as response: + cache[key] = response.read() + +if __name__ == "__main__": + CACHE_PATH.mkdir(parents=True, exist_ok=True) + + cache = DiskCache(CACHE_PATH) + + with open(INPUT_PATH) as infile: + data = json.load(infile) + + boardgames = fetch_boardgames(data, cache) + cache_images(boardgames, cache) + + with open(OUTPUT_PATH, 'w') as outfile: + json.dump([game.serialize() for game in boardgames], outfile) diff --git a/scripts/mechanics.py b/scripts/mechanics.py new file mode 100644 index 0000000..51c7aeb --- /dev/null +++ b/scripts/mechanics.py @@ -0,0 +1,15 @@ +import json +from collections import Counter +from pathlib import Path + +DATA_PATH = Path(__file__).parent.parent.joinpath("data.json").resolve() + +if __name__ == "__main__": + with open(DATA_PATH, 'r') as infile: + data = json.load(infile) + c = Counter() + for game in data: + c.update(game['mechanics']) + + print(c) + diff --git a/scripts/util.py b/scripts/util.py new file mode 100644 index 0000000..1231461 --- /dev/null +++ b/scripts/util.py @@ -0,0 +1,45 @@ +import pathlib +import time + +STALE_SECONDS = 60 * 60 * 24 * 1 # one day + +class DiskCache: + def __init__(self, root_path): + self.root_path = pathlib.Path(root_path) + self.root_path.mkdir(exist_ok=True) + assert(self.root_path.is_dir()) + + def __contains__(self, key): + self.clear_stale_files() + return self.root_path.joinpath(key).exists() + + def __getitem__(self, key): + try: + with self.root_path.joinpath(key).open('rb') as infile: + return infile.read() + except FileNotFoundError: + raise KeyError(key) + + def __setitem__(self, key, value): + try: + with self.root_path.joinpath(key).open('wb') as outfile: + outfile.write(value) + except FileNotFoundError: + raise KeyError(key) + + def __delitem__(self, key): + try: + self.root_path.joinpath(key).unlink() + except FileNotFoundError: + raise KeyError(key) + + def clear_stale_files(self): + files_to_delete = [] + for path in self.root_path.iterdir(): + if not path.is_file(): + continue + age = time.time() - path.stat().st_mtime + if age >= STALE_SECONDS: + files_to_delete.append(path) + for path in files_to_delete: + path.unlink() diff --git a/style.css b/style.css new file mode 100644 index 0000000..c8cf138 --- /dev/null +++ b/style.css @@ -0,0 +1,100 @@ +@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;700&display=swap'); + +html { + font-family: 'Roboto', sans-serif; + color: #707070; +} + +.boardgames { + width: 1100px; + margin: 0 auto; +} + +.boardgame { + margin-bottom: 12em; +} + + +@media print { + .boardgames:nth-child(3) { + page-break-after: always; + } +} + +.boardgame .stat { + font-size: 24px; + font-weight: 900; +} + +.boardgame .weight::before { + content: "🧠 "; + font-size: 42px; + font-weight: bold; +} + +.boardgame .time::before { + content: "⏲️ "; + font-size: 42px; + font-weight: bold; +} + +.boardgame .players::before { + content: "👥 "; + font-size: 42px; + font-weight: bold; +} + +.boardgame img { + width: 250px; + height: 300px; + object-fit: cover; +} + +.boardgame .title { + font-size: 42px; + font-weight: bold; + margin: 0; +} + +.boardgame .description { + font-size: 18px; + line-height: 24px; +} + +.boardgame .mechanics { + font-size: 20px; + font-weight: 300; +} + +.boardgame { + display: grid; + grid-template-columns: auto auto auto; + grid-template-rows: auto repeat(3, 1fr) auto; + grid-column-gap: 32px; + grid-row-gap: 8px; + height: 300px; +} + +.boardgame img { + grid-area: 1 / 1 / 6 / 2; +} + +.boardgame .title { + grid-area: 1 / 2 / 2 / 4; +} +.boardgame .players { + grid-area: 2 / 2 / 3 / 3; +} +.boardgame .time { + grid-area: 3 / 2 / 4 / 3; +} +.boardgame .weight { + grid-area: 4 / 2 / 5 / 3; +} +.boardgame .description { + grid-area: 2 / 3 / 5 / 4; + margin-top: 1em; +} +.boardgame .mechanics { + grid-area: 5 / 2 / 6 / 4; +}