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/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)