aboutsummaryrefslogtreecommitdiff
path: root/src/vgstash/__init__.py
blob: 48ac055ae907a29b1aa520ffbbd5858c5ff11b5e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
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 ASC",
    'backlog':  "SELECT * FROM games WHERE ownership > 0 AND progress > 0 AND progress < 3 ORDER BY system, title ASC",
    'borrowing': "SELECT * FROM games WHERE ownership = 0 AND progress = 2 ORDER BY system, title ASC",
    'complete': "SELECT * FROM games WHERE progress = 4 ORDER BY system, title ASC",
    'digital': "SELECT * FROM games WHERE ownership = 2 ORDER BY system, title ASC",
    'done': "SELECT * FROM games WHERE progress > 2 ORDER BY system, title ASC",
    'incomplete': "SELECT * FROM games WHERE progress = 3 AND ownership > 0 ORDER BY system, title ASC",
    'new': "SELECT * FROM games WHERE progress = 1 ORDER BY system, title ASC",
    'notes': "SELECT * FROM games WHERE notes NOT LIKE '' ORDER BY system, title ASC",
    'owned': "SELECT * FROM games WHERE ownership > 0 ORDER BY system, title ASC",
    '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",
}

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


def vtok(arg, dict_map):
    """
    Match an integer value to a key name in the mapping dictionary. Returns a
    string (the key name) if found, False if not found.
    """
    if isinstance(arg, int):
        for k, v in dict_map.items():
            if arg == v:
                return k
    elif isinstance(arg, str):
        for k, v in dict_map.items():
            if k.startswith(arg):
                return k
        return False


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("{}: {}".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 `vgstash list [filter name]`.
        'stmt' is a plain SELECT statement representing the internal 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 get_game(self, title, system):
        """
        Fetches a game's information from the database. Returns a Game 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'])
        else:
            raise KeyError

    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'):
        """
        Return a list of games that meet the filter criteria.

        If the filter exists, it returns a list of sqlite3.Row objects. If the
        filter does not exist, it returns False. If no filter is specified, it
        will return a list of all games in the database.
        """
        if filter not in FILTERS.keys():
            return False
        else:
            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=?, notes=?\
                    WHERE title=? AND system=?",
                    (source.title, source.system, source.ownership, source.progress, source.notes, 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