aboutsummaryrefslogtreecommitdiff
path: root/src/vgstash/__init__.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/vgstash/__init__.py')
-rwxr-xr-xsrc/vgstash/__init__.py297
1 files changed, 297 insertions, 0 deletions
diff --git a/src/vgstash/__init__.py b/src/vgstash/__init__.py
new file mode 100755
index 0000000..61ae9e6
--- /dev/null
+++ b/src/vgstash/__init__.py
@@ -0,0 +1,297 @@
+import os
+import sys
+import sqlite3
+
+# Remixed vgstash. This time I chose an individual file on purpose:
+#
+# * Only need a single module to interface with a game DB. That's awesome.
+# * Keeps the backend separate from the UI, as intended.
+# * SQLA and pandas are both too large and unwieldy for my intent, and deps
+# will need to be kept low if I want to be cross-platform. sqlite3's part of
+# the standard library, can't beat that.
+# * GUI will be implemented in tkinter, Qt, and/or Kivy.
+
+PROGRESS = {
+ 'unbeatable': 0,
+ 'new': 1,
+ 'playing': 2,
+ 'beaten': 3,
+ 'complete': 4,
+}
+
+OWNERSHIP = {
+ 'unowned': 0,
+ 'physical': 1,
+ 'digital': 2,
+ 'both': 3
+}
+
+DEFAULT_CONFIG = {
+ 'db_location': os.getenv('VGSTASH_DB_LOCATION', os.path.join(os.getenv('HOME', os.curdir), '.vgstash.db')),
+ 'progress': os.getenv('VGSTASH_DEFAULT_PROGRESS', PROGRESS['playing']),
+ 'ownership': os.getenv('VGSTASH_DEFAULT_OWNERSHIP', OWNERSHIP['physical'])
+}
+
+FILTERS = {
+ 'allgames': "SELECT * FROM games ORDER BY system, title",
+ 'backlog': "SELECT * FROM games WHERE ownership > 0 AND progress = 0 OR progress = 1 ORDER BY system, title",
+ 'playlog': "SELECT * FROM games WHERE ownership > 0 AND progress = 2 ORDER BY system, title"
+}
+
+def kvmatch(arg, dict_map, fallback):
+ """
+ Match arg against keys or values in dict_map, returning fallback if no match.
+
+ This function performs a prefix-match against the keys in dict_map. Doing
+ such iteration partially defeats the purpose of using a dictionary, but
+ it offers considerable ease-of-use for the CLI and acts as a validation
+ function that will always return a valid value ready for committing to the
+ database.
+
+ It is generalized to support any value that needs to be mapped to an
+ integer, via the dict_map.
+ """
+ try:
+ ret = int(arg)
+ except TypeError:
+ ret = fallback
+ except ValueError:
+ found = False
+ for k in dict_map.keys():
+ if k.startswith(arg):
+ ret = dict_map[k]
+ found = True
+ break
+ if not found:
+ ret = fallback
+ finally:
+ if ret not in dict_map.values():
+ ret = fallback
+ return ret
+
+
+class DB(object):
+ """
+ The central class of vgstash. It handles everything relating to storing the
+ game collection.
+ """
+ def __init__(self, path=DEFAULT_CONFIG['db_location']):
+ """
+ Initiates the DB object with a 'conn' attribute, which holds the SQLite
+ connection. Additionally, the connection's 'row_factory' attribute is
+ set to sqlite3.Row to allow for string *and* integer indexes. This value
+ may be changed by the caller after the object is returned.
+ """
+ try:
+ self.conn = sqlite3.connect(path)
+ self.conn.row_factory = sqlite3.Row
+ except sqlite3.OperationalError as e:
+ print("%s: %s".format(e, path))
+ exit()
+
+ def add_filter(self, filter_name, stmt):
+ """
+ Adds a new filter keyword to the database. Note that values are passed
+ directly in, with no escaping. Use with caution.
+
+ 'filter_name' is the name of your filter, and will be the 'name' key
+ when viewed with list_filters(). usable via `vgscli list [filter name]`.
+ 'stmt' is a plain SELECT statement representing the SQLite VIEW.
+ """
+ if filter_name.startswith("sqlite_"):
+ raise ValueError("Cannot create a filter with the 'sqlite_' prefix.")
+
+ # The call to format() is needed because sqlite doesn't allow
+ # parameterized view or table names. This may affect database
+ # integrity, and may disappear in a later release.
+ res = self.conn.execute("CREATE VIEW\
+ IF NOT EXISTS \
+ '{}'\
+ AS\
+ {}".format(filter_name, stmt))
+ FILTERS[str(filter_name)] = str(stmt)
+ return self.has_filter(filter_name)
+
+ def add_game(self, game, update=True):
+ """
+ Adds a Game to the database. Returns True on success and False on
+ failure.
+ """
+ if self.has_game(game) and update:
+ return self.update_game(game, game)
+ else:
+ c = self.conn.execute("INSERT INTO games\
+ (title, system, ownership, progress, notes)\
+ VALUES\
+ (?, ?, ?, ?, ?)",
+ (game.title, game.system, game.ownership, game.progress, game.notes))
+ self.conn.commit()
+ return (c.rowcount > 0)
+
+ def create_schema(self):
+ """
+ Initializes the database with this basic schema:
+
+ games:
+ title (TEXT)
+ system (TEXT)
+ ownership (INTEGER)
+ progress (INTEGER)
+ notes (TEXT)
+
+ 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
+ and system to be present in the database.
+
+ Additionally, create_schema will add the default set of filters, which
+ are considered important for an ordinary vgstash DB.
+ """
+ # The UNIQUE clause ensures that no two games with the same title and
+ # system are added to the database. An sqlite3.IntegrityError will be
+ # raised when a match occurs; clients can decide how to handle it.
+ #
+ # The 'rowid' field is automatically generated by sqlite, and is thus
+ # omitted. External modifications to the database should be done
+ # through additional tables, and should reference the 'rowid' field to
+ # 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\
+ )")
+ # setup default filters while we're here
+ for name, sql in sorted(FILTERS.items()):
+ self.add_filter(name, sql)
+ return True
+ except sqlite3.OperationalError:
+ print("Table already exists, skipping.")
+ return False
+
+ def delete_filter(self, filter_name):
+ with self.conn:
+ # try:
+ self.conn.execute("DROP VIEW IF EXISTS '{}'".format(filter_name))
+ return True
+ # except:
+ # sys.exc_info()
+ # print("Could not remove filter '{}'.".format((filter_name))
+ return False
+
+ def delete_game(self, game):
+ """
+ Deletes a game from the database. Returns True on success and False
+ on failure.
+ """
+ if self.has_game(game):
+ c = self.conn.cursor()
+ c.execute("DELETE FROM games\
+ WHERE title=? AND system=?",
+ (game.title, game.system))
+ self.conn.commit()
+ return True
+ else:
+ return False
+
+ def has_game(self, game, fuzzy=False):
+ """
+ Returns whether or not the game is in the database.
+
+ game - The Game object to search for.
+ fuzzy - Fuzzy search, using internal 'LIKE' and replacing the game
+ title's spaces with '%' characters. Defaults to False.
+ """
+ if fuzzy:
+ game.title = "%".join(['', game.title.replace(" ", "%"), ''])
+ stmt = "SELECT * FROM games WHERE title LIKE ? AND system=?"
+ else:
+ stmt = "SELECT * FROM games WHERE title=? AND system=?"
+
+ res = self.conn.execute(stmt, (game.title, game.system)).fetchone()
+ # res is None if there isn't a match, which evaluates to False
+ return bool(res)
+
+ def has_filter(self, filter_name):
+ return filter_name in self.list_filters().keys() \
+ and filter_name in FILTERS
+
+ def list_filters(self):
+ """
+ Provides an iterable of filter names and their associated SELECT
+ statements.
+ """
+ # The 'sqlite_master' table is a built-in.
+ # This returns an iterable of sqlite3.Row, which can be accessed
+ # similarly to a dictionary.
+ res = self.conn.execute(\
+ "SELECT name,sql\
+ FROM sqlite_master\
+ WHERE\
+ type='view'\
+ ORDER BY name ASC").fetchall()
+ ret = {}
+ for row in res:
+ ret[row['name']] = row['sql']
+ # Be sure to sync with internal representation
+ FILTERS = ret
+ return ret
+
+ def list_games(self, filter='allgames'):
+ if filter not in FILTERS.keys():
+ filter = 'allgames'
+ return self.conn.execute(FILTERS[filter]).fetchall()
+
+ def update_filter(self, filter_name, stmt):
+ """
+ Updates a filter's definition within the database.
+
+ SQLite does not have a way to update VIEWs directly, so this is a
+ convenience function to make updating possible.
+ """
+ try:
+ self.delete_filter(filter_name)
+ self.add_filter(filter_name, stmt)
+ return True
+ except:
+ return False
+
+ def update_game(self, target, source):
+ """
+ Look for target in the database and (if found) update it with source's
+ information. Returns True on success and False on failure.
+
+ The same Game object may be passed as both target and source to update
+ a given game's values within the database.
+ """
+ # don't update unless it exists
+ if self.has_game(target):
+ c = self.conn.cursor()
+ # TODO: do this better
+ c.execute("UPDATE games\
+ SET title=?, system=?, ownership=?, progress=?\
+ WHERE title=? AND system=?",
+ (source.title, source.system, source.ownership, source.progress, target.title, target.system))
+ self.conn.commit()
+ return (c.rowcount > 0)
+ else:
+ return False
+
+
+class Game(object):
+ """The core data structure of vgstash."""
+ def __init__(self, title, system,
+ ownership=DEFAULT_CONFIG['ownership'],
+ progress=DEFAULT_CONFIG['progress'],
+ notes=""):
+
+ 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