diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | MANIFEST.in | 2 | ||||
| -rw-r--r-- | README.md | 51 | ||||
| -rw-r--r-- | TODO.txt | 10 | ||||
| -rw-r--r-- | pyproject.toml | 3 | ||||
| -rwxr-xr-x | scripts/schema1-to-2.py | 86 | ||||
| -rw-r--r-- | setup.cfg | 7 | ||||
| -rwxr-xr-x | setup.py | 19 | ||||
| -rwxr-xr-x | src/vgstash/__init__.py | 67 | ||||
| -rw-r--r-- | src/vgstash/web/vgstash-favicon.png | bin | 0 -> 324 bytes | |||
| -rw-r--r-- | src/vgstash/web/vgstash-web.html | 46 | ||||
| -rw-r--r-- | src/vgstash/web/vgstash.css | 333 | ||||
| -rw-r--r-- | src/vgstash/web/vgstash.js | 278 | ||||
| -rwxr-xr-x | src/vgstash_cli.py | 165 | ||||
| -rw-r--r-- | tox.ini | 2 |
15 files changed, 989 insertions, 82 deletions
@@ -9,6 +9,8 @@ __pycache__/ .tox/ /build/ /dist/ +.pypirc .vgstash.db tests/data/test_export.* +/src/web/vgstash.json diff --git a/MANIFEST.in b/MANIFEST.in index 168ea16..593acb9 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,3 +3,5 @@ include LICENSE include README.md include requirements.txt include setup.py +graft src/vgstash/web +exclude src/vgstash/web/vgstash.json @@ -53,6 +53,17 @@ This is the bare minimum information you need to meaningfully track a video game in your collection. With it, you can begin to ask and answer questions you may have about your collection. +Other fields sometimes get added to the database format as new features are +added to VGStash: + +* Notes +* Purchase Date +* Beaten Date +* Completion Date + +These values are completely optional in your collection, but can make VGStash +more useful. + # Python Usage Importing the `vgstash` module is enough to get started! @@ -132,7 +143,7 @@ arguments in brackets are optional ### add ``` -add TITLE SYSTEM [OWNERSHIP] [PROGRESS] [NOTES] +add TITLE SYSTEM [OWNERSHIP] [PROGRESS] [NOTES] [P_DATE] [B_DATE] [C_DATE] ``` Adds a game to the database. @@ -143,6 +154,15 @@ Adds a game to the database. `NOTES` should be a fully-quoted string, with newlines escaped +`P_DATE` is a string representing the date you purchased a game. * + +`B_DATE` is a string representing the date you beat a game. (i.e. saw the +credits or defeated the primary antagonist) * + +`C_DATE` is a string representing the date you completed (100%d) a game. * + +<sup>* An ISO 8601 Date string, i.e. `2025-07-27`.</sup> + --- Adding a game is trickier than it seems; the OWNERSHIP and PROGRESS fields are @@ -209,7 +229,7 @@ exist in the database. If PATH is omitted, it will read from standard input ### list ``` -list [FILTER] [-w WIDTH] [-r] +list [FILTER] [-r] ``` List games in the database, optionally using a FILTER or restricting the output @@ -223,7 +243,7 @@ filters that allow you to reason about your game collection. For example, this command will show you every game marked "playing" that you also own in some way: ```bash -$ vgstash list -w 40 playlog +$ vgstash list playlog Title | System | Own | Progress ---------------------------------------- Crashmo | 3DS | D | P @@ -292,6 +312,13 @@ VGStash has filters for this, too: * **`incomplete`** tracks games whose progress is beaten, but *not* completed * **`complete`** tracks games whose progress is marked completed +#### How long has that game been sitting in my backlog? + +VGStash 0.3 supports date fields for purchasing, beating, and completing a game. + +* **`backlog_age`** shows you how long each game has been in the backlog, assuming + it has a purchase date. + Check `vgstash list --help` for more. ### notes @@ -394,26 +421,30 @@ processing its arguments, so please don't report any bugs dealing with quoting. # Roadmap -These are planned for the full 0.3 release: +Goals planned for the full 0.3 release: -* command line interface finished -* Match feature-set with `master` +With version `0.3b8`, I am feeling more confident in VGStash's capabilities. An +RC is planned with support for generating your vgstash-web page. Goals planned for the 0.4 release: -* import and export with JSON * Iron out any initial bugs on Windows and Mac (testers welcome!) Goals planned for the 0.5 release: -* some sort of GUI (Tk and Qt are current candidates) +* some sort of GUI (Tk, curses, and Qt are current candidates) Goals planned for the 1.0 release: -* Kivy-based interface (to release on Android via F-Droid) +* A richer GUI, built in LOVE2D, SDL, or maybe Web tech. # Contributing -If this interests you, please [e-mail me](mailto:zlg+vgstash@zlg.space). +I'm best reachable [via e-mail](mailto:zlg+vgstash@zlg.space), but I can also be +found on [Twitch](https://twitch.tv/zlg_creates), +[Ko-Fi](https://ko-fi.com/zlg_creates), and [my own website](https://zlg.space). + +Actual contributions should be e-mailed in a git-friendly patch format, so I can +use `git am` to easily merge it. Thank you for your consideration. [spdx-agpl3]: https://spdx.org/licenses/AGPL-3.0-only.html @@ -1,20 +1,12 @@ -* Get tests running in their own directory * Write CLI * in progress, using click - https://click.pocoo.org/ * Write GUI + * in progress (tkinter) * Write docs * How? Sphinx? Needs research --- -Consider adding a 'dates' table that matches games to dates for purchase, -beating, and completing. Currently implemented via RFC2822-style headers within -the 'notes' field. More research is needed to determine if the notes field or a -table is a better way to achieve this. If an addition to the database format is -deemed necessary, a restructuring may be in order. - ---- - One of the things curious about managing a game collection that doubles as a backlog is, you get games on systems that were originally on other systems. How do you classify those games? The original game is the actual content you're diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7ad0c21 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=64", "setuptools-scm>=8"] +build-backend = "setuptools.build_meta" diff --git a/scripts/schema1-to-2.py b/scripts/schema1-to-2.py new file mode 100755 index 0000000..12faa29 --- /dev/null +++ b/scripts/schema1-to-2.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 + +import sqlite3 +import vgstash as vgs +import datetime as dt +import argparse + +# Migrate VGStash schema v1 to schema v2: +# + purchase_date : UNIX timestamp, date of a game's purchase +# + beaten_date : UNIX timestamp, date a game was beaten +# + complete_date : UNIX timestamp, date a game was completed 100% + +parser = argparse.ArgumentParser( + prog="schema1-to-2", + description="Migrate a VGStash database, in-place, from v1 to v2. This adds three columns and moves header data." + ) + +parser.add_argument( + "dbfile", + help="path to a VGStash database file", + ) + +opts = parser.parse_args() + +vgdb = vgs.DB(path=opts.dbfile) + +# Step 1: Add columns +columns = [ + ["Purchased: ", "p_date"], + ["Beaten: ", "b_date"], + ["Completed: ", "c_date"], + ] +for c in columns: + try: + print("Adding '{}' column...".format(c[1])) + # This is RISKY and RIPE FOR INJECTION. SQLite does not support + # parameterized column names during creation; this is a workaround. + vgdb.conn.execute("ALTER TABLE games ADD COLUMN {} INTEGER DEFAULT NULL".format(c[1])) + vgdb.conn.commit() + print("Success!") + except sqlite3.OperationalError: + print("Column {} already exists in the database. Skipping.".format(c[1])) + +# Step 2: Fetch notes +res = vgdb.conn.execute("SELECT rowid,notes FROM games WHERE notes NOT LIKE ''") +targets = res.fetchall() +converted_lines = 0 +removed_notes = 0 +for row in targets: + lines = row['notes'].splitlines() + newlines = [] + # Step 3: Check for headers in notes + for l in lines: + has_header = False + for i in columns: + if l.startswith(i[0]): + # Step 4: Convert and add data to new column location + has_header = True + isodate = l.split(":")[1].strip() + unixdate = dt.date.fromisoformat(isodate).strftime("%s") + # Same warning applies to ''.format usage and SQL queries + set_ts = vgdb.conn.execute("UPDATE games SET {} = ? WHERE rowid = ?".format(i[1]), (unixdate, row['rowid'])) + vgdb.conn.commit() + if (set_ts.rowcount > 0): + converted_lines+= 1 + continue + + if not has_header: + newlines.append(l) + if len(newlines) > 0: + for l in newlines: + if len(l) == 0: + newlines.remove(l) + else: + break + + # Step 5: Update notes, if any lines were removed in Step 4. + if (len(newlines) < len(lines)): + set_notes = vgdb.conn.execute("UPDATE games SET notes = ? WHERE rowid = ?", ("\n".join(newlines), row['rowid'])) + vgdb.conn.commit() + if (set_notes.rowcount > 0): + removed_notes+=1 + +# Step 6: Summarize what was done. +print("{} games' notes were updated.".format(removed_notes)) +print("{} details converted from Notes header to Column.".format(converted_lines)) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..856f704 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,7 @@ +[metadata] +name = vgstash +version = 0.3b8 +description = "A meaningful video game collection tracker" + +[options] +packages = find: @@ -2,7 +2,7 @@ # Learn more: https://github.com/kennethreitz/setup.py -from setuptools import setup, find_packages +from setuptools import setup, find_namespace_packages with open('README.md') as f: @@ -10,16 +10,20 @@ with open('README.md') as f: setup( name='vgstash', - version='0.3-beta7', + version='0.3b8', description='a video game collection management module, backed by SQLite', long_description=readme, long_description_content_type="text/markdown; variant=CommonMark", author='zlg', - license='AGPL-3.0-only', + license='License-Expression: AGPL-3.0-only', author_email='zlg+vgstash@zlg.space', url='https://git.zlg.space/vgstash', - packages=find_packages(where='src'), - package_dir={'': 'src'}, + packages=find_namespace_packages(where='src'), + package_dir={"": "src"}, + package_data={ + "vgstash.web": ["web/vgstash-web.html", "web/vgstash.css", "web/vgstash.js", "web/vgstash-favicon.png"] + }, + include_package_data=True, py_modules=['vgstash_cli'], entry_points={ 'console_scripts': [ @@ -31,14 +35,13 @@ setup( 'Click>=6.0', # for CLI 'PyYAML', # import/export YAML files ], - classifiers=( + classifiers=[ "Development Status :: 4 - Beta", - "License :: OSI Approved :: GNU Affero General Public License v3", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3 :: Only", "Topic :: Games/Entertainment", "Topic :: Utilities", - ), + ], project_urls={ 'Source': 'https://git.zlg.space/vgstash', } diff --git a/src/vgstash/__init__.py b/src/vgstash/__init__.py index d28e587..5184fac 100755 --- a/src/vgstash/__init__.py +++ b/src/vgstash/__init__.py @@ -1,6 +1,7 @@ import os import sys import sqlite3 +import datetime # Remixed vgstash. This time I chose an individual file on purpose: # @@ -11,6 +12,8 @@ import sqlite3 # the standard library, can't beat that. # * GUI will be implemented in tkinter, Qt, and/or Kivy. +VERSION = "0.3b8" + PROGRESS = { 'unbeatable': 0, 'new': 1, @@ -48,8 +51,15 @@ FILTERS = { 'physical': "SELECT * FROM games WHERE ownership = 1 ORDER BY system, title ASC", 'playlog': "SELECT * FROM games WHERE ownership > 0 AND progress = 2 ORDER BY system, title ASC", 'unowned': "SELECT * FROM games WHERE ownership = 0 ORDER BY system, title ASC", + 'backlog_age': "SELECT title, system, ownership, ((unixepoch() - p_date) / (60 * 60 * 24 * 365)) AS years, ((unixepoch() - p_date) / (60 * 60 * 24) % 365) AS days FROM games WHERE (progress > 0 AND progress < 3) AND p_date ORDER BY years DESC, days DESC, system ASC, title ASC", } +def iso_to_unix(t): + return datetime.date.fromisoformat(t).strftime("%s") + +def unix_to_iso(t): + return datetime.date.fromtimestamp(t).isoformat() + def kvmatch(arg, dict_map, fallback): """ Match arg against keys or values in dict_map, returning fallback if no match. @@ -149,10 +159,10 @@ class DB(object): return self.update_game(game, game) else: c = self.conn.execute("INSERT INTO games\ - (title, system, ownership, progress, notes)\ + (title, system, ownership, progress, notes, p_date, b_date, c_date)\ VALUES\ - (?, ?, ?, ?, ?)", - (game.title, game.system, game.ownership, game.progress, game.notes)) + (?, ?, ?, ?, ?, ?, ?, ?)", + (game.title, game.system, game.ownership, game.progress, game.notes, game.p_date, game.b_date, game.c_date)) self.conn.commit() return (c.rowcount > 0) @@ -166,6 +176,9 @@ class DB(object): ownership (INTEGER) progress (INTEGER) notes (TEXT) + p_date (INTEGER) + b_date (INTEGER) + c_date (INTEGER) The schema is configured to use the 'title' and 'system' columns as primary keys, meaning it is impossible for two games with the same name @@ -184,16 +197,19 @@ class DB(object): # get more exact data manipulation. Alternatively, use the *_filter # methods of this class to create custom reporting filters. try: - self.conn.execute("CREATE TABLE\ - IF NOT EXISTS\ - games (\ - title TEXT NOT NULL,\ - system TEXT NOT NULL,\ - ownership INTEGER NOT NULL DEFAULT 1,\ - progress INTEGER NOT NULL DEFAULT 1,\ - notes TEXT DEFAULT '',\ - UNIQUE (title, system) ON CONFLICT ROLLBACK\ - )") + self.conn.execute("""CREATE TABLE + IF NOT EXISTS + games ( + title TEXT NOT NULL, + system TEXT NOT NULL, + ownership INTEGER NOT NULL DEFAULT 1, + progress INTEGER NOT NULL DEFAULT 1, + notes TEXT DEFAULT '', + p_date INTEGER DEFAULT NULL, + b_date INTEGER DEFAULT NULL, + c_date INTEGER DEFAULT NULL, + UNIQUE (title, system) ON CONFLICT ROLLBACK + )""") # setup default filters while we're here for name, sql in sorted(FILTERS.items()): self.add_filter(name, sql) @@ -234,7 +250,15 @@ class DB(object): stmt = "SELECT * FROM games WHERE title=? AND system=?" res = self.conn.execute(stmt, (title, system)).fetchone() if bool(res): - return Game(res['title'], res['system'], res['ownership'], res['progress'], res['notes']) + return Game( + title = res['title'], + system = res['system'], + ownership = res['ownership'], + progress = res['progress'], + notes = res['notes'], + p_date = res['p_date'], + b_date = res['b_date'], + c_date = res['c_date']) else: raise KeyError @@ -320,10 +344,11 @@ class DB(object): if self.has_game(target): c = self.conn.cursor() # TODO: do this better - c.execute("UPDATE games\ - SET title=?, system=?, ownership=?, progress=?, notes=?\ - WHERE title=? AND system=?", - (source.title, source.system, source.ownership, source.progress, source.notes, target.title, target.system)) + c.execute("""UPDATE games\ + SET title=?, system=?, ownership=?, progress=?, notes=?, + p_date=?, b_date=?, c_date=? + WHERE title=? AND system=?""", + (source.title, source.system, source.ownership, source.progress, source.notes, source.p_date, source.b_date, source.c_date, target.title, target.system)) self.conn.commit() return (c.rowcount > 0) else: @@ -335,10 +360,14 @@ class Game(object): def __init__(self, title, system, ownership=DEFAULT_CONFIG['ownership'], progress=DEFAULT_CONFIG['progress'], - notes=""): + notes="", + p_date="", b_date="", c_date=""): self.title = title self.system = system self.ownership = kvmatch(ownership, OWNERSHIP, DEFAULT_CONFIG['ownership']) self.progress = kvmatch(progress, PROGRESS, DEFAULT_CONFIG['progress']) self.notes = notes + self.p_date = p_date + self.b_date = b_date + self.c_date = c_date diff --git a/src/vgstash/web/vgstash-favicon.png b/src/vgstash/web/vgstash-favicon.png Binary files differnew file mode 100644 index 0000000..5456f3b --- /dev/null +++ b/src/vgstash/web/vgstash-favicon.png diff --git a/src/vgstash/web/vgstash-web.html b/src/vgstash/web/vgstash-web.html new file mode 100644 index 0000000..d5b6bdc --- /dev/null +++ b/src/vgstash/web/vgstash-web.html @@ -0,0 +1,46 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <title>vgstash for {{USERNAME}}</title> + <meta http-equiv="Content-Type" content="text/html; charset=utf8"> + <link rel="stylesheet" href="./vgstash.css" type="text/css"> + <link rel="icon" href="./vgstash-favicon.png" type="image/png"> + <script src="./vgstash.js"></script> + </head> + <body> + <header id="top"> + <h1><em>vgstash</em> for {{USERNAME}}</h1> + <p id="help" class="help" tabindex="0" title="Help">?</p> + </header> + <div class="notice"> + <p>The <strong>vgstash</strong> webview is currently in development, and is slated for the </code>v0.4</code> release. As such, consider this page a <em>work in progress</em>. Thanks. <a href="#" onclick="show_contact();">Issues?</a> + </div> + <nav> + <ul id="filter_list"> + <li>All</li> + <li title="Games marked New or Playing">Backlog</li> + <li title="Track the age of games in your backlog">Backlog Age</li> + <li title="Games marked Beaten">Beaten</li> + <li title="Games marked Complete">Complete</li> + <li title="Games I own digitally">Digital</li> + <li title="Games marked Beaten or Complete">Done</li> + <li title="Games belonging to a collection">Members</li> + <li title="Games with notes">Notes</li> + <li title="Games that I own somehow">Owned</li> + <li title="Games that I own physically">Physical</li> + <li title="Games marked Playing">Playlog</li> + </ul> + </nav> + <div id="vgs_view"> + <!-- script insertion point --> + </div> + <!-- modal box --> + <div id="modal" class="backdrop"> + <section id="modal_box" class="modal_box"> + </section> + </div> + <footer> + <a href="https://pypi.org/project/vgstash">VGStash</a> © 2016-2025 <a href="https://zlg.space/">zlg</a>. Licensed under <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL 3.0</a>. + </footer> + </body> +</html> diff --git a/src/vgstash/web/vgstash.css b/src/vgstash/web/vgstash.css new file mode 100644 index 0000000..8d45958 --- /dev/null +++ b/src/vgstash/web/vgstash.css @@ -0,0 +1,333 @@ +:root { + --main-bg-color: #152c0e; + --main-fg-color: #fff; + --header-color: #fff; + --header-highlight-color: #fff; + --notice-box-bg-color: #eceb13; + --notice-box-fg-color: var(--main-bg-color); + --button-bg-color: #1a5806; + --button-fg-color: #fff; + --hover-button-bg-color: #acd; + --hover-button-fg-color: #125; + --selected-button-bg-color: #000; + --selected-button-fg-color: #ffff6c; + --modal-bg-color: #dfdfdf; + --modal-fg-color: var(--main-bg-color); + --table-text-color: var(--main-bg-color); + --table-spacing-color: #fff; + --table-head-bg-color: #eceb13; + --table-head-fg-color: var(--table-text-color); + --table-even-bg-color: #dfdfdf; + --table-even-fg-color: var(--table-text-color); + --table-odd-bg-color: #b4b4b4; + --table-odd-fg-color: var(--table-text-color); + --note-link-color: var(--table-text-color); + --row-hover-bg-color: #f198f0; + --row-hover-fg-color: #882250; + --row-hover-link-color: var(--row-hover-fg-color); + --footer-fg-color: var(--main-fg-color); + --footer-link-color: #7cf; +} + +/* MODIFY VARIABLES ABOVE THIS LINE */ + +html { + font-size: 16px; + font-family: "Fira Sans", "Liberation Sans", "Arial", sans-serif; + margin: 0; + padding: 0; + background: var(--main-bg-color); + color: #fff; +} + +body { + font-size: 62.5%; + margin: 1.6em auto; + padding: 0; + width: 90%; +} + +#top { + margin-bottom: 1.5em; + min-height: 2.8em; +} + +#top h1 { + color: var(--header-color); + margin: 0; + padding: 0; + line-height: 1.3; + font-size: 2.2em; + float: left; +} + +#top h1 em { + background: var(--header-highlight-color); + color: var(--main-bg-color); + padding: .15em .2em; + border-radius: 0.3em; + font-style: normal; +} + +#top p { + float: right; +} + +.help { + display: inline-block; + border: 1px solid #6ce; + color: #fff; + cursor: pointer; + background: #39B; + padding: .1em .2em; + margin: 0; + border-radius: 1em; + width: .9em; + height: 1.1em; + text-align: center; + text-shadow: 1px 1px 1px #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000; + text-decoration: none; + font-size: 1.8em; + font-weight: bold; +} + +.notice { + border-radius: .5em; + background: var(--notice-box-bg-color); + color: var(--notice-box-fg-color); + padding: .2em .5em; + clear: both; +} + +.notice p { + margin: .5em; + padding: 0; + font-size: 1.5em; +} + +nav { + margin: 1em 0; + padding: 0; + /* background: #000; */ + clear: both; +} + +nav ul { + margin: 0; + padding: 0; + list-style: none; + display: block; +} + +nav ul li { + display: inline-block; + background: var(--button-bg-color); + box-shadow: 0 0 .1em rgba(5,0,10,0.7); + color: var(--button-fg-color); + font-size: 1.8em; + font-weight: bold; + text-decoration: none; + border-radius: .4em; + margin: .1em .2em; + padding: 0.1em 0.4em; +} + +nav ul li:hover, +nav ul li.selected:hover { + cursor: pointer; + background: var(--hover-button-bg-color); + color: var(--hover-button-fg-color); +} + +nav ul li.selected { + background: var(--selected-button-bg-color); + color: var(--selected-button-fg-color); +} + +table { + font-size: 1em; +} + +#modal { + display: none; +} + +.backdrop { + position: fixed; + left: 0; + right; 0; + top: 0; + left: 0; + background: rgba(30,30,30,0.8); + margin: 0; + padding: 0; + width: 100%; + height: 100%; + z-index: 1; + overflow: auto; +} + +#modal_box { + background: var(--modal-bg-color); + color: var(--modal-fg-color); + border-radius: 1.3em; + width: 85%; + margin: 3em auto; + padding: 1em 0; + font-size: 1.6em; +} + +#modal_box > div { + padding: 0 1em; + margin: 0; + /* overflow: auto; */ +} + +#modal_close { + background: #d30; + border-radius: 0.7em; + border: 1px solid #e76; + box-shadow: 0 0.1em 0.2em #000; + color: #fff; + cursor: pointer; + display: block; + float: right; + font-size: 2.4em; + line-height: 0.8; + margin: 0; + padding: 0; + position: relative; + right: -0.4em; + text-align: center; + text-decoration: none; + text-shadow: 1px 1px 1px #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000; + top: -0.7em; + width: 0.8em; +} + + +#modal_box h2 { + margin: 0; + font-size: 1.7em; + font-weight: bold; + text-align: center; + margin-bottom: 0.3em; + padding-bottom: 0.3em; +} + +#modal_box pre.notes { + white-space: pre-wrap; +} + +#modal_box table { + color: var(--main-bg-color); + background: var(--table-spacing-color); + box-shadow: 0 0 0.8em rgba(17,14,16,0.7); + border-radius: 0.3em; + line-height: 1.4; +} + +#modal_box table td { + padding: 0.3em; +} + +#modal_box thead tr th { + background: var(--table-head-bg-color); + color: var(--table-head-fg-color); + font-size: 1.2em; + border-bottom: 1px solid; +} + +#modal_box tbody tr:nth-of-type(2n+1) { + background: var(--table-odd-bg-color); +} + +#modal_box tbody tr:nth-of-type(2n) { + background: var(--table-even-bg-color); +} + +#modal_box p:last-child { + margin: 1em 0 0 0; +} + +#modal_box span.notes { + cursor: inherit; +} + +/* VGS VIEW BELOW */ + +#vgs_view { + clear: both; +} + +#vgs_view p:first-child { + font-size: 1.4em; + text-align: center; +} + +#vgs_view table { + color: var(--table-text-color); + background: var(--table-spacing-color); + box-shadow: 0 0 0.8em rgba(17,14,16,0.7); + border-radius: 0.3em; + line-height: 1.4; + font-size: 1.5em; + width: 100%; +} + +#vgs_view table thead { + position: sticky; + top: 0; +} + +#vgs_view table td { + padding: 0.3em; +} + +#vgs_view thead tr th { + background: var(--table-head-bg-color); + font-size: 1.2em; + border-bottom: 1px solid; +} + +#vgs_view tbody tr:hover td { + background: var(--row-hover-bg-color); + color: var(--row-hover-fg-color); +} + +#vgs_view tbody tr:hover td span.notes { + color: var(--row-hover-link-color); +} + +#vgs_view tr:nth-of-type(2n+1) { + background: var(--table-odd-bg-color); +} + +#vgs_view tr:nth-of-type(2n) { + background: var(--table-even-bg-color); +} + +tr[id|="n"]:target { + /* display: table-row !important; */ + background: red; +} + +span.notes { + text-decoration: underline; + cursor: pointer; + font-weight: bold; + color: var(--note-link-color); +} + +footer { + margin: 2em auto; + font-size: 1.5em; + text-align: center; + color: var(--footer-fg-color); +} + +footer a:link, +footer a:visited { + /* TODO: color is arbitrary, revisit */ + color: var(--footer-link-color); +} diff --git a/src/vgstash/web/vgstash.js b/src/vgstash/web/vgstash.js new file mode 100644 index 0000000..1b4046e --- /dev/null +++ b/src/vgstash/web/vgstash.js @@ -0,0 +1,278 @@ +function loadJSON() { + var xobj = new XMLHttpRequest(); + xobj.overrideMimeType("application/json"); + xobj.open('GET', './vgstash.json', true); + xobj.setRequestHeader("Accept", "application/json"); + xobj.onreadystatechange = function () { + if (xobj.readyState == 4 && xobj.status == "200") { + // Required use of an anonymous callback as .open will NOT return a value but simply returns undefined in asynchronous mode + data_loaded(xobj); + } + }; + xobj.send(null); +} + +/* compare two games based on their purchase date, sort ascending */ +function compare_p_date(a, b) { + var result; + if (a.p_date > b.p_date) { + // 1 = a comes AFTER b + result = 1; + } else if (a.p_date == b.p_date) { + // 0 = a and b are EQUAL + result = 0; + } else if (a.p_date < b.p_date) { + // -1 = b comes AFTER a + result = -1; + } + return result; +} + +var current_filter = ""; +var gamecol; +var anchor; +var own_map = { + 0: "–", + 1: "Physical", + 2: "Digital", + 3: "Both", + 4: "Member" +} +var prog_map = { + 0: "–", + 1: "New", + 2: "Playing", + 3: "Beaten", + 4: "Complete" +} + +window.addEventListener("load", loadJSON); + +function data_loaded(evt) { + console.log("VGStash JSON data loaded!"); + // set some globals + gamecol = JSON.parse(evt.responseText); + anchor = document.getElementById("vgs_view"); + // setup filter button actions + flist = document.getElementById("filter_list"); + // console.log(flist.children); + let i = 0; + // TODO: why doesn't 'for (f in flist)' work correctly here? + for (i = 0; i < flist.childElementCount; i++) { + flist.children[i].addEventListener("click", update); + } + document.getElementById("help").addEventListener('click', show_help); + // write to the page + render(anchor, filter_vgs(gamecol, "all")); +} + +function filter_vgs(vgscol, filter_name) { + var new_col = new Array(); + for (game in vgscol) { + vgscol[game].rowid = game; + switch (filter_name) { + case 'backlog': + if (vgscol[game].progress < 3 && vgscol[game].progress > 0) { + new_col.push(vgscol[game]); + } + break; + case 'backlog_age': + if (vgscol[game].progress < 3 && vgscol[game].progress > 0 + && vgscol[game].p_date != null) { + tgame = vgscol[game]; + tgame.years = Math.floor(((Date.now() / 1000) - tgame.p_date) / (60 * 60 * 24 * 365)); + tgame.days = Math.floor(((Date.now() / 1000) - tgame.p_date) / (60 * 60 * 24) % 365); + console.log(tgame); + console.log(vgscol[game]); + new_col.push(tgame); + } + break; + case 'beaten': + if (vgscol[game].progress == 3) { + new_col.push(vgscol[game]); + } + break; + case 'complete': + if (vgscol[game].progress > 3) { + new_col.push(vgscol[game]); + } + break; + case 'digital': + if (vgscol[game].ownership > 1 && vgscol[game].ownership < 4) { + new_col.push(vgscol[game]); + } + break; + case 'done': + if (vgscol[game].progress >= 3) { + new_col.push(vgscol[game]); + } + break; + case 'members': + if (vgscol[game].ownership == 4) { + new_col.push(vgscol[game]); + } + break; + case 'notes': + if (vgscol[game].notes.length > 0) { + new_col.push(vgscol[game]); + } + break; + case 'owned': + if (vgscol[game].ownership > 0 && vgscol[game].ownership < 4) { + new_col.push(vgscol[game]); + } + break; + case 'physical': + if (vgscol[game].ownership % 2 == 1) { + new_col.push(vgscol[game]); + } + break; + case 'playlog': + if (vgscol[game].progress == 2) { + new_col.push(vgscol[game]); + } + break; + default: + /* filter 'all' */ + new_col.push(vgscol[game]); + } + } + if (filter_name == "backlog_age") { + // sort + new_col.sort(compare_p_date); + } + current_filter = filter_name; + return new_col; +} + +function clear_button_styles() { + flist = document.getElementById("filter_list").children; + for (f in flist) { + flist[f].className = ""; + } +} + +function update() { + clear_button_styles(); + this.className = "selected"; + let fname = this.innerText.toLowerCase().replaceAll(" ", "_"); + console.log(fname); + render(anchor, filter_vgs(gamecol, fname)); +} + +function render(target, vgscol) { + var output = ""; + var note_count = 0; + var game_count = 0; + var system_set = new Array(); + var note_set = new Array(); + + if (current_filter == "backlog_age") { + output = "<table>\n<colgroup>\n<col id=\"title_col\"></col>\n<col></col>\n<col></col>\n<col></col>\n<col></col>\n<col></col>\n</colgroup><thead>\n<th>Title</th>\n<th>System</th>\n<th>Ownership</th>\n<th>Progress</th>\n<th>Years</th>\n<th>Days</th>\n</thead>\n<tbody>\n"; + } else { + output = "<table>\n<colgroup>\n<col id=\"title_col\"></col>\n<col></col>\n<col></col>\n<col></col>\n</colgroup><thead>\n<th>Title</th>\n<th>System</th>\n<th>Ownership</th>\n<th>Progress</th>\n</thead>\n<tbody>\n"; + } + for (var game in vgscol) { + var has_notes = false; + if (vgscol[game].notes.length > 0) { + has_notes = true; + note_count += 1; + note_set.push(vgscol[game].notes); + } + game_count += 1; + if (!system_set.includes(vgscol[game].system)) { + system_set.push(vgscol[game].system); + } + if (has_notes) { + var ntitle = '<span class="notes" onclick="show_notes(gamecol[' + vgscol[game].rowid + ']);">' + vgscol[game].title + '</span>'; + } else { + var ntitle = vgscol[game].title + } + if (current_filter == "backlog_age") { + output += "<tr><td>" + ntitle + "</td><td>" + vgscol[game].system + "</td><td>" + own_map[vgscol[game].ownership] + "</td><td>" + prog_map[vgscol[game].progress] + "</td><td>" + vgscol[game].years.toString() + "</td><td>" + vgscol[game].days.toString() + "</td></tr>\n"; + } else { + output += "<tr><td>" + ntitle + "</td><td>" + vgscol[game].system + "</td><td>" + own_map[vgscol[game].ownership] + "</td><td>" + prog_map[vgscol[game].progress] + "</td></tr>\n"; + } + } + output += "\n</tbody>\n</table>\n"; + message = "<p>Showing " + game_count + " games across " + system_set.length + " systems.</p>"; + target.innerHTML = message + output; +} + +function show_modal(heading, content) { + var backdrop = document.getElementById("modal"); + var modal_box = document.getElementById("modal_box"); + var text = ''; + text = "<span id=\"modal_close\">×</span>\n" + + "<h2>" + heading + "</h2>\n" + + "<div>" + content + "</div>"; + modal_box.innerHTML = text; + window.addEventListener('click', + function(evt) { + if (evt.target.id == 'modal_close' || + evt.target == backdrop) { + backdrop.style.display = 'none'; + } + } + ); + backdrop.style.display = 'block'; +} + +function show_contact() { + let mtext = '<p>If the VGStash webview doesn\'t work for you (i.e. the list of games isn\'t appearing), and you are using a browser that supports <code>XmlHttpRequest</code>, please <a href="mailto:zlg+vgsweb@zlg.space">e-mail me</a> and describe the issue. </p>' + + '<p>When reporting an error, <strong>please include your browser name, its version, and the error output from the browser Console.</strong> In Firefox, the Console can be accessed directly with <kbd>Ctrl+Shift+K</kbd>. Chrome users can open the Dev Tools with <kbd>Ctrl+Shift+I</kbd> and click on the "Console" tab. Next, just copy the text you see and send it in your error report.' + + '<p>Thanks! --zlg</p>'; + show_modal("Contacting ZLG with Issues", mtext); +} + +function show_help() { + let mtext = '<table>' + + '<thead>' + + '<tr>' + + '<th>Text</th>' + + '<th>Meaning</th>' + + '</tr>' + + '</thead>' + + '<tbody>' + + '<tr>' + + '<td>– (dash)</td>' + + '<td>Unbeatable, or Unowned</td>' + + '</tr>' + + '<tr>' + + '<td>Physical</td>' + + '<td>A tangible copy of the game, e.g. cartridge, disc, etc.</td>' + + '</tr>' + + '<tr>' + + '<td>Digital</td>' + + "<td>A virtual copy of the game, i.e. an executable file on your device's storage.</td>" + + '</tr>' + + '<tr>' + + '<td>Member</td>' + + "<td>A game that you own as part of a collection.</td>" + + '</tr>' + + '<tr>' + + '<td>New</td>' + + '<td>The game has not been started yet.</td>' + + '</tr>' + + '<tr>' + + '<td>Playing</td>' + + '<td>The game is in the "now playing" list.</td>' + + '</tr>' + + '<tr>' + + '<td>Beaten</td>' + + "<td>The game's main story has been completed.</td>" + + '</tr>' + + '<tr>' + + '<td>Complete</td>' + + '<td>Every objective has been completed, including any achievements or side missions. Exceptions are made for achievements that are unobtainable due to network dependency.</td>' + + '</tr>' + + '</tbody>' + + '</table>' + + '<p><span class="notes">Underlined</span> game titles have notes you can read if you click on them.</p>'; + show_modal("Legend", mtext); +} + +function show_notes(game) { + console.log(game); + show_modal(game.title + ' [' + game.system + '] Notes', '<pre class="notes">' + game.notes + '</pre>'); +} diff --git a/src/vgstash_cli.py b/src/vgstash_cli.py index 29f6026..ca53576 100755 --- a/src/vgstash_cli.py +++ b/src/vgstash_cli.py @@ -33,7 +33,6 @@ def init(): else: raise sqlite3.OperationalError("Cannot create schema.") - def row_format(row, width, header): """ Prints a row from the result set into a nice table. @@ -77,10 +76,20 @@ def row_format(row, width, header): @click.argument('ownership', type=str, required=False, default=vgstash.DEFAULT_CONFIG['ownership']) @click.argument('progress', type=str, required=False, default=vgstash.DEFAULT_CONFIG['progress']) @click.argument('notes', type=str, required=False, default="") -def add(title, system, ownership, progress, notes): +@click.argument('p_date', type=str, required=False, default="") +@click.argument('b_date', type=str, required=False, default="") +@click.argument('c_date', type=str, required=False, default="") +def add(title, system, ownership, progress, notes, p_date, b_date, c_date): db = get_db() - game = vgstash.Game(title, system, ownership, progress, notes) + game = vgstash.Game(title, system, ownership, progress, notes, p_date, b_date, c_date) try: + # Convert user-input (meant to be RFC2822/ISO8601 dates) to timestamps + if game.p_date: + game.p_date = vgstash.iso_to_unix(game.p_date) + if game.b_date: + game.b_date = vgstash.iso_to_unix(game.b_date) + if game.c_date: + game.c_date = vgstash.iso_to_unix(game.c_date) db.add_game(game, update=False) own_clause = ( "do not own it", @@ -96,13 +105,15 @@ def add(title, system, ownership, progress, notes): "have beaten", "have completed", ) - note_clause = "" if len(game.notes) == 0 else " It also has notes." - click.echo("Added {} for {}. You {} and {} it.{}".format( + note_clause = "" if len(game.notes) == 0 else " It has notes." + date_clause = "" if not (game.p_date or game.b_date or game.c_date) else " It has date data." + click.echo("Added {} for {}. You {} and {} it.{}{}".format( game.title, game.system, own_clause[game.ownership], progress_clause[game.progress], note_clause, + date_clause )) except sqlite3.IntegrityError as e: print(e) @@ -112,33 +123,107 @@ def add(title, system, ownership, progress, notes): @cli.command('list') @click.argument('filter', type=click.Choice(vgstash.FILTERS.keys()), required=False, default="allgames") @click.option('--raw', '-r', is_flag=True, show_default=True, default=False, help="Output raw, pipe-delimited lines") -@click.option('--width', '-w', type=str, required=False, default=get_terminal_size(fallback=(80,24)).columns, help="The width of the table to output, in characters.") -def list_games(filter, raw, width): +def list_games(filter, raw): db = get_db() - res = db.list_games(filter) - first_pass = True - # res can be False if the filter doesn't exist, but Click should catch it - # and spit out an error before this function even starts. - for r in res: - if 'notes' in r.keys() and len(r['notes']) > 0: - notes = r['notes'].replace('\n', '\\n') - notes = notes.replace('\r', '\\r') - else: - notes = '' - if raw: - click.echo("|".join(( - r['title'], - r['system'], - str(r['ownership']), - str(r['progress']), - notes - )) - ) - else: - row_format(r, width, first_pass) - first_pass = False + row_data = db.list_games(filter) + if len(row_data) == 0: + click.echo("No games matching this filter in your collection.") + return + if raw: + for r in row_data: + l = [] + for c in r: + if c == None: + l.append(str('')) + elif type(c) == int: + l.append(str(c)) + else: + tc = c.replace('\n', '\\n') + tc = tc.replace('\r', '\\r') + l.append(tc) + click.echo("|".join(l)) + return + # Get column names, and a list of widths ready to go with them + columns = row_data[0].keys() + widths = [] + for i in range(len(columns)): + widths.append(len(columns[i])) + + # Make a cache to manipulate the data with + row_cache = [] + for r in row_data: + cache_row = [] + for c in r: + cache_row.append(c) + row_cache.append(cache_row) + # We should have a full, mutable cache now! + + + for r in row_cache: + for i in range(len(columns)): + # possible values for r[i]: + # title -> str + # system -> str + # ownership -> int + # progress -> int + # *_date -> int + # process fields that need massaging for display + if r[i] == None: + r[i] = "" + else: + if columns[i] == "progress": + if r[i] == 0: + r[i] = '' + else: + r[i] = vgstash.vtok(r[i], vgstash.PROGRESS)[0].capitalize() + if columns[i] == "ownership": + if r[i] == 0: + r[i] = '' + else: + r[i] = vgstash.vtok(r[i], vgstash.OWNERSHIP)[0].capitalize() + if columns[i] == ("p_date") and r[i] != "": + r[i] = vgstash.unix_to_iso(int(r[i])) + if columns[i] == ("b_date") and r[i] != "": + r[i] = vgstash.unix_to_iso(int(r[i])) + if columns[i] == ("c_date") and r[i] != "": + r[i] = vgstash.unix_to_iso(int(r[i])) + if columns[i] == "notes" and len(r[i]) > 0: + r[i] = "*" + # if isinstance(r[i], int): + # r[i] = str(r[i]) + # Store width in relevant list + w = len(str(r[i])) + if w > widths[i]: + widths[i] = w + + # print the top header + l = [] + left_fst = "{: <{w}s}" + right_fst = "{: >{w}s}" + center_fst = "{: ^{w}s}" + + for i in range(len(columns)): + l.append(center_fst.format(columns[i], w=widths[i])) + click.echo(" | ".join(l)) + + l = [] + for w in widths: + l.append("-"*w) + click.echo("-+-".join(l)) + + # print the collection now that the hard part is done! + for r in row_cache: + l = [] + for i in range(len(columns)): + # TODO: set different fstring based on column name + if columns[i] == 'years' or columns[i] == 'days': + l.append(right_fst.format(str(r[i]), w=widths[i])) + else: + l.append(left_fst.format(r[i], w=widths[i])) + click.echo(" | ".join(l)) + @cli.command('delete') @click.argument('title', required=True) @click.argument('system', required=True) @@ -154,9 +239,16 @@ def delete_game(title, system): @cli.command('update') @click.argument('title', required=True) @click.argument('system', required=True) -@click.argument('attr', type=click.Choice(['title', 'system', 'ownership', 'progress']), required=True) +@click.argument('attr', type=click.Choice(['title', 'system', 'ownership', 'progress', 'p_date', 'b_date', 'c_date']), required=True) @click.argument('val', required=True) def update_game(title, system, attr, val): + """ + Update a specific game's attribute in the database. + + Use the title and system parameters to target the game to update. + Ownership and progress can be their letter or numeric values. + p_date, b_date, and c_date MUST be ISO8601 dates, i.e. 2025-07-27 + """ # TODO: Consider namedtuple as a solution db = get_db() try: @@ -169,11 +261,14 @@ def update_game(title, system, attr, val): if attr == 'progress': val = vgstash.vtok(val, vgstash.PROGRESS) updated_game = vgstash.Game( - val if attr == 'title' else target_game.title, - val if attr == 'system' else target_game.system, - val if attr == 'ownership' else target_game.ownership, - val if attr == 'progress' else target_game.progress, - target_game.notes + title = val if attr == 'title' else target_game.title, + system = val if attr == 'system' else target_game.system, + ownership = val if attr == 'ownership' else target_game.ownership, + progress = val if attr == 'progress' else target_game.progress, + notes = target_game.notes, + p_date = vgstash.iso_to_unix(val) if attr == 'p_date' else target_game.p_date, + b_date = vgstash.iso_to_unix(val) if attr == 'b_date' else target_game.b_date, + c_date = vgstash.iso_to_unix(val) if attr == 'c_date' else target_game.c_date ) if db.update_game(target_game, updated_game): click.echo("Updated {} for {}. Its {} is now {}.".format(title, system, attr, val)) @@ -4,7 +4,7 @@ # and then run "tox" from this directory. [tox] -envlist = py39 +envlist = py313 [testenv] deps = |
