hyprmuse 0.3.2__tar.gz

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.
@@ -0,0 +1,18 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ # leftover scraped output; the library lives in ~/.local/share/hyprmuse/
5
+ lyrics.json
6
+
7
+ # The tracker's specs, tickets and review records (docs/agents/issue-tracker.md)
8
+ .scratch/
9
+
10
+ # What scripts/gate writes
11
+ .coverage
12
+ coverage.xml
13
+ test-results.xml
14
+ quality.md
15
+
16
+ # What packaging/build-binary.sh writes
17
+ build/
18
+ dist/
hyprmuse-0.3.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Siegl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,178 @@
1
+ Metadata-Version: 2.5
2
+ Name: hyprmuse
3
+ Version: 0.3.2
4
+ Summary: Random quotes from musicians, authors and poets for hyprlock
5
+ Project-URL: Homepage, https://github.com/DavidSiegl/hyprmuse
6
+ Project-URL: Issues, https://github.com/DavidSiegl/hyprmuse/issues
7
+ Project-URL: Releases, https://github.com/DavidSiegl/hyprmuse/releases
8
+ Author: David Siegl
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: hyprland,hyprlock,lockscreen,quotes
12
+ Classifier: Environment :: Console
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Desktop Environment
17
+ Requires-Python: >=3.14
18
+ Requires-Dist: beautifulsoup4>=4.14.3
19
+ Requires-Dist: lxml>=6.0.2
20
+ Requires-Dist: requests>=2.32.5
21
+ Description-Content-Type: text/markdown
22
+
23
+ <h1 align="center">
24
+ <img src="https://raw.githubusercontent.com/DavidSiegl/hyprmuse/main/assets/logo.svg" width="84" height="84" alt="">
25
+ <br>
26
+ hyprmuse
27
+ </h1>
28
+
29
+ <p align="center">
30
+ Random quotes from musicians, literary authors, poets and public-domain books,
31
+ for your <code>hyprlock</code> screen.
32
+ </p>
33
+
34
+ Name a subject, hyprmuse harvests them from a fitting source into a local
35
+ library, and prints a random quote on demand. Everything is stored offline, so
36
+ the lockscreen never waits on the network.
37
+
38
+ ## Sources
39
+
40
+ | Source | Domain | Covers | Licence |
41
+ |---|---|---|---|
42
+ | `wikiquote` | quotations | authors, philosophers, scientists, films, TV, proverbs — any language edition | CC BY-SA |
43
+ | `genius` | music | song lyrics for any artist | scraped, personal use |
44
+ | `poetrydb` | poetry | ~130 classic poets, full poems | public domain |
45
+ | `gutenberg` | prose | passages from public-domain books | public domain |
46
+
47
+ Sources fall into two kinds. **Sliceable** items (lyrics, poems) are quoted as a
48
+ random window of consecutive lines taken from within one stanza, so a slice
49
+ never runs from a verse into its chorus. **Atomic** items (a Wikiquote
50
+ quotation, a prose passage) are quoted whole and word-wrapped.
51
+
52
+ Every pick is checked against the box it has to fit (`width`, `max_lines`,
53
+ `max_chars`). A window that overflows is shortened a line at a time, and an
54
+ item that cannot fit at all is skipped in favour of another. Only when nothing
55
+ in the library fits does the output get cut off.
56
+
57
+ Libraries harvested before stanza breaks were stored still work, but slice as
58
+ one long stanza. Run `hyprmuse update --all` once to pick the breaks up.
59
+
60
+ ## Setup
61
+
62
+ Download the Linux binary from the [latest release](https://github.com/DavidSiegl/hyprmuse/releases/latest),
63
+ which needs no Python, and check it:
64
+
65
+ ```bash
66
+ curl -LO https://github.com/DavidSiegl/hyprmuse/releases/latest/download/hyprmuse-linux-x86_64
67
+ curl -LO https://github.com/DavidSiegl/hyprmuse/releases/latest/download/hyprmuse-linux-x86_64.sha256
68
+ sha256sum -c hyprmuse-linux-x86_64.sha256
69
+ install -D -m 755 hyprmuse-linux-x86_64 ~/.local/bin/hyprmuse
70
+ ```
71
+
72
+ Or run it from a checkout with `uv sync`, then `uv run hyprmuse` in place of `hyprmuse`
73
+ below.
74
+
75
+ ## Usage
76
+
77
+ ```bash
78
+ # add subjects — omit --source to search every source and pick from a list
79
+ hyprmuse add "Element of Crime" --source genius
80
+ hyprmuse add "Ingeborg Bachmann" --source wikiquote --lang de
81
+ hyprmuse add "Emily Dickinson" --source poetrydb
82
+ hyprmuse add "Frankenstein" --source gutenberg
83
+
84
+ hyprmuse search "Nietzsche" --lang de # look without harvesting
85
+ hyprmuse list # what's stored
86
+ hyprmuse update --all # re-harvest everything
87
+ hyprmuse remove "genius:342499"
88
+
89
+ hyprmuse quote # a random quote
90
+ hyprmuse quote --domain poetry --width 50
91
+ hyprmuse quote --max-lines 4 --max-chars 160 # fit a small label
92
+ hyprmuse quote --source wikiquote --note
93
+ hyprmuse quote --seed 42 # deterministic
94
+ ```
95
+
96
+ `--lang` selects the Wikiquote language edition (`de`, `en`, `fr`, …). Harvesting
97
+ is throttled and cached; `add` is the slow step, `quote` reads only local files.
98
+
99
+ ## Hyprlock integration
100
+
101
+ ```ini
102
+ label {
103
+ monitor =
104
+ text = cmd[update:0] ~/.local/bin/hyprmuse quote --width 60
105
+ color = rgba(200, 200, 200, 1.0)
106
+ font_size = 20
107
+ font_family = Noto Sans
108
+ position = 0, 80
109
+ halign = center
110
+ valign = center
111
+ }
112
+ ```
113
+
114
+ From a checkout, the command is `/path/to/hyprmuse/.venv/bin/hyprmuse`.
115
+ `quote.py` is kept as a compatibility shim, so an existing config pointing at
116
+ `/path/to/hyprmuse/quote.py` keeps working — only the directory name changed.
117
+
118
+ ## Configuration
119
+
120
+ Optional, at `~/.config/hyprmuse/config.toml`:
121
+
122
+ ```toml
123
+ [quote]
124
+ lines = 3 # lines taken from sliceable items
125
+ width = 60 # wrap width, 0 disables wrapping
126
+ max_lines = 6 # most lines after wrapping, 0 disables
127
+ max_chars = 0 # most characters after wrapping, 0 disables
128
+ min_line_chars = 2 # skip lines shorter than this ("-", "I") when slicing
129
+ weighting = "subject" # "subject" = every subject equally likely
130
+ # "quote" = every quote equally likely
131
+ avoid_repeats = 20 # don't repeat within the last N quotes
132
+
133
+ # per-subject overrides, keyed as shown by `hyprmuse list`
134
+ [subjects."genius:342499"]
135
+ weight = 2.0
136
+ enabled = true
137
+ ```
138
+
139
+ `weighting = "subject"` is the default so a 162-song band does not drown out an
140
+ author with nine quotes.
141
+
142
+ ## Storage
143
+
144
+ ```
145
+ ~/.local/share/hyprmuse/subjects/*.json one file per subject
146
+ ~/.local/share/hyprmuse/recent.json recently shown, for repeat avoidance
147
+ ~/.config/hyprmuse/config.toml optional settings
148
+ ```
149
+
150
+ Harvested text lives outside the repository, so scraped content stays out of git.
151
+
152
+ ## Development
153
+
154
+ ```bash
155
+ uv run pytest # the tests
156
+ scripts/gate origin/main # the tests with coverage, then octoflow-ai's quality gate on
157
+ # the change since origin/main, as Jenkins runs it; writes quality.md
158
+ ```
159
+
160
+ Opening a pull request hands it over: once Jenkins passes it, one that changes only the
161
+ package's or the tests' Python, or Markdown, merges into `main` without a human.
162
+ `docs/agents/README.md` has the exact rule and how to hold a pull request back.
163
+
164
+ A merge into `main` that raises `version` in `pyproject.toml` releases it: Jenkins builds
165
+ the binary with `packaging/build-binary.sh` and publishes it as release `v<version>`.
166
+ The Jenkins job `hyprmuse-pypi` (`packaging/pypi/Jenkinsfile`) then builds that release's
167
+ tag on a trusted node and uploads the sdist and wheel to PyPI, and `hyprmuse-aur`
168
+ (`packaging/aur/Jenkinsfile`) pushes the AUR's `hyprmuse-bin` to that release with
169
+ `packaging/aur/publish.sh`. To change the AUR package itself between releases, edit
170
+ `packaging/aur/PKGBUILD`, run `packaging/aur/update.sh` with the latest released version,
171
+ raise `pkgrel`, and run `update.sh` again to refresh `.SRCINFO`. Once merged, the change
172
+ reaches the AUR on the job's next run, after the next build of `main` or within a day.
173
+
174
+ ## License
175
+
176
+ MIT; see `LICENSE`. The release binary also bundles Python, the packages hyprmuse
177
+ depends on, and the C libraries linked into them, each under its own license; each
178
+ release carries their notices as `hyprmuse-third-party-licenses.txt`.
@@ -0,0 +1,156 @@
1
+ <h1 align="center">
2
+ <img src="https://raw.githubusercontent.com/DavidSiegl/hyprmuse/main/assets/logo.svg" width="84" height="84" alt="">
3
+ <br>
4
+ hyprmuse
5
+ </h1>
6
+
7
+ <p align="center">
8
+ Random quotes from musicians, literary authors, poets and public-domain books,
9
+ for your <code>hyprlock</code> screen.
10
+ </p>
11
+
12
+ Name a subject, hyprmuse harvests them from a fitting source into a local
13
+ library, and prints a random quote on demand. Everything is stored offline, so
14
+ the lockscreen never waits on the network.
15
+
16
+ ## Sources
17
+
18
+ | Source | Domain | Covers | Licence |
19
+ |---|---|---|---|
20
+ | `wikiquote` | quotations | authors, philosophers, scientists, films, TV, proverbs — any language edition | CC BY-SA |
21
+ | `genius` | music | song lyrics for any artist | scraped, personal use |
22
+ | `poetrydb` | poetry | ~130 classic poets, full poems | public domain |
23
+ | `gutenberg` | prose | passages from public-domain books | public domain |
24
+
25
+ Sources fall into two kinds. **Sliceable** items (lyrics, poems) are quoted as a
26
+ random window of consecutive lines taken from within one stanza, so a slice
27
+ never runs from a verse into its chorus. **Atomic** items (a Wikiquote
28
+ quotation, a prose passage) are quoted whole and word-wrapped.
29
+
30
+ Every pick is checked against the box it has to fit (`width`, `max_lines`,
31
+ `max_chars`). A window that overflows is shortened a line at a time, and an
32
+ item that cannot fit at all is skipped in favour of another. Only when nothing
33
+ in the library fits does the output get cut off.
34
+
35
+ Libraries harvested before stanza breaks were stored still work, but slice as
36
+ one long stanza. Run `hyprmuse update --all` once to pick the breaks up.
37
+
38
+ ## Setup
39
+
40
+ Download the Linux binary from the [latest release](https://github.com/DavidSiegl/hyprmuse/releases/latest),
41
+ which needs no Python, and check it:
42
+
43
+ ```bash
44
+ curl -LO https://github.com/DavidSiegl/hyprmuse/releases/latest/download/hyprmuse-linux-x86_64
45
+ curl -LO https://github.com/DavidSiegl/hyprmuse/releases/latest/download/hyprmuse-linux-x86_64.sha256
46
+ sha256sum -c hyprmuse-linux-x86_64.sha256
47
+ install -D -m 755 hyprmuse-linux-x86_64 ~/.local/bin/hyprmuse
48
+ ```
49
+
50
+ Or run it from a checkout with `uv sync`, then `uv run hyprmuse` in place of `hyprmuse`
51
+ below.
52
+
53
+ ## Usage
54
+
55
+ ```bash
56
+ # add subjects — omit --source to search every source and pick from a list
57
+ hyprmuse add "Element of Crime" --source genius
58
+ hyprmuse add "Ingeborg Bachmann" --source wikiquote --lang de
59
+ hyprmuse add "Emily Dickinson" --source poetrydb
60
+ hyprmuse add "Frankenstein" --source gutenberg
61
+
62
+ hyprmuse search "Nietzsche" --lang de # look without harvesting
63
+ hyprmuse list # what's stored
64
+ hyprmuse update --all # re-harvest everything
65
+ hyprmuse remove "genius:342499"
66
+
67
+ hyprmuse quote # a random quote
68
+ hyprmuse quote --domain poetry --width 50
69
+ hyprmuse quote --max-lines 4 --max-chars 160 # fit a small label
70
+ hyprmuse quote --source wikiquote --note
71
+ hyprmuse quote --seed 42 # deterministic
72
+ ```
73
+
74
+ `--lang` selects the Wikiquote language edition (`de`, `en`, `fr`, …). Harvesting
75
+ is throttled and cached; `add` is the slow step, `quote` reads only local files.
76
+
77
+ ## Hyprlock integration
78
+
79
+ ```ini
80
+ label {
81
+ monitor =
82
+ text = cmd[update:0] ~/.local/bin/hyprmuse quote --width 60
83
+ color = rgba(200, 200, 200, 1.0)
84
+ font_size = 20
85
+ font_family = Noto Sans
86
+ position = 0, 80
87
+ halign = center
88
+ valign = center
89
+ }
90
+ ```
91
+
92
+ From a checkout, the command is `/path/to/hyprmuse/.venv/bin/hyprmuse`.
93
+ `quote.py` is kept as a compatibility shim, so an existing config pointing at
94
+ `/path/to/hyprmuse/quote.py` keeps working — only the directory name changed.
95
+
96
+ ## Configuration
97
+
98
+ Optional, at `~/.config/hyprmuse/config.toml`:
99
+
100
+ ```toml
101
+ [quote]
102
+ lines = 3 # lines taken from sliceable items
103
+ width = 60 # wrap width, 0 disables wrapping
104
+ max_lines = 6 # most lines after wrapping, 0 disables
105
+ max_chars = 0 # most characters after wrapping, 0 disables
106
+ min_line_chars = 2 # skip lines shorter than this ("-", "I") when slicing
107
+ weighting = "subject" # "subject" = every subject equally likely
108
+ # "quote" = every quote equally likely
109
+ avoid_repeats = 20 # don't repeat within the last N quotes
110
+
111
+ # per-subject overrides, keyed as shown by `hyprmuse list`
112
+ [subjects."genius:342499"]
113
+ weight = 2.0
114
+ enabled = true
115
+ ```
116
+
117
+ `weighting = "subject"` is the default so a 162-song band does not drown out an
118
+ author with nine quotes.
119
+
120
+ ## Storage
121
+
122
+ ```
123
+ ~/.local/share/hyprmuse/subjects/*.json one file per subject
124
+ ~/.local/share/hyprmuse/recent.json recently shown, for repeat avoidance
125
+ ~/.config/hyprmuse/config.toml optional settings
126
+ ```
127
+
128
+ Harvested text lives outside the repository, so scraped content stays out of git.
129
+
130
+ ## Development
131
+
132
+ ```bash
133
+ uv run pytest # the tests
134
+ scripts/gate origin/main # the tests with coverage, then octoflow-ai's quality gate on
135
+ # the change since origin/main, as Jenkins runs it; writes quality.md
136
+ ```
137
+
138
+ Opening a pull request hands it over: once Jenkins passes it, one that changes only the
139
+ package's or the tests' Python, or Markdown, merges into `main` without a human.
140
+ `docs/agents/README.md` has the exact rule and how to hold a pull request back.
141
+
142
+ A merge into `main` that raises `version` in `pyproject.toml` releases it: Jenkins builds
143
+ the binary with `packaging/build-binary.sh` and publishes it as release `v<version>`.
144
+ The Jenkins job `hyprmuse-pypi` (`packaging/pypi/Jenkinsfile`) then builds that release's
145
+ tag on a trusted node and uploads the sdist and wheel to PyPI, and `hyprmuse-aur`
146
+ (`packaging/aur/Jenkinsfile`) pushes the AUR's `hyprmuse-bin` to that release with
147
+ `packaging/aur/publish.sh`. To change the AUR package itself between releases, edit
148
+ `packaging/aur/PKGBUILD`, run `packaging/aur/update.sh` with the latest released version,
149
+ raise `pkgrel`, and run `update.sh` again to refresh `.SRCINFO`. Once merged, the change
150
+ reaches the AUR on the job's next run, after the next build of `main` or within a day.
151
+
152
+ ## License
153
+
154
+ MIT; see `LICENSE`. The release binary also bundles Python, the packages hyprmuse
155
+ depends on, and the C libraries linked into them, each under its own license; each
156
+ release carries their notices as `hyprmuse-third-party-licenses.txt`.
@@ -0,0 +1 @@
1
+ """hyprmuse - random quotes from musicians, authors and poets for hyprlock."""
@@ -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())
@@ -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())
@@ -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