aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--MANIFEST.in2
-rw-r--r--README.md51
-rw-r--r--TODO.txt10
-rw-r--r--pyproject.toml3
-rwxr-xr-xscripts/schema1-to-2.py86
-rw-r--r--setup.cfg7
-rwxr-xr-xsetup.py19
-rwxr-xr-xsrc/vgstash/__init__.py67
-rw-r--r--src/vgstash/web/vgstash-favicon.pngbin0 -> 324 bytes
-rw-r--r--src/vgstash/web/vgstash-web.html46
-rw-r--r--src/vgstash/web/vgstash.css333
-rw-r--r--src/vgstash/web/vgstash.js278
-rwxr-xr-xsrc/vgstash_cli.py165
-rw-r--r--tox.ini2
15 files changed, 989 insertions, 82 deletions
diff --git a/.gitignore b/.gitignore
index da6c42f..da33c32 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/README.md b/README.md
index 1f192c2..a89d432 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/TODO.txt b/TODO.txt
index 6f54d7f..705f65c 100644
--- a/TODO.txt
+++ b/TODO.txt
@@ -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:
diff --git a/setup.py b/setup.py
index 76795c0..23edf11 100755
--- a/setup.py
+++ b/setup.py
@@ -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
new file mode 100644
index 0000000..5456f3b
--- /dev/null
+++ b/src/vgstash/web/vgstash-favicon.png
Binary files differ
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> &copy; 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: "&ndash;",
+ 1: "Physical",
+ 2: "Digital",
+ 3: "Both",
+ 4: "Member"
+}
+var prog_map = {
+ 0: "&ndash;",
+ 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\">&times;</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>&ndash; (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))
diff --git a/tox.ini b/tox.ini
index f627225..668457d 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,7 +4,7 @@
# and then run "tox" from this directory.
[tox]
-envlist = py39
+envlist = py313
[testenv]
deps =