wirenote 0.1.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.
- wirenote/__init__.py +3 -0
- wirenote/calendar/__init__.py +2 -0
- wirenote/calendar/ics.py +236 -0
- wirenote/capture/__init__.py +2 -0
- wirenote/capture/recorder.py +304 -0
- wirenote/capture/wav.py +58 -0
- wirenote/cli.py +262 -0
- wirenote/config/__init__.py +2 -0
- wirenote/config/apps.py +158 -0
- wirenote/config/migrate.py +130 -0
- wirenote/config/perms.py +61 -0
- wirenote/config/secrets.py +98 -0
- wirenote/config/settings.py +176 -0
- wirenote/config/vocabulary.py +32 -0
- wirenote/data/wirenote-ui.service +15 -0
- wirenote/data/wirenote.desktop +10 -0
- wirenote/data/wirenote.service +15 -0
- wirenote/detector/__init__.py +2 -0
- wirenote/detector/core.py +115 -0
- wirenote/detector/daemon.py +743 -0
- wirenote/detector/graph.py +102 -0
- wirenote/detector/identity.py +205 -0
- wirenote/detector/streams.py +69 -0
- wirenote/detector/tracker.py +304 -0
- wirenote/net/__init__.py +2 -0
- wirenote/net/peer.py +101 -0
- wirenote/notify/__init__.py +2 -0
- wirenote/notify/clipboard.py +43 -0
- wirenote/notify/dbus.py +89 -0
- wirenote/process/__init__.py +2 -0
- wirenote/process/jobs.py +122 -0
- wirenote/process/merge.py +87 -0
- wirenote/process/models.py +57 -0
- wirenote/process/note.py +124 -0
- wirenote/process/speakers.py +295 -0
- wirenote/process/summarize.py +287 -0
- wirenote/process/transcribe.py +97 -0
- wirenote/process/worker.py +229 -0
- wirenote/tools/__init__.py +2 -0
- wirenote/tools/dump_streams.py +595 -0
- wirenote/tools/install.py +140 -0
- wirenote/tools/setup.py +338 -0
- wirenote/ui/__init__.py +2 -0
- wirenote/ui/data.py +783 -0
- wirenote/ui/launch.py +88 -0
- wirenote/ui/server.py +423 -0
- wirenote/ui/static/app.css +907 -0
- wirenote/ui/static/app.js +1267 -0
- wirenote/ui/static/fonts/OFL.txt +93 -0
- wirenote/ui/static/fonts/inter-latin-ext.woff2 +0 -0
- wirenote/ui/static/fonts/inter-latin.woff2 +0 -0
- wirenote/ui/static/icon.svg +1 -0
- wirenote/ui/static/icons.svg +31 -0
- wirenote/ui/static/index.html +57 -0
- wirenote/ui/static/login.html +19 -0
- wirenote-0.1.0.dist-info/METADATA +106 -0
- wirenote-0.1.0.dist-info/RECORD +60 -0
- wirenote-0.1.0.dist-info/WHEEL +4 -0
- wirenote-0.1.0.dist-info/entry_points.txt +2 -0
- wirenote-0.1.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""`wirenote setup install` / `uninstall`: systemd user units, the app-menu entry
|
|
4
|
+
and its icon, so nobody copies files by hand.
|
|
5
|
+
|
|
6
|
+
Nothing here needs root: everything goes to the user's own config and data folders.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from importlib.resources import files
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from ..config.settings import config_dir, data_dir, load_settings, state_dir
|
|
20
|
+
|
|
21
|
+
UNITS = ("wirenote.service", "wirenote-ui.service")
|
|
22
|
+
OLD_UNITS = ("meetnote.service", "meetnote-ui.service")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Action:
|
|
27
|
+
kind: str # "write" | "run" | "remove"
|
|
28
|
+
target: str # a path, or a description of the command
|
|
29
|
+
payload: object # file text, or argv
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _units_dir() -> Path:
|
|
33
|
+
return config_dir().parent / "systemd" / "user"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _xdg_data() -> Path:
|
|
37
|
+
return data_dir().parent
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _desktop_path() -> Path:
|
|
41
|
+
return _xdg_data() / "applications" / "wirenote.desktop"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _icon_path() -> Path:
|
|
45
|
+
return _xdg_data() / "icons" / "hicolor" / "scalable" / "apps" / "wirenote.svg"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _exe() -> str:
|
|
49
|
+
found = shutil.which("wirenote")
|
|
50
|
+
return found or str(Path(sys.argv[0]).resolve())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run(argv: list[str]) -> int:
|
|
54
|
+
return subprocess.run(argv, check=False).returncode
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _template(name: str, exe: str) -> str:
|
|
58
|
+
quoted = f'"{exe}"' if " " in exe else exe
|
|
59
|
+
return (files("wirenote") / "data" / name).read_text().replace("@EXEC@", quoted)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _systemctl(*args: str) -> Action:
|
|
63
|
+
argv = ["systemctl", "--user", *args]
|
|
64
|
+
return Action("run", " ".join(argv), argv)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def plan_install(exe: str) -> list[Action]:
|
|
68
|
+
units = _units_dir()
|
|
69
|
+
actions = [Action("write", str(units / u), _template(u, exe)) for u in UNITS]
|
|
70
|
+
actions.append(Action("write", str(_desktop_path()), _template("wirenote.desktop", exe)))
|
|
71
|
+
actions.append(Action("write", str(_icon_path()),
|
|
72
|
+
(files("wirenote") / "ui" / "static" / "icon.svg").read_text()))
|
|
73
|
+
actions.append(_systemctl("daemon-reload"))
|
|
74
|
+
if any((units / u).exists() for u in OLD_UNITS):
|
|
75
|
+
# Before enabling ours: the old UI holds the same port.
|
|
76
|
+
actions.append(_systemctl("disable", "--now", *OLD_UNITS))
|
|
77
|
+
actions += [Action("remove", str(units / u), None) for u in OLD_UNITS]
|
|
78
|
+
actions.append(_systemctl("daemon-reload"))
|
|
79
|
+
actions.append(_systemctl("enable", *UNITS))
|
|
80
|
+
# Restart rather than start, so an update runs the new code.
|
|
81
|
+
actions.append(_systemctl("restart", *UNITS))
|
|
82
|
+
return actions
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _apply(actions: list[Action], dry_run: bool) -> int:
|
|
86
|
+
failed = 0
|
|
87
|
+
for a in actions:
|
|
88
|
+
if a.kind == "write":
|
|
89
|
+
print(f"write {a.target}")
|
|
90
|
+
if dry_run:
|
|
91
|
+
print("".join(f" {line}\n" for line in str(a.payload).splitlines()), end="")
|
|
92
|
+
continue
|
|
93
|
+
path = Path(a.target)
|
|
94
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
95
|
+
path.write_text(str(a.payload))
|
|
96
|
+
elif a.kind == "remove":
|
|
97
|
+
print(f"remove {a.target}")
|
|
98
|
+
if not dry_run:
|
|
99
|
+
Path(a.target).unlink(missing_ok=True)
|
|
100
|
+
else:
|
|
101
|
+
print(f"run {a.target}")
|
|
102
|
+
if not dry_run and _run(list(a.payload)) != 0:
|
|
103
|
+
print(f" ✗ {a.target} failed")
|
|
104
|
+
failed += 1
|
|
105
|
+
return 1 if failed else 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def install(dry_run: bool = False) -> int:
|
|
109
|
+
rc = _apply(plan_install(_exe()), dry_run)
|
|
110
|
+
if not dry_run and rc == 0:
|
|
111
|
+
print("\n✓ Wirenote runs in the background and starts with your session.")
|
|
112
|
+
print(" Open it from the app menu, or run: wirenote open. Check the setup: wirenote doctor")
|
|
113
|
+
return rc
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def uninstall(purge: bool = False, dry_run: bool = False,
|
|
117
|
+
confirm: Callable[[str], str] = input) -> int:
|
|
118
|
+
actions = [_systemctl("disable", "--now", *UNITS)]
|
|
119
|
+
actions += [Action("remove", str(_units_dir() / u), None) for u in UNITS]
|
|
120
|
+
actions += [Action("remove", str(_desktop_path()), None),
|
|
121
|
+
Action("remove", str(_icon_path()), None), _systemctl("daemon-reload")]
|
|
122
|
+
folders: list[Path] = []
|
|
123
|
+
if purge:
|
|
124
|
+
settings = load_settings(config_dir() / "config.toml")
|
|
125
|
+
folders = [config_dir(), state_dir(), data_dir()]
|
|
126
|
+
if not settings.recordings_path().is_relative_to(data_dir()):
|
|
127
|
+
folders.append(settings.recordings_path())
|
|
128
|
+
print("This deletes all recordings, transcripts, settings and voice prints:")
|
|
129
|
+
for f in folders:
|
|
130
|
+
print(f" {f}")
|
|
131
|
+
print(f"Your Markdown notes in {settings.notes_path()} are kept.")
|
|
132
|
+
if not dry_run and confirm("Type DELETE to remove them: ").strip() != "DELETE":
|
|
133
|
+
print("nothing removed")
|
|
134
|
+
return 1
|
|
135
|
+
rc = _apply(actions, dry_run)
|
|
136
|
+
for f in folders:
|
|
137
|
+
print(f"remove {f}")
|
|
138
|
+
if not dry_run:
|
|
139
|
+
shutil.rmtree(f, ignore_errors=True)
|
|
140
|
+
return rc
|
wirenote/tools/setup.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""`wirenote setup`, `wirenote calendar` and `wirenote doctor`.
|
|
4
|
+
|
|
5
|
+
Secrets (API key, calendar links) are read from the terminal, never from the
|
|
6
|
+
command line, so they stay out of shell history, and go to secrets.toml.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime as dt
|
|
12
|
+
import getpass
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import urllib.request
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from ..config.secrets import (calendar_sources, load_secrets, save_secrets, secrets_path,
|
|
22
|
+
set_config_value)
|
|
23
|
+
from ..net.peer import listener_uid, trusted_owner
|
|
24
|
+
from ..config.settings import Settings, config_dir, load_settings, state_dir
|
|
25
|
+
|
|
26
|
+
OK, BAD, WARN = "✓", "✗", "!"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _settings() -> Settings:
|
|
30
|
+
return load_settings(config_dir() / "config.toml")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _poke_daemon(command: str) -> None:
|
|
34
|
+
from ..detector.daemon import control_socket_path
|
|
35
|
+
import socket
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
|
39
|
+
s.settimeout(3)
|
|
40
|
+
s.connect(str(control_socket_path()))
|
|
41
|
+
s.sendall(f"{command}\n".encode())
|
|
42
|
+
s.makefile().readline()
|
|
43
|
+
except OSError:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# setup -------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def setup_claude() -> int:
|
|
50
|
+
s = _settings()
|
|
51
|
+
print("Summaries through the Claude API: transcripts are sent to Anthropic.")
|
|
52
|
+
print("Check your employer's policy before using this for work calls.\n")
|
|
53
|
+
key = getpass.getpass("Anthropic API key (input hidden, empty to keep the current one): ")
|
|
54
|
+
secrets = load_secrets()
|
|
55
|
+
if key.strip():
|
|
56
|
+
secrets["anthropic_api_key"] = key.strip()
|
|
57
|
+
if not secrets.get("anthropic_api_key") and not os.environ.get("ANTHROPIC_API_KEY"):
|
|
58
|
+
print("no key given")
|
|
59
|
+
return 1
|
|
60
|
+
import anthropic
|
|
61
|
+
|
|
62
|
+
client = anthropic.Anthropic(api_key=secrets.get("anthropic_api_key"))
|
|
63
|
+
try:
|
|
64
|
+
client.models.retrieve(s.anthropic_model) # free; checks key and model
|
|
65
|
+
except anthropic.AuthenticationError:
|
|
66
|
+
print(f"{BAD} the key was rejected; nothing changed")
|
|
67
|
+
return 1
|
|
68
|
+
except anthropic.NotFoundError:
|
|
69
|
+
print(f"{BAD} model {s.anthropic_model} not available to this key; nothing changed")
|
|
70
|
+
return 1
|
|
71
|
+
except anthropic.APIConnectionError as e:
|
|
72
|
+
print(f"{BAD} cannot reach the API ({e}); nothing changed")
|
|
73
|
+
return 1
|
|
74
|
+
save_secrets(secrets)
|
|
75
|
+
set_config_value(config_dir() / "config.toml", "summary_backend", '"anthropic"')
|
|
76
|
+
print(f"{OK} key works with {s.anthropic_model}; saved to {secrets_path()} (mode 600)")
|
|
77
|
+
print(f"{OK} summary_backend = \"anthropic\"; the next note uses it (no restart needed)")
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def setup_ollama() -> int:
|
|
82
|
+
s = _settings()
|
|
83
|
+
ok, msg = _check_ollama(s)
|
|
84
|
+
print(f"{OK if ok else BAD} {msg}")
|
|
85
|
+
if not ok:
|
|
86
|
+
return 1
|
|
87
|
+
set_config_value(config_dir() / "config.toml", "summary_backend", '"ollama"')
|
|
88
|
+
print(f"{OK} summary_backend = \"ollama\"; the next note uses it (no restart needed)")
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def setup_consent(text: str | None) -> int:
|
|
93
|
+
if text is None:
|
|
94
|
+
text = input("Consent line (empty to turn it off): ").strip()
|
|
95
|
+
set_config_value(config_dir() / "config.toml", "consent_line", json.dumps(text, ensure_ascii=False))
|
|
96
|
+
print(f"{OK} consent line {'set' if text else 'turned off'}; restart to apply: "
|
|
97
|
+
"systemctl --user restart wirenote")
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# calendar ----------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def calendar_list() -> int:
|
|
104
|
+
from ..calendar import ics
|
|
105
|
+
|
|
106
|
+
s = _settings()
|
|
107
|
+
sources = calendar_sources(s.calendar_urls)
|
|
108
|
+
if not sources:
|
|
109
|
+
print("no calendar yet; add one with: wirenote calendar add")
|
|
110
|
+
return 1
|
|
111
|
+
now = dt.datetime.now().astimezone()
|
|
112
|
+
events, errors = ics.load(sources, now)
|
|
113
|
+
for err in errors:
|
|
114
|
+
print(f"{BAD} {err}")
|
|
115
|
+
window = set(id(e) for e in ics.in_window(events, now, s.calendar_before_s,
|
|
116
|
+
s.calendar_after_start_s))
|
|
117
|
+
upcoming = sorted((e for e in events if e.end > now), key=lambda e: e.start)
|
|
118
|
+
print(f"{len(sources)} calendar(s), {len(upcoming)} event(s) in the next 24 h:")
|
|
119
|
+
for e in upcoming:
|
|
120
|
+
mark = "→" if id(e) in window else " "
|
|
121
|
+
call = " [call link]" if e.service_links(None) else ""
|
|
122
|
+
print(f" {mark} {e.start.astimezone():%a %H:%M}-{e.end.astimezone():%H:%M} {e.title}{call}")
|
|
123
|
+
if window:
|
|
124
|
+
print("→ in the prompt window now")
|
|
125
|
+
return 1 if errors and not events else 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def calendar_add() -> int:
|
|
129
|
+
from ..calendar import ics
|
|
130
|
+
|
|
131
|
+
print("Paste the calendar's ICS link (Outlook: Settings > Calendar > Shared calendars >")
|
|
132
|
+
print("Publish a calendar > ICS; Google: Settings > your calendar > Secret address in iCal).")
|
|
133
|
+
print("It is stored in secrets.toml, not in config.toml or the logs.\n")
|
|
134
|
+
url = getpass.getpass("ICS link (input hidden): ").strip()
|
|
135
|
+
if not url:
|
|
136
|
+
print("nothing added")
|
|
137
|
+
return 1
|
|
138
|
+
now = dt.datetime.now().astimezone()
|
|
139
|
+
try:
|
|
140
|
+
events = ics.parse(ics.fetch(url), now)
|
|
141
|
+
except Exception as e: # noqa: BLE001
|
|
142
|
+
print(f"{BAD} could not read that calendar: {ics.describe_error(url, e)}")
|
|
143
|
+
return 1
|
|
144
|
+
secrets = load_secrets()
|
|
145
|
+
urls = list(dict.fromkeys([*secrets.get("calendar_urls", []), url]))
|
|
146
|
+
secrets["calendar_urls"] = urls
|
|
147
|
+
save_secrets(secrets)
|
|
148
|
+
_poke_daemon("refresh-calendar")
|
|
149
|
+
print(f"{OK} calendar added ({len(events)} timed events in the next 24 h); "
|
|
150
|
+
"the daemon picks it up now")
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def calendar_clear() -> int:
|
|
155
|
+
secrets = load_secrets()
|
|
156
|
+
n = len(secrets.pop("calendar_urls", []))
|
|
157
|
+
save_secrets(secrets)
|
|
158
|
+
_poke_daemon("refresh-calendar")
|
|
159
|
+
print(f"{OK} removed {n} calendar link(s) from {secrets_path()}")
|
|
160
|
+
return 0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# doctor ------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def _check_ollama(s: Settings) -> tuple[bool, str]:
|
|
166
|
+
from ..process.summarize import pin_local
|
|
167
|
+
|
|
168
|
+
url = s.ollama_url.rstrip("/")
|
|
169
|
+
local = pin_local(url)
|
|
170
|
+
if local:
|
|
171
|
+
url, ip, port = local
|
|
172
|
+
owner = listener_uid(port, ip)
|
|
173
|
+
if owner is not None and not trusted_owner(owner):
|
|
174
|
+
return False, (f"port {port} belongs to another user (uid {owner}), so Wirenote "
|
|
175
|
+
"will not send transcripts there; start your own Ollama or set "
|
|
176
|
+
"ollama_url")
|
|
177
|
+
try:
|
|
178
|
+
with urllib.request.urlopen(f"{url}/api/tags", timeout=5) as r:
|
|
179
|
+
models = [m["name"] for m in json.loads(r.read()).get("models", [])]
|
|
180
|
+
except OSError as e:
|
|
181
|
+
return False, (f"Ollama not reachable at {s.ollama_url} ({e}); "
|
|
182
|
+
"start it: systemctl --user start ollama")
|
|
183
|
+
want = s.ollama_model if ":" in s.ollama_model else f"{s.ollama_model}:latest"
|
|
184
|
+
if want not in models:
|
|
185
|
+
return False, f"Ollama runs, but {s.ollama_model} is not pulled: ollama pull {s.ollama_model}"
|
|
186
|
+
return True, f"Ollama runs with {s.ollama_model}"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True)
|
|
190
|
+
class Check:
|
|
191
|
+
status: str # "ok" | "warn" | "bad"
|
|
192
|
+
message: str
|
|
193
|
+
fix: str | None = None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _ok(cond: bool, ok_msg: str, bad_msg: str, fix: str | None = None,
|
|
197
|
+
warn: bool = False) -> Check:
|
|
198
|
+
if cond:
|
|
199
|
+
return Check("ok", ok_msg)
|
|
200
|
+
return Check("warn" if warn else "bad", bad_msg, fix)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _daemon_checks() -> list[Check]:
|
|
204
|
+
try:
|
|
205
|
+
status = json.loads((state_dir() / "status.json").read_text())
|
|
206
|
+
except (OSError, ValueError):
|
|
207
|
+
return [Check("bad", "the background service has never run",
|
|
208
|
+
"wirenote setup install")]
|
|
209
|
+
alive = bool(status.get("running") and Path(f"/proc/{status.get('pid')}").exists())
|
|
210
|
+
out = [_ok(alive, "background service running", "background service not running",
|
|
211
|
+
"systemctl --user start wirenote")]
|
|
212
|
+
out.append(_ok(bool(status.get("notifications")), "desktop notifications work",
|
|
213
|
+
"desktop notifications unavailable"))
|
|
214
|
+
if status.get("notifications") and status.get("notification_buttons") is False:
|
|
215
|
+
out.append(Check("warn", "notifications here have no buttons: record from the Wirenote "
|
|
216
|
+
"window or with `wirenote record`"))
|
|
217
|
+
failed = (status.get("jobs") or {}).get("failed", 0)
|
|
218
|
+
out.append(_ok(not failed, "no failed note jobs", f"{failed} failed note job(s)",
|
|
219
|
+
"wirenote jobs", warn=True))
|
|
220
|
+
return out
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _pipewire_checks() -> list[Check]:
|
|
224
|
+
try:
|
|
225
|
+
dump = json.loads(subprocess.run(["pw-dump"], capture_output=True, timeout=10,
|
|
226
|
+
check=True).stdout)
|
|
227
|
+
from ..detector.graph import Graph
|
|
228
|
+
g = Graph()
|
|
229
|
+
g.apply(dump)
|
|
230
|
+
out = [Check("ok", f"PipeWire: mic {g.default_node('default.audio.source')}, "
|
|
231
|
+
f"output {g.default_node('default.audio.sink')}")]
|
|
232
|
+
except (OSError, subprocess.SubprocessError, ValueError) as e:
|
|
233
|
+
out = [Check("bad", f"PipeWire not readable: {e}")]
|
|
234
|
+
out.append(_ok(shutil.which("pw-record") is not None, "pw-record installed",
|
|
235
|
+
"pw-record missing", "install your distribution's pipewire tools"))
|
|
236
|
+
out.append(_ok(shutil.which("ffmpeg") is not None, "ffmpeg installed",
|
|
237
|
+
"ffmpeg missing: the web app cannot play recordings",
|
|
238
|
+
"install ffmpeg", warn=True))
|
|
239
|
+
return out
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _model_checks(s: Settings) -> list[Check]:
|
|
243
|
+
from ..process.models import EMB_REPO, SEG_FILE, SEG_REPO, hub_file, whisper_path
|
|
244
|
+
|
|
245
|
+
out = []
|
|
246
|
+
try:
|
|
247
|
+
whisper_path(s.whisper_model, local_only=True)
|
|
248
|
+
out.append(Check("ok", f"Whisper model {s.whisper_model} is downloaded"))
|
|
249
|
+
except Exception: # noqa: BLE001
|
|
250
|
+
out.append(Check("warn", f"Whisper model {s.whisper_model} downloads on the first note "
|
|
251
|
+
"(about 1.6 GB for large-v3-turbo)"))
|
|
252
|
+
if s.diarize:
|
|
253
|
+
try:
|
|
254
|
+
hub_file(SEG_REPO, SEG_FILE, local_only=True)
|
|
255
|
+
hub_file(EMB_REPO, s.diarize_model, local_only=True)
|
|
256
|
+
out.append(Check("ok", "speaker separation models are downloaded"))
|
|
257
|
+
except Exception: # noqa: BLE001
|
|
258
|
+
out.append(Check("warn", "speaker separation models download on the first note "
|
|
259
|
+
"(about 32 MB)"))
|
|
260
|
+
return out
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _summary_checks(s: Settings) -> list[Check]:
|
|
264
|
+
if s.summary_backend == "ollama":
|
|
265
|
+
ok, msg = _check_ollama(s)
|
|
266
|
+
return [Check("ok" if ok else "bad", msg)]
|
|
267
|
+
if s.summary_backend == "anthropic":
|
|
268
|
+
has_key = bool(load_secrets().get("anthropic_api_key") or os.environ.get("ANTHROPIC_API_KEY"))
|
|
269
|
+
return [_ok(has_key, f"Claude API ({s.anthropic_model}): key set; transcripts are "
|
|
270
|
+
"sent to Anthropic",
|
|
271
|
+
f"Claude API ({s.anthropic_model}): no key", "wirenote setup claude")]
|
|
272
|
+
return [Check("warn", "summaries are off (summary_backend = \"none\")")]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _calendar_checks(s: Settings) -> list[Check]:
|
|
276
|
+
sources = calendar_sources(s.calendar_urls)
|
|
277
|
+
if not sources:
|
|
278
|
+
return [Check("warn", "no calendar (optional): titles and invitees come from it",
|
|
279
|
+
"wirenote calendar add")]
|
|
280
|
+
from ..calendar import ics
|
|
281
|
+
events, errors = ics.load(sources, dt.datetime.now().astimezone())
|
|
282
|
+
return [_ok(not errors, f"{len(sources)} calendar(s), {len(events)} events around now",
|
|
283
|
+
f"{len(errors)} of {len(sources)} calendar(s) failed: {errors[0] if errors else ''}")]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _desktop_checks(s: Settings) -> list[Check]:
|
|
287
|
+
from ..notify.clipboard import available_tools
|
|
288
|
+
|
|
289
|
+
desktop = os.environ.get("XDG_CURRENT_DESKTOP") or "unknown"
|
|
290
|
+
out = [Check("ok", f"desktop: {desktop}")]
|
|
291
|
+
if s.consent_line and "KDE" not in desktop.upper() and not available_tools():
|
|
292
|
+
out.append(Check("warn", "no clipboard tool for the consent line",
|
|
293
|
+
"install wl-clipboard (Wayland) or xclip (X11)"))
|
|
294
|
+
return out
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _file_checks(s: Settings) -> list[Check]:
|
|
298
|
+
from ..calendar.ics import SCHEME
|
|
299
|
+
from ..config.perms import loose_files, private_paths
|
|
300
|
+
|
|
301
|
+
out = [Check("ok", "consent line set") if s.consent_line else
|
|
302
|
+
Check("warn", "no consent line (optional)", 'wirenote setup consent "…"')]
|
|
303
|
+
rec_root = s.recordings_path()
|
|
304
|
+
rec_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
305
|
+
free_gb = shutil.disk_usage(rec_root).free / 1e9
|
|
306
|
+
out.append(_ok(free_gb > 5, f"{free_gb:.0f} GB free for recordings (about 0.23 GB per hour)",
|
|
307
|
+
f"only {free_gb:.1f} GB free for recordings"))
|
|
308
|
+
notes = s.notes_path()
|
|
309
|
+
notes.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
310
|
+
out.append(_ok(os.access(notes, os.W_OK), f"notes go to {notes}", f"cannot write to {notes}"))
|
|
311
|
+
loose = loose_files(*private_paths(s))
|
|
312
|
+
out.append(_ok(not loose, "recordings, notes and settings are private to you",
|
|
313
|
+
f"{len(loose)} file(s) or folder(s) readable by other users",
|
|
314
|
+
"wirenote doctor --fix"))
|
|
315
|
+
if any(SCHEME.match(str(u)) for u in s.calendar_urls):
|
|
316
|
+
out.append(Check("warn", "calendar link in config.toml: links are secret, keep them "
|
|
317
|
+
"in secrets.toml",
|
|
318
|
+
"wirenote calendar add, then remove it from config.toml"))
|
|
319
|
+
return out
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def checks(s: Settings) -> list[Check]:
|
|
323
|
+
return [*_daemon_checks(), *_pipewire_checks(), *_model_checks(s), *_summary_checks(s),
|
|
324
|
+
*_calendar_checks(s), *_desktop_checks(s), *_file_checks(s)]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def doctor(fix: bool = False) -> int:
|
|
328
|
+
s = _settings()
|
|
329
|
+
if fix:
|
|
330
|
+
from ..config.perms import fix_modes, private_paths
|
|
331
|
+
print(f"{OK} made {fix_modes(*private_paths(s))} file(s) and folder(s) private")
|
|
332
|
+
found = checks(s)
|
|
333
|
+
for c in found:
|
|
334
|
+
mark = {"ok": OK, "warn": WARN, "bad": BAD}[c.status]
|
|
335
|
+
print(f"{mark} {c.message}" + (f": {c.fix}" if c.fix else ""))
|
|
336
|
+
problems = sum(c.status == "bad" for c in found)
|
|
337
|
+
print("\nall good" if not problems else f"\n{problems} problem(s)")
|
|
338
|
+
return 1 if problems else 0
|
wirenote/ui/__init__.py
ADDED