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.
Files changed (60) hide show
  1. wirenote/__init__.py +3 -0
  2. wirenote/calendar/__init__.py +2 -0
  3. wirenote/calendar/ics.py +236 -0
  4. wirenote/capture/__init__.py +2 -0
  5. wirenote/capture/recorder.py +304 -0
  6. wirenote/capture/wav.py +58 -0
  7. wirenote/cli.py +262 -0
  8. wirenote/config/__init__.py +2 -0
  9. wirenote/config/apps.py +158 -0
  10. wirenote/config/migrate.py +130 -0
  11. wirenote/config/perms.py +61 -0
  12. wirenote/config/secrets.py +98 -0
  13. wirenote/config/settings.py +176 -0
  14. wirenote/config/vocabulary.py +32 -0
  15. wirenote/data/wirenote-ui.service +15 -0
  16. wirenote/data/wirenote.desktop +10 -0
  17. wirenote/data/wirenote.service +15 -0
  18. wirenote/detector/__init__.py +2 -0
  19. wirenote/detector/core.py +115 -0
  20. wirenote/detector/daemon.py +743 -0
  21. wirenote/detector/graph.py +102 -0
  22. wirenote/detector/identity.py +205 -0
  23. wirenote/detector/streams.py +69 -0
  24. wirenote/detector/tracker.py +304 -0
  25. wirenote/net/__init__.py +2 -0
  26. wirenote/net/peer.py +101 -0
  27. wirenote/notify/__init__.py +2 -0
  28. wirenote/notify/clipboard.py +43 -0
  29. wirenote/notify/dbus.py +89 -0
  30. wirenote/process/__init__.py +2 -0
  31. wirenote/process/jobs.py +122 -0
  32. wirenote/process/merge.py +87 -0
  33. wirenote/process/models.py +57 -0
  34. wirenote/process/note.py +124 -0
  35. wirenote/process/speakers.py +295 -0
  36. wirenote/process/summarize.py +287 -0
  37. wirenote/process/transcribe.py +97 -0
  38. wirenote/process/worker.py +229 -0
  39. wirenote/tools/__init__.py +2 -0
  40. wirenote/tools/dump_streams.py +595 -0
  41. wirenote/tools/install.py +140 -0
  42. wirenote/tools/setup.py +338 -0
  43. wirenote/ui/__init__.py +2 -0
  44. wirenote/ui/data.py +783 -0
  45. wirenote/ui/launch.py +88 -0
  46. wirenote/ui/server.py +423 -0
  47. wirenote/ui/static/app.css +907 -0
  48. wirenote/ui/static/app.js +1267 -0
  49. wirenote/ui/static/fonts/OFL.txt +93 -0
  50. wirenote/ui/static/fonts/inter-latin-ext.woff2 +0 -0
  51. wirenote/ui/static/fonts/inter-latin.woff2 +0 -0
  52. wirenote/ui/static/icon.svg +1 -0
  53. wirenote/ui/static/icons.svg +31 -0
  54. wirenote/ui/static/index.html +57 -0
  55. wirenote/ui/static/login.html +19 -0
  56. wirenote-0.1.0.dist-info/METADATA +106 -0
  57. wirenote-0.1.0.dist-info/RECORD +60 -0
  58. wirenote-0.1.0.dist-info/WHEEL +4 -0
  59. wirenote-0.1.0.dist-info/entry_points.txt +2 -0
  60. 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())
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
@@ -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)."
@@ -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