aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--TODO.txt10
-rwxr-xr-xscripts/schema1-to-2.py86
-rwxr-xr-xsrc/vgstash/__init__.py57
-rwxr-xr-xsrc/vgstash_cli.py127
-rw-r--r--tox.ini2
5 files changed, 224 insertions, 58 deletions
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/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/src/vgstash/__init__.py b/src/vgstash/__init__.py
index d28e587..dcf6c5b 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)
@@ -320,10 +336,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 +352,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_cli.py b/src/vgstash_cli.py
index 29f6026..046443d 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,89 @@ 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 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)):
+ # process fields that need massaging for display
+ if r[i]:
+ if columns[i] == ("p_date"):
+ r[i] = vgstash.unix_to_iso(r[i])
+ if columns[i] == ("b_date"):
+ r[i] = vgstash.unix_to_iso(r[i])
+ if columns[i] == ("c_date"):
+ r[i] = vgstash.unix_to_iso(r[i])
+ if columns[i] == "notes" and len(r[i]) > 0:
+ r[i] = "*"
+ if columns[i] == "progress":
+ r[i] = vgstash.vtok(r[i], vgstash.PROGRESS)[0].capitalize()
+ if columns[i] == "ownership":
+ r[i] = vgstash.vtok(r[i], vgstash.OWNERSHIP)[0].capitalize()
+ if r[i] == None:
+ 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
+ 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)
diff --git a/tox.ini b/tox.ini
index f627225..14ef142 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,7 +4,7 @@
# and then run "tox" from this directory.
[tox]
-envlist = py39
+envlist = py311
[testenv]
deps =