Compare commits
5
Commits
8d4f46a7d1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
422a3548fd | ||
|
|
bc60c16079 | ||
|
|
229b8d1ab1 | ||
|
|
01f2a92a84 | ||
|
|
0fb66f2cee |
@@ -8,6 +8,7 @@ RUN apt-get update \
|
|||||||
&& pip install --no-cache-dir -r requirements.txt
|
&& pip install --no-cache-dir -r requirements.txt
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
COPY frontend ./frontend
|
COPY frontend ./frontend
|
||||||
|
COPY ["audio test.wav", "./audio test.wav"]
|
||||||
|
|
||||||
ENV DATA_DIR=/data
|
ENV DATA_DIR=/data
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ In Safari on iPhone, open the HTTPS address and use **Share > Add to Home Screen
|
|||||||
|
|
||||||
## Local speech-to-text
|
## Local speech-to-text
|
||||||
|
|
||||||
The API can use a local `whisper.cpp` server when `WHISPER_SERVER_URL` is configured. Audio is sent only to that local endpoint; with the variable unset, uploads remain stored as `awaiting_transcription` until a non-empty transcript is entered and saved. Saving that transcript deletes both server-side audio files.
|
The API can use a local `onerahmet/openai-whisper-asr-webservice` server when `WHISPER_SERVER_URL` is configured. Set it to the service's `/asr` endpoint, including its listening port, for example `http://whisper:9000/asr` or `http://192.168.1.102:9000/asr`. Faerro KB sends the converted WAV as the `audio_file` multipart field with `task=transcribe` and `output=json`; audio is sent only to that local endpoint. At startup, the API also sends the bundled `audio test.wav` to the endpoint as a connection test before resuming queued jobs. Logs report the HTTP result, detected language, segment count, and elapsed time, but never log the transcript. With the variable unset, uploads remain stored as `awaiting_transcription` until a non-empty transcript is entered and saved. When the API starts with a successful Whisper test, it resumes awaiting, interrupted, and failed transcription jobs whose audio is still stored. Restart/recreate the API container after changing this environment variable; configuration is read at startup. Saving a non-empty transcript deletes both server-side audio files.
|
||||||
|
|
||||||
For NVIDIA GPU transcription, run a CUDA-enabled `whisper.cpp` server with a model on the same machine, then set `WHISPER_SERVER_URL` to its local inference endpoint, for example `http://whisper:8080/inference`. Use a model that fits the RTX 3070's VRAM; start with `small` or a quantized `medium` model and measure with your audio/language. Do not expose the transcription server outside the private Docker network. See the [whisper.cpp NVIDIA instructions](https://github.com/ggml-org/whisper.cpp#nvidia-gpu-support) for CUDA builds.
|
Run the ASR web service with a model that fits the available hardware, then set `WHISPER_SERVER_URL` to its `/asr` endpoint. Keep the transcription server private to the Docker network and do not expose it publicly.
|
||||||
|
|
||||||
The transcript remains in SQLite after the audio files are deleted. A future local correction model can propose punctuation and name fixes without replacing the stored transcription. Proper-name matching and offline geolocation data are not enabled yet.
|
The transcript remains in SQLite after the audio files are deleted. A future local correction model can propose punctuation and name fixes without replacing the stored transcription. Proper-name matching and offline geolocation data are not enabled yet.
|
||||||
|
|
||||||
@@ -48,6 +48,12 @@ The transcript remains in SQLite after the audio files are deleted. A future loc
|
|||||||
|
|
||||||
Docker persists SQLite metadata in `./data/voice-kb.sqlite3` and audio awaiting transcription in `./data/audio/`. After a non-empty transcript is saved, its audio files are removed. Back up the whole `./data` directory while the service is stopped or use a SQLite-aware backup procedure. Model files should also be stored locally and backed up separately if needed.
|
Docker persists SQLite metadata in `./data/voice-kb.sqlite3` and audio awaiting transcription in `./data/audio/`. After a non-empty transcript is saved, its audio files are removed. Back up the whole `./data` directory while the service is stopped or use a SQLite-aware backup procedure. Model files should also be stored locally and backed up separately if needed.
|
||||||
|
|
||||||
|
Faerro KB can upload a consistent SQLite snapshot to a Nextcloud or other WebDAV folder whenever a transcript is saved. Open **Backup settings** in the app and enter the folder's WebDAV URL, username, and an app password. The destination file is `faerro-kb.sqlite3`; the folder must already exist. For Nextcloud, the URL commonly starts with `https://<host>/remote.php/dav/files/<username>/`. Use HTTPS and an app password rather than your account password.
|
||||||
|
|
||||||
|
Alternatively, set `BACKUP_WEBDAV_URL`, `BACKUP_WEBDAV_USERNAME`, and `BACKUP_WEBDAV_PASSWORD` in `.env`. A non-empty `BACKUP_WEBDAV_URL` makes those settings environment-managed and read-only in the UI. When configured in the UI, credentials are stored on the server in `./data/webdav-backup.json` with owner-only file permissions; include the data directory in your own local backups if you need to preserve that configuration.
|
||||||
|
|
||||||
|
The API records pending backup work in SQLite and retries it at startup or after a later transcript save; a failed upload does not undo the saved transcript. The settings dialog shows failures and offers a retry. Rotation keeps the current `faerro-kb.sqlite3` latest file plus timestamped archives: the newest snapshot for each of the last 7 UTC dates, 4 ISO weeks, 12 calendar months, and 5 years. Overlapping periods share an archive. Only files matching Faerro KB's timestamped archive naming pattern in the configured folder are eligible for deletion; other files are left alone. WebDAV `PUT` replaces the complete database file: WebDAV does not provide safe incremental SQLite page syncing. Configure Nextcloud file versioning separately if you want server-side historical versions. This feature backs up the database only, not audio files, and does not replace a backup of the full `./data` directory. The app has no login layer, so keep it behind your VPN/firewall; anyone who can reach it can alter backup settings, as well as access the notes API.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+426
-14
@@ -1,12 +1,17 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
import xml.etree.ElementTree as ET
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import unquote, urlsplit
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, UploadFile
|
from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, UploadFile
|
||||||
@@ -16,8 +21,18 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
DATA_DIR = Path(os.environ.get("DATA_DIR", "./data"))
|
DATA_DIR = Path(os.environ.get("DATA_DIR", "./data"))
|
||||||
AUDIO_DIR = DATA_DIR / "audio"
|
AUDIO_DIR = DATA_DIR / "audio"
|
||||||
DB_PATH = DATA_DIR / "faerro-kb.sqlite3"
|
DB_PATH = DATA_DIR / "faerro-kb.sqlite3"
|
||||||
|
BACKUP_SETTINGS_PATH = DATA_DIR / "webdav-backup.json"
|
||||||
WHISPER_SERVER_URL = os.environ.get("WHISPER_SERVER_URL", "").strip()
|
WHISPER_SERVER_URL = os.environ.get("WHISPER_SERVER_URL", "").strip()
|
||||||
|
BACKUP_WEBDAV_URL = os.environ.get("BACKUP_WEBDAV_URL", "").strip()
|
||||||
|
BACKUP_WEBDAV_USERNAME = os.environ.get("BACKUP_WEBDAV_USERNAME", "")
|
||||||
|
BACKUP_WEBDAV_PASSWORD = os.environ.get("BACKUP_WEBDAV_PASSWORD", "")
|
||||||
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
||||||
|
WHISPER_TEST_AUDIO_PATH = Path(__file__).resolve().parent.parent / "audio test.wav"
|
||||||
|
TRANSCRIPTION_TASKS: set[asyncio.Task[None]] = set()
|
||||||
|
BACKUP_TASK: asyncio.Task[None] | None = None
|
||||||
|
BACKUP_LOCK = asyncio.Lock()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
|
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -40,6 +55,7 @@ def initialize_db() -> None:
|
|||||||
filename TEXT NOT NULL,
|
filename TEXT NOT NULL,
|
||||||
audio_path TEXT NOT NULL,
|
audio_path TEXT NOT NULL,
|
||||||
transcript TEXT NOT NULL DEFAULT '',
|
transcript TEXT NOT NULL DEFAULT '',
|
||||||
|
corrected_transcript TEXT NOT NULL DEFAULT '',
|
||||||
status TEXT NOT NULL
|
status TEXT NOT NULL
|
||||||
)"""
|
)"""
|
||||||
)
|
)
|
||||||
@@ -47,9 +63,20 @@ def initialize_db() -> None:
|
|||||||
"""CREATE VIRTUAL TABLE IF NOT EXISTS note_search
|
"""CREATE VIRTUAL TABLE IF NOT EXISTS note_search
|
||||||
USING fts5(note_id UNINDEXED, transcript)"""
|
USING fts5(note_id UNINDEXED, transcript)"""
|
||||||
)
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS backup_state (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
revision INTEGER NOT NULL DEFAULT 0,
|
||||||
|
completed_revision INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
connection.execute("INSERT OR IGNORE INTO backup_state (id) VALUES (1)")
|
||||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(notes)")}
|
columns = {row[1] for row in connection.execute("PRAGMA table_info(notes)")}
|
||||||
if "original_audio_path" not in columns:
|
if "original_audio_path" not in columns:
|
||||||
connection.execute("ALTER TABLE notes ADD COLUMN original_audio_path TEXT")
|
connection.execute("ALTER TABLE notes ADD COLUMN original_audio_path TEXT")
|
||||||
|
if "corrected_transcript" not in columns:
|
||||||
|
connection.execute("ALTER TABLE notes ADD COLUMN corrected_transcript TEXT NOT NULL DEFAULT ''")
|
||||||
|
|
||||||
|
|
||||||
initialize_db()
|
initialize_db()
|
||||||
@@ -80,6 +107,228 @@ def update_transcript(note_id: str, transcript: str, status: str) -> None:
|
|||||||
"INSERT INTO note_search (note_id, transcript) VALUES (?, ?)",
|
"INSERT INTO note_search (note_id, transcript) VALUES (?, ?)",
|
||||||
(note_id, transcript.strip()),
|
(note_id, transcript.strip()),
|
||||||
)
|
)
|
||||||
|
if result.rowcount and status != "transcription_failed":
|
||||||
|
connection.execute("UPDATE backup_state SET revision = revision + 1 WHERE id = 1")
|
||||||
|
if result.rowcount and status != "transcription_failed":
|
||||||
|
schedule_backup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_backup_settings() -> dict[str, str]:
|
||||||
|
if BACKUP_WEBDAV_URL:
|
||||||
|
return {
|
||||||
|
"url": BACKUP_WEBDAV_URL,
|
||||||
|
"username": BACKUP_WEBDAV_USERNAME,
|
||||||
|
"password": BACKUP_WEBDAV_PASSWORD,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
settings = json.loads(BACKUP_SETTINGS_PATH.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
settings = {}
|
||||||
|
return {
|
||||||
|
"url": settings.get("url", ""),
|
||||||
|
"username": settings.get("username", ""),
|
||||||
|
"password": settings.get("password", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_backup_settings(settings: dict[str, str]) -> None:
|
||||||
|
temporary_path = BACKUP_SETTINGS_PATH.with_suffix(".tmp")
|
||||||
|
temporary_path.write_text(json.dumps(settings), encoding="utf-8")
|
||||||
|
temporary_path.chmod(0o600)
|
||||||
|
temporary_path.replace(BACKUP_SETTINGS_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def backup_state() -> dict[str, object]:
|
||||||
|
with connect_db() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT revision, completed_revision, last_error FROM backup_state WHERE id = 1"
|
||||||
|
).fetchone()
|
||||||
|
settings = get_backup_settings()
|
||||||
|
return {
|
||||||
|
"enabled": bool(settings["url"]),
|
||||||
|
"managed_by_environment": bool(BACKUP_WEBDAV_URL),
|
||||||
|
"url": settings["url"],
|
||||||
|
"username": settings["username"],
|
||||||
|
"password_set": bool(settings["password"]),
|
||||||
|
"revision": row["revision"],
|
||||||
|
"completed_revision": row["completed_revision"],
|
||||||
|
"pending": row["revision"] > row["completed_revision"],
|
||||||
|
"last_error": row["last_error"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_backup() -> None:
|
||||||
|
global BACKUP_TASK
|
||||||
|
if BACKUP_TASK and not BACKUP_TASK.done():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
return
|
||||||
|
BACKUP_TASK = loop.create_task(run_backup())
|
||||||
|
|
||||||
|
|
||||||
|
async def file_chunks(path: Path):
|
||||||
|
with path.open("rb") as backup_file:
|
||||||
|
while chunk := await asyncio.to_thread(backup_file.read, 1024 * 1024):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
|
||||||
|
async def create_database_snapshot(path: Path) -> None:
|
||||||
|
def copy_database() -> None:
|
||||||
|
source = sqlite3.connect(DB_PATH)
|
||||||
|
destination = sqlite3.connect(path)
|
||||||
|
try:
|
||||||
|
source.backup(destination)
|
||||||
|
finally:
|
||||||
|
destination.close()
|
||||||
|
source.close()
|
||||||
|
|
||||||
|
await asyncio.to_thread(copy_database)
|
||||||
|
|
||||||
|
|
||||||
|
def webdav_file_url(folder_url: str) -> str:
|
||||||
|
parsed = urlsplit(folder_url)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.query or parsed.fragment:
|
||||||
|
raise ValueError("Enter a valid WebDAV folder URL")
|
||||||
|
if parsed.username or parsed.password:
|
||||||
|
raise ValueError("Put WebDAV credentials in the username and password fields")
|
||||||
|
return folder_url.rstrip("/") + "/faerro-kb.sqlite3"
|
||||||
|
|
||||||
|
|
||||||
|
def retained_backup_archives(names: list[str], now: datetime) -> set[str]:
|
||||||
|
archives = []
|
||||||
|
for name in names:
|
||||||
|
match = re.fullmatch(r"faerro-kb-(\d{8}T\d{6}Z)-r\d+\.sqlite3", name)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
timestamp = datetime.strptime(match.group(1), "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
|
||||||
|
if timestamp <= now:
|
||||||
|
archives.append((name, timestamp))
|
||||||
|
|
||||||
|
daily: dict[object, tuple[str, datetime]] = {}
|
||||||
|
weekly: dict[object, tuple[str, datetime]] = {}
|
||||||
|
monthly: dict[object, tuple[str, datetime]] = {}
|
||||||
|
yearly: dict[object, tuple[str, datetime]] = {}
|
||||||
|
today = now.date()
|
||||||
|
current_week = today - timedelta(days=today.weekday())
|
||||||
|
current_month = now.year * 12 + now.month
|
||||||
|
|
||||||
|
for name, timestamp in archives:
|
||||||
|
date = timestamp.date()
|
||||||
|
age_days = (today - date).days
|
||||||
|
week = date - timedelta(days=date.weekday())
|
||||||
|
age_weeks = (current_week - week).days // 7
|
||||||
|
age_months = current_month - (timestamp.year * 12 + timestamp.month)
|
||||||
|
age_years = now.year - timestamp.year
|
||||||
|
for buckets, key, age, limit in (
|
||||||
|
(daily, date, age_days, 7),
|
||||||
|
(weekly, (week.isocalendar().year, week.isocalendar().week), age_weeks, 4),
|
||||||
|
(monthly, (timestamp.year, timestamp.month), age_months, 12),
|
||||||
|
(yearly, timestamp.year, age_years, 5),
|
||||||
|
):
|
||||||
|
if 0 <= age < limit and (key not in buckets or timestamp > buckets[key][1]):
|
||||||
|
buckets[key] = (name, timestamp)
|
||||||
|
|
||||||
|
return {
|
||||||
|
name
|
||||||
|
for buckets in (daily, weekly, monthly, yearly)
|
||||||
|
for name, _timestamp in buckets.values()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def rotate_remote_backups(client: httpx.AsyncClient, folder_url: str) -> None:
|
||||||
|
request_body = b"""<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>"""
|
||||||
|
response = await client.request(
|
||||||
|
"PROPFIND",
|
||||||
|
folder_url,
|
||||||
|
headers={"Depth": "1", "Content-Type": "application/xml"},
|
||||||
|
content=request_body,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
root = ET.fromstring(response.content)
|
||||||
|
folder_path = urlsplit(folder_url).path.rstrip("/")
|
||||||
|
remote_archives = []
|
||||||
|
for item in root.findall("{DAV:}response"):
|
||||||
|
href = item.findtext("{DAV:}href")
|
||||||
|
if not href:
|
||||||
|
continue
|
||||||
|
item_url = httpx.URL(folder_url).join(href)
|
||||||
|
item_path = item_url.path
|
||||||
|
if item_path.rsplit("/", 1)[0].rstrip("/") != folder_path:
|
||||||
|
continue
|
||||||
|
name = unquote(item_path.rsplit("/", 1)[-1])
|
||||||
|
if re.fullmatch(r"faerro-kb-\d{8}T\d{6}Z-r\d+\.sqlite3", name):
|
||||||
|
remote_archives.append((name, str(item_url)))
|
||||||
|
|
||||||
|
retained = retained_backup_archives([name for name, _url in remote_archives], datetime.now(timezone.utc))
|
||||||
|
for name, item_url in remote_archives:
|
||||||
|
if name not in retained:
|
||||||
|
response = await client.delete(item_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_backup() -> None:
|
||||||
|
async with BACKUP_LOCK:
|
||||||
|
while True:
|
||||||
|
settings = get_backup_settings()
|
||||||
|
if not settings["url"]:
|
||||||
|
return
|
||||||
|
with connect_db() as connection:
|
||||||
|
state = connection.execute(
|
||||||
|
"SELECT revision, completed_revision FROM backup_state WHERE id = 1"
|
||||||
|
).fetchone()
|
||||||
|
if state["revision"] <= state["completed_revision"]:
|
||||||
|
return
|
||||||
|
revision = state["revision"]
|
||||||
|
snapshot_path = None
|
||||||
|
try:
|
||||||
|
target_url = webdav_file_url(settings["url"])
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".sqlite3", dir=DATA_DIR, delete=False) as snapshot:
|
||||||
|
snapshot_path = Path(snapshot.name)
|
||||||
|
await create_database_snapshot(snapshot_path)
|
||||||
|
with sqlite3.connect(snapshot_path) as snapshot_connection:
|
||||||
|
snapshot_connection.execute(
|
||||||
|
"UPDATE backup_state SET completed_revision = ?, last_error = NULL WHERE id = 1",
|
||||||
|
(revision,),
|
||||||
|
)
|
||||||
|
auth = (settings["username"], settings["password"]) if settings["username"] else None
|
||||||
|
archive_name = (
|
||||||
|
f"faerro-kb-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}"
|
||||||
|
f"-r{revision:010d}.sqlite3"
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient(timeout=120, auth=auth) as client:
|
||||||
|
response = await client.put(
|
||||||
|
f"{settings['url'].rstrip('/')}/{archive_name}",
|
||||||
|
content=file_chunks(snapshot_path),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
response = await client.put(target_url, content=file_chunks(snapshot_path))
|
||||||
|
response.raise_for_status()
|
||||||
|
await rotate_remote_backups(client, settings["url"])
|
||||||
|
with connect_db() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE backup_state SET completed_revision = ?, last_error = NULL WHERE id = 1",
|
||||||
|
(revision,),
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as error:
|
||||||
|
error_message = f"WebDAV returned HTTP {error.response.status_code}"
|
||||||
|
with connect_db() as connection:
|
||||||
|
connection.execute("UPDATE backup_state SET last_error = ? WHERE id = 1", (error_message,))
|
||||||
|
logger.warning("WebDAV database backup failed: HTTP %s", error.response.status_code)
|
||||||
|
return
|
||||||
|
except Exception as error:
|
||||||
|
error_message = "WebDAV backup failed; check the folder URL and credentials"
|
||||||
|
if isinstance(error, ValueError):
|
||||||
|
error_message = str(error)
|
||||||
|
with connect_db() as connection:
|
||||||
|
connection.execute("UPDATE backup_state SET last_error = ? WHERE id = 1", (error_message,))
|
||||||
|
logger.warning("WebDAV database backup failed: %s", type(error).__name__)
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
if snapshot_path:
|
||||||
|
snapshot_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def remove_transcribed_audio(note_id: str) -> None:
|
def remove_transcribed_audio(note_id: str) -> None:
|
||||||
@@ -107,8 +356,8 @@ async def transcribe_note(note_id: str, audio_path: Path) -> None:
|
|||||||
with audio_path.open("rb") as audio_file:
|
with audio_path.open("rb") as audio_file:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
WHISPER_SERVER_URL,
|
WHISPER_SERVER_URL,
|
||||||
files={"file": (audio_path.name, audio_file, "application/octet-stream")},
|
params={"task": "transcribe", "output": "json"},
|
||||||
data={"response_format": "json"},
|
files={"audio_file": (audio_path.name, audio_file, "application/octet-stream")},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
transcript = response.json().get("text", "").strip()
|
transcript = response.json().get("text", "").strip()
|
||||||
@@ -120,11 +369,164 @@ async def transcribe_note(note_id: str, audio_path: Path) -> None:
|
|||||||
remove_transcribed_audio(note_id)
|
remove_transcribed_audio(note_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_whisper_connection() -> bool:
|
||||||
|
if not WHISPER_SERVER_URL:
|
||||||
|
logger.warning("Whisper startup test skipped: WHISPER_SERVER_URL is not configured")
|
||||||
|
return False
|
||||||
|
if not WHISPER_TEST_AUDIO_PATH.is_file():
|
||||||
|
logger.error("Whisper startup test failed: bundled test audio is missing")
|
||||||
|
return False
|
||||||
|
|
||||||
|
started_at = time.monotonic()
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(120, connect=5)) as client:
|
||||||
|
with WHISPER_TEST_AUDIO_PATH.open("rb") as audio_file:
|
||||||
|
response = await client.post(
|
||||||
|
WHISPER_SERVER_URL,
|
||||||
|
params={"encode": "true", "task": "transcribe", "output": "json"},
|
||||||
|
files={"audio_file": (WHISPER_TEST_AUDIO_PATH.name, audio_file, "audio/wav")},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
transcript = result.get("text") if isinstance(result, dict) else None
|
||||||
|
if not isinstance(transcript, str) or not transcript.strip():
|
||||||
|
logger.error(
|
||||||
|
"Whisper startup test failed: HTTP %s returned no transcript (%.1fs)",
|
||||||
|
response.status_code,
|
||||||
|
time.monotonic() - started_at,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
language = result.get("language", "unknown")
|
||||||
|
if not isinstance(language, str) or not re.fullmatch(r"[A-Za-z-]{1,12}", language):
|
||||||
|
language = "unknown"
|
||||||
|
segments = result.get("segments", [])
|
||||||
|
segment_count = len(segments) if isinstance(segments, list) else 0
|
||||||
|
logger.info(
|
||||||
|
"Whisper startup test passed: HTTP %s, language=%s, segments=%d, elapsed=%.1fs",
|
||||||
|
response.status_code,
|
||||||
|
language,
|
||||||
|
segment_count,
|
||||||
|
time.monotonic() - started_at,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except httpx.HTTPStatusError as error:
|
||||||
|
logger.error("Whisper startup test failed: HTTP %s", error.response.status_code)
|
||||||
|
except Exception as error:
|
||||||
|
logger.error("Whisper startup test failed: %s", type(error).__name__)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def resume_pending_transcriptions() -> None:
|
||||||
|
if BACKUP_WEBDAV_URL:
|
||||||
|
with connect_db() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE backup_state SET revision = 1 WHERE id = 1 AND revision = 0 AND completed_revision = 0"
|
||||||
|
)
|
||||||
|
schedule_backup()
|
||||||
|
with connect_db() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE notes SET status = 'awaiting_transcription' WHERE status = 'transcribing'"
|
||||||
|
)
|
||||||
|
if not await test_whisper_connection():
|
||||||
|
return
|
||||||
|
with connect_db() as connection:
|
||||||
|
notes = connection.execute(
|
||||||
|
"""SELECT id, audio_path FROM notes
|
||||||
|
WHERE status IN ('awaiting_transcription', 'transcribing', 'transcription_failed')"""
|
||||||
|
).fetchall()
|
||||||
|
for note in notes:
|
||||||
|
audio_path = Path(note["audio_path"])
|
||||||
|
if audio_path.is_file():
|
||||||
|
with connect_db() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE notes SET status = 'transcribing' WHERE id = ?",
|
||||||
|
(note["id"],),
|
||||||
|
)
|
||||||
|
task = asyncio.create_task(transcribe_note(note["id"], audio_path))
|
||||||
|
TRANSCRIPTION_TASKS.add(task)
|
||||||
|
task.add_done_callback(TRANSCRIPTION_TASKS.discard)
|
||||||
|
else:
|
||||||
|
with connect_db() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE notes SET status = 'transcription_failed' WHERE id = ?",
|
||||||
|
(note["id"],),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health() -> dict[str, str]:
|
def health() -> dict[str, str]:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/backup")
|
||||||
|
def get_backup_status() -> dict[str, object]:
|
||||||
|
return backup_state()
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/backup/config")
|
||||||
|
async def configure_backup(payload: dict[str, str]) -> dict[str, object]:
|
||||||
|
if BACKUP_WEBDAV_URL:
|
||||||
|
raise HTTPException(status_code=409, detail="WebDAV backup is managed by environment variables")
|
||||||
|
url = payload.get("url", "").strip()
|
||||||
|
username = payload.get("username", "").strip()
|
||||||
|
current = get_backup_settings()
|
||||||
|
password = "" if payload.get("clear_password") == "true" else current["password"]
|
||||||
|
if payload.get("password"):
|
||||||
|
password = payload["password"]
|
||||||
|
if url:
|
||||||
|
try:
|
||||||
|
webdav_file_url(url)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||||
|
save_backup_settings({"url": url, "username": username, "password": password})
|
||||||
|
if url:
|
||||||
|
with connect_db() as connection:
|
||||||
|
connection.execute("UPDATE backup_state SET revision = revision + 1 WHERE id = 1")
|
||||||
|
schedule_backup()
|
||||||
|
return backup_state()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/backup/test")
|
||||||
|
async def test_backup_connection() -> dict[str, str]:
|
||||||
|
settings = get_backup_settings()
|
||||||
|
if not settings["url"]:
|
||||||
|
raise HTTPException(status_code=400, detail="Configure a WebDAV folder first")
|
||||||
|
try:
|
||||||
|
folder_url = settings["url"].rstrip("/")
|
||||||
|
webdav_file_url(folder_url)
|
||||||
|
test_url = f"{folder_url}/faerro-kb-test-{uuid.uuid4().hex}"
|
||||||
|
auth = (settings["username"], settings["password"]) if settings["username"] else None
|
||||||
|
async with httpx.AsyncClient(timeout=30, auth=auth) as client:
|
||||||
|
response = await client.request(
|
||||||
|
"PROPFIND",
|
||||||
|
folder_url,
|
||||||
|
headers={"Depth": "1", "Content-Type": "application/xml"},
|
||||||
|
content=b'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>',
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
response = await client.put(test_url, content=b"Faerro KB WebDAV test")
|
||||||
|
response.raise_for_status()
|
||||||
|
response = await client.delete(test_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
return {"status": "ok", "message": "WebDAV listing, write, and delete test passed."}
|
||||||
|
except httpx.HTTPStatusError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail=f"WebDAV returned HTTP {error.response.status_code} during the test",
|
||||||
|
) from error
|
||||||
|
except Exception as error:
|
||||||
|
raise HTTPException(status_code=502, detail="Could not connect to the WebDAV folder") from error
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/backup/retry", status_code=202)
|
||||||
|
async def retry_backup() -> dict[str, object]:
|
||||||
|
if not get_backup_settings()["url"]:
|
||||||
|
raise HTTPException(status_code=400, detail="Configure a WebDAV folder first")
|
||||||
|
schedule_backup()
|
||||||
|
return backup_state()
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/notes", status_code=201)
|
@app.post("/api/notes", status_code=201)
|
||||||
async def create_note(
|
async def create_note(
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
@@ -237,7 +639,7 @@ def list_notes(q: str = "", status: str = "") -> list[dict[str, str]]:
|
|||||||
query = " AND ".join(f'"{term.replace(chr(34), chr(34) * 2)}"' for term in search_terms)
|
query = " AND ".join(f'"{term.replace(chr(34), chr(34) * 2)}"' for term in search_terms)
|
||||||
if status:
|
if status:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"""SELECT n.id, n.created_at, n.filename, n.transcript, n.status
|
"""SELECT n.id, n.created_at, n.filename, n.transcript, n.corrected_transcript, n.status
|
||||||
FROM note_search s JOIN notes n ON n.id = s.note_id
|
FROM note_search s JOIN notes n ON n.id = s.note_id
|
||||||
WHERE note_search MATCH ? AND n.status = ?
|
WHERE note_search MATCH ? AND n.status = ?
|
||||||
ORDER BY n.created_at DESC LIMIT 100""",
|
ORDER BY n.created_at DESC LIMIT 100""",
|
||||||
@@ -245,20 +647,20 @@ def list_notes(q: str = "", status: str = "") -> list[dict[str, str]]:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
else:
|
else:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"""SELECT n.id, n.created_at, n.filename, n.transcript, n.status
|
"""SELECT n.id, n.created_at, n.filename, n.transcript, n.corrected_transcript, n.status
|
||||||
FROM note_search s JOIN notes n ON n.id = s.note_id
|
FROM note_search s JOIN notes n ON n.id = s.note_id
|
||||||
WHERE note_search MATCH ? ORDER BY n.created_at DESC LIMIT 100""",
|
WHERE note_search MATCH ? ORDER BY n.created_at DESC LIMIT 100""",
|
||||||
(query,),
|
(query,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
elif status:
|
elif status:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"""SELECT id, created_at, filename, transcript, status
|
"""SELECT id, created_at, filename, transcript, corrected_transcript, status
|
||||||
FROM notes WHERE status = ? ORDER BY created_at DESC LIMIT 100""",
|
FROM notes WHERE status = ? ORDER BY created_at DESC LIMIT 100""",
|
||||||
(status,),
|
(status,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
else:
|
else:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"""SELECT id, created_at, filename, transcript, status
|
"""SELECT id, created_at, filename, transcript, corrected_transcript, status
|
||||||
FROM notes ORDER BY created_at DESC LIMIT 100"""
|
FROM notes ORDER BY created_at DESC LIMIT 100"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
@@ -282,17 +684,27 @@ def delete_note(note_id: str) -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
@app.put("/api/notes/{note_id}/transcript")
|
@app.put("/api/notes/{note_id}/transcript")
|
||||||
def save_transcript(note_id: str, payload: dict[str, str]) -> dict[str, str]:
|
async def save_transcript(note_id: str, payload: dict[str, str]) -> dict[str, str]:
|
||||||
transcript = payload.get("transcript", "").strip()
|
transcript = payload.get("transcript", "").strip()
|
||||||
with connect_db() as connection:
|
with connect_db() as connection:
|
||||||
note = connection.execute("SELECT status FROM notes WHERE id = ?", (note_id,)).fetchone()
|
note = connection.execute("SELECT transcript FROM notes WHERE id = ?", (note_id,)).fetchone()
|
||||||
if not note:
|
if not note:
|
||||||
raise HTTPException(status_code=404, detail="Note not found")
|
raise HTTPException(status_code=404, detail="Note not found")
|
||||||
status = "written" if note["status"] == "written" else "transcribed" if transcript else "awaiting_transcription"
|
with connect_db() as connection:
|
||||||
update_transcript(note_id, transcript, status)
|
connection.execute(
|
||||||
if transcript and note["status"] != "written":
|
"UPDATE notes SET corrected_transcript = ? WHERE id = ?",
|
||||||
remove_transcribed_audio(note_id)
|
(transcript, note_id),
|
||||||
return {"id": note_id, "status": status}
|
)
|
||||||
|
connection.execute("DELETE FROM note_search WHERE note_id = ?", (note_id,))
|
||||||
|
searchable_text = transcript or note["transcript"]
|
||||||
|
if searchable_text.strip():
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO note_search (note_id, transcript) VALUES (?, ?)",
|
||||||
|
(note_id, searchable_text.strip()),
|
||||||
|
)
|
||||||
|
connection.execute("UPDATE backup_state SET revision = revision + 1 WHERE id = 1")
|
||||||
|
schedule_backup()
|
||||||
|
return {"id": note_id, "status": "saved"}
|
||||||
|
|
||||||
|
|
||||||
app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")
|
app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")
|
||||||
Binary file not shown.
@@ -6,6 +6,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATA_DIR: /data
|
DATA_DIR: /data
|
||||||
WHISPER_SERVER_URL: ${WHISPER_SERVER_URL:-}
|
WHISPER_SERVER_URL: ${WHISPER_SERVER_URL:-}
|
||||||
|
BACKUP_WEBDAV_URL: ${BACKUP_WEBDAV_URL:-}
|
||||||
|
BACKUP_WEBDAV_USERNAME: ${BACKUP_WEBDAV_USERNAME:-}
|
||||||
|
BACKUP_WEBDAV_PASSWORD: ${BACKUP_WEBDAV_PASSWORD:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
+151
-15
@@ -6,7 +6,10 @@ const MAX_RECORDING_MS = 30 * 60 * 1000;
|
|||||||
const elements = Object.fromEntries([
|
const elements = Object.fromEntries([
|
||||||
'record-button', 'record-title', 'record-help', 'record-time', 'record-meter-fill', 'audio-level', 'audio-level-fill', 'capture-message', 'audio-file-input',
|
'record-button', 'record-title', 'record-help', 'record-time', 'record-meter-fill', 'audio-level', 'audio-level-fill', 'capture-message', 'audio-file-input',
|
||||||
'queue-list', 'queue-empty', 'queue-count', 'sync-button', 'archive-list', 'archive-empty',
|
'queue-list', 'queue-empty', 'queue-count', 'sync-button', 'archive-list', 'archive-empty',
|
||||||
'network-state', 'storage-status', 'voice-mode', 'write-mode', 'voice-panel', 'write-panel', 'written-note', 'draft-status', 'save-written-note',
|
'network-state', 'storage-status', 'main', 'voice-mode', 'write-mode', 'read-mode', 'voice-panel', 'write-panel', 'written-note', 'draft-status', 'save-written-note',
|
||||||
|
'queue-section', 'archive-section', 'archive-title',
|
||||||
|
'settings-button', 'settings-dialog', 'backup-form', 'backup-fields', 'backup-url', 'backup-username', 'backup-password',
|
||||||
|
'clear-backup-password', 'close-settings', 'test-backup', 'backup-status', 'retry-backup',
|
||||||
].map((id) => [id, document.getElementById(id)]));
|
].map((id) => [id, document.getElementById(id)]));
|
||||||
|
|
||||||
let databasePromise;
|
let databasePromise;
|
||||||
@@ -19,6 +22,7 @@ let recordingStartedAt = 0;
|
|||||||
let recordingTimer;
|
let recordingTimer;
|
||||||
let syncing = false;
|
let syncing = false;
|
||||||
let draftSaveTimer;
|
let draftSaveTimer;
|
||||||
|
let transcriptionPoll;
|
||||||
|
|
||||||
function openDatabase() {
|
function openDatabase() {
|
||||||
if (databasePromise) return databasePromise;
|
if (databasePromise) return databasePromise;
|
||||||
@@ -68,12 +72,18 @@ const deleteTextRecord = (id) => textTransaction('readwrite', (store) => store.d
|
|||||||
|
|
||||||
function setCaptureMode(mode) {
|
function setCaptureMode(mode) {
|
||||||
const writing = mode === 'write';
|
const writing = mode === 'write';
|
||||||
elements['voice-mode'].classList.toggle('active', !writing);
|
const reading = mode === 'read';
|
||||||
|
elements['voice-mode'].classList.toggle('active', !writing && !reading);
|
||||||
elements['write-mode'].classList.toggle('active', writing);
|
elements['write-mode'].classList.toggle('active', writing);
|
||||||
elements['voice-mode'].setAttribute('aria-selected', String(!writing));
|
elements['read-mode'].classList.toggle('active', reading);
|
||||||
|
elements['voice-mode'].setAttribute('aria-selected', String(!writing && !reading));
|
||||||
elements['write-mode'].setAttribute('aria-selected', String(writing));
|
elements['write-mode'].setAttribute('aria-selected', String(writing));
|
||||||
elements['voice-panel'].hidden = writing;
|
elements['read-mode'].setAttribute('aria-selected', String(reading));
|
||||||
elements['write-panel'].hidden = !writing;
|
elements['voice-panel'].hidden = writing || reading;
|
||||||
|
elements['write-panel'].hidden = !writing || reading;
|
||||||
|
elements['main'].classList.toggle('reading', reading);
|
||||||
|
elements['archive-title'].textContent = reading ? 'Stored transcriptions' : 'Saved notes';
|
||||||
|
loadAwaitingNotes();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveDraft() {
|
async function saveDraft() {
|
||||||
@@ -153,6 +163,94 @@ function updateNetwork() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshBackupStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/backup');
|
||||||
|
if (!response.ok) throw new Error('Backup status unavailable');
|
||||||
|
const backup = await response.json();
|
||||||
|
elements['backup-fields'].disabled = backup.managed_by_environment;
|
||||||
|
const managedNotice = backup.managed_by_environment ? 'Settings are managed by environment variables. ' : '';
|
||||||
|
if (backup.last_error) {
|
||||||
|
elements['backup-status'].textContent = `${managedNotice}Backup issue: ${backup.last_error}`;
|
||||||
|
} else if (backup.pending && backup.enabled) {
|
||||||
|
elements['backup-status'].textContent = `${managedNotice}A database snapshot is waiting to upload.`;
|
||||||
|
} else if (backup.pending) {
|
||||||
|
elements['backup-status'].textContent = 'Backup is waiting; configure a WebDAV folder to upload it.';
|
||||||
|
} else if (backup.managed_by_environment) {
|
||||||
|
elements['backup-status'].textContent = `${managedNotice}Latest database snapshot is current.`;
|
||||||
|
} else if (backup.enabled) {
|
||||||
|
elements['backup-status'].textContent = 'WebDAV backup is enabled. Latest database snapshot is current.';
|
||||||
|
} else {
|
||||||
|
elements['backup-status'].textContent = 'WebDAV backup is not configured.';
|
||||||
|
}
|
||||||
|
elements['retry-backup'].hidden = !backup.enabled || !backup.pending;
|
||||||
|
if (!backup.managed_by_environment) {
|
||||||
|
elements['backup-url'].value = backup.url;
|
||||||
|
elements['backup-username'].value = backup.username;
|
||||||
|
}
|
||||||
|
return backup;
|
||||||
|
} catch {
|
||||||
|
elements['backup-status'].textContent = 'Could not load backup status.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBackupConfiguration() {
|
||||||
|
const response = await fetch('/api/backup/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: elements['backup-url'].value.trim(),
|
||||||
|
username: elements['backup-username'].value.trim(),
|
||||||
|
password: elements['backup-password'].value,
|
||||||
|
clear_password: String(elements['clear-backup-password'].checked),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) throw new Error(result.detail || 'Could not save backup settings.');
|
||||||
|
elements['backup-password'].value = '';
|
||||||
|
elements['clear-backup-password'].checked = false;
|
||||||
|
await refreshBackupStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
elements['settings-button'].addEventListener('click', () => {
|
||||||
|
elements['settings-dialog'].showModal();
|
||||||
|
refreshBackupStatus();
|
||||||
|
});
|
||||||
|
elements['close-settings'].addEventListener('click', () => elements['settings-dialog'].close());
|
||||||
|
elements['backup-form'].addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
elements['backup-status'].textContent = 'Saving settings…';
|
||||||
|
try {
|
||||||
|
await saveBackupConfiguration();
|
||||||
|
} catch (error) {
|
||||||
|
elements['backup-status'].textContent = error.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
elements['test-backup'].addEventListener('click', async () => {
|
||||||
|
elements['backup-status'].textContent = 'Testing WebDAV…';
|
||||||
|
try {
|
||||||
|
await saveBackupConfiguration();
|
||||||
|
const response = await fetch('/api/backup/test', { method: 'POST' });
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) throw new Error(result.detail || 'WebDAV test failed.');
|
||||||
|
elements['backup-status'].textContent = result.message;
|
||||||
|
} catch (error) {
|
||||||
|
elements['backup-status'].textContent = error.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
elements['retry-backup'].addEventListener('click', async () => {
|
||||||
|
elements['backup-status'].textContent = 'Queuing backup retry…';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/backup/retry', { method: 'POST' });
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) throw new Error(result.detail || 'Could not retry backup.');
|
||||||
|
await refreshBackupStatus();
|
||||||
|
} catch (error) {
|
||||||
|
elements['backup-status'].textContent = error.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function formatDuration(milliseconds) {
|
function formatDuration(milliseconds) {
|
||||||
const seconds = Math.floor(milliseconds / 1000);
|
const seconds = Math.floor(milliseconds / 1000);
|
||||||
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
||||||
@@ -421,6 +519,16 @@ function renderArchive(notes) {
|
|||||||
elements['archive-empty'].hidden = notes.length > 0;
|
elements['archive-empty'].hidden = notes.length > 0;
|
||||||
if (!navigator.onLine) elements['archive-empty'].textContent = 'Reconnect to view saved notes.';
|
if (!navigator.onLine) elements['archive-empty'].textContent = 'Reconnect to view saved notes.';
|
||||||
else elements['archive-empty'].textContent = 'No saved notes yet.';
|
else elements['archive-empty'].textContent = 'No saved notes yet.';
|
||||||
|
if (notes.some((note) => note.status === 'transcribing')) {
|
||||||
|
if (!transcriptionPoll) {
|
||||||
|
transcriptionPoll = window.setInterval(() => {
|
||||||
|
if (navigator.onLine && document.visibilityState === 'visible') loadAwaitingNotes();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clearInterval(transcriptionPoll);
|
||||||
|
transcriptionPoll = undefined;
|
||||||
|
}
|
||||||
for (const note of notes) {
|
for (const note of notes) {
|
||||||
const item = document.createElement('article');
|
const item = document.createElement('article');
|
||||||
item.className = 'archive-item';
|
item.className = 'archive-item';
|
||||||
@@ -436,12 +544,19 @@ function renderArchive(notes) {
|
|||||||
const transcript = document.createElement('textarea');
|
const transcript = document.createElement('textarea');
|
||||||
transcript.className = 'archive-transcript';
|
transcript.className = 'archive-transcript';
|
||||||
transcript.placeholder = isWrittenNote ? 'Write or edit your note…' : note.status === 'transcribing' ? 'Transcription in progress…' : 'Transcript not available yet. Add or correct it here.';
|
transcript.placeholder = isWrittenNote ? 'Write or edit your note…' : note.status === 'transcribing' ? 'Transcription in progress…' : 'Transcript not available yet. Add or correct it here.';
|
||||||
transcript.value = note.transcript;
|
transcript.value = note.corrected_transcript || note.transcript;
|
||||||
transcript.setAttribute('aria-label', `${isWrittenNote ? 'Note' : 'Transcript'} for ${note.filename}`);
|
transcript.setAttribute('aria-label', `${isWrittenNote ? 'Note' : 'Edited transcript'} for ${note.filename}`);
|
||||||
|
const original = document.createElement('details');
|
||||||
|
original.className = 'original-transcript';
|
||||||
|
const originalLabel = document.createElement('summary');
|
||||||
|
originalLabel.textContent = 'Original transcript';
|
||||||
|
const originalText = document.createElement('p');
|
||||||
|
originalText.textContent = note.transcript || 'No original transcript is available.';
|
||||||
|
original.append(originalLabel, originalText);
|
||||||
const save = document.createElement('button');
|
const save = document.createElement('button');
|
||||||
save.className = 'save-transcript';
|
save.className = 'save-transcript';
|
||||||
save.type = 'button';
|
save.type = 'button';
|
||||||
save.textContent = isWrittenNote ? 'SAVE NOTE' : 'SAVE TRANSCRIPT';
|
save.textContent = isWrittenNote ? 'SAVE NOTE' : 'SAVE EDIT';
|
||||||
save.addEventListener('click', async () => {
|
save.addEventListener('click', async () => {
|
||||||
save.disabled = true;
|
save.disabled = true;
|
||||||
try {
|
try {
|
||||||
@@ -449,7 +564,10 @@ function renderArchive(notes) {
|
|||||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transcript: transcript.value }),
|
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transcript: transcript.value }),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Save failed');
|
if (!response.ok) throw new Error('Save failed');
|
||||||
await loadAwaitingNotes();
|
save.textContent = 'SAVED';
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (save.isConnected) save.textContent = isWrittenNote ? 'SAVE NOTE' : 'SAVE EDIT';
|
||||||
|
}, 1500);
|
||||||
} catch {
|
} catch {
|
||||||
save.textContent = 'RETRY SAVE';
|
save.textContent = 'RETRY SAVE';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -458,7 +576,20 @@ function renderArchive(notes) {
|
|||||||
});
|
});
|
||||||
const status = document.createElement('div');
|
const status = document.createElement('div');
|
||||||
status.className = 'archive-status';
|
status.className = 'archive-status';
|
||||||
status.textContent = isWrittenNote ? 'WRITTEN NOTE' : note.status === 'transcribing' ? 'TRANSCRIBING' : note.status === 'transcribed' ? 'TRANSCRIPT SAVED' : 'AWAITING TRANSCRIPTION';
|
status.textContent = isWrittenNote ? 'WRITTEN NOTE' : note.status === 'transcribing' ? 'TRANSCRIBING' : note.status === 'transcribed' ? 'TRANSCRIPT SAVED' : note.status === 'transcription_failed' ? 'TRANSCRIPTION FAILED; AUDIO RETAINED' : note.status === 'empty_transcript' ? 'NO SPEECH DETECTED; AUDIO RETAINED' : 'AWAITING TRANSCRIPTION';
|
||||||
|
if (note.status === 'transcribing') {
|
||||||
|
const progress = document.createElement('div');
|
||||||
|
progress.className = 'transcription-progress';
|
||||||
|
progress.setAttribute('role', 'progressbar');
|
||||||
|
progress.setAttribute('aria-label', 'Transcription in progress');
|
||||||
|
progress.setAttribute('aria-valuemin', '0');
|
||||||
|
progress.setAttribute('aria-valuemax', '100');
|
||||||
|
progress.setAttribute('aria-valuetext', 'In progress');
|
||||||
|
progress.append(document.createElement('span'));
|
||||||
|
body.append(name, original, transcript, save, status, progress);
|
||||||
|
} else {
|
||||||
|
body.append(name, original, transcript, save, status);
|
||||||
|
}
|
||||||
const remove = createDeleteButton(`Delete note ${note.filename}`);
|
const remove = createDeleteButton(`Delete note ${note.filename}`);
|
||||||
remove.addEventListener('click', async () => {
|
remove.addEventListener('click', async () => {
|
||||||
remove.disabled = true;
|
remove.disabled = true;
|
||||||
@@ -471,7 +602,6 @@ function renderArchive(notes) {
|
|||||||
setMessage('Could not delete this note. Please try again.', true);
|
setMessage('Could not delete this note. Please try again.', true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
body.append(name, transcript, save, status);
|
|
||||||
item.append(date, body, remove);
|
item.append(date, body, remove);
|
||||||
elements['archive-list'].append(item);
|
elements['archive-list'].append(item);
|
||||||
}
|
}
|
||||||
@@ -483,10 +613,15 @@ async function loadAwaitingNotes() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const responses = await Promise.all([
|
const responses = elements['main'].classList.contains('reading')
|
||||||
fetch('/api/notes?status=awaiting_transcription'),
|
? [await fetch('/api/notes')]
|
||||||
fetch('/api/notes?status=written'),
|
: await Promise.all([
|
||||||
]);
|
fetch('/api/notes?status=awaiting_transcription'),
|
||||||
|
fetch('/api/notes?status=written'),
|
||||||
|
fetch('/api/notes?status=transcribing'),
|
||||||
|
fetch('/api/notes?status=transcription_failed'),
|
||||||
|
fetch('/api/notes?status=empty_transcript'),
|
||||||
|
]);
|
||||||
if (responses.some((response) => !response.ok)) throw new Error('Archive unavailable');
|
if (responses.some((response) => !response.ok)) throw new Error('Archive unavailable');
|
||||||
const notes = (await Promise.all(responses.map((response) => response.json()))).flat();
|
const notes = (await Promise.all(responses.map((response) => response.json()))).flat();
|
||||||
notes.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
notes.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||||
@@ -501,6 +636,7 @@ async function loadAwaitingNotes() {
|
|||||||
elements['record-button'].addEventListener('click', () => mediaRecorder?.state === 'recording' ? stopRecording() : startRecording());
|
elements['record-button'].addEventListener('click', () => mediaRecorder?.state === 'recording' ? stopRecording() : startRecording());
|
||||||
elements['voice-mode'].addEventListener('click', () => setCaptureMode('voice'));
|
elements['voice-mode'].addEventListener('click', () => setCaptureMode('voice'));
|
||||||
elements['write-mode'].addEventListener('click', () => setCaptureMode('write'));
|
elements['write-mode'].addEventListener('click', () => setCaptureMode('write'));
|
||||||
|
elements['read-mode'].addEventListener('click', () => setCaptureMode('read'));
|
||||||
elements['written-note'].addEventListener('input', scheduleDraftSave);
|
elements['written-note'].addEventListener('input', scheduleDraftSave);
|
||||||
elements['save-written-note'].addEventListener('click', queueWrittenNote);
|
elements['save-written-note'].addEventListener('click', queueWrittenNote);
|
||||||
window.addEventListener('pagehide', () => {
|
window.addEventListener('pagehide', () => {
|
||||||
|
|||||||
+32
-3
@@ -14,14 +14,43 @@
|
|||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a class="brand" href="/" aria-label="Faerro KB"><span class="brand-mark">KB</span><span>Faerro <b>KB</b></span></a>
|
<a class="brand" href="/" aria-label="Faerro KB"><span class="brand-mark">KB</span><span>Faerro <b>KB</b></span></a>
|
||||||
<span id="network-state" class="network-state"><i></i> Checking</span>
|
<div class="topbar-actions">
|
||||||
|
<button id="settings-button" class="icon-button settings-button" type="button" title="Backup settings" aria-label="Backup settings">⚙</button>
|
||||||
|
<span id="network-state" class="network-state"><i></i> Checking</span>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<dialog id="settings-dialog" class="settings-dialog" aria-labelledby="settings-title">
|
||||||
|
<form id="backup-form" method="dialog">
|
||||||
|
<div class="dialog-heading">
|
||||||
|
<h2 id="settings-title">Database backup</h2>
|
||||||
|
<button id="close-settings" class="icon-button dialog-close" type="button" title="Close settings" aria-label="Close settings">×</button>
|
||||||
|
</div>
|
||||||
|
<p class="settings-copy">A consistent SQLite snapshot is uploaded after each saved transcript. Keeps latest, 7 daily, 4 weekly, 12 monthly, and 5 yearly snapshots. WebDAV receives the full database file each time.</p>
|
||||||
|
<fieldset id="backup-fields">
|
||||||
|
<label for="backup-url">WebDAV folder URL</label>
|
||||||
|
<input id="backup-url" name="url" type="url" placeholder="https://cloud.example/remote.php/dav/files/name/Faerro" autocomplete="url" />
|
||||||
|
<label for="backup-username">Username</label>
|
||||||
|
<input id="backup-username" name="username" type="text" autocomplete="username" />
|
||||||
|
<label for="backup-password">App password</label>
|
||||||
|
<input id="backup-password" name="password" type="password" autocomplete="new-password" placeholder="Leave blank to keep the saved password" />
|
||||||
|
<label class="clear-password"><input id="clear-backup-password" type="checkbox" /> Remove saved password</label>
|
||||||
|
<div class="settings-actions">
|
||||||
|
<button class="save-written-note" type="submit">Save settings</button>
|
||||||
|
<button id="test-backup" class="secondary-button" type="button">Test WebDAV</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<p id="backup-status" class="backup-status" role="status">Loading backup status…</p>
|
||||||
|
<button id="retry-backup" class="secondary-button retry-backup" type="button" hidden>Retry backup</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section class="capture" aria-labelledby="capture-title">
|
<section class="capture" aria-labelledby="capture-title">
|
||||||
<div class="capture-modes" role="tablist" aria-label="Note type">
|
<div class="capture-modes" role="tablist" aria-label="Note type">
|
||||||
<button id="voice-mode" class="capture-mode active" type="button" role="tab" aria-selected="true" aria-controls="voice-panel">Voice</button>
|
<button id="voice-mode" class="capture-mode active" type="button" role="tab" aria-selected="true" aria-controls="voice-panel">Voice</button>
|
||||||
<button id="write-mode" class="capture-mode" type="button" role="tab" aria-selected="false" aria-controls="write-panel">Write</button>
|
<button id="write-mode" class="capture-mode" type="button" role="tab" aria-selected="false" aria-controls="write-panel">Write</button>
|
||||||
|
<button id="read-mode" class="capture-mode" type="button" role="tab" aria-selected="false" aria-controls="archive-section">Read</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="voice-panel" role="tabpanel" aria-labelledby="voice-mode">
|
<div id="voice-panel" role="tabpanel" aria-labelledby="voice-mode">
|
||||||
<div class="recorder-row">
|
<div class="recorder-row">
|
||||||
@@ -54,7 +83,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="queue-section" aria-labelledby="queue-title">
|
<section id="queue-section" class="queue-section" aria-labelledby="queue-title">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div><span class="section-kicker">ON THIS DEVICE</span><h2 id="queue-title">Upload queue <span id="queue-count">0</span></h2></div>
|
<div><span class="section-kicker">ON THIS DEVICE</span><h2 id="queue-title">Upload queue <span id="queue-count">0</span></h2></div>
|
||||||
<button id="sync-button" class="icon-button" type="button" title="Sync queued recordings" aria-label="Sync queued recordings">↻</button>
|
<button id="sync-button" class="icon-button" type="button" title="Sync queued recordings" aria-label="Sync queued recordings">↻</button>
|
||||||
@@ -63,7 +92,7 @@
|
|||||||
<p id="queue-empty" class="empty-state">New recordings wait here until the server confirms they’re stored.</p>
|
<p id="queue-empty" class="empty-state">New recordings wait here until the server confirms they’re stored.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="archive-section" aria-labelledby="archive-title">
|
<section id="archive-section" class="archive-section" aria-labelledby="archive-title">
|
||||||
<div class="section-heading archive-heading">
|
<div class="section-heading archive-heading">
|
||||||
<div><span class="section-kicker">SERVER STORED</span><h2 id="archive-title">Saved notes</h2></div>
|
<div><span class="section-kicker">SERVER STORED</span><h2 id="archive-title">Saved notes</h2></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+34
-2
@@ -16,6 +16,7 @@
|
|||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--sans); }
|
body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--sans); }
|
||||||
.topbar { height: 68px; padding: 0 max(24px, calc((100vw - 1040px) / 2)); border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; }
|
.topbar { height: 68px; padding: 0 max(24px, calc((100vw - 1040px) / 2)); border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.topbar-actions { display: flex; align-items: center; gap: 18px; }
|
||||||
.brand { display: flex; align-items: center; gap: 10px; text-decoration: none; color: var(--ink); font-size: 15px; font-weight: 600; }
|
.brand { display: flex; align-items: center; gap: 10px; text-decoration: none; color: var(--ink); font-size: 15px; font-weight: 600; }
|
||||||
.brand b { color: var(--moss); }
|
.brand b { color: var(--moss); }
|
||||||
.brand-mark { width: 28px; height: 28px; flex: 0 0 auto; border-radius: 50%; background: var(--moss-dark); color: #fff; display: grid; place-items: center; font: 500 15px var(--serif); }
|
.brand-mark { width: 28px; height: 28px; flex: 0 0 auto; border-radius: 50%; background: var(--moss-dark); color: #fff; display: grid; place-items: center; font: 500 15px var(--serif); }
|
||||||
@@ -27,6 +28,15 @@ main { max-width: 1040px; margin: auto; padding: 0 24px; }
|
|||||||
.capture-modes { display: flex; width: fit-content; margin: 0 auto 24px; border-bottom: 1px solid var(--line); }
|
.capture-modes { display: flex; width: fit-content; margin: 0 auto 24px; border-bottom: 1px solid var(--line); }
|
||||||
.capture-mode { min-width: 108px; min-height: 40px; border: 0; border-bottom: 2px solid transparent; padding: 0 16px; color: var(--muted); background: transparent; font: 12px var(--mono); cursor: pointer; }
|
.capture-mode { min-width: 108px; min-height: 40px; border: 0; border-bottom: 2px solid transparent; padding: 0 16px; color: var(--muted); background: transparent; font: 12px var(--mono); cursor: pointer; }
|
||||||
.capture-mode.active { border-bottom-color: var(--moss); color: var(--moss-dark); }
|
.capture-mode.active { border-bottom-color: var(--moss); color: var(--moss-dark); }
|
||||||
|
.reading .capture { padding-bottom: 0; border-bottom: 0; background-image: none; }
|
||||||
|
.reading #queue-section { display: none; }
|
||||||
|
.reading #archive-section { min-height: calc(100vh - 190px); padding-top: 24px; border-top: 1px solid var(--line); }
|
||||||
|
.reading .archive-list { margin-top: 24px; }
|
||||||
|
.reading .archive-item { padding: 22px 0; }
|
||||||
|
.reading .archive-transcript { min-height: 180px; resize: vertical; border: 1px solid var(--line); padding: 14px; background: #fffefa; }
|
||||||
|
.original-transcript { margin: 0 0 12px; color: var(--muted); font-size: 11px; }
|
||||||
|
.original-transcript summary { width: fit-content; font: 10px var(--mono); cursor: pointer; }
|
||||||
|
.original-transcript p { margin: 9px 0 0; color: var(--ink); font: 13px/1.55 var(--sans); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
.write-panel { max-width: 590px; margin: auto; }
|
.write-panel { max-width: 590px; margin: auto; }
|
||||||
.write-label { display: block; margin: 0 0 10px; font: 500 20px var(--serif); }
|
.write-label { display: block; margin: 0 0 10px; font: 500 20px var(--serif); }
|
||||||
.written-note { display: block; width: 100%; min-height: 190px; resize: vertical; border: 1px solid var(--line); padding: 14px; color: var(--ink); background: #fffefa; font: 14px/1.6 var(--sans); }
|
.written-note { display: block; width: 100%; min-height: 190px; resize: vertical; border: 1px solid var(--line); padding: 14px; color: var(--ink); background: #fffefa; font: 14px/1.6 var(--sans); }
|
||||||
@@ -57,7 +67,7 @@ h1 em { color: var(--moss); font-weight: 500; }
|
|||||||
.capture-message { min-height: 20px; max-width: 590px; margin: 8px auto 0; padding-top: 6px; color: var(--muted); font-size: 11px; text-align: center; }
|
.capture-message { min-height: 20px; max-width: 590px; margin: 8px auto 0; padding-top: 6px; color: var(--muted); font-size: 11px; text-align: center; }
|
||||||
.capture-message.upload-confirmed { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; color: forestgreen; font-size: 16px; }
|
.capture-message.upload-confirmed { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; color: forestgreen; font-size: 16px; }
|
||||||
.capture-message.upload-confirmed::after { content: '✓'; color: forestgreen; font-size: 1.05em; line-height: 1; }
|
.capture-message.upload-confirmed::after { content: '✓'; color: forestgreen; font-size: 1.05em; line-height: 1; }
|
||||||
.audio-upload-row { max-width: 590px; margin: 14px auto 6px; display: flex; align-items: center; justify-content: center; gap: 12px; }
|
.audio-upload-row { max-width: 590px; margin: 10px auto 10 px; display: flex; align-items: center; justify-content: center; gap: 12px; }
|
||||||
.audio-upload-button { display: inline-flex; min-height: 36px; align-items: center; gap: 7px; border: 1px solid var(--line); padding: 0 12px; color: var(--moss-dark); background: #fffefa; font-size: 12px; cursor: pointer; }
|
.audio-upload-button { display: inline-flex; min-height: 36px; align-items: center; gap: 7px; border: 1px solid var(--line); padding: 0 12px; color: var(--moss-dark); background: #fffefa; font-size: 12px; cursor: pointer; }
|
||||||
.audio-upload-button:hover { border-color: var(--moss); }
|
.audio-upload-button:hover { border-color: var(--moss); }
|
||||||
#audio-file-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; clip-path: inset(50%); }
|
#audio-file-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; clip-path: inset(50%); }
|
||||||
@@ -68,6 +78,23 @@ h1 em { color: var(--moss); font-weight: 500; }
|
|||||||
h2 { margin: 8px 0 0; font: 500 25px/1.1 var(--serif); }
|
h2 { margin: 8px 0 0; font: 500 25px/1.1 var(--serif); }
|
||||||
#queue-count { display: inline-grid; place-items: center; width: 23px; height: 23px; margin-left: 5px; border-radius: 50%; background: #e6e7df; color: var(--moss-dark); font: 11px var(--mono); vertical-align: 3px; }
|
#queue-count { display: inline-grid; place-items: center; width: 23px; height: 23px; margin-left: 5px; border-radius: 50%; background: #e6e7df; color: var(--moss-dark); font: 11px var(--mono); vertical-align: 3px; }
|
||||||
.icon-button { position: absolute; right: 0; width: 38px; height: 38px; border: 1px solid var(--line); background: transparent; color: var(--moss-dark); font-size: 22px; cursor: pointer; }
|
.icon-button { position: absolute; right: 0; width: 38px; height: 38px; border: 1px solid var(--line); background: transparent; color: var(--moss-dark); font-size: 22px; cursor: pointer; }
|
||||||
|
.settings-button { position: static; flex: 0 0 auto; font-size: 18px; }
|
||||||
|
.settings-dialog { width: min(520px, calc(100% - 32px)); max-height: min(720px, calc(100% - 32px)); border: 1px solid var(--line); padding: 24px; color: var(--ink); background: var(--paper); }
|
||||||
|
.settings-dialog::backdrop { background: rgb(32 39 36 / 45%); }
|
||||||
|
.dialog-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||||
|
.dialog-heading h2 { margin: 0; }
|
||||||
|
.dialog-close { position: static; flex: 0 0 auto; font-size: 24px; }
|
||||||
|
.settings-copy { margin: 16px 0; color: #606b63; font-size: 12px; line-height: 1.5; }
|
||||||
|
#backup-fields { display: grid; gap: 8px; margin: 0; border: 0; padding: 0; }
|
||||||
|
#backup-fields:disabled { opacity: .62; }
|
||||||
|
#backup-fields > label { margin-top: 6px; font: 10px var(--mono); color: var(--muted); }
|
||||||
|
#backup-fields > input { width: 100%; min-width: 0; height: 40px; border: 1px solid var(--line); padding: 0 10px; color: var(--ink); background: #fffefa; font: 12px var(--sans); }
|
||||||
|
#backup-fields > input:focus { outline: 2px solid var(--moss); outline-offset: 1px; }
|
||||||
|
.clear-password { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.settings-actions { display: flex; flex-wrap: wrap; gap: 9px; margin-top: 8px; }
|
||||||
|
.secondary-button { min-height: 40px; border: 1px solid var(--line); padding: 0 13px; color: var(--moss-dark); background: transparent; font: 11px var(--mono); cursor: pointer; }
|
||||||
|
.backup-status { min-height: 20px; margin: 16px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||||
|
.retry-backup { margin-top: 8px; }
|
||||||
.icon-button:disabled { opacity: .4; cursor: wait; }
|
.icon-button:disabled { opacity: .4; cursor: wait; }
|
||||||
.queue-list, .archive-list { list-style: none; margin: 16px 0 0; padding: 0; }
|
.queue-list, .archive-list { list-style: none; margin: 16px 0 0; padding: 0; }
|
||||||
.queue-item, .archive-item { display: flex; align-items: flex-start; gap: 15px; padding: 14px 0; border-top: 1px solid #e3e4dd; animation: reveal .35s ease both; }
|
.queue-item, .archive-item { display: flex; align-items: flex-start; gap: 15px; padding: 14px 0; border-top: 1px solid #e3e4dd; animation: reveal .35s ease both; }
|
||||||
@@ -87,13 +114,17 @@ h2 { margin: 8px 0 0; font: 500 25px/1.1 var(--serif); }
|
|||||||
.archive-transcript:focus { border-bottom-color: var(--moss); }
|
.archive-transcript:focus { border-bottom-color: var(--moss); }
|
||||||
.save-transcript { margin-top: 7px; border: 0; padding: 0; color: var(--moss-dark); background: transparent; font: 11px var(--mono); cursor: pointer; }
|
.save-transcript { margin-top: 7px; border: 0; padding: 0; color: var(--moss-dark); background: transparent; font: 11px var(--mono); cursor: pointer; }
|
||||||
.archive-status { color: var(--muted); font: 10px var(--mono); margin-top: 7px; }
|
.archive-status { color: var(--muted); font: 10px var(--mono); margin-top: 7px; }
|
||||||
|
.transcription-progress { width: min(240px, 100%); height: 3px; margin-top: 9px; overflow: hidden; background: #e4e3dc; }
|
||||||
|
.transcription-progress span { display: block; width: 34%; height: 100%; background: var(--moss); animation: transcription-progress 1.4s ease-in-out infinite; }
|
||||||
footer { max-width: 1040px; margin: auto; padding: 18px 24px calc(18px + env(safe-area-inset-bottom)); display: flex; justify-content: space-between; gap: 20px; color: var(--muted); font: 9px var(--mono); }
|
footer { max-width: 1040px; margin: auto; padding: 18px 24px calc(18px + env(safe-area-inset-bottom)); display: flex; justify-content: space-between; gap: 20px; color: var(--muted); font: 9px var(--mono); }
|
||||||
@keyframes reveal { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
|
@keyframes reveal { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
@keyframes transcription-progress { from { transform: translateX(-110%); } to { transform: translateX(300%); } }
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.topbar { height: 58px; padding: 0 18px; }
|
.topbar { height: 58px; padding: 0 18px; }
|
||||||
|
.topbar-actions { gap: 10px; }
|
||||||
main { padding: 0 18px; }
|
main { padding: 0 18px; }
|
||||||
.capture { padding: 38px 0 30px; background-position: right 8px top 25px; }
|
.capture { padding: 38px 0 30px; background-position: right 8px top 25px; }
|
||||||
.capture-mode { min-width: 100px; }
|
.capture-mode { min-width: 86px; padding-inline: 12px; }
|
||||||
.write-actions { align-items: flex-start; }
|
.write-actions { align-items: flex-start; }
|
||||||
h1 { font-size: 48px; }
|
h1 { font-size: 48px; }
|
||||||
.recorder-row { gap: 12px; padding-right: 12px; }
|
.recorder-row { gap: 12px; padding-right: 12px; }
|
||||||
@@ -105,5 +136,6 @@ footer { max-width: 1040px; margin: auto; padding: 18px 24px calc(18px + env(saf
|
|||||||
h2 { font-size: 23px; }
|
h2 { font-size: 23px; }
|
||||||
.archive-date { width: 60px; font-size: 9px; }
|
.archive-date { width: 60px; font-size: 9px; }
|
||||||
footer { padding-left: 18px; padding-right: 18px; font-size: 8px; }
|
footer { padding-left: 18px; padding-right: 18px; font-size: 8px; }
|
||||||
|
.settings-dialog { padding: 18px; }
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } }
|
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } }
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = 'faerro-kb-shell-v3';
|
const CACHE_NAME = 'faerro-kb-shell-v4';
|
||||||
const APP_SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/favicon.svg'];
|
const APP_SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/favicon.svg'];
|
||||||
|
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user