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/ui/launch.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""Open the web UI through a one-time login link.
|
|
4
|
+
|
|
5
|
+
A launcher (the app menu, `wirenote open`, "Open note") writes a random code as
|
|
6
|
+
an empty file in a folder only this user can read, and opens
|
|
7
|
+
/login?code=…; the server accepts a code only if that file exists, belongs to
|
|
8
|
+
its own user and is fresh. Another user on the machine cannot make one.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import secrets
|
|
16
|
+
import subprocess
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from urllib.parse import quote
|
|
20
|
+
|
|
21
|
+
from ..net.peer import listener_uid
|
|
22
|
+
|
|
23
|
+
CODE = re.compile(r"^[A-Za-z0-9_-]{32}$")
|
|
24
|
+
MAX_AGE_S = 60.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def runtime_dir() -> Path:
|
|
28
|
+
return Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def login_dir() -> Path:
|
|
32
|
+
d = runtime_dir() / "wirenote" / "login"
|
|
33
|
+
d.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
34
|
+
os.chmod(d.parent, 0o700)
|
|
35
|
+
os.chmod(d, 0o700)
|
|
36
|
+
return d
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _sweep(d: Path) -> None:
|
|
40
|
+
"""Drop codes nobody redeemed."""
|
|
41
|
+
for f in d.iterdir():
|
|
42
|
+
try:
|
|
43
|
+
if time.time() - f.lstat().st_mtime > MAX_AGE_S:
|
|
44
|
+
f.unlink()
|
|
45
|
+
except OSError:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def new_login_code() -> str:
|
|
50
|
+
d = login_dir()
|
|
51
|
+
_sweep(d)
|
|
52
|
+
code = secrets.token_urlsafe(24)
|
|
53
|
+
os.close(os.open(d / code, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600))
|
|
54
|
+
return code
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def redeem_login_code(code: str) -> bool:
|
|
58
|
+
"""True once for a fresh code of this user's; the code is used up either way."""
|
|
59
|
+
if not CODE.match(code or ""):
|
|
60
|
+
return False
|
|
61
|
+
path = login_dir() / code
|
|
62
|
+
try:
|
|
63
|
+
st = path.lstat()
|
|
64
|
+
path.unlink()
|
|
65
|
+
except OSError:
|
|
66
|
+
return False
|
|
67
|
+
return st.st_uid == os.getuid() and time.time() - st.st_mtime <= MAX_AGE_S
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def login_url(port: int, route: str = "#/") -> str:
|
|
71
|
+
return (f"http://127.0.0.1:{port}/login?code={new_login_code()}"
|
|
72
|
+
f"&next={quote(route, safe='')}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _xdg_open(target: str) -> None:
|
|
76
|
+
subprocess.Popen(["xdg-open", target], start_new_session=True,
|
|
77
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def open_ui(port: int, route: str = "#/", fallback: str | None = None) -> bool:
|
|
81
|
+
"""Open the UI if this user's server listens on port; else open fallback, if any.
|
|
82
|
+
Never opens a server that belongs to someone else."""
|
|
83
|
+
if listener_uid(port) == os.getuid():
|
|
84
|
+
_xdg_open(login_url(port, route))
|
|
85
|
+
return True
|
|
86
|
+
if fallback:
|
|
87
|
+
_xdg_open(fallback)
|
|
88
|
+
return False
|
wirenote/ui/server.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""`wirenote ui`: the local web app on 127.0.0.1 (docs/dev/ui-design.md).
|
|
4
|
+
|
|
5
|
+
Stdlib only. Localhost only, Host header checked (DNS rebinding). Every
|
|
6
|
+
connection must come from this user (other accounts on the machine reach
|
|
7
|
+
127.0.0.1 too), and every request needs the session cookie, which only a
|
|
8
|
+
one-time login link from a launcher hands out (launch.py). Writes also need the
|
|
9
|
+
token embedded in the page, so another site open in the browser
|
|
10
|
+
cannot change or delete anything.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import hmac
|
|
17
|
+
import json
|
|
18
|
+
import mimetypes
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import secrets
|
|
22
|
+
import socket
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
from http import HTTPStatus
|
|
26
|
+
from http.cookies import CookieError, SimpleCookie
|
|
27
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from urllib.parse import parse_qs, unquote, urlsplit
|
|
30
|
+
|
|
31
|
+
from .. import __version__
|
|
32
|
+
from ..config.settings import Settings
|
|
33
|
+
from ..net.peer import tcp_owner
|
|
34
|
+
from .data import Invalid, NotFound, Store
|
|
35
|
+
from .launch import redeem_login_code
|
|
36
|
+
|
|
37
|
+
STATIC = Path(__file__).parent / "static"
|
|
38
|
+
mimetypes.add_type("font/woff2", ".woff2")
|
|
39
|
+
mimetypes.add_type("text/javascript", ".js")
|
|
40
|
+
|
|
41
|
+
ROUTE_CALL = re.compile(r"^/api/calls/([^/]+)(/[a-z-]+)?(?:/(\d+))?$")
|
|
42
|
+
NEXT_ROUTE = re.compile(r"^#/[\w/?=.&%-]*$")
|
|
43
|
+
COOKIE = "wirenote_session"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _session_key(state_dir: Path) -> str:
|
|
47
|
+
"""One key per install, kept across restarts so an open tab stays logged in."""
|
|
48
|
+
path = state_dir / "ui.key"
|
|
49
|
+
try:
|
|
50
|
+
key = path.read_text().strip()
|
|
51
|
+
if key:
|
|
52
|
+
return key
|
|
53
|
+
except FileNotFoundError:
|
|
54
|
+
pass
|
|
55
|
+
state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
56
|
+
key = secrets.token_urlsafe(32)
|
|
57
|
+
tmp = path.with_suffix(".tmp")
|
|
58
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
59
|
+
with os.fdopen(fd, "w") as f:
|
|
60
|
+
f.write(key + "\n")
|
|
61
|
+
tmp.replace(path)
|
|
62
|
+
return key
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class App:
|
|
66
|
+
def __init__(self, settings: Settings, state_dir: Path, port: int) -> None:
|
|
67
|
+
self.store = Store(settings, state_dir)
|
|
68
|
+
self.session_key = _session_key(state_dir)
|
|
69
|
+
# Derived from the persistent key, so an open tab's writes still work after a
|
|
70
|
+
# restart; a page can only learn it from index.html, which needs the cookie.
|
|
71
|
+
self.token = hmac.new(self.session_key.encode(), b"wirenote-write-token",
|
|
72
|
+
hashlib.sha256).hexdigest()[:32]
|
|
73
|
+
self.port = port
|
|
74
|
+
self.hosts = {f"127.0.0.1:{port}", f"localhost:{port}"}
|
|
75
|
+
|
|
76
|
+
def index_html(self) -> bytes:
|
|
77
|
+
html = (STATIC / "index.html").read_text()
|
|
78
|
+
return html.replace("{{TOKEN}}", self.token).replace("{{VERSION}}", __version__).encode()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def make_handler(app: App):
|
|
82
|
+
class Handler(BaseHTTPRequestHandler):
|
|
83
|
+
server_version = "wirenote"
|
|
84
|
+
protocol_version = "HTTP/1.1"
|
|
85
|
+
|
|
86
|
+
def log_message(self, fmt, *args): # quiet; errors still go to stderr
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
# Plumbing ----------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def _send(self, status: int, body: bytes, ctype: str, extra: dict | None = None):
|
|
92
|
+
self.send_response(status)
|
|
93
|
+
self.send_header("Content-Type", ctype)
|
|
94
|
+
self.send_header("Content-Length", str(len(body)))
|
|
95
|
+
self.send_header("Cache-Control", "no-store")
|
|
96
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
97
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
98
|
+
# Other sites must not embed or probe our responses (<audio src=…>).
|
|
99
|
+
self.send_header("Cross-Origin-Resource-Policy", "same-origin")
|
|
100
|
+
for k, v in (extra or {}).items():
|
|
101
|
+
self.send_header(k, v)
|
|
102
|
+
if self.close_connection:
|
|
103
|
+
self.send_header("Connection", "close")
|
|
104
|
+
self.end_headers()
|
|
105
|
+
if self.command != "HEAD":
|
|
106
|
+
self.wfile.write(body)
|
|
107
|
+
|
|
108
|
+
def _json(self, data, status: int = 200):
|
|
109
|
+
self._send(status, json.dumps(data, ensure_ascii=False).encode(),
|
|
110
|
+
"application/json; charset=utf-8")
|
|
111
|
+
|
|
112
|
+
def _error(self, status: int, message: str):
|
|
113
|
+
self._json({"error": message}, status)
|
|
114
|
+
|
|
115
|
+
def _drain(self) -> None:
|
|
116
|
+
"""Read a refused request's body so keep-alive stays in step; a body too big
|
|
117
|
+
(or a bad length) cannot be skipped, so that connection ends here."""
|
|
118
|
+
try:
|
|
119
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
120
|
+
except ValueError:
|
|
121
|
+
n = -1
|
|
122
|
+
if 0 <= n <= 1 << 20:
|
|
123
|
+
if n:
|
|
124
|
+
self.rfile.read(n)
|
|
125
|
+
else:
|
|
126
|
+
self.close_connection = True
|
|
127
|
+
|
|
128
|
+
def _refuse(self, status: int, message: str) -> bool:
|
|
129
|
+
self._drain()
|
|
130
|
+
self._error(status, message)
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
def _from_me(self) -> bool:
|
|
134
|
+
"""The client socket belongs to this user; unknown counts as someone else."""
|
|
135
|
+
return tcp_owner(app.port, self.client_address[1]) == os.getuid()
|
|
136
|
+
|
|
137
|
+
def _session_ok(self) -> bool:
|
|
138
|
+
try:
|
|
139
|
+
jar = SimpleCookie(self.headers.get("Cookie", ""))
|
|
140
|
+
except CookieError:
|
|
141
|
+
return False
|
|
142
|
+
morsel = jar.get(COOKIE)
|
|
143
|
+
return bool(morsel) and secrets.compare_digest(morsel.value, app.session_key)
|
|
144
|
+
|
|
145
|
+
def _allowed(self, write: bool) -> bool:
|
|
146
|
+
if not self._from_me():
|
|
147
|
+
self.close_connection = True
|
|
148
|
+
return self._refuse(HTTPStatus.FORBIDDEN, "not your server")
|
|
149
|
+
if self.headers.get("Host") not in app.hosts:
|
|
150
|
+
return self._refuse(HTTPStatus.FORBIDDEN, "bad host")
|
|
151
|
+
if self.headers.get("Sec-Fetch-Site") == "cross-site":
|
|
152
|
+
return self._refuse(HTTPStatus.FORBIDDEN, "cross-site request")
|
|
153
|
+
if write and not self._session_ok():
|
|
154
|
+
return self._refuse(HTTPStatus.UNAUTHORIZED, "not logged in; open Wirenote from the app menu")
|
|
155
|
+
if write:
|
|
156
|
+
origin = self.headers.get("Origin")
|
|
157
|
+
if origin and origin not in {f"http://{h}" for h in app.hosts}:
|
|
158
|
+
self._drain()
|
|
159
|
+
self._error(HTTPStatus.FORBIDDEN, "bad origin")
|
|
160
|
+
return False
|
|
161
|
+
if not secrets.compare_digest(self.headers.get("X-Wirenote-Token", ""), app.token):
|
|
162
|
+
self._drain()
|
|
163
|
+
self._error(HTTPStatus.FORBIDDEN, "missing token; reload the page")
|
|
164
|
+
return False
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
def _body(self) -> dict:
|
|
168
|
+
if hasattr(self, "_payload"):
|
|
169
|
+
return self._payload
|
|
170
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
171
|
+
if n > 1 << 20:
|
|
172
|
+
raise Invalid("request too large")
|
|
173
|
+
raw = self.rfile.read(n) if n else b"{}"
|
|
174
|
+
try:
|
|
175
|
+
data = json.loads(raw or b"{}")
|
|
176
|
+
except ValueError as e:
|
|
177
|
+
raise Invalid("bad JSON") from e
|
|
178
|
+
if not isinstance(data, dict):
|
|
179
|
+
raise Invalid("expected an object")
|
|
180
|
+
self._payload = data
|
|
181
|
+
return data
|
|
182
|
+
|
|
183
|
+
def _dispatch(self, fn):
|
|
184
|
+
try:
|
|
185
|
+
fn()
|
|
186
|
+
except NotFound as e:
|
|
187
|
+
self._error(HTTPStatus.NOT_FOUND, f"not found: {e}")
|
|
188
|
+
except Invalid as e:
|
|
189
|
+
self._error(HTTPStatus.BAD_REQUEST, str(e))
|
|
190
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
191
|
+
pass
|
|
192
|
+
except Exception as e: # noqa: BLE001 - keep serving; report the error
|
|
193
|
+
# The query can hold search words: log the path only.
|
|
194
|
+
print(f"ui: {self.command} {urlsplit(self.path).path}: {e!r}",
|
|
195
|
+
file=sys.stderr, flush=True)
|
|
196
|
+
self._error(HTTPStatus.INTERNAL_SERVER_ERROR, "internal error")
|
|
197
|
+
|
|
198
|
+
# GET ---------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def do_HEAD(self):
|
|
201
|
+
self.do_GET()
|
|
202
|
+
|
|
203
|
+
def do_GET(self):
|
|
204
|
+
if not self._allowed(write=False):
|
|
205
|
+
return
|
|
206
|
+
self._dispatch(self._get)
|
|
207
|
+
|
|
208
|
+
def _login_page(self, status: int):
|
|
209
|
+
self._send(status, (STATIC / "login.html").read_bytes(), "text/html; charset=utf-8",
|
|
210
|
+
{"Content-Security-Policy": "default-src 'none'; style-src 'self'; "
|
|
211
|
+
"img-src 'self'; frame-ancestors 'none'"})
|
|
212
|
+
|
|
213
|
+
def _login(self, query: str):
|
|
214
|
+
q = parse_qs(query)
|
|
215
|
+
if not redeem_login_code(q.get("code", [""])[0]):
|
|
216
|
+
return self._login_page(HTTPStatus.FORBIDDEN)
|
|
217
|
+
nxt = q.get("next", ["#/"])[0]
|
|
218
|
+
self._send(HTTPStatus.SEE_OTHER, b"", "text/plain", {
|
|
219
|
+
"Location": "/" + (nxt if NEXT_ROUTE.match(nxt) else "#/"),
|
|
220
|
+
"Set-Cookie": f"{COOKIE}={app.session_key}; HttpOnly; SameSite=Strict; Path=/"})
|
|
221
|
+
|
|
222
|
+
def _get(self):
|
|
223
|
+
url = urlsplit(self.path)
|
|
224
|
+
path = url.path
|
|
225
|
+
if path == "/login":
|
|
226
|
+
return self._login(url.query)
|
|
227
|
+
if path.startswith("/static/"):
|
|
228
|
+
return self._static(path.removeprefix("/static/"))
|
|
229
|
+
if not self._session_ok():
|
|
230
|
+
if path in ("/", "/index.html"):
|
|
231
|
+
return self._login_page(HTTPStatus.UNAUTHORIZED)
|
|
232
|
+
return self._error(HTTPStatus.UNAUTHORIZED, "not logged in; open Wirenote from the app menu")
|
|
233
|
+
if path in ("/", "/index.html"):
|
|
234
|
+
return self._send(200, app.index_html(), "text/html; charset=utf-8", {
|
|
235
|
+
"Content-Security-Policy":
|
|
236
|
+
"default-src 'self'; media-src 'self' blob:; img-src 'self' data:; "
|
|
237
|
+
"style-src 'self'; script-src 'self'; frame-ancestors 'none'; "
|
|
238
|
+
"base-uri 'none'; form-action 'self'"})
|
|
239
|
+
if path == "/api/status":
|
|
240
|
+
return self._json(app.store.status())
|
|
241
|
+
if path == "/api/calls":
|
|
242
|
+
q = parse_qs(url.query).get("q", [""])[0]
|
|
243
|
+
return self._json({"calls": app.store.calls(q)})
|
|
244
|
+
if path == "/api/upcoming":
|
|
245
|
+
return self._json({"events": app.store.upcoming()})
|
|
246
|
+
if path == "/api/actions":
|
|
247
|
+
return self._json({"items": app.store.action_items()})
|
|
248
|
+
if path == "/api/asks":
|
|
249
|
+
return self._json({"asks": app.store.asks()})
|
|
250
|
+
if path == "/api/settings":
|
|
251
|
+
return self._json(app.store.settings_info())
|
|
252
|
+
if path == "/api/doctor":
|
|
253
|
+
return self._json(app.store.doctor())
|
|
254
|
+
m = ROUTE_CALL.match(path)
|
|
255
|
+
if m and not m.group(2):
|
|
256
|
+
return self._json(app.store.call(m.group(1)))
|
|
257
|
+
if m and m.group(2) == "/audio":
|
|
258
|
+
return self._file(app.store.mixed_audio(m.group(1)), "audio/ogg")
|
|
259
|
+
raise NotFound(path)
|
|
260
|
+
|
|
261
|
+
def _static(self, rel: str):
|
|
262
|
+
target = (STATIC / rel).resolve()
|
|
263
|
+
if STATIC.resolve() not in target.parents or not target.is_file():
|
|
264
|
+
raise NotFound(rel)
|
|
265
|
+
ctype = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
|
|
266
|
+
if ctype.startswith("text/") or ctype == "text/javascript":
|
|
267
|
+
ctype += "; charset=utf-8"
|
|
268
|
+
self._send(200, target.read_bytes(), ctype)
|
|
269
|
+
|
|
270
|
+
def _file(self, path: Path, ctype: str):
|
|
271
|
+
"""Serve with HTTP Range, so the audio player can seek."""
|
|
272
|
+
size = path.stat().st_size
|
|
273
|
+
rng = re.match(r"bytes=(\d*)-(\d*)$", self.headers.get("Range", ""))
|
|
274
|
+
if not rng:
|
|
275
|
+
return self._send(200, path.read_bytes(), ctype, {"Accept-Ranges": "bytes"})
|
|
276
|
+
start = int(rng.group(1)) if rng.group(1) else max(0, size - int(rng.group(2) or 0))
|
|
277
|
+
end = int(rng.group(2)) if rng.group(1) and rng.group(2) else size - 1
|
|
278
|
+
end = min(end, size - 1)
|
|
279
|
+
if start > end:
|
|
280
|
+
return self._send(416, b"", ctype, {"Content-Range": f"bytes */{size}"})
|
|
281
|
+
with path.open("rb") as f:
|
|
282
|
+
f.seek(start)
|
|
283
|
+
body = f.read(end - start + 1)
|
|
284
|
+
self._send(206, body, ctype, {"Accept-Ranges": "bytes",
|
|
285
|
+
"Content-Range": f"bytes {start}-{end}/{size}"})
|
|
286
|
+
|
|
287
|
+
# POST / DELETE -----------------------------------------------------
|
|
288
|
+
|
|
289
|
+
def _read_body_first(self, handler):
|
|
290
|
+
# Every route must consume its body: on a kept-alive connection unread
|
|
291
|
+
# bytes would be taken as the start of the next request.
|
|
292
|
+
def run():
|
|
293
|
+
# One handler serves every request on a connection: drop the last body.
|
|
294
|
+
self.__dict__.pop("_payload", None)
|
|
295
|
+
self._body()
|
|
296
|
+
handler()
|
|
297
|
+
return run
|
|
298
|
+
|
|
299
|
+
def do_POST(self):
|
|
300
|
+
if self._allowed(write=True):
|
|
301
|
+
self._dispatch(self._read_body_first(self._post))
|
|
302
|
+
|
|
303
|
+
def do_PUT(self):
|
|
304
|
+
if self._allowed(write=True):
|
|
305
|
+
self._dispatch(self._read_body_first(self._put))
|
|
306
|
+
|
|
307
|
+
def do_DELETE(self):
|
|
308
|
+
if self._allowed(write=True):
|
|
309
|
+
self._dispatch(self._read_body_first(self._delete))
|
|
310
|
+
|
|
311
|
+
def _put(self):
|
|
312
|
+
if urlsplit(self.path).path != "/api/settings":
|
|
313
|
+
raise NotFound(self.path)
|
|
314
|
+
body = self._body()
|
|
315
|
+
return self._json(app.store.update_settings(body.get("changes"),
|
|
316
|
+
body.get("confirm_cloud") is True))
|
|
317
|
+
|
|
318
|
+
def _post(self):
|
|
319
|
+
path = urlsplit(self.path).path
|
|
320
|
+
store = app.store
|
|
321
|
+
if path == "/api/stop":
|
|
322
|
+
return self._json(_control("stop"))
|
|
323
|
+
if path == "/api/record":
|
|
324
|
+
return self._json(_control("record-now"))
|
|
325
|
+
if path == "/api/ask":
|
|
326
|
+
return self._json(store.ask_all(str(self._body().get("question", ""))))
|
|
327
|
+
if path == "/api/vocabulary":
|
|
328
|
+
terms = self._body().get("terms", [])
|
|
329
|
+
if not isinstance(terms, list):
|
|
330
|
+
raise Invalid("terms must be a list")
|
|
331
|
+
return self._json({"terms": store.set_vocabulary(terms)})
|
|
332
|
+
if path == "/api/settings/anthropic-key":
|
|
333
|
+
return self._json(store.set_api_key(str(self._body().get("key", ""))))
|
|
334
|
+
if path == "/api/calendars":
|
|
335
|
+
return self._json({"calendars": store.add_calendar(str(self._body().get("url", "")))})
|
|
336
|
+
m = ROUTE_CALL.match(path)
|
|
337
|
+
if not m:
|
|
338
|
+
raise NotFound(path)
|
|
339
|
+
rec_id, action, index = m.group(1), m.group(2), m.group(3)
|
|
340
|
+
body = self._body()
|
|
341
|
+
if action == "/resummarize":
|
|
342
|
+
return self._json({"job": store.requeue(rec_id, summary_only=True)})
|
|
343
|
+
if action == "/retranscribe":
|
|
344
|
+
return self._json({"job": store.requeue(rec_id, summary_only=False)})
|
|
345
|
+
if action == "/ask":
|
|
346
|
+
return self._json(store.ask(rec_id, str(body.get("question", ""))))
|
|
347
|
+
if action == "/notes":
|
|
348
|
+
store.save_my_notes(rec_id, str(body.get("text", "")))
|
|
349
|
+
return self._json({"ok": True})
|
|
350
|
+
if action == "/speaker":
|
|
351
|
+
store.rename_speaker(rec_id, str(body.get("label", "")), str(body.get("name", "")),
|
|
352
|
+
bool(body.get("remember")), str(body.get("merge_into", "")))
|
|
353
|
+
return self._json({"ok": True})
|
|
354
|
+
if action == "/followup":
|
|
355
|
+
return self._json(store.followup(rec_id))
|
|
356
|
+
if action == "/rename":
|
|
357
|
+
store.rename(rec_id, str(body.get("title", "")))
|
|
358
|
+
return self._json({"ok": True})
|
|
359
|
+
if action == "/action" and index is not None:
|
|
360
|
+
store.set_action_done(rec_id, int(index), bool(body.get("done")))
|
|
361
|
+
return self._json({"ok": True})
|
|
362
|
+
if action == "/open":
|
|
363
|
+
subprocess.Popen(["xdg-open", str(store.note_path(rec_id))],
|
|
364
|
+
start_new_session=True, stdout=subprocess.DEVNULL,
|
|
365
|
+
stderr=subprocess.DEVNULL)
|
|
366
|
+
return self._json({"ok": True})
|
|
367
|
+
if action == "/folder":
|
|
368
|
+
subprocess.Popen(["xdg-open", str(store.rec_dir(rec_id))],
|
|
369
|
+
start_new_session=True, stdout=subprocess.DEVNULL,
|
|
370
|
+
stderr=subprocess.DEVNULL)
|
|
371
|
+
return self._json({"ok": True})
|
|
372
|
+
raise NotFound(path)
|
|
373
|
+
|
|
374
|
+
def _delete(self):
|
|
375
|
+
path = urlsplit(self.path).path
|
|
376
|
+
if path.startswith("/api/voices/"):
|
|
377
|
+
app.store.forget_voice(unquote(path.removeprefix("/api/voices/")))
|
|
378
|
+
return self._json({"ok": True})
|
|
379
|
+
if path.startswith("/api/calendars/"):
|
|
380
|
+
cal_id = path.removeprefix("/api/calendars/")
|
|
381
|
+
return self._json({"calendars": app.store.remove_calendar(cal_id)})
|
|
382
|
+
m = ROUTE_CALL.match(path)
|
|
383
|
+
if not m:
|
|
384
|
+
raise NotFound(self.path)
|
|
385
|
+
if m.group(2) == "/audio":
|
|
386
|
+
app.store.delete_audio(m.group(1))
|
|
387
|
+
elif not m.group(2):
|
|
388
|
+
app.store.delete_call(m.group(1))
|
|
389
|
+
else:
|
|
390
|
+
raise NotFound(self.path)
|
|
391
|
+
self._json({"ok": True})
|
|
392
|
+
|
|
393
|
+
return Handler
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _control(command: str) -> dict:
|
|
397
|
+
from ..detector.daemon import control_socket_path
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
|
401
|
+
s.settimeout(5)
|
|
402
|
+
s.connect(str(control_socket_path()))
|
|
403
|
+
s.sendall(f"{command}\n".encode())
|
|
404
|
+
return json.loads(s.makefile().readline())
|
|
405
|
+
except (OSError, ValueError) as e:
|
|
406
|
+
return {"ok": False, "error": f"daemon not reachable: {e}"}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def serve(settings: Settings, state_dir: Path, port: int, open_browser: bool = False) -> None:
|
|
410
|
+
app = App(settings, state_dir, port)
|
|
411
|
+
httpd = ThreadingHTTPServer(("127.0.0.1", port), make_handler(app))
|
|
412
|
+
httpd.daemon_threads = True
|
|
413
|
+
print(f"wirenote ui on http://127.0.0.1:{port}/ (open it with `wirenote open`)",
|
|
414
|
+
file=sys.stderr, flush=True)
|
|
415
|
+
if open_browser:
|
|
416
|
+
from .launch import open_ui
|
|
417
|
+
open_ui(port)
|
|
418
|
+
try:
|
|
419
|
+
httpd.serve_forever()
|
|
420
|
+
except KeyboardInterrupt:
|
|
421
|
+
pass
|
|
422
|
+
finally:
|
|
423
|
+
httpd.server_close()
|