Implement WebDAV backup feature: add configuration options, API endpoints, and UI for managing database backups

This commit is contained in:
2026-09-28 16:37:37 +02:00
parent 01f2a92a84
commit 229b8d1ab1
7 changed files with 373 additions and 3 deletions
+6
View File
@@ -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. 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
+224 -1
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import json
import logging import logging
import os import os
import re import re
@@ -9,6 +10,7 @@ import time
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from urllib.parse import urlsplit
import httpx import httpx
from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, UploadFile from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, UploadFile
@@ -18,10 +20,16 @@ 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" WHISPER_TEST_AUDIO_PATH = Path(__file__).resolve().parent.parent / "audio test.wav"
TRANSCRIPTION_TASKS: set[asyncio.Task[None]] = set() TRANSCRIPTION_TASKS: set[asyncio.Task[None]] = set()
BACKUP_TASK: asyncio.Task[None] | None = None
BACKUP_LOCK = asyncio.Lock()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("httpx").setLevel(logging.WARNING)
@@ -53,6 +61,15 @@ 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")
@@ -86,6 +103,145 @@ 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"
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
async with httpx.AsyncClient(timeout=120, auth=auth) as client:
response = await client.put(target_url, content=file_chunks(snapshot_path))
response.raise_for_status()
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:
@@ -175,6 +331,12 @@ async def test_whisper_connection() -> bool:
@app.on_event("startup") @app.on_event("startup")
async def resume_pending_transcriptions() -> None: 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: with connect_db() as connection:
connection.execute( connection.execute(
"UPDATE notes SET status = 'awaiting_transcription' WHERE status = 'transcribing'" "UPDATE notes SET status = 'awaiting_transcription' WHERE status = 'transcribing'"
@@ -210,6 +372,67 @@ 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")
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.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 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)
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,
@@ -367,7 +590,7 @@ 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 status FROM notes WHERE id = ?", (note_id,)).fetchone()
+3
View File
@@ -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
+90
View File
@@ -7,6 +7,8 @@ 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', 'voice-mode', 'write-mode', 'voice-panel', 'write-panel', 'written-note', 'draft-status', 'save-written-note',
'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;
@@ -154,6 +156,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')}`;
+29 -1
View File
@@ -14,9 +14,37 @@
<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. 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">
+20
View File
@@ -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); }
@@ -68,6 +69,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; }
@@ -94,6 +112,7 @@ footer { max-width: 1040px; margin: auto; padding: 18px 24px calc(18px + env(saf
@keyframes transcription-progress { from { transform: translateX(-110%); } to { transform: translateX(300%); } } @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: 100px; }
@@ -108,5 +127,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
View File
@@ -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) => {