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
wirenote/cli.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""wirenote command line: run | status | stop | record | jobs | reprocess | setup | calendar | doctor | ui | open."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import socket
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from .config.apps import AppsError, load_apps
|
|
15
|
+
from .config.settings import config_dir, load_settings, state_dir
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _settings():
|
|
19
|
+
try:
|
|
20
|
+
return load_settings(config_dir() / "config.toml")
|
|
21
|
+
except ValueError as e:
|
|
22
|
+
raise SystemExit(f"wirenote: {e}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cmd_run(_args) -> int:
|
|
26
|
+
from .detector.daemon import Daemon
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
settings = load_settings(config_dir() / "config.toml")
|
|
30
|
+
rules = load_apps(config_dir() / "apps.toml")
|
|
31
|
+
except (AppsError, ValueError) as e:
|
|
32
|
+
print(f"wirenote: {e}", file=sys.stderr)
|
|
33
|
+
return 2
|
|
34
|
+
asyncio.run(Daemon(settings, rules, state_dir()).run())
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cmd_status(_args) -> int:
|
|
39
|
+
path = state_dir() / "status.json"
|
|
40
|
+
try:
|
|
41
|
+
status = json.loads(path.read_text())
|
|
42
|
+
except (OSError, ValueError):
|
|
43
|
+
print(f"no status yet ({path}); is `wirenote run` running?")
|
|
44
|
+
return 1
|
|
45
|
+
print(f"updated {status['updated']} pid {status['pid']} "
|
|
46
|
+
f"{'running' if status.get('running') else 'stopped'}"
|
|
47
|
+
f"{'' if status.get('notifications') else ' NO NOTIFICATIONS'}")
|
|
48
|
+
if status.get("recording"):
|
|
49
|
+
print(f"recording: {status['recording']} ({status.get('recording_id')})")
|
|
50
|
+
jobs = status.get("jobs") or {}
|
|
51
|
+
if any(jobs.get(s) for s in ("queued", "transcribing", "summarizing", "failed")):
|
|
52
|
+
print("jobs: " + " ".join(f"{k}={v}" for k, v in sorted(jobs.items())))
|
|
53
|
+
apps = status.get("apps") or {}
|
|
54
|
+
if not apps:
|
|
55
|
+
print("no app on the mic")
|
|
56
|
+
for key, a in apps.items():
|
|
57
|
+
extra = " ".join(f"{k}={v}" for k, v in a.items() if k not in ("state", "name"))
|
|
58
|
+
print(f" {a['name']:<12} {a['state']:<13} {extra}")
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _control(command: str) -> int:
|
|
63
|
+
from .detector.daemon import control_socket_path
|
|
64
|
+
|
|
65
|
+
path = control_socket_path()
|
|
66
|
+
try:
|
|
67
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
|
68
|
+
s.settimeout(5)
|
|
69
|
+
s.connect(str(path))
|
|
70
|
+
s.sendall(f"{command}\n".encode())
|
|
71
|
+
reply = json.loads(s.makefile().readline())
|
|
72
|
+
except (OSError, ValueError) as e:
|
|
73
|
+
print(f"wirenote: cannot reach the daemon at {path} ({e}); is it running?")
|
|
74
|
+
return 1
|
|
75
|
+
if reply.get("ok"):
|
|
76
|
+
print(f"{command}: {reply.get('app')}")
|
|
77
|
+
return 0
|
|
78
|
+
print(f"{command}: {reply.get('error')}")
|
|
79
|
+
return 1
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cmd_stop(_args) -> int:
|
|
83
|
+
return _control("stop")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_record(args) -> int:
|
|
87
|
+
return _control("record-now" if args.now else "record")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_jobs(args) -> int:
|
|
91
|
+
from .process.jobs import Jobs
|
|
92
|
+
|
|
93
|
+
jobs = Jobs(state_dir() / "jobs.db")
|
|
94
|
+
rows = jobs.recent(args.n)
|
|
95
|
+
if not rows:
|
|
96
|
+
print("no jobs yet")
|
|
97
|
+
for j in rows:
|
|
98
|
+
print(f"{j.id:>4} {j.state:<12} {j.recording.name:<34} {j.updated}")
|
|
99
|
+
if j.note:
|
|
100
|
+
print(f" note: {j.note}")
|
|
101
|
+
if j.error and j.state != "done":
|
|
102
|
+
print(f" error (attempt {j.attempts}): {j.error.strip().splitlines()[-1]}")
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_reprocess(args) -> int:
|
|
107
|
+
from .process.jobs import Jobs
|
|
108
|
+
|
|
109
|
+
rec = _settings().recordings_path() / args.recording
|
|
110
|
+
if not (rec / "manifest.json").is_file():
|
|
111
|
+
print(f"wirenote: no recording {args.recording} in {rec.parent}")
|
|
112
|
+
return 1
|
|
113
|
+
if not args.summary and not any(rec.glob("*.wav")):
|
|
114
|
+
print("wirenote: the audio was deleted; only --summary can be redone")
|
|
115
|
+
return 1
|
|
116
|
+
job_id = Jobs(state_dir() / "jobs.db").add(rec, summary_only=args.summary)
|
|
117
|
+
print(f"queued job {job_id}; the daemon picks it up within 30 s (`wirenote jobs`)")
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cmd_process(args) -> int:
|
|
122
|
+
"""Run one job in this process. The daemon starts this; it exits 0 once the
|
|
123
|
+
job's outcome is recorded, even a failed attempt."""
|
|
124
|
+
from .process.jobs import Jobs
|
|
125
|
+
from .process.worker import run_job
|
|
126
|
+
|
|
127
|
+
job = run_job(args.job, _settings(), Jobs(state_dir() / "jobs.db"))
|
|
128
|
+
print(f"job {job.id}: {job.state}", file=sys.stderr)
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def cmd_setup(args) -> int:
|
|
133
|
+
from .tools import install, setup
|
|
134
|
+
|
|
135
|
+
if args.what == "install":
|
|
136
|
+
return install.install(dry_run=args.dry_run)
|
|
137
|
+
if args.what == "uninstall":
|
|
138
|
+
return install.uninstall(purge=args.purge, dry_run=args.dry_run)
|
|
139
|
+
if args.what == "claude":
|
|
140
|
+
return setup.setup_claude()
|
|
141
|
+
if args.what == "ollama":
|
|
142
|
+
return setup.setup_ollama()
|
|
143
|
+
return setup.setup_consent(args.text)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def cmd_calendar(args) -> int:
|
|
147
|
+
from .tools import setup
|
|
148
|
+
|
|
149
|
+
return {"list": setup.calendar_list, "add": setup.calendar_add,
|
|
150
|
+
"clear": setup.calendar_clear}[args.action]()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def cmd_ui(args) -> int:
|
|
154
|
+
from .ui.server import serve
|
|
155
|
+
|
|
156
|
+
s = _settings()
|
|
157
|
+
serve(s, state_dir(), args.port or s.ui_port, open_browser=args.open)
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def cmd_open(args) -> int:
|
|
162
|
+
"""Open the web UI through a one-time login link, starting it if needed."""
|
|
163
|
+
import subprocess
|
|
164
|
+
import time
|
|
165
|
+
|
|
166
|
+
from .ui import launch
|
|
167
|
+
|
|
168
|
+
port = _settings().ui_port
|
|
169
|
+
if launch.listener_uid(port) is None:
|
|
170
|
+
subprocess.Popen([sys.executable, "-m", "wirenote.cli", "ui"], start_new_session=True,
|
|
171
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
172
|
+
stderr=subprocess.DEVNULL)
|
|
173
|
+
for _ in range(50):
|
|
174
|
+
if launch.listener_uid(port) is not None:
|
|
175
|
+
break
|
|
176
|
+
time.sleep(0.1)
|
|
177
|
+
if launch.listener_uid(port) != os.getuid():
|
|
178
|
+
print(f"wirenote: port {port} belongs to another user or nothing answers there; "
|
|
179
|
+
"not opening it. Set ui_port in config.toml to a free port.", file=sys.stderr)
|
|
180
|
+
return 1
|
|
181
|
+
launch.open_ui(port, f"#/call/{args.call}" if args.call else "#/")
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def cmd_doctor(args) -> int:
|
|
186
|
+
from .tools import setup
|
|
187
|
+
|
|
188
|
+
return setup.doctor(fix=args.fix)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def cmd_dump_streams(args) -> int:
|
|
192
|
+
from .tools.dump_streams import main as dump_main
|
|
193
|
+
return dump_main(args.rest)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def main(argv: list[str] | None = None) -> int:
|
|
197
|
+
if not sys.platform.startswith("linux"):
|
|
198
|
+
print("wirenote: runs on Linux only; it needs PipeWire, systemd and D-Bus.",
|
|
199
|
+
file=sys.stderr)
|
|
200
|
+
return 2
|
|
201
|
+
os.umask(0o077) # recordings, transcripts and notes are private
|
|
202
|
+
from .config.migrate import MigrationError, migrate_if_needed
|
|
203
|
+
try:
|
|
204
|
+
moved = migrate_if_needed()
|
|
205
|
+
except MigrationError as e:
|
|
206
|
+
print(f"wirenote: {e}", file=sys.stderr)
|
|
207
|
+
return 2
|
|
208
|
+
if moved:
|
|
209
|
+
print(moved, file=sys.stderr)
|
|
210
|
+
ap = argparse.ArgumentParser(prog="wirenote", description=__doc__)
|
|
211
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
212
|
+
sub.add_parser("run", help="run the daemon").set_defaults(fn=cmd_run)
|
|
213
|
+
sub.add_parser("status", help="show what the daemon sees").set_defaults(fn=cmd_status)
|
|
214
|
+
sub.add_parser("stop", help="stop the current recording").set_defaults(fn=cmd_stop)
|
|
215
|
+
rec = sub.add_parser("record", help="answer the open prompt with Record")
|
|
216
|
+
rec.add_argument("--now", action="store_true",
|
|
217
|
+
help="record a meeting without a call app (in person, phone on speaker)")
|
|
218
|
+
rec.set_defaults(fn=cmd_record)
|
|
219
|
+
jobs = sub.add_parser("jobs", help="list recent processing jobs")
|
|
220
|
+
jobs.add_argument("-n", type=int, default=10)
|
|
221
|
+
jobs.set_defaults(fn=cmd_jobs)
|
|
222
|
+
rep = sub.add_parser("reprocess", help="queue a recording again")
|
|
223
|
+
rep.add_argument("recording", help="recording id, e.g. 20261027-100000-chrome")
|
|
224
|
+
rep.add_argument("--summary", action="store_true",
|
|
225
|
+
help="keep the transcript, redo only the summary and the note")
|
|
226
|
+
rep.set_defaults(fn=cmd_reprocess)
|
|
227
|
+
st = sub.add_parser("setup", help="install the services, choose the summary backend, "
|
|
228
|
+
"or set the consent line")
|
|
229
|
+
st.add_argument("what", choices=["install", "uninstall", "ollama", "claude", "consent"])
|
|
230
|
+
st.add_argument("text", nargs="?", help="consent line (for `setup consent`)")
|
|
231
|
+
st.add_argument("--dry-run", action="store_true",
|
|
232
|
+
help="install/uninstall: show the files and commands, change nothing")
|
|
233
|
+
st.add_argument("--purge", action="store_true",
|
|
234
|
+
help="uninstall: also delete recordings, transcripts and settings")
|
|
235
|
+
st.set_defaults(fn=cmd_setup)
|
|
236
|
+
cal = sub.add_parser("calendar", help="list upcoming events, add or remove ICS links")
|
|
237
|
+
cal.add_argument("action", nargs="?", default="list", choices=["list", "add", "clear"])
|
|
238
|
+
cal.set_defaults(fn=cmd_calendar)
|
|
239
|
+
doc = sub.add_parser("doctor", help="check that everything is set up")
|
|
240
|
+
doc.add_argument("--fix", action="store_true",
|
|
241
|
+
help="make recordings, notes and settings private to you (chmod)")
|
|
242
|
+
doc.set_defaults(fn=cmd_doctor)
|
|
243
|
+
ui = sub.add_parser("ui", help="serve the meetings web app on 127.0.0.1")
|
|
244
|
+
ui.add_argument("--open", action="store_true", help="open it in the browser")
|
|
245
|
+
ui.add_argument("--port", type=int, default=0)
|
|
246
|
+
ui.set_defaults(fn=cmd_ui)
|
|
247
|
+
op = sub.add_parser("open", help="open the meetings web app in the browser")
|
|
248
|
+
op.add_argument("call", nargs="?", help="recording id to open")
|
|
249
|
+
op.set_defaults(fn=cmd_open)
|
|
250
|
+
proc = sub.add_parser("process", help=argparse.SUPPRESS)
|
|
251
|
+
proc.add_argument("job", type=int)
|
|
252
|
+
proc.set_defaults(fn=cmd_process)
|
|
253
|
+
dump = sub.add_parser("dump-streams", help="probe PipeWire capture streams (test fixtures)",
|
|
254
|
+
add_help=False)
|
|
255
|
+
dump.add_argument("rest", nargs=argparse.REMAINDER)
|
|
256
|
+
dump.set_defaults(fn=cmd_dump_streams)
|
|
257
|
+
args = ap.parse_args(argv)
|
|
258
|
+
return args.fn(args)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
sys.exit(main())
|
wirenote/config/apps.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""App rules from apps.toml: exact matches only, never substrings.
|
|
4
|
+
|
|
5
|
+
Each rule has a key, a display name, a kind (meeting, browser or ignore) and
|
|
6
|
+
lists of exact values for flatpak, snap, exe, binary and app_name. Browser
|
|
7
|
+
rules may carry title patterns: regexes on the stream's media.name that turn
|
|
8
|
+
the stream into a known meeting (Firefox puts the page title there; see
|
|
9
|
+
docs/dev/m0-findings.md › Firefox details).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import tomllib
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
KINDS = ("meeting", "browser", "ignore")
|
|
20
|
+
MATCH_FIELDS = ("flatpak", "snap", "exe", "binary", "app_name")
|
|
21
|
+
|
|
22
|
+
DEFAULT_APPS_TOML = r'''# wirenote app rules. Exact matches only; see docs/dev/PLAN.md › Matching rules.
|
|
23
|
+
# Fields: key, name, kind (meeting | browser | ignore), and any of
|
|
24
|
+
# flatpak, snap, exe, binary, app_name (lists of exact values).
|
|
25
|
+
# [[app.title]] entries match the stream's media.name with a regex; a match
|
|
26
|
+
# makes the stream a meeting. A named group "room" is shown in the prompt.
|
|
27
|
+
|
|
28
|
+
[[app]]
|
|
29
|
+
key = "chrome"
|
|
30
|
+
name = "Chrome"
|
|
31
|
+
kind = "browser"
|
|
32
|
+
exe = ["chrome"] # verified: jitsi-chrome (audio service process)
|
|
33
|
+
|
|
34
|
+
[[app]]
|
|
35
|
+
key = "brave"
|
|
36
|
+
name = "Brave"
|
|
37
|
+
kind = "browser"
|
|
38
|
+
exe = ["brave"] # verified: live log 2026-10-26 (audio service process, pid ok)
|
|
39
|
+
|
|
40
|
+
[[app]]
|
|
41
|
+
key = "firefox"
|
|
42
|
+
name = "Firefox"
|
|
43
|
+
kind = "browser"
|
|
44
|
+
exe = ["firefox-bin"] # verified: jitsi-firefox (main process)
|
|
45
|
+
|
|
46
|
+
[[app.title]]
|
|
47
|
+
service = "Jitsi"
|
|
48
|
+
pattern = '^(?:(?P<room>.+) \| )?Jitsi Meet$'
|
|
49
|
+
|
|
50
|
+
[[app]]
|
|
51
|
+
key = "discord"
|
|
52
|
+
name = "Discord"
|
|
53
|
+
kind = "meeting"
|
|
54
|
+
flatpak = ["com.discordapp.Discord"] # unverified: confirm from detector log
|
|
55
|
+
|
|
56
|
+
[[app]]
|
|
57
|
+
key = "rocketchat"
|
|
58
|
+
name = "Rocket.Chat"
|
|
59
|
+
kind = "meeting"
|
|
60
|
+
flatpak = ["chat.rocket.RocketChat"] # unverified: confirm from detector log
|
|
61
|
+
'''
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AppsError(ValueError):
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class TitleRule:
|
|
70
|
+
service: str
|
|
71
|
+
pattern: re.Pattern
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class AppRule:
|
|
76
|
+
key: str
|
|
77
|
+
name: str
|
|
78
|
+
kind: str
|
|
79
|
+
match: dict[str, frozenset[str]] = field(default_factory=dict)
|
|
80
|
+
titles: tuple[TitleRule, ...] = ()
|
|
81
|
+
|
|
82
|
+
def title_match(self, media_name: str | None) -> tuple[str, str | None] | None:
|
|
83
|
+
"""(service, room) when media_name matches one of the title rules."""
|
|
84
|
+
if not media_name:
|
|
85
|
+
return None
|
|
86
|
+
for t in self.titles:
|
|
87
|
+
m = t.pattern.search(media_name)
|
|
88
|
+
if m:
|
|
89
|
+
room = m.groupdict().get("room")
|
|
90
|
+
return t.service, room
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AppRules:
|
|
95
|
+
def __init__(self, rules: list[AppRule]) -> None:
|
|
96
|
+
self.rules = rules
|
|
97
|
+
self._index: dict[tuple[str, str], AppRule] = {}
|
|
98
|
+
for rule in rules:
|
|
99
|
+
for fld, values in rule.match.items():
|
|
100
|
+
for v in values:
|
|
101
|
+
other = self._index.setdefault((fld, v), rule)
|
|
102
|
+
if other is not rule:
|
|
103
|
+
raise AppsError(f"{fld}={v!r} is claimed by both "
|
|
104
|
+
f"{other.key!r} and {rule.key!r}")
|
|
105
|
+
|
|
106
|
+
def lookup(self, fld: str, value: str | None) -> AppRule | None:
|
|
107
|
+
if not value:
|
|
108
|
+
return None
|
|
109
|
+
return self._index.get((fld, value))
|
|
110
|
+
|
|
111
|
+
def get(self, key: str) -> AppRule | None:
|
|
112
|
+
return next((r for r in self.rules if r.key == key), None)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def parse_apps(text: str) -> AppRules:
|
|
116
|
+
try:
|
|
117
|
+
data = tomllib.loads(text)
|
|
118
|
+
except tomllib.TOMLDecodeError as e:
|
|
119
|
+
raise AppsError(f"apps.toml: {e}") from e
|
|
120
|
+
rules = []
|
|
121
|
+
seen = set()
|
|
122
|
+
for i, raw in enumerate(data.get("app", [])):
|
|
123
|
+
where = f"apps.toml [[app]] #{i + 1}"
|
|
124
|
+
unknown = set(raw) - {"key", "name", "kind", "title", *MATCH_FIELDS}
|
|
125
|
+
if unknown:
|
|
126
|
+
raise AppsError(f"{where}: unknown field(s) {sorted(unknown)}")
|
|
127
|
+
key = raw.get("key")
|
|
128
|
+
if not key or key in seen:
|
|
129
|
+
raise AppsError(f"{where}: missing or duplicate key {key!r}")
|
|
130
|
+
seen.add(key)
|
|
131
|
+
kind = raw.get("kind")
|
|
132
|
+
if kind not in KINDS:
|
|
133
|
+
raise AppsError(f"{where} ({key}): kind must be one of {KINDS}, got {kind!r}")
|
|
134
|
+
match = {}
|
|
135
|
+
for fld in MATCH_FIELDS:
|
|
136
|
+
values = raw.get(fld, [])
|
|
137
|
+
if isinstance(values, str):
|
|
138
|
+
values = [values]
|
|
139
|
+
if values:
|
|
140
|
+
match[fld] = frozenset(values)
|
|
141
|
+
if not match:
|
|
142
|
+
raise AppsError(f"{where} ({key}): needs at least one of {MATCH_FIELDS}")
|
|
143
|
+
titles = []
|
|
144
|
+
for t in raw.get("title", []):
|
|
145
|
+
try:
|
|
146
|
+
titles.append(TitleRule(t["service"], re.compile(t["pattern"])))
|
|
147
|
+
except (KeyError, re.error) as e:
|
|
148
|
+
raise AppsError(f"{where} ({key}): bad title rule: {e}") from e
|
|
149
|
+
rules.append(AppRule(key, raw.get("name", key), kind, match, tuple(titles)))
|
|
150
|
+
return AppRules(rules)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def load_apps(path: Path) -> AppRules:
|
|
154
|
+
"""Read apps.toml, writing the default file first if it does not exist."""
|
|
155
|
+
if not path.exists():
|
|
156
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
157
|
+
path.write_text(DEFAULT_APPS_TOML)
|
|
158
|
+
return parse_apps(path.read_text())
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""One-time move from meetnote's folders to Wirenote's.
|
|
4
|
+
|
|
5
|
+
Copies first and deletes the old folders only after everything worked, so a
|
|
6
|
+
failure leaves meetnote's data exactly as it was.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import socket
|
|
16
|
+
import sqlite3
|
|
17
|
+
import tomllib
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .perms import fix_modes
|
|
21
|
+
from .settings import config_dir, data_dir, state_dir
|
|
22
|
+
|
|
23
|
+
OLD = "meetnote"
|
|
24
|
+
LINK = re.compile(r"^(https?|webcal)://", re.IGNORECASE)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MigrationError(Exception):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _pairs() -> list[tuple[Path, Path]]:
|
|
32
|
+
"""(old, new) for the config, state and data folders."""
|
|
33
|
+
return [(new.parent / OLD, new) for new in (config_dir(), state_dir(), data_dir())]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _old_daemon_running() -> bool:
|
|
37
|
+
runtime = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"))
|
|
38
|
+
path = runtime / f"{OLD}.sock"
|
|
39
|
+
if not path.exists():
|
|
40
|
+
return False
|
|
41
|
+
try:
|
|
42
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
|
43
|
+
s.settimeout(1)
|
|
44
|
+
s.connect(str(path))
|
|
45
|
+
return True
|
|
46
|
+
except OSError:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _replace_all(value, swaps: list[tuple[str, str]]):
|
|
51
|
+
if isinstance(value, str):
|
|
52
|
+
for old, new in swaps:
|
|
53
|
+
if value == old or value.startswith(old + "/"):
|
|
54
|
+
return new + value[len(old):]
|
|
55
|
+
return value
|
|
56
|
+
if isinstance(value, list):
|
|
57
|
+
return [_replace_all(v, swaps) for v in value]
|
|
58
|
+
if isinstance(value, dict):
|
|
59
|
+
return {k: _replace_all(v, swaps) for k, v in value.items()}
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _rewrite_paths(new_data: Path, new_state: Path, swaps: list[tuple[str, str]]) -> None:
|
|
64
|
+
"""Absolute paths in manifests and the jobs database point at the new folders."""
|
|
65
|
+
for manifest in (new_data / "recordings").glob("*/manifest.json"):
|
|
66
|
+
data = json.loads(manifest.read_text())
|
|
67
|
+
manifest.write_text(json.dumps(_replace_all(data, swaps), indent=2, ensure_ascii=False))
|
|
68
|
+
db_path = new_state / "jobs.db"
|
|
69
|
+
if db_path.exists():
|
|
70
|
+
db = sqlite3.connect(db_path)
|
|
71
|
+
try:
|
|
72
|
+
for old, new in swaps:
|
|
73
|
+
for col in ("recording", "note"):
|
|
74
|
+
db.execute(f"UPDATE jobs SET {col} = ? || substr({col}, ?) "
|
|
75
|
+
f"WHERE {col} = ? OR {col} LIKE ?",
|
|
76
|
+
(new, len(old) + 1, old, old + "/%"))
|
|
77
|
+
db.commit()
|
|
78
|
+
finally:
|
|
79
|
+
db.close()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _move_calendar_links(cfg_dir: Path) -> None:
|
|
83
|
+
"""Calendar links are secrets: they go from config.toml to secrets.toml (mode 600)."""
|
|
84
|
+
from .secrets import load_secrets, save_secrets, set_config_value
|
|
85
|
+
|
|
86
|
+
path = cfg_dir / "config.toml"
|
|
87
|
+
if not path.exists():
|
|
88
|
+
return
|
|
89
|
+
urls = tomllib.loads(path.read_text()).get("calendar_urls", [])
|
|
90
|
+
links = [u for u in urls if LINK.match(str(u))]
|
|
91
|
+
if not links:
|
|
92
|
+
return
|
|
93
|
+
secrets = load_secrets()
|
|
94
|
+
secrets["calendar_urls"] = list(dict.fromkeys([*secrets.get("calendar_urls", []), *links]))
|
|
95
|
+
save_secrets(secrets)
|
|
96
|
+
files = [u for u in urls if u not in links]
|
|
97
|
+
set_config_value(path, "calendar_urls", json.dumps(files, ensure_ascii=False))
|
|
98
|
+
tomllib.loads(path.read_text()) # a broken result rolls the migration back
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def migrate_if_needed() -> str | None:
|
|
102
|
+
"""Move meetnote's data once; None when there is nothing to move."""
|
|
103
|
+
pairs = _pairs()
|
|
104
|
+
if not any(old.is_dir() for old, _ in pairs) or any(new.exists() for _, new in pairs):
|
|
105
|
+
return None
|
|
106
|
+
if _old_daemon_running():
|
|
107
|
+
raise MigrationError("meetnote is still running; stop the old service first: "
|
|
108
|
+
"systemctl --user disable --now meetnote meetnote-ui")
|
|
109
|
+
created: list[Path] = []
|
|
110
|
+
try:
|
|
111
|
+
for old, new in pairs:
|
|
112
|
+
if not old.is_dir():
|
|
113
|
+
continue
|
|
114
|
+
tmp = new.with_name(f".{new.name}.migrating")
|
|
115
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
116
|
+
shutil.copytree(old, tmp, symlinks=True)
|
|
117
|
+
created.append(tmp)
|
|
118
|
+
tmp.rename(new)
|
|
119
|
+
created[-1] = new
|
|
120
|
+
swaps = [(str(old), str(new)) for old, new in pairs]
|
|
121
|
+
_rewrite_paths(data_dir(), state_dir(), swaps)
|
|
122
|
+
_move_calendar_links(config_dir())
|
|
123
|
+
fix_modes([new for _, new in pairs])
|
|
124
|
+
except Exception as e: # noqa: BLE001 - undo the copies, keep the originals
|
|
125
|
+
for path in created:
|
|
126
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
127
|
+
raise MigrationError(f"could not move meetnote's data ({e}); nothing was changed") from e
|
|
128
|
+
for old, _ in pairs:
|
|
129
|
+
shutil.rmtree(old, ignore_errors=True)
|
|
130
|
+
return "Wirenote: migrated your meetnote data (settings, recordings, jobs)."
|
wirenote/config/perms.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""Private files: recordings, transcripts, notes and logs are for their
|
|
4
|
+
owner only. New files are private through umask 077; these find and fix older ones."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import stat
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .settings import Settings, config_dir, data_dir, state_dir
|
|
15
|
+
|
|
16
|
+
def _walk(roots: list[Path]) -> Iterator[Path]:
|
|
17
|
+
for root in roots:
|
|
18
|
+
if not root.is_dir():
|
|
19
|
+
continue
|
|
20
|
+
yield root
|
|
21
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
22
|
+
for name in dirnames + filenames:
|
|
23
|
+
yield Path(dirpath) / name
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _loose(p: Path) -> bool:
|
|
27
|
+
st = p.lstat()
|
|
28
|
+
return not stat.S_ISLNK(st.st_mode) and bool(st.st_mode & 0o077)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def loose_files(roots: list[Path], files: list[Path] = ()) -> list[Path]:
|
|
32
|
+
"""Folders and files under roots, plus files, that group or others can access."""
|
|
33
|
+
extra = [f for f in files if f.exists() and _loose(f)]
|
|
34
|
+
return [p for p in _walk(roots) if _loose(p)] + extra
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fix_modes(roots: list[Path], files: list[Path] = ()) -> int:
|
|
38
|
+
"""Take group and other access away; returns how many changed. The owner's bits
|
|
39
|
+
(an executable script stays executable) and symlinks are left alone."""
|
|
40
|
+
changed = 0
|
|
41
|
+
for p in loose_files(roots, files):
|
|
42
|
+
os.chmod(p, stat.S_IMODE(p.lstat().st_mode) & 0o700)
|
|
43
|
+
changed += 1
|
|
44
|
+
return changed
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def private_paths(settings: Settings) -> tuple[list[Path], list[Path]]:
|
|
48
|
+
"""(folders Wirenote owns, to check whole; the notes it wrote). The notes folder
|
|
49
|
+
may be a vault or ~/Documents, so only the notes recordings point to are ours."""
|
|
50
|
+
from ..process.note import own_note
|
|
51
|
+
|
|
52
|
+
rec_root = settings.recordings_path()
|
|
53
|
+
notes = []
|
|
54
|
+
for manifest in sorted(rec_root.glob("*/manifest.json")) if rec_root.is_dir() else []:
|
|
55
|
+
try:
|
|
56
|
+
note = own_note(json.loads(manifest.read_text()), settings.notes_path())
|
|
57
|
+
except (OSError, ValueError):
|
|
58
|
+
continue
|
|
59
|
+
if note:
|
|
60
|
+
notes.append(note)
|
|
61
|
+
return [config_dir(), state_dir(), data_dir(), rec_root], notes
|