hyprmuse 0.3.2__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.
- hyprmuse/__init__.py +1 -0
- hyprmuse/__main__.py +10 -0
- hyprmuse/cli.py +232 -0
- hyprmuse/config.py +46 -0
- hyprmuse/library.py +111 -0
- hyprmuse/lockscreen.py +224 -0
- hyprmuse/sources/__init__.py +25 -0
- hyprmuse/sources/base.py +117 -0
- hyprmuse/sources/genius.py +201 -0
- hyprmuse/sources/gutenberg.py +142 -0
- hyprmuse/sources/poetrydb.py +57 -0
- hyprmuse/sources/wikiquote.py +138 -0
- hyprmuse/text.py +49 -0
- hyprmuse-0.3.2.dist-info/METADATA +178 -0
- hyprmuse-0.3.2.dist-info/RECORD +18 -0
- hyprmuse-0.3.2.dist-info/WHEEL +4 -0
- hyprmuse-0.3.2.dist-info/entry_points.txt +2 -0
- hyprmuse-0.3.2.dist-info/licenses/LICENSE +21 -0
hyprmuse/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""hyprmuse - random quotes from musicians, authors and poets for hyprlock."""
|
hyprmuse/__main__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Run the hyprmuse command: `python -m hyprmuse`, and the release binary's entry point.
|
|
2
|
+
|
|
3
|
+
The import is absolute because packaging/build-binary.sh hands this file to PyInstaller
|
|
4
|
+
as a plain script, outside the package, where a relative import has no parent.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from hyprmuse.cli import main
|
|
8
|
+
|
|
9
|
+
if __name__ == "__main__":
|
|
10
|
+
raise SystemExit(main())
|
hyprmuse/cli.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Command line interface for hyprmuse."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import config, library, lockscreen
|
|
7
|
+
from .sources import AUTO_ORDER, REGISTRY, SourceError, get_source
|
|
8
|
+
from .sources.base import Candidate
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _err(msg: str) -> int:
|
|
12
|
+
print(f"hyprmuse: {msg}", file=sys.stderr)
|
|
13
|
+
return 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _progress(msg: str) -> None:
|
|
17
|
+
print(f" {msg}", file=sys.stderr)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _gather(query: str, source: str, lang: str) -> list[Candidate]:
|
|
21
|
+
"""Candidates from one named source, or from every source in auto order."""
|
|
22
|
+
names = [source] if source else AUTO_ORDER
|
|
23
|
+
found: list[Candidate] = []
|
|
24
|
+
for name in names:
|
|
25
|
+
try:
|
|
26
|
+
hits = get_source(name).search(query, lang)
|
|
27
|
+
except SourceError as exc:
|
|
28
|
+
if source:
|
|
29
|
+
raise
|
|
30
|
+
print(f" ({name} unavailable: {exc})", file=sys.stderr)
|
|
31
|
+
continue
|
|
32
|
+
found.extend(hits)
|
|
33
|
+
if source:
|
|
34
|
+
break
|
|
35
|
+
return found
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _choose(cands: list[Candidate], assume_yes: bool) -> Candidate | None:
|
|
39
|
+
if not cands:
|
|
40
|
+
return None
|
|
41
|
+
if assume_yes or len(cands) == 1 or not sys.stdin.isatty():
|
|
42
|
+
return cands[0]
|
|
43
|
+
print("\nMatches:", file=sys.stderr)
|
|
44
|
+
for i, c in enumerate(cands, 1):
|
|
45
|
+
hint = f" ({c.hint})" if c.hint else ""
|
|
46
|
+
print(f" {i:2}. [{c.source}/{c.domain}] {c.name}{hint}", file=sys.stderr)
|
|
47
|
+
try:
|
|
48
|
+
raw = input("\nPick a number (blank to cancel): ").strip()
|
|
49
|
+
except (EOFError, KeyboardInterrupt):
|
|
50
|
+
return None
|
|
51
|
+
if not raw.isdigit() or not 1 <= int(raw) <= len(cands):
|
|
52
|
+
return None
|
|
53
|
+
return cands[int(raw) - 1]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_search(args) -> int:
|
|
57
|
+
cands = _gather(args.query, args.source, args.lang)
|
|
58
|
+
if not cands:
|
|
59
|
+
return _err(f"no matches for {args.query!r}")
|
|
60
|
+
for c in cands:
|
|
61
|
+
hint = f" ({c.hint})" if c.hint else ""
|
|
62
|
+
print(f"[{c.source}/{c.domain}] {c.name}{hint}\n {c.source}:{c.source_id}")
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_add(args) -> int:
|
|
67
|
+
try:
|
|
68
|
+
cands = _gather(args.query, args.source, args.lang)
|
|
69
|
+
except SourceError as exc:
|
|
70
|
+
return _err(str(exc))
|
|
71
|
+
cand = _choose(cands, args.yes)
|
|
72
|
+
if cand is None:
|
|
73
|
+
return _err(f"no match chosen for {args.query!r}")
|
|
74
|
+
print(f"Harvesting {cand.name} from {cand.source}…", file=sys.stderr)
|
|
75
|
+
try:
|
|
76
|
+
subject = get_source(cand.source).fetch(cand.source_id, _progress)
|
|
77
|
+
except SourceError as exc:
|
|
78
|
+
return _err(str(exc))
|
|
79
|
+
path = library.save(subject)
|
|
80
|
+
print(f"Saved {len(subject.items)} items for {subject.name} -> {path}")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cmd_list(args) -> int:
|
|
85
|
+
subjects = library.load_all()
|
|
86
|
+
if not subjects:
|
|
87
|
+
print("No subjects yet. Try: hyprmuse add \"Franz Kafka\"")
|
|
88
|
+
return 0
|
|
89
|
+
width = max(len(s.name) for s in subjects)
|
|
90
|
+
total = 0
|
|
91
|
+
for s in subjects:
|
|
92
|
+
total += len(s.items)
|
|
93
|
+
kind = "atomic" if s.items and s.items[0].atomic else "sliceable"
|
|
94
|
+
print(f"{s.name:{width}} {len(s.items):5} items {s.domain:11} "
|
|
95
|
+
f"{s.lang:3} {kind:10} {library.subject_key(s.source, s.source_id)}")
|
|
96
|
+
print(f"\n{len(subjects)} subjects, {total} items total")
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cmd_remove(args) -> int:
|
|
101
|
+
matches = library.find(args.token)
|
|
102
|
+
if not matches:
|
|
103
|
+
return _err(f"no subject matching {args.token!r}")
|
|
104
|
+
if len(matches) > 1:
|
|
105
|
+
names = ", ".join(library.subject_key(m.source, m.source_id) for m in matches)
|
|
106
|
+
return _err(f"{args.token!r} is ambiguous: {names}")
|
|
107
|
+
subject = matches[0]
|
|
108
|
+
library.remove(subject)
|
|
109
|
+
print(f"Removed {subject.name}")
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cmd_update(args) -> int:
|
|
114
|
+
targets = library.load_all() if args.all else library.find(args.token or "")
|
|
115
|
+
if not targets:
|
|
116
|
+
return _err("nothing to update")
|
|
117
|
+
failed = 0
|
|
118
|
+
for subj in targets:
|
|
119
|
+
print(f"Updating {subj.name} ({subj.source})…", file=sys.stderr)
|
|
120
|
+
try:
|
|
121
|
+
fresh = get_source(subj.source).fetch(subj.source_id, _progress)
|
|
122
|
+
except SourceError as exc:
|
|
123
|
+
print(f" failed: {exc}", file=sys.stderr)
|
|
124
|
+
failed += 1
|
|
125
|
+
continue
|
|
126
|
+
library.save(fresh)
|
|
127
|
+
print(f" {len(fresh.items)} items")
|
|
128
|
+
return 1 if failed and failed == len(targets) else 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cmd_sources(args) -> int:
|
|
132
|
+
for name in AUTO_ORDER:
|
|
133
|
+
src = REGISTRY[name]
|
|
134
|
+
kind = "atomic" if src.produces_atomic else "sliceable"
|
|
135
|
+
print(f"{name:11} {src.domain:11} {kind}")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cmd_quote(args) -> int:
|
|
140
|
+
cfg = config.load_config()
|
|
141
|
+
for key, flag in (("lines", args.lines), ("width", args.width),
|
|
142
|
+
("max_lines", args.max_lines), ("max_chars", args.max_chars)):
|
|
143
|
+
if flag is not None:
|
|
144
|
+
cfg[key] = flag
|
|
145
|
+
text = lockscreen.quote(cfg, subject=args.subject, domain=args.domain,
|
|
146
|
+
source=args.source, seed=args.seed,
|
|
147
|
+
show_note=args.note)
|
|
148
|
+
if text is None:
|
|
149
|
+
print("No quotes stored yet — run: hyprmuse add \"Franz Kafka\"")
|
|
150
|
+
return 0
|
|
151
|
+
print(text)
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
157
|
+
p = argparse.ArgumentParser(
|
|
158
|
+
prog="hyprmuse",
|
|
159
|
+
description="Random quotes from musicians, authors and poets, for hyprlock.")
|
|
160
|
+
sub = p.add_subparsers(dest="cmd")
|
|
161
|
+
|
|
162
|
+
def add_search_flags(sp):
|
|
163
|
+
sp.add_argument("--source", default="", choices=sorted(REGISTRY) + [""],
|
|
164
|
+
help="restrict to one source (default: try all)")
|
|
165
|
+
sp.add_argument("--lang", default="en",
|
|
166
|
+
help="language edition, for wikiquote (e.g. de)")
|
|
167
|
+
|
|
168
|
+
sp = sub.add_parser("add", help="harvest a new subject")
|
|
169
|
+
sp.add_argument("query")
|
|
170
|
+
add_search_flags(sp)
|
|
171
|
+
sp.add_argument("--yes", "-y", action="store_true",
|
|
172
|
+
help="take the best match without prompting")
|
|
173
|
+
sp.set_defaults(func=cmd_add)
|
|
174
|
+
|
|
175
|
+
sp = sub.add_parser("search", help="look up subjects without harvesting")
|
|
176
|
+
sp.add_argument("query")
|
|
177
|
+
add_search_flags(sp)
|
|
178
|
+
sp.set_defaults(func=cmd_search)
|
|
179
|
+
|
|
180
|
+
sp = sub.add_parser("list", help="show stored subjects")
|
|
181
|
+
sp.set_defaults(func=cmd_list)
|
|
182
|
+
|
|
183
|
+
sp = sub.add_parser("remove", help="delete a stored subject")
|
|
184
|
+
sp.add_argument("token", help="source:id or part of the name")
|
|
185
|
+
sp.set_defaults(func=cmd_remove)
|
|
186
|
+
|
|
187
|
+
sp = sub.add_parser("update", help="re-harvest stored subjects")
|
|
188
|
+
sp.add_argument("token", nargs="?", default="")
|
|
189
|
+
sp.add_argument("--all", action="store_true")
|
|
190
|
+
sp.set_defaults(func=cmd_update)
|
|
191
|
+
|
|
192
|
+
sp = sub.add_parser("sources", help="list available sources")
|
|
193
|
+
sp.set_defaults(func=cmd_sources)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
sp = sub.add_parser("quote", help="print a random quote (default command)")
|
|
197
|
+
sp.add_argument("--lines", type=int, default=None,
|
|
198
|
+
help="lines to take from sliceable items")
|
|
199
|
+
sp.add_argument("--width", type=int, default=None, help="wrap width, 0 disables")
|
|
200
|
+
sp.add_argument("--max-lines", type=int, default=None,
|
|
201
|
+
help="most lines after wrapping; shorter picks are preferred")
|
|
202
|
+
sp.add_argument("--max-chars", type=int, default=None,
|
|
203
|
+
help="most characters after wrapping, 0 disables")
|
|
204
|
+
sp.add_argument("--subject", default="", help="restrict to one subject")
|
|
205
|
+
sp.add_argument("--domain", default="", help="restrict to a domain")
|
|
206
|
+
sp.add_argument("--source", default="", help="restrict to a source")
|
|
207
|
+
sp.add_argument("--note", action="store_true", help="include the citation note")
|
|
208
|
+
sp.add_argument("--seed", type=int, default=None, help="deterministic pick")
|
|
209
|
+
sp.set_defaults(func=cmd_quote)
|
|
210
|
+
return p
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def main(argv: list[str] | None = None) -> int:
|
|
214
|
+
parser = build_parser()
|
|
215
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
216
|
+
# Bare `hyprmuse` prints a quote, so hyprlock can call it with no arguments.
|
|
217
|
+
if not argv or (argv[0].startswith("-") and argv[0] not in ("-h", "--help")):
|
|
218
|
+
argv.insert(0, "quote")
|
|
219
|
+
args = parser.parse_args(argv)
|
|
220
|
+
if not hasattr(args, "func"):
|
|
221
|
+
parser.print_help()
|
|
222
|
+
return 1
|
|
223
|
+
try:
|
|
224
|
+
return args.func(args)
|
|
225
|
+
except KeyboardInterrupt:
|
|
226
|
+
return 130
|
|
227
|
+
except SourceError as exc:
|
|
228
|
+
return _err(str(exc))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == "__main__":
|
|
232
|
+
raise SystemExit(main())
|
hyprmuse/config.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""XDG paths and user configuration."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
APP = "hyprmuse"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _xdg(env: str, fallback: str) -> Path:
|
|
11
|
+
root = os.environ.get(env)
|
|
12
|
+
return (Path(root) if root else Path.home() / fallback) / APP
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
DATA_DIR = _xdg("XDG_DATA_HOME", ".local/share")
|
|
16
|
+
CONFIG_DIR = _xdg("XDG_CONFIG_HOME", ".config")
|
|
17
|
+
SUBJECTS_DIR = DATA_DIR / "subjects"
|
|
18
|
+
RECENT_FILE = DATA_DIR / "recent.json"
|
|
19
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
20
|
+
|
|
21
|
+
DEFAULTS = {
|
|
22
|
+
"lines": 3,
|
|
23
|
+
"width": 60,
|
|
24
|
+
"max_lines": 6,
|
|
25
|
+
"max_chars": 0, # total characters after wrapping, 0 = unlimited
|
|
26
|
+
"weighting": "subject", # "subject" or "quote"
|
|
27
|
+
"avoid_repeats": 20,
|
|
28
|
+
"min_line_chars": 2, # lines shorter than this ("-", "I") are skipped when slicing
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_config() -> dict:
|
|
33
|
+
"""Merge config.toml over DEFAULTS. Missing or broken file falls back cleanly."""
|
|
34
|
+
cfg = dict(DEFAULTS)
|
|
35
|
+
cfg["subjects"] = {}
|
|
36
|
+
if not CONFIG_FILE.exists():
|
|
37
|
+
return cfg
|
|
38
|
+
try:
|
|
39
|
+
raw = tomllib.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
40
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
41
|
+
return cfg
|
|
42
|
+
for key in DEFAULTS:
|
|
43
|
+
if key in raw.get("quote", {}):
|
|
44
|
+
cfg[key] = raw["quote"][key]
|
|
45
|
+
cfg["subjects"] = raw.get("subjects", {})
|
|
46
|
+
return cfg
|
hyprmuse/library.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""On-disk store: one JSON file per subject under the XDG data directory."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import tempfile
|
|
7
|
+
import unicodedata
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import config
|
|
12
|
+
from .sources.base import Item, Subject
|
|
13
|
+
|
|
14
|
+
SCHEMA = 1
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def slugify(text: str) -> str:
|
|
18
|
+
norm = unicodedata.normalize("NFKD", text)
|
|
19
|
+
norm = "".join(c for c in norm if not unicodedata.combining(c))
|
|
20
|
+
norm = re.sub(r"[^a-zA-Z0-9]+", "-", norm).strip("-").lower()
|
|
21
|
+
return norm or "unnamed"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def subject_key(source: str, source_id: str) -> str:
|
|
25
|
+
return f"{source}:{source_id}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def subject_path(source: str, source_id: str) -> Path:
|
|
29
|
+
return config.SUBJECTS_DIR / f"{source}--{slugify(source_id)}.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def save(subject: Subject) -> Path:
|
|
33
|
+
"""Write a subject atomically so a crash can never truncate the store."""
|
|
34
|
+
config.SUBJECTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
path = subject_path(subject.source, subject.source_id)
|
|
36
|
+
payload = {
|
|
37
|
+
"schema": SCHEMA,
|
|
38
|
+
"source": subject.source,
|
|
39
|
+
"source_id": subject.source_id,
|
|
40
|
+
"name": subject.name,
|
|
41
|
+
"domain": subject.domain,
|
|
42
|
+
"lang": subject.lang,
|
|
43
|
+
"url": subject.url,
|
|
44
|
+
"fetched_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
45
|
+
"attribution": subject.attribution,
|
|
46
|
+
"items": [
|
|
47
|
+
{"work": i.work, "url": i.url, "atomic": i.atomic,
|
|
48
|
+
"note": i.note, "lines": i.lines}
|
|
49
|
+
for i in subject.items
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
fd, tmp = tempfile.mkstemp(dir=config.SUBJECTS_DIR, suffix=".tmp")
|
|
53
|
+
try:
|
|
54
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
55
|
+
json.dump(payload, fh, ensure_ascii=False, indent=2)
|
|
56
|
+
os.replace(tmp, path)
|
|
57
|
+
except BaseException:
|
|
58
|
+
Path(tmp).unlink(missing_ok=True)
|
|
59
|
+
raise
|
|
60
|
+
return path
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _to_subject(data: dict) -> Subject:
|
|
64
|
+
return Subject(
|
|
65
|
+
source=data["source"],
|
|
66
|
+
source_id=data["source_id"],
|
|
67
|
+
name=data["name"],
|
|
68
|
+
domain=data.get("domain", "unknown"),
|
|
69
|
+
lang=data.get("lang", "en"),
|
|
70
|
+
url=data.get("url", ""),
|
|
71
|
+
attribution=data.get("attribution", {}),
|
|
72
|
+
items=[
|
|
73
|
+
Item(lines=i["lines"], work=i.get("work", ""), url=i.get("url", ""),
|
|
74
|
+
atomic=i.get("atomic", False), note=i.get("note", ""))
|
|
75
|
+
for i in data.get("items", [])
|
|
76
|
+
],
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_all() -> list[Subject]:
|
|
81
|
+
"""Every stored subject. Unreadable files are skipped, not fatal."""
|
|
82
|
+
if not config.SUBJECTS_DIR.is_dir():
|
|
83
|
+
return []
|
|
84
|
+
out = []
|
|
85
|
+
for path in sorted(config.SUBJECTS_DIR.glob("*.json")):
|
|
86
|
+
try:
|
|
87
|
+
out.append(_to_subject(json.loads(path.read_text(encoding="utf-8"))))
|
|
88
|
+
except (OSError, ValueError, KeyError):
|
|
89
|
+
continue
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def find(token: str) -> list[Subject]:
|
|
94
|
+
"""Resolve 'source:id', or a case-insensitive substring of the name."""
|
|
95
|
+
subjects = load_all()
|
|
96
|
+
if ":" in token:
|
|
97
|
+
src, _, sid = token.partition(":")
|
|
98
|
+
exact = [s for s in subjects
|
|
99
|
+
if s.source == src and s.source_id == sid]
|
|
100
|
+
if exact:
|
|
101
|
+
return exact
|
|
102
|
+
low = token.lower()
|
|
103
|
+
return [s for s in subjects if low in s.name.lower()]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def remove(subject: Subject) -> bool:
|
|
107
|
+
path = subject_path(subject.source, subject.source_id)
|
|
108
|
+
if path.exists():
|
|
109
|
+
path.unlink()
|
|
110
|
+
return True
|
|
111
|
+
return False
|
hyprmuse/lockscreen.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""The quote hyprlock shows: drawn from the library, fitted to a box, attributed.
|
|
2
|
+
|
|
3
|
+
Everything about the box (wrapping, line and character caps, what happens when
|
|
4
|
+
nothing fits) and about repeat avoidance lives here, behind ``quote``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import random
|
|
10
|
+
import textwrap
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from . import config, library
|
|
14
|
+
from .sources.base import Item, Subject
|
|
15
|
+
from .text import stanzas, wrap
|
|
16
|
+
|
|
17
|
+
DASH = "—"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def quote(cfg: dict, *, subject: str = "", domain: str = "", source: str = "",
|
|
21
|
+
seed: int | None = None, show_note: bool = False) -> str | None:
|
|
22
|
+
"""A random quote from the library, rendered as text for the lockscreen.
|
|
23
|
+
|
|
24
|
+
``cfg`` is a config dict shaped like ``config.load_config()``'s: the keys
|
|
25
|
+
of ``config.DEFAULTS`` (each documented there) plus ``"subjects"``, the
|
|
26
|
+
per-subject ``enabled``/``weight`` table. A caller overriding a setting
|
|
27
|
+
replaces its key before the call.
|
|
28
|
+
|
|
29
|
+
``subject`` (a name substring or a ``source:id`` key), ``domain`` and
|
|
30
|
+
``source`` restrict which stored subjects are drawn from; empty means any.
|
|
31
|
+
``seed`` makes the draw deterministic and leaves the repeat history alone.
|
|
32
|
+
``show_note`` appends the item's citation note, when it has one.
|
|
33
|
+
|
|
34
|
+
The result is the quote body, a blank line, then "— Name" or "— Name: Work",
|
|
35
|
+
then the note if shown. The body fits the box ``cfg`` describes; when no
|
|
36
|
+
stored quote fits, the best effort is trimmed to it, and only then can a
|
|
37
|
+
line end in "…". Returns None when no stored subject passes the filters,
|
|
38
|
+
or none of their items has a line longer than ``cfg["min_line_chars"]``.
|
|
39
|
+
|
|
40
|
+
Unless ``seed`` is set, records the quote in ``config.RECENT_FILE`` so the
|
|
41
|
+
next ``cfg["avoid_repeats"]`` draws avoid it; a failure to write that file
|
|
42
|
+
is ignored, since a read-only home must not break the lockscreen.
|
|
43
|
+
"""
|
|
44
|
+
pool = _candidates(cfg, subject=subject, domain=domain, source=source)
|
|
45
|
+
if not pool:
|
|
46
|
+
return None
|
|
47
|
+
rng = random.Random(seed)
|
|
48
|
+
recent = _load_recent()
|
|
49
|
+
keep = int(cfg.get("avoid_repeats", 20))
|
|
50
|
+
weights = [_weight(s, cfg) for s in pool]
|
|
51
|
+
if sum(weights) <= 0:
|
|
52
|
+
weights = [1.0] * len(pool)
|
|
53
|
+
fit = _Fit(width=int(cfg["width"]), max_lines=int(cfg["max_lines"]),
|
|
54
|
+
max_chars=int(cfg["max_chars"]))
|
|
55
|
+
|
|
56
|
+
draw = dict(num_lines=int(cfg["lines"]),
|
|
57
|
+
min_chars=int(cfg.get("min_line_chars", 0)),
|
|
58
|
+
recent=recent if keep > 0 else [])
|
|
59
|
+
chosen = _draw(pool, weights, rng, fit=fit, **draw)
|
|
60
|
+
if chosen is None and fit != _UNCONSTRAINED:
|
|
61
|
+
# Nothing in the library fits the box; a trimmed quote beats a blank
|
|
62
|
+
# lockscreen, so draw without it and let _render trim the result.
|
|
63
|
+
chosen = _draw(pool, weights, rng, fit=_UNCONSTRAINED, **draw)
|
|
64
|
+
if chosen is None:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
if keep > 0 and seed is None:
|
|
68
|
+
_save_recent(recent + [chosen.fingerprint()], keep)
|
|
69
|
+
return _render(chosen, fit, show_note=show_note)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class _Fit:
|
|
74
|
+
"""The box a rendered quote has to fit in. Zero means unconstrained."""
|
|
75
|
+
|
|
76
|
+
width: int = 0
|
|
77
|
+
max_lines: int = 0
|
|
78
|
+
max_chars: int = 0 # summed over the wrapped lines, newlines not counted
|
|
79
|
+
|
|
80
|
+
def accepts(self, lines: list[str]) -> bool:
|
|
81
|
+
body = wrap(lines, self.width)
|
|
82
|
+
if self.max_lines > 0 and len(body) > self.max_lines:
|
|
83
|
+
return False
|
|
84
|
+
if self.max_chars > 0 and sum(len(ln) for ln in body) > self.max_chars:
|
|
85
|
+
return False
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
def trim(self, lines: list[str]) -> list[str]:
|
|
89
|
+
"""Wrap ``lines`` and cut them to the box, keeping at least one line.
|
|
90
|
+
|
|
91
|
+
Whole lines go from the end first; only a single line still over
|
|
92
|
+
max_chars is cut inside, at a word boundary, and marked with "…".
|
|
93
|
+
"""
|
|
94
|
+
body = wrap(lines, self.width)
|
|
95
|
+
if self.max_lines > 0:
|
|
96
|
+
body = body[:self.max_lines]
|
|
97
|
+
if self.max_chars <= 0:
|
|
98
|
+
return body
|
|
99
|
+
while len(body) > 1 and sum(len(ln) for ln in body) > self.max_chars:
|
|
100
|
+
body = body[:-1]
|
|
101
|
+
if body and len(body[0]) > self.max_chars:
|
|
102
|
+
short = textwrap.shorten(body[0], width=self.max_chars, placeholder="…")
|
|
103
|
+
# shorten() yields the bare placeholder when the first word alone
|
|
104
|
+
# is too long; a hard cut keeps some text instead.
|
|
105
|
+
if short == "…":
|
|
106
|
+
short = body[0][:self.max_chars - 1] + "…"
|
|
107
|
+
body = [short]
|
|
108
|
+
return body
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_UNCONSTRAINED = _Fit()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class _Pick:
|
|
116
|
+
"""One drawn quote: the lines taken from ``item``, before wrapping."""
|
|
117
|
+
|
|
118
|
+
subject: Subject
|
|
119
|
+
item: Item
|
|
120
|
+
lines: list[str]
|
|
121
|
+
|
|
122
|
+
def fingerprint(self) -> str:
|
|
123
|
+
"""Identifies the quote in the repeat history; equal text, equal print."""
|
|
124
|
+
seed = f"{self.subject.source}:{self.subject.source_id}:" + "|".join(self.lines)
|
|
125
|
+
return hashlib.sha1(seed.encode("utf-8")).hexdigest()[:16]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _render(pick: _Pick, fit: _Fit, *, show_note: bool) -> str:
|
|
129
|
+
who = pick.subject.name
|
|
130
|
+
work = pick.item.work.strip()
|
|
131
|
+
parts = ["\n".join(fit.trim(pick.lines)), "",
|
|
132
|
+
f"{DASH} {who}: {work}" if work else f"{DASH} {who}"]
|
|
133
|
+
if show_note and pick.item.note:
|
|
134
|
+
parts.extend(wrap([pick.item.note], fit.width))
|
|
135
|
+
return "\n".join(parts)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _load_recent() -> list[str]:
|
|
139
|
+
try:
|
|
140
|
+
return json.loads(config.RECENT_FILE.read_text(encoding="utf-8"))
|
|
141
|
+
except (OSError, ValueError):
|
|
142
|
+
return []
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _save_recent(seen: list[str], keep: int) -> None:
|
|
146
|
+
try:
|
|
147
|
+
config.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
config.RECENT_FILE.write_text(json.dumps(seen[-keep:]), encoding="utf-8")
|
|
149
|
+
except OSError:
|
|
150
|
+
pass # a read-only home must not break the lockscreen
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _candidates(cfg: dict, *, subject: str, domain: str,
|
|
154
|
+
source: str) -> list[Subject]:
|
|
155
|
+
"""Stored subjects with items, minus those disabled in config or filtered out."""
|
|
156
|
+
out = []
|
|
157
|
+
for subj in library.load_all():
|
|
158
|
+
settings = cfg.get("subjects", {}).get(
|
|
159
|
+
library.subject_key(subj.source, subj.source_id), {})
|
|
160
|
+
if settings.get("enabled") is False:
|
|
161
|
+
continue
|
|
162
|
+
if domain and subj.domain != domain:
|
|
163
|
+
continue
|
|
164
|
+
if source and subj.source != source:
|
|
165
|
+
continue
|
|
166
|
+
if subject and subject.lower() not in subj.name.lower() \
|
|
167
|
+
and subject != library.subject_key(subj.source, subj.source_id):
|
|
168
|
+
continue
|
|
169
|
+
if subj.items:
|
|
170
|
+
out.append(subj)
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _weight(subj: Subject, cfg: dict) -> float:
|
|
175
|
+
settings = cfg.get("subjects", {}).get(
|
|
176
|
+
library.subject_key(subj.source, subj.source_id), {})
|
|
177
|
+
base = float(settings.get("weight", 1.0))
|
|
178
|
+
# "quote" weighting makes every stored quote equally likely, so prolific
|
|
179
|
+
# subjects dominate; "subject" (the default) gives each subject equal say.
|
|
180
|
+
return base * len(subj.items) if cfg.get("weighting") == "quote" else base
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _extract(item: Item, num_lines: int, rng: random.Random, *,
|
|
184
|
+
min_chars: int, fit: _Fit) -> list[str]:
|
|
185
|
+
"""Lines to quote from one item, or [] if nothing from it fits.
|
|
186
|
+
|
|
187
|
+
Sliceable items are cut within a single stanza (blank-line delimited), so
|
|
188
|
+
a window never straddles a verse and its chorus. If the window overflows
|
|
189
|
+
the fit it is shortened from the end, a line at a time, before giving up.
|
|
190
|
+
"""
|
|
191
|
+
groups = stanzas(item.lines, min_chars)
|
|
192
|
+
if not groups:
|
|
193
|
+
return []
|
|
194
|
+
if item.atomic:
|
|
195
|
+
whole = [ln for g in groups for ln in g]
|
|
196
|
+
return whole if fit.accepts(whole) else []
|
|
197
|
+
# A one-line stanza is usually a stray refrain tag or an orphaned line;
|
|
198
|
+
# prefer real stanzas whenever the item has any.
|
|
199
|
+
usable = [g for g in groups if len(g) >= 2] or groups
|
|
200
|
+
stanza = rng.choice(usable)
|
|
201
|
+
take = min(num_lines, len(stanza))
|
|
202
|
+
start = rng.randint(0, len(stanza) - take)
|
|
203
|
+
for n in range(take, 0, -1):
|
|
204
|
+
window = stanza[start:start + n]
|
|
205
|
+
if fit.accepts(window):
|
|
206
|
+
return window
|
|
207
|
+
return []
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _draw(pool: list[Subject], weights: list[float], rng: random.Random, *,
|
|
211
|
+
num_lines: int, min_chars: int, fit: _Fit, recent: list[str],
|
|
212
|
+
tries: int = 40) -> _Pick | None:
|
|
213
|
+
"""Weighted random pick, retrying briefly to avoid a recently shown one."""
|
|
214
|
+
chosen = None
|
|
215
|
+
for _ in range(tries):
|
|
216
|
+
subj = rng.choices(pool, weights=weights, k=1)[0]
|
|
217
|
+
item = rng.choice(subj.items)
|
|
218
|
+
lines = _extract(item, num_lines, rng, min_chars=min_chars, fit=fit)
|
|
219
|
+
if not lines:
|
|
220
|
+
continue
|
|
221
|
+
chosen = _Pick(subj, item, lines)
|
|
222
|
+
if chosen.fingerprint() not in recent:
|
|
223
|
+
break
|
|
224
|
+
return chosen
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Registry of quote sources."""
|
|
2
|
+
|
|
3
|
+
from . import genius, gutenberg, poetrydb, wikiquote
|
|
4
|
+
from .base import Candidate, Item, Source, SourceError, Subject
|
|
5
|
+
|
|
6
|
+
REGISTRY: dict[str, Source] = {
|
|
7
|
+
s.SOURCE.name: s.SOURCE for s in (genius, wikiquote, poetrydb, gutenberg)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
# Order tried when the user does not name a source explicitly. Wikiquote first:
|
|
11
|
+
# it has the broadest subject coverage across every domain.
|
|
12
|
+
AUTO_ORDER = ["wikiquote", "genius", "poetrydb", "gutenberg"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_source(name: str) -> Source:
|
|
16
|
+
try:
|
|
17
|
+
return REGISTRY[name]
|
|
18
|
+
except KeyError:
|
|
19
|
+
raise SourceError(
|
|
20
|
+
f"unknown source '{name}' (have: {', '.join(sorted(REGISTRY))})"
|
|
21
|
+
) from None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
__all__ = ["REGISTRY", "AUTO_ORDER", "get_source", "Candidate", "Item",
|
|
25
|
+
"Source", "SourceError", "Subject"]
|