Implement WebDAV backup feature: add configuration options, API endpoints, and UI for managing database backups
This commit is contained in:
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
```sh
|
||||
|
||||
+224
-1
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -9,6 +10,7 @@ import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
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"))
|
||||
AUDIO_DIR = DATA_DIR / "audio"
|
||||
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()
|
||||
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"
|
||||
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)
|
||||
|
||||
@@ -53,6 +61,15 @@ def initialize_db() -> None:
|
||||
"""CREATE VIRTUAL TABLE IF NOT EXISTS note_search
|
||||
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)")}
|
||||
if "original_audio_path" not in columns:
|
||||
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 (?, ?)",
|
||||
(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:
|
||||
@@ -175,6 +331,12 @@ async def test_whisper_connection() -> bool:
|
||||
|
||||
@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'"
|
||||
@@ -210,6 +372,67 @@ def health() -> dict[str, str]:
|
||||
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)
|
||||
async def create_note(
|
||||
background_tasks: BackgroundTasks,
|
||||
@@ -367,7 +590,7 @@ def delete_note(note_id: str) -> dict[str, str]:
|
||||
|
||||
|
||||
@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()
|
||||
with connect_db() as connection:
|
||||
note = connection.execute("SELECT status FROM notes WHERE id = ?", (note_id,)).fetchone()
|
||||
|
||||
@@ -6,6 +6,9 @@ services:
|
||||
environment:
|
||||
DATA_DIR: /data
|
||||
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:
|
||||
- ./data:/data
|
||||
restart: unless-stopped
|
||||
@@ -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',
|
||||
'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',
|
||||
'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)]));
|
||||
|
||||
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) {
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
||||
|
||||
+29
-1
@@ -14,9 +14,37 @@
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<section class="capture" aria-labelledby="capture-title">
|
||||
<div class="capture-modes" role="tablist" aria-label="Note type">
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* { box-sizing: border-box; }
|
||||
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-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 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); }
|
||||
@@ -68,6 +69,23 @@ h1 em { color: var(--moss); font-weight: 500; }
|
||||
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; }
|
||||
.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; }
|
||||
.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; }
|
||||
@@ -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%); } }
|
||||
@media (max-width: 600px) {
|
||||
.topbar { height: 58px; padding: 0 18px; }
|
||||
.topbar-actions { gap: 10px; }
|
||||
main { padding: 0 18px; }
|
||||
.capture { padding: 38px 0 30px; background-position: right 8px top 25px; }
|
||||
.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; }
|
||||
.archive-date { width: 60px; font-size: 9px; }
|
||||
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; } }
|
||||
+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'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
|
||||
Reference in New Issue
Block a user