vinsynlib 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vinsynlib/__init__.py +107 -0
- vinsynlib/cli.py +256 -0
- vinsynlib/config.py +454 -0
- vinsynlib/conformance.py +274 -0
- vinsynlib/devchecks.py +148 -0
- vinsynlib/favorites.py +718 -0
- vinsynlib/keys.py +267 -0
- vinsynlib/midi.py +329 -0
- vinsynlib/py.typed +0 -0
- vinsynlib/spec.py +341 -0
- vinsynlib/terms.py +234 -0
- vinsynlib/ui/__init__.py +24 -0
- vinsynlib/ui/hints.py +84 -0
- vinsynlib-0.2.0.dist-info/METADATA +284 -0
- vinsynlib-0.2.0.dist-info/RECORD +19 -0
- vinsynlib-0.2.0.dist-info/WHEEL +5 -0
- vinsynlib-0.2.0.dist-info/licenses/COPYING +339 -0
- vinsynlib-0.2.0.dist-info/licenses/LICENSE +58 -0
- vinsynlib-0.2.0.dist-info/top_level.txt +1 -0
vinsynlib/favorites.py
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (C) 2026 vinsynlib contributors
|
|
3
|
+
#
|
|
4
|
+
# This file is part of vinsynlib.
|
|
5
|
+
#
|
|
6
|
+
# Assembled from the favourites databases that every browser in the family
|
|
7
|
+
# carried its own copy of:
|
|
8
|
+
# Copyright (C) 2026 emorphed contributors - GPL-2.0-or-later
|
|
9
|
+
# Copyright (C) 2026 ensqsqed contributors - GPL-2.0-or-later
|
|
10
|
+
# Copyright (C) 2026 kwsed contributors - GPL-2.0-or-later
|
|
11
|
+
# Copyright (C) 2026 nanosyned contributors - GPL-2.0-or-later
|
|
12
|
+
# Copyright (C) 2026 p2ked contributors - GPL-2.0-or-later
|
|
13
|
+
# Copyright (C) 2026 rxved contributors - GPL-2.0-or-later
|
|
14
|
+
# Copyright (C) 2026 x5ded contributors - GPL-2.0-or-later
|
|
15
|
+
#
|
|
16
|
+
# The seven copies were byte-identical apart from the prose and the
|
|
17
|
+
# example bank names in it, which is why this is one file.
|
|
18
|
+
#
|
|
19
|
+
# vinsynlib is free software: you can redistribute it and/or modify it under
|
|
20
|
+
# the terms of the GNU General Public License as published by the Free
|
|
21
|
+
# Software Foundation, either version 2 of the License, or (at your option)
|
|
22
|
+
# any later version.
|
|
23
|
+
#
|
|
24
|
+
# vinsynlib is distributed in the hope that it will be useful, but WITHOUT
|
|
25
|
+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
26
|
+
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
27
|
+
# more details.
|
|
28
|
+
|
|
29
|
+
"""The local favourites database.
|
|
30
|
+
|
|
31
|
+
The one piece of state in these tools that is genuinely the **user's**
|
|
32
|
+
rather than the device's or the manufacturer's, which is why it gets a
|
|
33
|
+
real database rather than another key in ``config.toml``. Three things
|
|
34
|
+
follow from that:
|
|
35
|
+
|
|
36
|
+
**It is keyed on the slot, not on the name.** A row records the bank and
|
|
37
|
+
the displayed number, never the name -- so a favourite survives a rename,
|
|
38
|
+
and a favourite of a slot means the slot, permanently. The name is stored
|
|
39
|
+
too, but only as a label to show when the catalog is missing; it is never
|
|
40
|
+
the identity. The displayed number is what the unit shows, not the wire
|
|
41
|
+
byte: on many instruments the first patch of a bank is number 1 and
|
|
42
|
+
program change 0.
|
|
43
|
+
|
|
44
|
+
**The bank id is the catalog's, not a wire number.** Wire numbering is the
|
|
45
|
+
instrument's business and can differ from the order a person sees the
|
|
46
|
+
banks in; a favourites file outlives any particular reading of it.
|
|
47
|
+
|
|
48
|
+
**It is SQLite, and that is not over-engineering for a list of
|
|
49
|
+
favourites.** A JSON file rewritten on every toggle loses the whole file to
|
|
50
|
+
a crash or a full disk mid-write, and the natural next features --
|
|
51
|
+
ratings, tags, notes, "when did I last use this" -- are all things a flat
|
|
52
|
+
file grows badly. SQLite gives atomic writes for free. The schema is
|
|
53
|
+
versioned from the first commit so the second version does not have to
|
|
54
|
+
guess what the first one wrote.
|
|
55
|
+
|
|
56
|
+
**It lives outside the project directory by default**, in the per-platform
|
|
57
|
+
application-*data* directory -- see :func:`data_dir` for where that is on
|
|
58
|
+
each and why. Unlike ``config.toml``, which is a disposable cache of which
|
|
59
|
+
MIDI port answered last and lives in the working directory, favourites are
|
|
60
|
+
the user's own work: the one thing here worth backing up, and the one
|
|
61
|
+
thing that must not be lost to a ``git clean`` in a checkout.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
from __future__ import annotations
|
|
65
|
+
|
|
66
|
+
import os
|
|
67
|
+
import sqlite3
|
|
68
|
+
import sys
|
|
69
|
+
import threading
|
|
70
|
+
import time
|
|
71
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
72
|
+
from dataclasses import dataclass
|
|
73
|
+
from typing import Any
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"APP_NAME",
|
|
77
|
+
"DB_NAME",
|
|
78
|
+
"SCHEMA_VERSION",
|
|
79
|
+
"Bound",
|
|
80
|
+
"Favorite",
|
|
81
|
+
"Favorites",
|
|
82
|
+
"bind",
|
|
83
|
+
"data_dir",
|
|
84
|
+
"default_path",
|
|
85
|
+
"split_key",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
#: Bumped whenever the schema changes; :meth:`Favorites._migrate` reads it.
|
|
89
|
+
SCHEMA_VERSION = 2
|
|
90
|
+
|
|
91
|
+
#: The database's filename, in whichever per-platform data root applies.
|
|
92
|
+
DB_NAME = "favorites.db"
|
|
93
|
+
|
|
94
|
+
#: Used when no application name is given. A library cannot know the name
|
|
95
|
+
#: of the program using it; each project passes its own.
|
|
96
|
+
APP_NAME = "vinsynlib"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def data_dir(app_name: str = APP_NAME) -> str:
|
|
100
|
+
"""The per-platform directory for an application's **data**.
|
|
101
|
+
|
|
102
|
+
Data, not configuration: the distinction is real on every platform and
|
|
103
|
+
matters here, because favourites are the user's own work -- the one
|
|
104
|
+
thing in these tools worth backing up -- while ``config.toml`` is a
|
|
105
|
+
cache of which MIDI port answered last and is disposable.
|
|
106
|
+
|
|
107
|
+
* **Windows** -- ``%LOCALAPPDATA%\\<app>`` (typically
|
|
108
|
+
``C:\\Users\\<user>\\AppData\\Local\\<app>``).
|
|
109
|
+
|
|
110
|
+
Local rather than Roaming deliberately. ``%APPDATA%`` roams, and a
|
|
111
|
+
roaming profile copies files wholesale at logon and logoff; a SQLite
|
|
112
|
+
database caught mid-write by that copy, or opened on two machines at
|
|
113
|
+
once against one synced file, is a known way to corrupt one. SQLite's
|
|
114
|
+
own documentation warns against network filesystems for the same
|
|
115
|
+
reason. Roaming would be the right answer for a small settings file
|
|
116
|
+
and is the wrong one for a database.
|
|
117
|
+
|
|
118
|
+
* **macOS** -- ``~/Library/Application Support/<app>``, which is where
|
|
119
|
+
Apple's File System Programming Guide puts application data that is
|
|
120
|
+
not a cache and not a user document.
|
|
121
|
+
|
|
122
|
+
* **Linux, BSD, everything else** -- the XDG Base Directory
|
|
123
|
+
Specification: ``$XDG_DATA_HOME/<app>``, falling back to
|
|
124
|
+
``~/.local/share/<app>``. Note that XDG requires ``XDG_DATA_HOME`` to
|
|
125
|
+
be an **absolute** path and says a relative one must be ignored, so a
|
|
126
|
+
stray relative value falls back rather than creating a directory
|
|
127
|
+
wherever the process happens to have been started.
|
|
128
|
+
|
|
129
|
+
Every branch falls back to the XDG layout if the platform's own
|
|
130
|
+
environment variable is missing, which is what happens on a stripped
|
|
131
|
+
Windows service account or a macOS process with no HOME. A wrong-looking
|
|
132
|
+
but writable path beats raising on startup, since the alternative is a
|
|
133
|
+
browser that will not open because it cannot decide where to put a file
|
|
134
|
+
the user may never use.
|
|
135
|
+
"""
|
|
136
|
+
if sys.platform == "win32":
|
|
137
|
+
base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
|
|
138
|
+
if base:
|
|
139
|
+
return os.path.join(base, app_name)
|
|
140
|
+
elif sys.platform == "darwin":
|
|
141
|
+
home = os.path.expanduser("~")
|
|
142
|
+
# Absolute, not merely different from "~": a process with HOME set
|
|
143
|
+
# to a relative value -- a stripped service account, a container
|
|
144
|
+
# with a placeholder -- expands to that relative path rather than
|
|
145
|
+
# to a tilde, and the branch below would then return a path
|
|
146
|
+
# relative to the working directory, which is never where the
|
|
147
|
+
# user's data is.
|
|
148
|
+
if os.path.isabs(home):
|
|
149
|
+
return os.path.join(
|
|
150
|
+
home, "Library", "Application Support", app_name
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
base = os.environ.get("XDG_DATA_HOME")
|
|
154
|
+
# XDG: "If $XDG_DATA_HOME is either not set or empty, a default equal to
|
|
155
|
+
# $HOME/.local/share should be used." and paths in it must be absolute.
|
|
156
|
+
if not base or not os.path.isabs(base):
|
|
157
|
+
base = os.path.join(os.path.expanduser("~"), ".local", "share")
|
|
158
|
+
return os.path.join(base, app_name)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def default_path(app_name: str = APP_NAME, db_name: str = DB_NAME) -> str:
|
|
162
|
+
"""Where an application's favourites database lives, per platform.
|
|
163
|
+
|
|
164
|
+
Overridable everywhere it is used -- ``--favorites`` on both front
|
|
165
|
+
ends -- so a user who wants it beside a project, on a stick, or in a
|
|
166
|
+
synced folder can say so.
|
|
167
|
+
"""
|
|
168
|
+
return os.path.join(data_dir(app_name), db_name)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class _Result:
|
|
172
|
+
"""The rows of one statement, already fetched.
|
|
173
|
+
|
|
174
|
+
:class:`_LockedConnection` hands this back instead of a live cursor,
|
|
175
|
+
because a cursor iterated *after* the lock is released is exactly the
|
|
176
|
+
interleaving the lock exists to prevent. Everything is read inside the
|
|
177
|
+
lock; what comes out is an ordinary list.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
__slots__ = ("rowcount", "rows")
|
|
181
|
+
|
|
182
|
+
def __init__(self, rows: list[Any], rowcount: int) -> None:
|
|
183
|
+
self.rows = rows
|
|
184
|
+
self.rowcount = rowcount
|
|
185
|
+
|
|
186
|
+
def fetchone(self) -> Any | None:
|
|
187
|
+
return self.rows[0] if self.rows else None
|
|
188
|
+
|
|
189
|
+
def fetchall(self) -> list[Any]:
|
|
190
|
+
return self.rows
|
|
191
|
+
|
|
192
|
+
def __iter__(self) -> Iterator[Any]:
|
|
193
|
+
return iter(self.rows)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class _LockedConnection:
|
|
197
|
+
"""A SQLite connection usable from more than one thread.
|
|
198
|
+
|
|
199
|
+
SQLite refuses cross-thread use of a connection by default, and that
|
|
200
|
+
default is a silent mine in a Textual application: the store is opened
|
|
201
|
+
on the main thread, and any background worker that so much as asks for
|
|
202
|
+
``len()`` raises ``ProgrammingError`` *out of the worker*, which takes
|
|
203
|
+
the whole application down. That is not hypothetical -- it is what
|
|
204
|
+
pressing `i` did, the first time anybody pressed it, because the
|
|
205
|
+
device-info worker built its report on the wrong thread.
|
|
206
|
+
|
|
207
|
+
The structural fix was to move that work back to the main thread. This
|
|
208
|
+
is the belt to that's braces: one lock, held across each statement and
|
|
209
|
+
its fetch, so the next worker to reach for the database finds it safe
|
|
210
|
+
rather than fatal. SQLite is content with a connection shared between
|
|
211
|
+
threads as long as the calls do not overlap, and serialising operations
|
|
212
|
+
this small costs nothing at this size.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
def __init__(self, path: str) -> None:
|
|
216
|
+
self._lock = threading.RLock()
|
|
217
|
+
self._db = sqlite3.connect(path, check_same_thread=False)
|
|
218
|
+
self._db.row_factory = sqlite3.Row
|
|
219
|
+
|
|
220
|
+
def execute(self, sql: str, parameters: Sequence[Any] = ()) -> _Result:
|
|
221
|
+
with self._lock:
|
|
222
|
+
cursor = self._db.execute(sql, parameters)
|
|
223
|
+
rows = cursor.fetchall()
|
|
224
|
+
return _Result(rows, cursor.rowcount)
|
|
225
|
+
|
|
226
|
+
def executescript(self, sql: str) -> None:
|
|
227
|
+
with self._lock:
|
|
228
|
+
self._db.executescript(sql)
|
|
229
|
+
|
|
230
|
+
def commit(self) -> None:
|
|
231
|
+
with self._lock:
|
|
232
|
+
self._db.commit()
|
|
233
|
+
|
|
234
|
+
def close(self) -> None:
|
|
235
|
+
with self._lock:
|
|
236
|
+
self._db.close()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass(frozen=True)
|
|
240
|
+
class Favorite:
|
|
241
|
+
"""One favourited slot."""
|
|
242
|
+
|
|
243
|
+
bank_id: str
|
|
244
|
+
number: int
|
|
245
|
+
#: The name at the time of favouriting, as a label of last resort. Not
|
|
246
|
+
#: the identity: see the module docstring.
|
|
247
|
+
name: str = ""
|
|
248
|
+
rating: int = 0
|
|
249
|
+
tags: str = ""
|
|
250
|
+
note: str = ""
|
|
251
|
+
added: float = 0.0
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def key(self) -> str:
|
|
255
|
+
"""``BANK:NNN`` -- the display form of this slot's identity."""
|
|
256
|
+
return f"{self.bank_id}:{self.number:03d}"
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def tag_list(self) -> list[str]:
|
|
260
|
+
"""The tags, split and stripped."""
|
|
261
|
+
return [
|
|
262
|
+
t for t in (part.strip() for part in self.tags.split(",")) if t
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class Favorites:
|
|
267
|
+
"""A SQLite-backed set of favourited slots.
|
|
268
|
+
|
|
269
|
+
Usable as a context manager. Safe to construct against a path whose
|
|
270
|
+
directory does not exist yet -- it is created.
|
|
271
|
+
|
|
272
|
+
``app_name`` only decides where :meth:`path` defaults to, and what that
|
|
273
|
+
decision is called in the error message for a database written by a
|
|
274
|
+
newer schema.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
def __init__(
|
|
278
|
+
self,
|
|
279
|
+
path: str | None = None,
|
|
280
|
+
*,
|
|
281
|
+
app_name: str = APP_NAME,
|
|
282
|
+
db_name: str = DB_NAME,
|
|
283
|
+
) -> None:
|
|
284
|
+
self.app_name = app_name
|
|
285
|
+
self.path = path or default_path(app_name, db_name)
|
|
286
|
+
if self.path != ":memory:":
|
|
287
|
+
os.makedirs(
|
|
288
|
+
os.path.dirname(os.path.abspath(self.path)), exist_ok=True
|
|
289
|
+
)
|
|
290
|
+
# Shared across threads on purpose; see _LockedConnection.
|
|
291
|
+
self._db = _LockedConnection(self.path)
|
|
292
|
+
self._db.execute("PRAGMA journal_mode=WAL")
|
|
293
|
+
self._migrate()
|
|
294
|
+
|
|
295
|
+
# --- schema -------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
def _migrate(self) -> None:
|
|
298
|
+
"""Create or upgrade the schema.
|
|
299
|
+
|
|
300
|
+
``user_version`` rather than a table of our own: it is a single
|
|
301
|
+
integer in the database header, costs no row, and cannot itself be
|
|
302
|
+
the thing that is missing when we go looking for the version.
|
|
303
|
+
"""
|
|
304
|
+
row = self._db.execute("PRAGMA user_version").fetchone()
|
|
305
|
+
current = int(row[0]) if row is not None else 0
|
|
306
|
+
if current > SCHEMA_VERSION:
|
|
307
|
+
raise RuntimeError(
|
|
308
|
+
f"{self.path} was written by a newer {self.app_name} "
|
|
309
|
+
f"(schema {current}, this build understands "
|
|
310
|
+
f"{SCHEMA_VERSION}). Refusing to touch it rather than risk "
|
|
311
|
+
f"dropping columns it has and this does not."
|
|
312
|
+
)
|
|
313
|
+
if current < 1:
|
|
314
|
+
self._db.executescript(
|
|
315
|
+
"""
|
|
316
|
+
CREATE TABLE IF NOT EXISTS favorites (
|
|
317
|
+
bank_id TEXT NOT NULL,
|
|
318
|
+
number INTEGER NOT NULL,
|
|
319
|
+
name TEXT NOT NULL DEFAULT '',
|
|
320
|
+
rating INTEGER NOT NULL DEFAULT 0,
|
|
321
|
+
tags TEXT NOT NULL DEFAULT '',
|
|
322
|
+
note TEXT NOT NULL DEFAULT '',
|
|
323
|
+
added REAL NOT NULL DEFAULT 0,
|
|
324
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
325
|
+
PRIMARY KEY (bank_id, number)
|
|
326
|
+
);
|
|
327
|
+
CREATE INDEX IF NOT EXISTS favorites_rating
|
|
328
|
+
ON favorites (rating DESC);
|
|
329
|
+
"""
|
|
330
|
+
)
|
|
331
|
+
if current < 2:
|
|
332
|
+
# Un-favouriting stopped deleting the row. It clears this flag
|
|
333
|
+
# instead, so a rating, tags, a note and the original `added`
|
|
334
|
+
# date survive a slot being toggled off and on -- two presses of
|
|
335
|
+
# `f` used to destroy all four, silently, on the one store these
|
|
336
|
+
# tools call the user's own. Every read filters on it.
|
|
337
|
+
#
|
|
338
|
+
# DEFAULT 1 is what makes the upgrade a no-op for existing rows:
|
|
339
|
+
# everything already in the table is a live favourite.
|
|
340
|
+
have = {
|
|
341
|
+
row["name"]
|
|
342
|
+
for row in self._db.execute("PRAGMA table_info(favorites)")
|
|
343
|
+
}
|
|
344
|
+
if "active" not in have:
|
|
345
|
+
self._db.execute(
|
|
346
|
+
"ALTER TABLE favorites ADD COLUMN active INTEGER "
|
|
347
|
+
"NOT NULL DEFAULT 1"
|
|
348
|
+
)
|
|
349
|
+
self._db.execute(
|
|
350
|
+
"CREATE INDEX IF NOT EXISTS favorites_active "
|
|
351
|
+
"ON favorites (active)"
|
|
352
|
+
)
|
|
353
|
+
self._db.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
|
354
|
+
self._db.commit()
|
|
355
|
+
|
|
356
|
+
# --- lifecycle ----------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
def close(self) -> None:
|
|
359
|
+
try:
|
|
360
|
+
self._db.commit()
|
|
361
|
+
finally:
|
|
362
|
+
self._db.close()
|
|
363
|
+
|
|
364
|
+
def __enter__(self) -> Favorites:
|
|
365
|
+
return self
|
|
366
|
+
|
|
367
|
+
def __exit__(self, *_exc: Any) -> None:
|
|
368
|
+
self.close()
|
|
369
|
+
|
|
370
|
+
# --- reading ------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
def __contains__(self, key: str | tuple[str, int]) -> bool:
|
|
373
|
+
bank_id, number = split_key(key)
|
|
374
|
+
row = self._db.execute(
|
|
375
|
+
"SELECT 1 FROM favorites WHERE bank_id = ? AND number = ? AND active = 1",
|
|
376
|
+
(bank_id, number),
|
|
377
|
+
).fetchone()
|
|
378
|
+
return row is not None
|
|
379
|
+
|
|
380
|
+
def __len__(self) -> int:
|
|
381
|
+
row = self._db.execute(
|
|
382
|
+
"SELECT COUNT(*) FROM favorites WHERE active = 1"
|
|
383
|
+
).fetchone()
|
|
384
|
+
return int(row[0]) if row is not None else 0
|
|
385
|
+
|
|
386
|
+
def get(self, bank_id: str, number: int) -> Favorite | None:
|
|
387
|
+
"""The live favourite for a slot, or ``None``."""
|
|
388
|
+
row = self._db.execute(
|
|
389
|
+
"SELECT * FROM favorites WHERE bank_id = ? AND number = ? AND active = 1",
|
|
390
|
+
(bank_id, int(number)),
|
|
391
|
+
).fetchone()
|
|
392
|
+
return _to_favorite(row) if row is not None else None
|
|
393
|
+
|
|
394
|
+
def _row(self, bank_id: str, number: int) -> Favorite | None:
|
|
395
|
+
"""Like :meth:`get`, but sees un-favourited rows too.
|
|
396
|
+
|
|
397
|
+
Private because every caller outside this class means "is this a
|
|
398
|
+
favourite", and a dormant row is not one.
|
|
399
|
+
"""
|
|
400
|
+
row = self._db.execute(
|
|
401
|
+
"SELECT * FROM favorites WHERE bank_id = ? AND number = ?",
|
|
402
|
+
(bank_id, int(number)),
|
|
403
|
+
).fetchone()
|
|
404
|
+
return _to_favorite(row) if row is not None else None
|
|
405
|
+
|
|
406
|
+
def keys(self) -> set[str]:
|
|
407
|
+
"""Every favourited slot, as ``BANK:NNN`` strings.
|
|
408
|
+
|
|
409
|
+
One query for the whole set rather than a ``__contains__`` per row:
|
|
410
|
+
the browser needs to mark every visible line, and a bank is over a
|
|
411
|
+
hundred lines redrawn on every cursor move.
|
|
412
|
+
"""
|
|
413
|
+
return {
|
|
414
|
+
f"{row['bank_id']}:{row['number']:03d}"
|
|
415
|
+
for row in self._db.execute(
|
|
416
|
+
"SELECT bank_id, number FROM favorites WHERE active = 1"
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
def keys_for_bank(self, bank_id: str) -> set[int]:
|
|
421
|
+
"""The favourited numbers in one bank."""
|
|
422
|
+
return {
|
|
423
|
+
row["number"]
|
|
424
|
+
for row in self._db.execute(
|
|
425
|
+
"SELECT number FROM favorites WHERE bank_id = ? AND active = 1",
|
|
426
|
+
(bank_id,),
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
def all(self, *, order: str = "added") -> list[Favorite]:
|
|
431
|
+
"""Every favourite, newest first by default.
|
|
432
|
+
|
|
433
|
+
``order`` is validated against a fixed set rather than interpolated:
|
|
434
|
+
it reaches here from a command-line argument, and an ORDER BY is the
|
|
435
|
+
one clause a parameter placeholder cannot carry.
|
|
436
|
+
"""
|
|
437
|
+
clauses = {
|
|
438
|
+
"added": "added DESC",
|
|
439
|
+
"rating": "rating DESC, added DESC",
|
|
440
|
+
"bank": "bank_id, number",
|
|
441
|
+
"name": "name COLLATE NOCASE, bank_id, number",
|
|
442
|
+
}
|
|
443
|
+
if order not in clauses:
|
|
444
|
+
raise ValueError(
|
|
445
|
+
f"unknown order {order!r}; have {', '.join(sorted(clauses))}"
|
|
446
|
+
)
|
|
447
|
+
return [
|
|
448
|
+
_to_favorite(row)
|
|
449
|
+
for row in self._db.execute(
|
|
450
|
+
f"SELECT * FROM favorites WHERE active = 1 ORDER BY {clauses[order]}"
|
|
451
|
+
)
|
|
452
|
+
]
|
|
453
|
+
|
|
454
|
+
def search(self, needle: str) -> list[Favorite]:
|
|
455
|
+
"""Favourites whose name, tags or note contain ``needle``."""
|
|
456
|
+
# % and _ are LIKE wildcards. Without escaping them a search for
|
|
457
|
+
# "%" matches every favourite and one for "_" matches every name of
|
|
458
|
+
# any length -- silently, as a result that looks like a real hit.
|
|
459
|
+
escaped = (
|
|
460
|
+
needle.replace("\\", "\\\\")
|
|
461
|
+
.replace("%", "\\%")
|
|
462
|
+
.replace("_", "\\_")
|
|
463
|
+
)
|
|
464
|
+
pattern = f"%{escaped}%"
|
|
465
|
+
return [
|
|
466
|
+
_to_favorite(row)
|
|
467
|
+
for row in self._db.execute(
|
|
468
|
+
"SELECT * FROM favorites WHERE active = 1 AND ("
|
|
469
|
+
"name LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\' "
|
|
470
|
+
"OR note LIKE ? ESCAPE '\\') "
|
|
471
|
+
"ORDER BY bank_id, number",
|
|
472
|
+
(pattern, pattern, pattern),
|
|
473
|
+
)
|
|
474
|
+
]
|
|
475
|
+
|
|
476
|
+
def with_tag(self, tag: str) -> list[Favorite]:
|
|
477
|
+
"""Favourites carrying ``tag``.
|
|
478
|
+
|
|
479
|
+
Matched against the parsed list rather than with a ``LIKE '%tag%'``,
|
|
480
|
+
which would make "pad" match "padded" and "lead" match
|
|
481
|
+
"misleading". Tags are a short comma-separated string; filtering
|
|
482
|
+
them in Python costs nothing at this size and is correct.
|
|
483
|
+
"""
|
|
484
|
+
wanted = tag.strip().lower()
|
|
485
|
+
return [
|
|
486
|
+
fav
|
|
487
|
+
for fav in self.all(order="bank")
|
|
488
|
+
if wanted in {t.lower() for t in fav.tag_list}
|
|
489
|
+
]
|
|
490
|
+
|
|
491
|
+
def tags(self) -> dict[str, int]:
|
|
492
|
+
"""Every tag in use, with how many favourites carry it."""
|
|
493
|
+
counts: dict[str, int] = {}
|
|
494
|
+
for fav in self.all(order="bank"):
|
|
495
|
+
for tag in fav.tag_list:
|
|
496
|
+
counts[tag] = counts.get(tag, 0) + 1
|
|
497
|
+
return dict(sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])))
|
|
498
|
+
|
|
499
|
+
# --- writing ------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
def add(
|
|
502
|
+
self,
|
|
503
|
+
bank_id: str,
|
|
504
|
+
number: int,
|
|
505
|
+
*,
|
|
506
|
+
name: str = "",
|
|
507
|
+
rating: int = 0,
|
|
508
|
+
tags: str = "",
|
|
509
|
+
note: str = "",
|
|
510
|
+
) -> Favorite:
|
|
511
|
+
"""Favourite a slot, or update the one that is already there.
|
|
512
|
+
|
|
513
|
+
An upsert that **preserves ``added``** on an existing row: re-adding
|
|
514
|
+
a favourite is an edit, not a re-acquisition, and quietly resetting
|
|
515
|
+
the date would corrupt the one ordering the user cannot reconstruct.
|
|
516
|
+
"""
|
|
517
|
+
existing = self._row(bank_id, number)
|
|
518
|
+
added = existing.added if existing is not None else time.time()
|
|
519
|
+
self._db.execute(
|
|
520
|
+
"INSERT INTO favorites (bank_id, number, name, rating, tags, "
|
|
521
|
+
"note, added, active) VALUES (?, ?, ?, ?, ?, ?, ?, 1) "
|
|
522
|
+
"ON CONFLICT (bank_id, number) DO UPDATE SET "
|
|
523
|
+
"name = excluded.name, rating = excluded.rating, "
|
|
524
|
+
"tags = excluded.tags, note = excluded.note, active = 1",
|
|
525
|
+
(bank_id, int(number), name, int(rating), tags, note, added),
|
|
526
|
+
)
|
|
527
|
+
self._db.commit()
|
|
528
|
+
return Favorite(
|
|
529
|
+
bank_id, int(number), name, int(rating), tags, note, added
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
def remove(self, bank_id: str, number: int) -> bool:
|
|
533
|
+
"""Un-favourite a slot. ``True`` if it was one.
|
|
534
|
+
|
|
535
|
+
The row is kept and its ``active`` flag cleared, not deleted. A
|
|
536
|
+
favourite carries a rating, tags, a note and the date it was made --
|
|
537
|
+
the user's own work, and the only thing here that cannot be
|
|
538
|
+
reconstructed from the instrument. Deleting the row put all four one
|
|
539
|
+
keystroke from destruction, since the browser's ``f`` unfavourites
|
|
540
|
+
without asking; keeping it means the same keystroke brings them
|
|
541
|
+
back. :meth:`forget` is the one that really deletes.
|
|
542
|
+
"""
|
|
543
|
+
cursor = self._db.execute(
|
|
544
|
+
"UPDATE favorites SET active = 0 "
|
|
545
|
+
"WHERE bank_id = ? AND number = ? AND active = 1",
|
|
546
|
+
(bank_id, int(number)),
|
|
547
|
+
)
|
|
548
|
+
self._db.commit()
|
|
549
|
+
return cursor.rowcount > 0
|
|
550
|
+
|
|
551
|
+
def forget(self, bank_id: str, number: int) -> bool:
|
|
552
|
+
"""Delete a slot's row outright, annotations and all.
|
|
553
|
+
|
|
554
|
+
What :meth:`remove` used to do. No keystroke should be able to
|
|
555
|
+
reach it; that is the whole reason :meth:`remove` does not do this.
|
|
556
|
+
"""
|
|
557
|
+
cursor = self._db.execute(
|
|
558
|
+
"DELETE FROM favorites WHERE bank_id = ? AND number = ?",
|
|
559
|
+
(bank_id, int(number)),
|
|
560
|
+
)
|
|
561
|
+
self._db.commit()
|
|
562
|
+
return cursor.rowcount > 0
|
|
563
|
+
|
|
564
|
+
def dormant(self) -> list[Favorite]:
|
|
565
|
+
"""Slots un-favourited but still holding annotations."""
|
|
566
|
+
return [
|
|
567
|
+
_to_favorite(row)
|
|
568
|
+
for row in self._db.execute(
|
|
569
|
+
"SELECT * FROM favorites WHERE active = 0 ORDER BY bank_id, number"
|
|
570
|
+
)
|
|
571
|
+
]
|
|
572
|
+
|
|
573
|
+
def toggle(self, bank_id: str, number: int, *, name: str = "") -> bool:
|
|
574
|
+
"""Flip a slot's favourite state. ``True`` if it is now a favourite.
|
|
575
|
+
|
|
576
|
+
Re-favouriting restores whatever the slot carried when it was last
|
|
577
|
+
un-favourited rather than starting it blank, which is the whole
|
|
578
|
+
point of :meth:`remove` keeping the row. ``name`` is used only if
|
|
579
|
+
there is nothing to restore, or if the stored one is empty: the name
|
|
580
|
+
is a label that the instrument can supply again, unlike the rest.
|
|
581
|
+
"""
|
|
582
|
+
if self.remove(bank_id, number):
|
|
583
|
+
return False
|
|
584
|
+
old = self._row(bank_id, number)
|
|
585
|
+
if old is None:
|
|
586
|
+
self.add(bank_id, number, name=name)
|
|
587
|
+
else:
|
|
588
|
+
self.add(
|
|
589
|
+
bank_id,
|
|
590
|
+
number,
|
|
591
|
+
name=old.name or name,
|
|
592
|
+
rating=old.rating,
|
|
593
|
+
tags=old.tags,
|
|
594
|
+
note=old.note,
|
|
595
|
+
)
|
|
596
|
+
return True
|
|
597
|
+
|
|
598
|
+
def set_rating(self, bank_id: str, number: int, rating: int) -> None:
|
|
599
|
+
"""Set a slot's rating, 0-5, leaving its other annotations alone."""
|
|
600
|
+
if not 0 <= rating <= 5:
|
|
601
|
+
raise ValueError(f"rating {rating} is outside 0-5")
|
|
602
|
+
self.add(
|
|
603
|
+
bank_id, number, **_merge(self.get(bank_id, number), rating=rating)
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
def set_tags(self, bank_id: str, number: int, tags: str) -> None:
|
|
607
|
+
"""Set a slot's tags, leaving its other annotations alone."""
|
|
608
|
+
self.add(
|
|
609
|
+
bank_id,
|
|
610
|
+
number,
|
|
611
|
+
**_merge(self.get(bank_id, number), tags=_clean_tags(tags)),
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
def set_note(self, bank_id: str, number: int, note: str) -> None:
|
|
615
|
+
"""Set a slot's note, leaving its other annotations alone."""
|
|
616
|
+
self.add(
|
|
617
|
+
bank_id, number, **_merge(self.get(bank_id, number), note=note)
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
def refresh_names(self, lookup: Callable[[str, int], str | None]) -> int:
|
|
621
|
+
"""Re-label favourites from a name source. Returns how many changed.
|
|
622
|
+
|
|
623
|
+
Used after a catalog is generated or a bank is read from the device,
|
|
624
|
+
so that favourites made before there were any names stop showing
|
|
625
|
+
"--". ``lookup(bank_id, number)`` returns a name or ``None``.
|
|
626
|
+
"""
|
|
627
|
+
changed = 0
|
|
628
|
+
for fav in self.all(order="bank") + self.dormant():
|
|
629
|
+
fresh = lookup(fav.bank_id, fav.number)
|
|
630
|
+
if fresh and fresh != fav.name:
|
|
631
|
+
self._db.execute(
|
|
632
|
+
"UPDATE favorites SET name = ? WHERE bank_id = ? AND number = ?",
|
|
633
|
+
(fresh, fav.bank_id, fav.number),
|
|
634
|
+
)
|
|
635
|
+
changed += 1
|
|
636
|
+
if changed:
|
|
637
|
+
self._db.commit()
|
|
638
|
+
return changed
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
@dataclass(frozen=True)
|
|
642
|
+
class Bound:
|
|
643
|
+
"""One application's view of this module.
|
|
644
|
+
|
|
645
|
+
The three names a project's ``favorites.py`` used to define for itself:
|
|
646
|
+
the application name, where its data goes, and a store class that
|
|
647
|
+
already knows both.
|
|
648
|
+
"""
|
|
649
|
+
|
|
650
|
+
app_name: str
|
|
651
|
+
db_name: str = DB_NAME
|
|
652
|
+
|
|
653
|
+
def data_dir(self) -> str:
|
|
654
|
+
"""This application's data directory."""
|
|
655
|
+
return data_dir(self.app_name)
|
|
656
|
+
|
|
657
|
+
def default_path(self) -> str:
|
|
658
|
+
"""Where this application's favourites database lives."""
|
|
659
|
+
return default_path(self.app_name, self.db_name)
|
|
660
|
+
|
|
661
|
+
def store(self, path: str | None = None) -> Favorites:
|
|
662
|
+
"""A store for this application, at ``path`` or the default."""
|
|
663
|
+
return Favorites(path, app_name=self.app_name, db_name=self.db_name)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def bind(app_name: str, db_name: str = DB_NAME) -> Bound:
|
|
667
|
+
"""A :class:`Bound` view for one application."""
|
|
668
|
+
return Bound(app_name=app_name, db_name=db_name)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
# --- helpers ---------------------------------------------------------------
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def split_key(key: str | tuple[str, int]) -> tuple[str, int]:
|
|
675
|
+
"""Accept either a ``(bank, number)`` pair or ``"BANK:012"``."""
|
|
676
|
+
if isinstance(key, tuple):
|
|
677
|
+
return key[0], int(key[1])
|
|
678
|
+
bank_id, _, number = str(key).partition(":")
|
|
679
|
+
return bank_id, int(number)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _to_favorite(row: sqlite3.Row) -> Favorite:
|
|
683
|
+
return Favorite(
|
|
684
|
+
bank_id=row["bank_id"],
|
|
685
|
+
number=row["number"],
|
|
686
|
+
name=row["name"],
|
|
687
|
+
rating=row["rating"],
|
|
688
|
+
tags=row["tags"],
|
|
689
|
+
note=row["note"],
|
|
690
|
+
added=row["added"],
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _merge(existing: Favorite | None, **changes: Any) -> dict[str, Any]:
|
|
695
|
+
"""Fields for :meth:`Favorites.add` that change one thing, keep the rest.
|
|
696
|
+
|
|
697
|
+
Without this, setting a rating on a favourite would blank its tags and
|
|
698
|
+
note, because ``add`` takes every field and defaults the ones it is not
|
|
699
|
+
given.
|
|
700
|
+
"""
|
|
701
|
+
base: dict[str, Any] = {
|
|
702
|
+
"name": existing.name if existing else "",
|
|
703
|
+
"rating": existing.rating if existing else 0,
|
|
704
|
+
"tags": existing.tags if existing else "",
|
|
705
|
+
"note": existing.note if existing else "",
|
|
706
|
+
}
|
|
707
|
+
base.update(changes)
|
|
708
|
+
return base
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _clean_tags(tags: str) -> str:
|
|
712
|
+
"""Normalise a comma-separated tag string: trimmed, de-duplicated."""
|
|
713
|
+
seen: list[str] = []
|
|
714
|
+
for part in tags.split(","):
|
|
715
|
+
tag = part.strip()
|
|
716
|
+
if tag and tag.lower() not in {t.lower() for t in seen}:
|
|
717
|
+
seen.append(tag)
|
|
718
|
+
return ", ".join(seen)
|