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
+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',
'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
View File
@@ -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">
+20
View File
@@ -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
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'];
self.addEventListener('install', (event) => {