Replace logbook backup v1 JSON with v2 ZIP archives.

ZIP .daagbok files use a compact manifest and binary KDAB blobs so large
photo, voice, and GPS payloads no longer inflate in a single JSON file.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 15:13:51 +02:00
parent 975c7a2e40
commit 6f28ea0b16
16 changed files with 1360 additions and 396 deletions
+1
View File
@@ -12,6 +12,7 @@
"bip39": "^3.1.0", "bip39": "^3.1.0",
"dexie": "^4.4.2", "dexie": "^4.4.2",
"dexie-react-hooks": "^4.4.0", "dexie-react-hooks": "^4.4.0",
"fflate": "^0.8.3",
"i18next": "^26.3.0", "i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
+3 -2
View File
@@ -22,15 +22,16 @@
"bip39": "^3.1.0", "bip39": "^3.1.0",
"dexie": "^4.4.2", "dexie": "^4.4.2",
"dexie-react-hooks": "^4.4.0", "dexie-react-hooks": "^4.4.0",
"fflate": "^0.8.3",
"i18next": "^26.3.0", "i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"qrcode": "^1.5.4",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-i18next": "^17.0.8", "react-i18next": "^17.0.8"
"qrcode": "^1.5.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
+58 -9
View File
@@ -5,10 +5,12 @@ import { useDialog } from './ModalDialog.tsx'
import { import {
downloadBackupBlob, downloadBackupBlob,
exportLogbookBackup, exportLogbookBackup,
formatBackupBytes,
parseLogbookBackupFile, parseLogbookBackupFile,
previewLogbookBackup, previewLogbookBackup,
restoreLogbookBackup, restoreLogbookBackup,
type LogbookBackupFile, BACKUP_SIZE_CONFIRM_BYTES,
type ParsedLogbookBackup,
type LogbookBackupPreview type LogbookBackupPreview
} from '../services/logbookBackup.js' } from '../services/logbookBackup.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -27,6 +29,12 @@ function mapBackupError(code: string, t: (key: string) => string): string {
return t('settings.backup_not_owner') return t('settings.backup_not_owner')
case 'BACKUP_INVALID_JSON': case 'BACKUP_INVALID_JSON':
return t('settings.backup_invalid_json') return t('settings.backup_invalid_json')
case 'BACKUP_INVALID_ARCHIVE':
return t('settings.backup_invalid_archive')
case 'BACKUP_VERSION_UNSUPPORTED':
return t('settings.backup_version_unsupported')
case 'BACKUP_WRONG_PASSPHRASE':
return t('settings.backup_wrong_passphrase')
case 'BACKUP_INVALID_FORMAT': case 'BACKUP_INVALID_FORMAT':
return t('settings.backup_invalid_format') return t('settings.backup_invalid_format')
case 'BACKUP_NOT_AUTHENTICATED': case 'BACKUP_NOT_AUTHENTICATED':
@@ -53,9 +61,10 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
const [importPassphrase, setImportPassphrase] = useState('') const [importPassphrase, setImportPassphrase] = useState('')
const [importFile, setImportFile] = useState<File | null>(null) const [importFile, setImportFile] = useState<File | null>(null)
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null) const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
const [parsedBackup, setParsedBackup] = useState<LogbookBackupFile | null>(null) const [parsedBackup, setParsedBackup] = useState<ParsedLogbookBackup | null>(null)
const [importing, setImporting] = useState(false) const [importing, setImporting] = useState(false)
const [previewing, setPreviewing] = useState(false) const [previewing, setPreviewing] = useState(false)
const [exportProgress, setExportProgress] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null) const [success, setSuccess] = useState<string | null>(null)
@@ -83,21 +92,36 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
} }
setExporting(true) setExporting(true)
setExportProgress(null)
try { try {
const { blob, filename, backup } = await exportLogbookBackup(logbookId, exportPassphrase) const { blob, filename, manifest } = await exportLogbookBackup(logbookId, exportPassphrase, {
onProgress: (p) => {
if (p.phase === 'pack') {
setExportProgress(
t('settings.backup_export_progress', {
current: p.current,
total: p.total
})
)
}
}
})
downloadBackupBlob(blob, filename) downloadBackupBlob(blob, filename)
setSuccess(t('settings.backup_export_success', { count: backup.counts.entries })) setSuccess(t('settings.backup_export_success', { count: manifest.counts.entries }))
setExportPassphrase('') setExportPassphrase('')
setExportConfirm('') setExportConfirm('')
trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, { trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, {
entries: backup.counts.entries, entries: manifest.counts.entries,
photos: backup.counts.photos photos: manifest.counts.photos,
voiceMemos: manifest.counts.voiceMemos,
bytes: manifest.totalUncompressedBytes
}) })
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
setError(mapBackupError(message, t)) setError(mapBackupError(message, t))
} finally { } finally {
setExporting(false) setExporting(false)
setExportProgress(null)
} }
} }
@@ -138,6 +162,18 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => { const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
if (!parsedBackup || !importPassphrase) return if (!parsedBackup || !importPassphrase) return
if (parsedBackup.manifest.totalUncompressedBytes > BACKUP_SIZE_CONFIRM_BYTES) {
const ok = await showConfirm(
t('settings.backup_import_size_confirm', {
size: formatBackupBytes(parsedBackup.manifest.totalUncompressedBytes)
}),
t('settings.backup_restore_title'),
t('logs.confirm_yes'),
t('logs.confirm_no')
)
if (!ok) return
}
setImporting(true) setImporting(true)
setError(null) setError(null)
try { try {
@@ -149,8 +185,10 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
setParsedBackup(null) setParsedBackup(null)
if (fileInputRef.current) fileInputRef.current.value = '' if (fileInputRef.current) fileInputRef.current.value = ''
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, { trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
entries: parsedBackup.counts.entries, entries: parsedBackup.manifest.counts.entries,
photos: parsedBackup.counts.photos, photos: parsedBackup.manifest.counts.photos,
voiceMemos: parsedBackup.manifest.counts.voiceMemos,
bytes: parsedBackup.manifest.totalUncompressedBytes,
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id' mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
}) })
onRestored?.(result.logbookId, result.title) onRestored?.(result.logbookId, result.title)
@@ -258,6 +296,11 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
<Download size={16} /> <Download size={16} />
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')} {exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
</button> </button>
{exportProgress && (
<p className="text-muted backup-export-progress" role="status">
{exportProgress}
</p>
)}
</form> </form>
</section> </section>
@@ -275,7 +318,7 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
id="backup-import-file" id="backup-import-file"
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept=".daagbok.json,application/json" accept=".daagbok,application/zip"
className="input-text" className="input-text"
onChange={handleFileChange} onChange={handleFileChange}
disabled={importing} disabled={importing}
@@ -330,8 +373,14 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
<ul className="backup-preview-stats"> <ul className="backup-preview-stats">
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li> <li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li> <li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
<li>{t('settings.backup_stat_voice', { count: importPreview.counts.voiceMemos })}</li>
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li> <li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li> <li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
<li className="text-muted">
{t('settings.backup_stat_size', {
size: formatBackupBytes(importPreview.totalUncompressedBytes)
})}
</li>
</ul> </ul>
<p className="text-muted backup-preview-date"> <p className="text-muted backup-preview-date">
{t('settings.backup_exported_at', { {t('settings.backup_exported_at', {
+10 -4
View File
@@ -495,7 +495,7 @@
"new_logbook_placeholder": "Navn på logbog eller yacht", "new_logbook_placeholder": "Navn på logbog eller yacht",
"logout": "Log ud", "logout": "Log ud",
"logged_in_as": "Logget ind som {{name}}", "logged_in_as": "Logget ind som {{name}}",
"delete_confirm": "Er du sikker på, at du vil slette denne logbog permanent? Alle lokale data og serverkopier vil blive destrueret.\n\nTip: Lav en sikkerhedskopi (.daagbok.json) på forhånd under Indstillinger → Sikkerhedskopiering og gendannelse, hvis du vil beholde dataene senere.", "delete_confirm": "Er du sikker på, at du vil slette denne logbog permanent? Alle lokale data og serverkopier vil blive destrueret.\n\nTip: Lav en sikkerhedskopi (.daagbok) på forhånd under Indstillinger → Sikkerhedskopiering og gendannelse, hvis du vil beholde dataene senere.",
"no_logbooks": "Ingen logbøger fundet. Opret din første logbog for at komme i gang!", "no_logbooks": "Ingen logbøger fundet. Opret din første logbog for at komme i gang!",
"loading": "Logbøgerne er fyldt op...", "loading": "Logbøgerne er fyldt op...",
"status_synced": "Synkroniseret", "status_synced": "Synkroniseret",
@@ -774,7 +774,7 @@
"delete_account_confirm_yes": "Ja, slet konto og alle data", "delete_account_confirm_yes": "Ja, slet konto og alle data",
"delete_account_confirm_no": "Annuller", "delete_account_confirm_no": "Annuller",
"delete_account_failed": "Kontoen kunne ikke slettes. Prøv venligst igen.", "delete_account_failed": "Kontoen kunne ikke slettes. Prøv venligst igen.",
"delete_backup_hint": "Tip: Lav sikkerhedskopier af dine logbøger (.daagbok.json) i indstillingerne for hver logbog, før du sletter dem.", "delete_backup_hint": "Tip: Lav sikkerhedskopier af dine logbøger (.daagbok) i indstillingerne for hver logbog, før du sletter dem.",
"deleting_account": "Kontoen vil blive slettet...", "deleting_account": "Kontoen vil blive slettet...",
"invite_push_prompt_title": "Aktivere push-meddelelser?", "invite_push_prompt_title": "Aktivere push-meddelelser?",
"invite_push_prompt_message": "Så snart inviterede Crew-medlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.", "invite_push_prompt_message": "Så snart inviterede Crew-medlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.",
@@ -785,7 +785,7 @@
"backup_title": "Sikkerhedskopiering og gendannelse", "backup_title": "Sikkerhedskopiering og gendannelse",
"backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, crew, skib). Beskyttet med backup-passphrase - til gendannelse til denne eller en ny konto.", "backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, crew, skib). Beskyttet med backup-passphrase - til gendannelse til denne eller en ny konto.",
"backup_export_title": "Opret backup", "backup_export_title": "Opret backup",
"backup_export_desc": "Downloader alle lokale data som .daagbok.json. Hold filen og adgangssætningen adskilt og sikker.", "backup_export_desc": "Downloader alle lokale data som et komprimeret .daagbok-arkiv. Hold filen og adgangssætningen adskilt og sikker.",
"backup_restore_title": "Gendan sikkerhedskopi", "backup_restore_title": "Gendan sikkerhedskopi",
"backup_restore_desc": "Gendanner en sikkerhedskopi til din nuværende konto - selv efter registrering af en ny konto.", "backup_restore_desc": "Gendanner en sikkerhedskopi til din nuværende konto - selv efter registrering af en ny konto.",
"backup_passphrase": "Backup-passphrase", "backup_passphrase": "Backup-passphrase",
@@ -797,7 +797,13 @@
"backup_export_btn": "Download backup", "backup_export_btn": "Download backup",
"backup_exporting": "Sikkerhedskopien er oprettet...", "backup_exporting": "Sikkerhedskopien er oprettet...",
"backup_export_success": "Backup oprettet ({{count}} rejsedage).", "backup_export_success": "Backup oprettet ({{count}} rejsedage).",
"backup_file_label": "Backup-fil (.daagbok.json)", "backup_file_label": "Backup-fil (.daagbok)",
"backup_export_progress": "Pakker filer {{current}} / {{total}}…",
"backup_invalid_archive": "Filen er ikke et gyldigt backup-arkiv.",
"backup_version_unsupported": "Gammelt backup-format (v1). Brug en aktuel .daagbok-backup.",
"backup_import_size_confirm": "Denne backup er ca. {{size}} ukomprimeret. Gendannelse kan tage længere tid. Fortsæt?",
"backup_stat_voice": "{{count}} stemmenotater",
"backup_stat_size": "Ca. {{size}} ukomprimeret",
"backup_preview_btn": "Tjek indhold", "backup_preview_btn": "Tjek indhold",
"backup_previewing": "Tjek...", "backup_previewing": "Tjek...",
"backup_restore_btn": "Gendan", "backup_restore_btn": "Gendan",
+11 -5
View File
@@ -495,7 +495,7 @@
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht", "new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
"logout": "Abmelden", "logout": "Abmelden",
"logged_in_as": "Angemeldet als {{name}}", "logged_in_as": "Angemeldet als {{name}}",
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok.json), falls du die Daten später behalten möchtest.", "delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok), falls du die Daten später behalten möchtest.",
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!", "no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
"loading": "Logbücher werden geladen...", "loading": "Logbücher werden geladen...",
"status_synced": "Synchronisiert", "status_synced": "Synchronisiert",
@@ -774,7 +774,7 @@
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen", "delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
"delete_account_confirm_no": "Abbrechen", "delete_account_confirm_no": "Abbrechen",
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.", "delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.", "delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok) in den Einstellungen jedes Logbuchs.",
"deleting_account": "Konto wird gelöscht…", "deleting_account": "Konto wird gelöscht…",
"invite_push_prompt_title": "Push-Benachrichtigungen aktivieren?", "invite_push_prompt_title": "Push-Benachrichtigungen aktivieren?",
"invite_push_prompt_message": "Sobald eingeladene Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Es werden keine Logbuch-Inhalte im Klartext gesendet.", "invite_push_prompt_message": "Sobald eingeladene Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Es werden keine Logbuch-Inhalte im Klartext gesendet.",
@@ -783,9 +783,9 @@
"invite_push_prompt_later": "Später", "invite_push_prompt_later": "Später",
"invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.", "invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
"backup_title": "Backup & Wiederherstellung", "backup_title": "Backup & Wiederherstellung",
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.", "backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, Sprachnotizen, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
"backup_export_title": "Backup erstellen", "backup_export_title": "Backup erstellen",
"backup_export_desc": "Lädt alle lokalen Daten als .daagbok.json herunter. Bewahre Datei und Passphrase getrennt und sicher auf.", "backup_export_desc": "Lädt alle lokalen Daten als komprimierte .daagbok-Datei herunter. Bewahre Datei und Passphrase getrennt und sicher auf.",
"backup_restore_title": "Backup wiederherstellen", "backup_restore_title": "Backup wiederherstellen",
"backup_restore_desc": "Stellt ein Backup in deinem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.", "backup_restore_desc": "Stellt ein Backup in deinem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
"backup_passphrase": "Backup-Passphrase", "backup_passphrase": "Backup-Passphrase",
@@ -797,7 +797,13 @@
"backup_export_btn": "Backup herunterladen", "backup_export_btn": "Backup herunterladen",
"backup_exporting": "Backup wird erstellt…", "backup_exporting": "Backup wird erstellt…",
"backup_export_success": "Backup erstellt ({{count}} Reisetage).", "backup_export_success": "Backup erstellt ({{count}} Reisetage).",
"backup_file_label": "Backup-Datei (.daagbok.json)", "backup_file_label": "Backup-Datei (.daagbok)",
"backup_export_progress": "Packe Dateien {{current}} / {{total}}…",
"backup_invalid_archive": "Die Datei ist kein gültiges Backup-Archiv.",
"backup_version_unsupported": "Altes Backup-Format (v1). Bitte ein aktuelles .daagbok-Backup verwenden.",
"backup_import_size_confirm": "Dieses Backup ist etwa {{size}} groß. Wiederherstellung kann auf dem Gerät länger dauern und viel Speicher belegen. Fortfahren?",
"backup_stat_voice": "{{count}} Sprachnotizen",
"backup_stat_size": "Unkomprimiert ca. {{size}}",
"backup_preview_btn": "Inhalt prüfen", "backup_preview_btn": "Inhalt prüfen",
"backup_previewing": "Prüfe…", "backup_previewing": "Prüfe…",
"backup_restore_btn": "Wiederherstellen", "backup_restore_btn": "Wiederherstellen",
+11 -5
View File
@@ -495,7 +495,7 @@
"new_logbook_placeholder": "Logbook or Yacht Name", "new_logbook_placeholder": "Logbook or Yacht Name",
"logout": "Logout", "logout": "Logout",
"logged_in_as": "Signed in as {{name}}", "logged_in_as": "Signed in as {{name}}",
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok.json) if you may need the data later.", "delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok) if you may need the data later.",
"no_logbooks": "No logbooks found. Create your first logbook to begin!", "no_logbooks": "No logbooks found. Create your first logbook to begin!",
"loading": "Loading logbooks...", "loading": "Loading logbooks...",
"status_synced": "Synced", "status_synced": "Synced",
@@ -774,7 +774,7 @@
"delete_account_confirm_yes": "Yes, Delete Account and All Data", "delete_account_confirm_yes": "Yes, Delete Account and All Data",
"delete_account_confirm_no": "Cancel", "delete_account_confirm_no": "Cancel",
"delete_account_failed": "Failed to delete account. Please try again.", "delete_account_failed": "Failed to delete account. Please try again.",
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.", "delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok) in each logbook's settings.",
"deleting_account": "Deleting account…", "deleting_account": "Deleting account…",
"invite_push_prompt_title": "Enable push notifications?", "invite_push_prompt_title": "Enable push notifications?",
"invite_push_prompt_message": "When invited crew members sync changes, you can be notified via push. No logbook content is sent in plain text.", "invite_push_prompt_message": "When invited crew members sync changes, you can be notified via push. No logbook content is sent in plain text.",
@@ -783,9 +783,9 @@
"invite_push_prompt_later": "Later", "invite_push_prompt_later": "Later",
"invite_push_prompt_success": "Push notifications are active on this device.", "invite_push_prompt_success": "Push notifications are active on this device.",
"backup_title": "Backup & restore", "backup_title": "Backup & restore",
"backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.", "backup_desc": "Full encrypted backup of this logbook (entries, photos, voice memos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
"backup_export_title": "Create backup", "backup_export_title": "Create backup",
"backup_export_desc": "Downloads all local data as a .daagbok.json file. Keep the file and passphrase separate and secure.", "backup_export_desc": "Downloads all local data as a compressed .daagbok archive. Keep the file and passphrase separate and secure.",
"backup_restore_title": "Restore backup", "backup_restore_title": "Restore backup",
"backup_restore_desc": "Restores a backup into your current account — including after registering a new account.", "backup_restore_desc": "Restores a backup into your current account — including after registering a new account.",
"backup_passphrase": "Backup passphrase", "backup_passphrase": "Backup passphrase",
@@ -797,7 +797,13 @@
"backup_export_btn": "Download backup", "backup_export_btn": "Download backup",
"backup_exporting": "Creating backup…", "backup_exporting": "Creating backup…",
"backup_export_success": "Backup created ({{count}} travel days).", "backup_export_success": "Backup created ({{count}} travel days).",
"backup_file_label": "Backup file (.daagbok.json)", "backup_file_label": "Backup file (.daagbok)",
"backup_export_progress": "Packing files {{current}} / {{total}}…",
"backup_invalid_archive": "The file is not a valid backup archive.",
"backup_version_unsupported": "Legacy backup format (v1). Please use a current .daagbok backup.",
"backup_import_size_confirm": "This backup is about {{size}} uncompressed. Restore may take longer and use significant memory. Continue?",
"backup_stat_voice": "{{count}} voice memos",
"backup_stat_size": "Approx. {{size}} uncompressed",
"backup_preview_btn": "Verify contents", "backup_preview_btn": "Verify contents",
"backup_previewing": "Verifying…", "backup_previewing": "Verifying…",
"backup_restore_btn": "Restore", "backup_restore_btn": "Restore",
+10 -4
View File
@@ -495,7 +495,7 @@
"new_logbook_placeholder": "Navn på loggboken eller båten", "new_logbook_placeholder": "Navn på loggboken eller båten",
"logout": "Logg ut", "logout": "Logg ut",
"logged_in_as": "Innlogget som {{name}}", "logged_in_as": "Innlogget som {{name}}",
"delete_confirm": "Er du sikker på at du vil slette denne loggboken permanent? Alle lokale data og serverkopier vil bli ødelagt.\n\nTips: Lag en sikkerhetskopi (.daagbok.json) på forhånd under Innstillinger → Sikkerhetskopiering og gjenoppretting hvis du ønsker å beholde dataene senere.", "delete_confirm": "Er du sikker på at du vil slette denne loggboken permanent? Alle lokale data og serverkopier vil bli ødelagt.\n\nTips: Lag en sikkerhetskopi (.daagbok) på forhånd under Innstillinger → Sikkerhetskopiering og gjenoppretting hvis du ønsker å beholde dataene senere.",
"no_logbooks": "Ingen loggbøker funnet. Opprett din første loggbok for å komme i gang!", "no_logbooks": "Ingen loggbøker funnet. Opprett din første loggbok for å komme i gang!",
"loading": "Loggbøker er lastet...", "loading": "Loggbøker er lastet...",
"status_synced": "Synkronisert", "status_synced": "Synkronisert",
@@ -774,7 +774,7 @@
"delete_account_confirm_yes": "Ja, slett konto og alle data", "delete_account_confirm_yes": "Ja, slett konto og alle data",
"delete_account_confirm_no": "Avbryt", "delete_account_confirm_no": "Avbryt",
"delete_account_failed": "Kontoen kunne ikke slettes. Vennligst prøv igjen.", "delete_account_failed": "Kontoen kunne ikke slettes. Vennligst prøv igjen.",
"delete_backup_hint": "Tips: Lag sikkerhetskopier av loggbøkene dine (.daagbok.json) i innstillingene for hver loggbok før du sletter dem.", "delete_backup_hint": "Tips: Lag sikkerhetskopier av loggbøkene dine (.daagbok) i innstillingene for hver loggbok før du sletter dem.",
"deleting_account": "Kontoen vil bli slettet...", "deleting_account": "Kontoen vil bli slettet...",
"invite_push_prompt_title": "Aktivere push-varsler?", "invite_push_prompt_title": "Aktivere push-varsler?",
"invite_push_prompt_message": "Så snart inviterte Crew-medlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.", "invite_push_prompt_message": "Så snart inviterte Crew-medlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.",
@@ -785,7 +785,7 @@
"backup_title": "Sikkerhetskopiering og gjenoppretting", "backup_title": "Sikkerhetskopiering og gjenoppretting",
"backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, crew, skip). Beskyttet med sikkerhetskopieringspassord - for gjenoppretting til denne eller en ny konto.", "backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, crew, skip). Beskyttet med sikkerhetskopieringspassord - for gjenoppretting til denne eller en ny konto.",
"backup_export_title": "Opprett sikkerhetskopi", "backup_export_title": "Opprett sikkerhetskopi",
"backup_export_desc": "Laster ned alle lokale data som .daagbok.json. Hold filen og passordfrasen adskilt og sikker.", "backup_export_desc": "Laster ned alle lokale data som et komprimert .daagbok-arkiv. Hold filen og passordfrasen adskilt og sikker.",
"backup_restore_title": "Gjenopprett sikkerhetskopi", "backup_restore_title": "Gjenopprett sikkerhetskopi",
"backup_restore_desc": "Gjenoppretter en sikkerhetskopi til din nåværende konto - selv etter at du har registrert en ny konto.", "backup_restore_desc": "Gjenoppretter en sikkerhetskopi til din nåværende konto - selv etter at du har registrert en ny konto.",
"backup_passphrase": "Passord for sikkerhetskopiering", "backup_passphrase": "Passord for sikkerhetskopiering",
@@ -797,7 +797,13 @@
"backup_export_btn": "Last ned sikkerhetskopi", "backup_export_btn": "Last ned sikkerhetskopi",
"backup_exporting": "Sikkerhetskopien er opprettet...", "backup_exporting": "Sikkerhetskopien er opprettet...",
"backup_export_success": "Sikkerhetskopi opprettet ({{count}} reisedager).", "backup_export_success": "Sikkerhetskopi opprettet ({{count}} reisedager).",
"backup_file_label": "Sikkerhetskopifil (.daagbok.json)", "backup_file_label": "Sikkerhetskopifil (.daagbok)",
"backup_export_progress": "Pakker filer {{current}} / {{total}}…",
"backup_invalid_archive": "Filen er ikke et gyldig backup-arkiv.",
"backup_version_unsupported": "Gammelt backup-format (v1). Bruk en aktuell .daagbok-sikkerhetskopi.",
"backup_import_size_confirm": "Denne sikkerhetskopien er ca. {{size}} ukomprimert. Gjenoppretting kan ta lengre tid. Fortsette?",
"backup_stat_voice": "{{count}} talemeldinger",
"backup_stat_size": "Ca. {{size}} ukomprimert",
"backup_preview_btn": "Sjekk innhold", "backup_preview_btn": "Sjekk innhold",
"backup_previewing": "Sjekk...", "backup_previewing": "Sjekk...",
"backup_restore_btn": "Gjenopprett", "backup_restore_btn": "Gjenopprett",
+10 -4
View File
@@ -495,7 +495,7 @@
"new_logbook_placeholder": "Loggbokens eller båtens namn", "new_logbook_placeholder": "Loggbokens eller båtens namn",
"logout": "Logga ut", "logout": "Logga ut",
"logged_in_as": "Inloggad som {{name}}", "logged_in_as": "Inloggad som {{name}}",
"delete_confirm": "Är du säker på att du vill radera den här loggboken permanent? Alla lokala data och serverkopior kommer att förstöras.\n\nTips: Skapa en säkerhetskopia (.daagbok.json) i förväg under Inställningar → Säkerhetskopiering och återställning om du vill behålla data senare.", "delete_confirm": "Är du säker på att du vill radera den här loggboken permanent? Alla lokala data och serverkopior kommer att förstöras.\n\nTips: Skapa en säkerhetskopia (.daagbok) i förväg under Inställningar → Säkerhetskopiering och återställning om du vill behålla data senare.",
"no_logbooks": "Inga loggböcker hittades. Skapa din första loggbok för att komma igång!", "no_logbooks": "Inga loggböcker hittades. Skapa din första loggbok för att komma igång!",
"loading": "Loggböckerna är fulla...", "loading": "Loggböckerna är fulla...",
"status_synced": "Synkroniserad", "status_synced": "Synkroniserad",
@@ -774,7 +774,7 @@
"delete_account_confirm_yes": "Ja, radera konto och all data", "delete_account_confirm_yes": "Ja, radera konto och all data",
"delete_account_confirm_no": "Avbryt", "delete_account_confirm_no": "Avbryt",
"delete_account_failed": "Kontot kunde inte raderas. Vänligen försök igen.", "delete_account_failed": "Kontot kunde inte raderas. Vänligen försök igen.",
"delete_backup_hint": "Tips: Skapa säkerhetskopior av dina loggböcker (.daagbok.json) i inställningarna för varje loggbok innan du raderar dem.", "delete_backup_hint": "Tips: Skapa säkerhetskopior av dina loggböcker (.daagbok) i inställningarna för varje loggbok innan du raderar dem.",
"deleting_account": "Kontot kommer att raderas...", "deleting_account": "Kontot kommer att raderas...",
"invite_push_prompt_title": "Aktivera push-meddelanden?", "invite_push_prompt_title": "Aktivera push-meddelanden?",
"invite_push_prompt_message": "Så snart inbjudna Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.", "invite_push_prompt_message": "Så snart inbjudna Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.",
@@ -785,7 +785,7 @@
"backup_title": "Säkerhetskopiering och återställning", "backup_title": "Säkerhetskopiering och återställning",
"backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, crew, fartyg). Skyddad med lösenfras för säkerhetskopian - för återställning till detta eller ett nytt konto.", "backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, crew, fartyg). Skyddad med lösenfras för säkerhetskopian - för återställning till detta eller ett nytt konto.",
"backup_export_title": "Skapa säkerhetskopia", "backup_export_title": "Skapa säkerhetskopia",
"backup_export_desc": "Laddar ner alla lokala data som .daagbok.json. Förvara filen och lösenfrasen separat och säkert.", "backup_export_desc": "Laddar ner alla lokala data som ett komprimerat .daagbok-arkiv. Förvara filen och lösenfrasen separat och säkert.",
"backup_restore_title": "Återställ säkerhetskopian", "backup_restore_title": "Återställ säkerhetskopian",
"backup_restore_desc": "Återställer en säkerhetskopia till ditt nuvarande konto - även efter att du har registrerat ett nytt konto.", "backup_restore_desc": "Återställer en säkerhetskopia till ditt nuvarande konto - även efter att du har registrerat ett nytt konto.",
"backup_passphrase": "Lösenord för säkerhetskopiering", "backup_passphrase": "Lösenord för säkerhetskopiering",
@@ -797,7 +797,13 @@
"backup_export_btn": "Ladda ner backup", "backup_export_btn": "Ladda ner backup",
"backup_exporting": "Säkerhetskopian skapas...", "backup_exporting": "Säkerhetskopian skapas...",
"backup_export_success": "Säkerhetskopia skapad ({{count}} resdagar).", "backup_export_success": "Säkerhetskopia skapad ({{count}} resdagar).",
"backup_file_label": "Säkerhetskopieringsfil (.daagbok.json)", "backup_file_label": "Säkerhetskopieringsfil (.daagbok)",
"backup_export_progress": "Packar filer {{current}} / {{total}}…",
"backup_invalid_archive": "Filen är inte ett giltigt backup-arkiv.",
"backup_version_unsupported": "Gammalt backup-format (v1). Använd en aktuell .daagbok-säkerhetskopia.",
"backup_import_size_confirm": "Denna säkerhetskopia är ca. {{size}} okomprimerad. Återställning kan ta längre tid. Fortsätta?",
"backup_stat_voice": "{{count}} röstanteckningar",
"backup_stat_size": "Ca. {{size}} okomprimerat",
"backup_preview_btn": "Kontrollera innehåll", "backup_preview_btn": "Kontrollera innehåll",
"backup_previewing": "Check...", "backup_previewing": "Check...",
"backup_restore_btn": "Återställ", "backup_restore_btn": "Återställ",
+321 -344
View File
@@ -9,98 +9,54 @@ import { decryptLogbookTitle, deleteLocalLogbookCache } from './logbook.js'
import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js' import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js'
import { syncLogbook } from './sync.js' import { syncLogbook } from './sync.js'
import type { SyncQueueItem } from './db.js' import type { SyncQueueItem } from './db.js'
import { getAppVersion } from './pwaVersion.js'
import { dexieFieldsFromEncBytes, encBytesFromDexieFields } from './logbookBackup/encBlob.js'
import {
BACKUP_FORMAT,
BACKUP_VERSION,
type BackupManifestCounts,
type BackupManifestV2,
type LogbookMetaJson
} from './logbookBackup/manifest.js'
import {
buildArchiveFromCollected,
collectLogbookBackupData,
type BackupExportProgress
} from './logbookBackup/collector.js'
import {
isZipArchive,
readBinaryFile,
readManifestFromArchive,
readTextFile,
unzipArchive
} from './logbookBackup/zipArchive.js'
export const BACKUP_FORMAT = 'kapteins-daagbok-backup' as const export { BACKUP_FORMAT, BACKUP_VERSION }
export const BACKUP_VERSION = 1 as const export type { BackupExportProgress, BackupManifestCounts, BackupManifestV2 }
export interface LogbookBackupFile {
format: typeof BACKUP_FORMAT
version: typeof BACKUP_VERSION
exportedAt: string
logbook: {
id: string
encryptedTitle: string
updatedAt: string
isDemo?: boolean
}
logbookKey: {
ciphertext: string
iv: string
tag: string
}
payloads: {
yacht: {
encryptedData: string
iv: string
tag: string
updatedAt: string
} | null
deviation: {
encryptedData: string
iv: string
tag: string
updatedAt: string
} | null
crews: Array<{
payloadId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
entries: Array<{
payloadId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
photos: Array<{
payloadId: string
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
voiceMemos: Array<{
payloadId: string
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
gpsTracks: Array<{
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
}
counts: {
entries: number
photos: number
voiceMemos: number
crews: number
gpsTracks: number
hasYacht: boolean
hasDeviation: boolean
}
}
export interface LogbookBackupPreview { export interface LogbookBackupPreview {
title: string title: string
exportedAt: string exportedAt: string
sourceLogbookId: string sourceLogbookId: string
counts: LogbookBackupFile['counts'] counts: BackupManifestCounts
totalUncompressedBytes: number
} }
export interface ParsedLogbookBackup {
manifest: BackupManifestV2
files: Record<string, Uint8Array>
}
export interface ExportLogbookBackupOptions {
onProgress?: (progress: BackupExportProgress) => void
}
const BACKUP_PASSPHRASE_SALT = 'KapteinsDaagbokBackupFileSalt_v1'
async function deriveBackupPassphraseKey(passphrase: string): Promise<CryptoKey> { async function deriveBackupPassphraseKey(passphrase: string): Promise<CryptoKey> {
const encoder = new TextEncoder() const encoder = new TextEncoder()
const passphraseBytes = encoder.encode(passphrase.trim()) const passphraseBytes = encoder.encode(passphrase.trim())
const saltBytes = encoder.encode('KapteinsDaagbokBackupFileSalt_v1') const saltBytes = encoder.encode(BACKUP_PASSPHRASE_SALT)
const baseKey = await window.crypto.subtle.importKey( const baseKey = await window.crypto.subtle.importKey(
'raw', 'raw',
@@ -129,35 +85,17 @@ async function wrapLogbookKey(logbookKey: ArrayBuffer, passphrase: string) {
return encryptBuffer(logbookKey, key) return encryptBuffer(logbookKey, key)
} }
async function unwrapLogbookKey( async function unwrapLogbookKeyFromEnc(
wrapped: LogbookBackupFile['logbookKey'], keyEnc: Uint8Array,
passphrase: string passphrase: string
): Promise<ArrayBuffer> { ): Promise<ArrayBuffer> {
const key = await deriveBackupPassphraseKey(passphrase) try {
return decryptBuffer(wrapped.ciphertext, wrapped.iv, wrapped.tag, key) const fields = dexieFieldsFromEncBytes(keyEnc)
const cryptoKey = await deriveBackupPassphraseKey(passphrase)
return decryptBuffer(fields.encryptedData, fields.iv, fields.tag, cryptoKey)
} catch {
throw new Error('BACKUP_WRONG_PASSPHRASE')
} }
function normalizeBackupPayloads(
payloads: LogbookBackupFile['payloads']
): LogbookBackupFile['payloads'] {
return {
...payloads,
voiceMemos: payloads.voiceMemos ?? []
}
}
function isBackupFile(value: unknown): value is LogbookBackupFile {
if (!value || typeof value !== 'object') return false
const obj = value as Partial<LogbookBackupFile>
return (
obj.format === BACKUP_FORMAT &&
obj.version === BACKUP_VERSION &&
typeof obj.exportedAt === 'string' &&
!!obj.logbook?.id &&
!!obj.logbook?.encryptedTitle &&
!!obj.logbookKey?.ciphertext &&
!!obj.payloads
)
} }
function encryptedPayloadData( function encryptedPayloadData(
@@ -174,106 +112,12 @@ function encryptedPayloadData(
}) })
} }
async function collectLogbookPayloads(logbookId: string): Promise<LogbookBackupFile['payloads']> {
const [yacht, deviation, crews, entries, photos, voiceMemos, gpsTracks] = await Promise.all([
db.yachts.get(logbookId),
db.deviations.get(logbookId),
db.crews.where({ logbookId }).toArray(),
db.entries.where({ logbookId }).toArray(),
db.photos.where({ logbookId }).toArray(),
db.voiceMemos.where({ logbookId }).toArray(),
db.gpsTracks.where({ logbookId }).toArray()
])
return {
yacht: yacht
? {
encryptedData: yacht.encryptedData,
iv: yacht.iv,
tag: yacht.tag,
updatedAt: yacht.updatedAt
}
: null,
deviation: deviation
? {
encryptedData: deviation.encryptedData,
iv: deviation.iv,
tag: deviation.tag,
updatedAt: deviation.updatedAt
}
: null,
crews: crews.map((c) => ({
payloadId: c.payloadId,
encryptedData: c.encryptedData,
iv: c.iv,
tag: c.tag,
updatedAt: c.updatedAt
})),
entries: entries.map((e) => ({
payloadId: e.payloadId,
encryptedData: e.encryptedData,
iv: e.iv,
tag: e.tag,
updatedAt: e.updatedAt
})),
photos: photos.map((p) => ({
payloadId: p.payloadId,
entryId: p.entryId,
encryptedData: p.encryptedData,
iv: p.iv,
tag: p.tag,
updatedAt: p.updatedAt
})),
voiceMemos: voiceMemos.map((v) => ({
payloadId: v.payloadId,
entryId: v.entryId,
encryptedData: v.encryptedData,
iv: v.iv,
tag: v.tag,
updatedAt: v.updatedAt
})),
gpsTracks: gpsTracks.map((t) => ({
entryId: t.entryId,
encryptedData: t.encryptedData,
iv: t.iv,
tag: t.tag,
updatedAt: t.updatedAt
}))
}
}
function remapBackup(
backup: LogbookBackupFile,
newLogbookId: string
): LogbookBackupFile {
return {
...backup,
logbook: {
...backup.logbook,
id: newLogbookId
},
payloads: {
...backup.payloads,
yacht: backup.payloads.yacht
? { ...backup.payloads.yacht, updatedAt: backup.payloads.yacht.updatedAt }
: null,
deviation: backup.payloads.deviation
? { ...backup.payloads.deviation, updatedAt: backup.payloads.deviation.updatedAt }
: null,
crews: backup.payloads.crews.map((c) => ({ ...c })),
entries: backup.payloads.entries.map((e) => ({ ...e })),
photos: backup.payloads.photos.map((p) => ({ ...p })),
voiceMemos: (backup.payloads.voiceMemos ?? []).map((v) => ({ ...v })),
gpsTracks: backup.payloads.gpsTracks.map((t) => ({ ...t }))
}
}
}
async function queueRestoredLogbookForSync( async function queueRestoredLogbookForSync(
logbookId: string, logbookId: string,
encryptedTitle: string, encryptedTitle: string,
logbookKey: ArrayBuffer, logbookKey: ArrayBuffer,
payloads: LogbookBackupFile['payloads'] manifest: BackupManifestV2,
files: Record<string, Uint8Array>
): Promise<void> { ): Promise<void> {
const masterKey = getActiveMasterKey() const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found') if (!masterKey) throw new Error('Master key not found')
@@ -304,91 +148,123 @@ async function queueRestoredLogbookForSync(
} }
] ]
if (payloads.yacht) { const readFields = (path: string | null) => {
if (!path) return null
return dexieFieldsFromEncBytes(readBinaryFile(files, path))
}
const yacht = readFields(manifest.files.yacht)
if (yacht) {
items.push({ items.push({
action: 'update', action: 'update',
type: 'yacht', type: 'yacht',
payloadId: logbookId, payloadId: logbookId,
logbookId, logbookId,
data: encryptedPayloadData( data: encryptedPayloadData(yacht.encryptedData, yacht.iv, yacht.tag),
payloads.yacht.encryptedData, updatedAt: now
payloads.yacht.iv,
payloads.yacht.tag
),
updatedAt: payloads.yacht.updatedAt
}) })
} }
if (payloads.deviation) { const deviation = readFields(manifest.files.deviation)
if (deviation) {
items.push({ items.push({
action: 'update', action: 'update',
type: 'deviation', type: 'deviation',
payloadId: logbookId, payloadId: logbookId,
logbookId, logbookId,
data: encryptedPayloadData( data: encryptedPayloadData(deviation.encryptedData, deviation.iv, deviation.tag),
payloads.deviation.encryptedData, updatedAt: now
payloads.deviation.iv,
payloads.deviation.tag
),
updatedAt: payloads.deviation.updatedAt
}) })
} }
for (const crew of payloads.crews) { const logbookCrew = readFields(manifest.files.logbookCrewSelection)
if (logbookCrew) {
items.push({
action: 'update',
type: 'logbookCrew',
payloadId: logbookId,
logbookId,
data: encryptedPayloadData(logbookCrew.encryptedData, logbookCrew.iv, logbookCrew.tag),
updatedAt: now
})
}
const logbookVessel = readFields(manifest.files.logbookVesselSelection)
if (logbookVessel) {
items.push({
action: 'update',
type: 'logbookVessel',
payloadId: logbookId,
logbookId,
data: encryptedPayloadData(
logbookVessel.encryptedData,
logbookVessel.iv,
logbookVessel.tag
),
updatedAt: now
})
}
for (const crew of manifest.files.crews) {
const f = readFields(crew.path)
items.push({ items.push({
action: 'create', action: 'create',
type: 'crew', type: 'crew',
payloadId: crew.payloadId, payloadId: crew.payloadId,
logbookId, logbookId,
data: encryptedPayloadData(crew.encryptedData, crew.iv, crew.tag), data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
updatedAt: crew.updatedAt updatedAt: crew.updatedAt
}) })
} }
for (const entry of payloads.entries) { for (const entry of manifest.files.entries) {
const f = readFields(entry.path)
items.push({ items.push({
action: 'create', action: 'create',
type: 'entry', type: 'entry',
payloadId: entry.payloadId, payloadId: entry.payloadId,
logbookId, logbookId,
data: encryptedPayloadData(entry.encryptedData, entry.iv, entry.tag), data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
updatedAt: entry.updatedAt updatedAt: entry.updatedAt
}) })
} }
for (const photo of payloads.photos) { for (const photo of manifest.files.photos) {
const f = readFields(photo.path)
items.push({ items.push({
action: 'create', action: 'create',
type: 'photo', type: 'photo',
payloadId: photo.payloadId, payloadId: photo.payloadId,
logbookId, logbookId,
data: encryptedPayloadData(photo.encryptedData, photo.iv, photo.tag, { data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, {
entryId: photo.entryId entryId: photo.entryId
}), }),
updatedAt: photo.updatedAt updatedAt: photo.updatedAt
}) })
} }
for (const voice of payloads.voiceMemos ?? []) { for (const voice of manifest.files.voiceMemos) {
const f = readFields(voice.path)
items.push({ items.push({
action: 'create', action: 'create',
type: 'voiceMemo', type: 'voiceMemo',
payloadId: voice.payloadId, payloadId: voice.payloadId,
logbookId, logbookId,
data: encryptedPayloadData(voice.encryptedData, voice.iv, voice.tag, { data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, {
entryId: voice.entryId entryId: voice.entryId
}), }),
updatedAt: voice.updatedAt updatedAt: voice.updatedAt
}) })
} }
for (const track of payloads.gpsTracks) { for (const track of manifest.files.gpsTracks) {
const f = readFields(track.path)
items.push({ items.push({
action: 'create', action: 'create',
type: 'gpsTrack', type: 'gpsTrack',
payloadId: track.entryId, payloadId: track.entryId,
logbookId, logbookId,
data: encryptedPayloadData(track.encryptedData, track.iv, track.tag), data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
updatedAt: track.updatedAt updatedAt: track.updatedAt
}) })
} }
@@ -398,116 +274,190 @@ async function queueRestoredLogbookForSync(
async function writeBackupToDexie( async function writeBackupToDexie(
logbookId: string, logbookId: string,
backup: LogbookBackupFile, logbookMeta: LogbookMetaJson,
logbookKey: ArrayBuffer logbookKey: ArrayBuffer,
manifest: BackupManifestV2,
files: Record<string, Uint8Array>
): Promise<void> { ): Promise<void> {
const { logbook, payloads } = backup
await db.logbooks.put({ await db.logbooks.put({
id: logbookId, id: logbookId,
encryptedTitle: logbook.encryptedTitle, encryptedTitle: logbookMeta.encryptedTitle,
updatedAt: logbook.updatedAt, updatedAt: logbookMeta.updatedAt,
isSynced: 0, isSynced: 0,
isShared: 0, isShared: 0,
isDemo: logbook.isDemo ? 1 : 0 isDemo: logbookMeta.isDemo ? 1 : 0
}) })
await saveLogbookKey(logbookId, logbookKey) await saveLogbookKey(logbookId, logbookKey)
if (payloads.yacht) { const readFields = (path: string | null) => {
if (!path) return null
return dexieFieldsFromEncBytes(readBinaryFile(files, path))
}
const yacht = readFields(manifest.files.yacht)
if (yacht) {
await db.yachts.put({ await db.yachts.put({
logbookId, logbookId,
encryptedData: payloads.yacht.encryptedData, encryptedData: yacht.encryptedData,
iv: payloads.yacht.iv, iv: yacht.iv,
tag: payloads.yacht.tag, tag: yacht.tag,
updatedAt: payloads.yacht.updatedAt updatedAt: logbookMeta.updatedAt
}) })
} }
if (payloads.deviation) { const deviation = readFields(manifest.files.deviation)
if (deviation) {
await db.deviations.put({ await db.deviations.put({
logbookId, logbookId,
encryptedData: payloads.deviation.encryptedData, encryptedData: deviation.encryptedData,
iv: payloads.deviation.iv, iv: deviation.iv,
tag: payloads.deviation.tag, tag: deviation.tag,
updatedAt: payloads.deviation.updatedAt updatedAt: logbookMeta.updatedAt
}) })
} }
if (payloads.crews.length > 0) { const logbookCrew = readFields(manifest.files.logbookCrewSelection)
if (logbookCrew) {
await db.logbookCrewSelections.put({
logbookId,
encryptedData: logbookCrew.encryptedData,
iv: logbookCrew.iv,
tag: logbookCrew.tag,
updatedAt: logbookMeta.updatedAt
})
}
const logbookVessel = readFields(manifest.files.logbookVesselSelection)
if (logbookVessel) {
await db.logbookVesselSelections.put({
logbookId,
encryptedData: logbookVessel.encryptedData,
iv: logbookVessel.iv,
tag: logbookVessel.tag,
updatedAt: logbookMeta.updatedAt
})
}
if (manifest.files.crews.length > 0) {
await db.crews.bulkPut( await db.crews.bulkPut(
payloads.crews.map((c) => ({ manifest.files.crews.map((c) => {
const f = dexieFieldsFromEncBytes(readBinaryFile(files, c.path))
return {
payloadId: c.payloadId, payloadId: c.payloadId,
logbookId, logbookId,
encryptedData: c.encryptedData, encryptedData: f.encryptedData,
iv: c.iv, iv: f.iv,
tag: c.tag, tag: f.tag,
updatedAt: c.updatedAt updatedAt: c.updatedAt
})) }
})
) )
} }
if (payloads.entries.length > 0) { if (manifest.files.entries.length > 0) {
await db.entries.bulkPut( await db.entries.bulkPut(
payloads.entries.map((e) => ({ manifest.files.entries.map((e) => {
const f = dexieFieldsFromEncBytes(readBinaryFile(files, e.path))
return {
payloadId: e.payloadId, payloadId: e.payloadId,
logbookId, logbookId,
encryptedData: e.encryptedData, encryptedData: f.encryptedData,
iv: e.iv, iv: f.iv,
tag: e.tag, tag: f.tag,
updatedAt: e.updatedAt updatedAt: e.updatedAt
})) }
})
) )
} }
if (payloads.photos.length > 0) { if (manifest.files.photos.length > 0) {
await db.photos.bulkPut( await db.photos.bulkPut(
payloads.photos.map((p) => ({ manifest.files.photos.map((p) => {
const f = dexieFieldsFromEncBytes(readBinaryFile(files, p.path))
return {
payloadId: p.payloadId, payloadId: p.payloadId,
entryId: p.entryId, entryId: p.entryId,
logbookId, logbookId,
encryptedData: p.encryptedData, encryptedData: f.encryptedData,
iv: p.iv, iv: f.iv,
tag: p.tag, tag: f.tag,
caption: '', caption: '',
updatedAt: p.updatedAt updatedAt: p.updatedAt
})) }
})
) )
} }
const voiceMemosToRestore = payloads.voiceMemos ?? [] if (manifest.files.voiceMemos.length > 0) {
if (voiceMemosToRestore.length > 0) {
await db.voiceMemos.bulkPut( await db.voiceMemos.bulkPut(
voiceMemosToRestore.map((v) => ({ manifest.files.voiceMemos.map((v) => {
const f = dexieFieldsFromEncBytes(readBinaryFile(files, v.path))
return {
payloadId: v.payloadId, payloadId: v.payloadId,
entryId: v.entryId, entryId: v.entryId,
logbookId, logbookId,
encryptedData: v.encryptedData, encryptedData: f.encryptedData,
iv: v.iv, iv: f.iv,
tag: v.tag, tag: f.tag,
updatedAt: v.updatedAt updatedAt: v.updatedAt
})) }
})
) )
} }
if (payloads.gpsTracks.length > 0) { if (manifest.files.gpsTracks.length > 0) {
await db.gpsTracks.bulkPut( await db.gpsTracks.bulkPut(
payloads.gpsTracks.map((t) => ({ manifest.files.gpsTracks.map((t) => {
const f = dexieFieldsFromEncBytes(readBinaryFile(files, t.path))
return {
entryId: t.entryId, entryId: t.entryId,
logbookId, logbookId,
encryptedData: t.encryptedData, encryptedData: f.encryptedData,
iv: t.iv, iv: f.iv,
tag: t.tag, tag: f.tag,
updatedAt: t.updatedAt updatedAt: t.updatedAt
})) }
})
) )
} }
if (manifest.files.nmeaArchives.length > 0) {
await db.nmeaArchives.bulkPut(
manifest.files.nmeaArchives.map((n) => {
const f = dexieFieldsFromEncBytes(readBinaryFile(files, n.path))
return {
entryId: n.entryId,
logbookId,
encryptedData: f.encryptedData,
iv: f.iv,
tag: f.tag,
updatedAt: n.updatedAt
}
})
)
}
}
function remapParsedBackup(
parsed: ParsedLogbookBackup,
newLogbookId: string
): ParsedLogbookBackup {
const logbookMeta = JSON.parse(readTextFile(parsed.files, parsed.manifest.files.logbook)) as LogbookMetaJson
logbookMeta.id = newLogbookId
const newFiles = { ...parsed.files }
newFiles[parsed.manifest.files.logbook] = new TextEncoder().encode(JSON.stringify(logbookMeta))
return {
manifest: { ...parsed.manifest, logbookId: newLogbookId },
files: newFiles
}
} }
export async function exportLogbookBackup( export async function exportLogbookBackup(
logbookId: string, logbookId: string,
passphrase: string passphrase: string,
): Promise<{ blob: Blob; filename: string; backup: LogbookBackupFile }> { options: ExportLogbookBackupOptions = {}
): Promise<{ blob: Blob; filename: string; manifest: BackupManifestV2 }> {
if (!passphrase.trim() || passphrase.length < 8) { if (!passphrase.trim() || passphrase.length < 8) {
throw new Error('BACKUP_PASSPHRASE_TOO_SHORT') throw new Error('BACKUP_PASSPHRASE_TOO_SHORT')
} }
@@ -523,78 +473,84 @@ export async function exportLogbookBackup(
}) })
} }
options.onProgress?.({ phase: 'collect', current: 0, total: 1, bytesPacked: 0 })
const collected = await collectLogbookBackupData(logbookId)
const logbookKey = (await getLogbookKey(logbookId)) ?? (await ensureLogbookKey(logbookId)) const logbookKey = (await getLogbookKey(logbookId)) ?? (await ensureLogbookKey(logbookId))
const payloads = await collectLogbookPayloads(logbookId) const wrapped = await wrapLogbookKey(logbookKey, passphrase)
const wrappedKey = await wrapLogbookKey(logbookKey, passphrase) const keyEnc = encBytesFromDexieFields({
encryptedData: wrapped.ciphertext,
iv: wrapped.iv,
tag: wrapped.tag
})
const backup: LogbookBackupFile = { const { zipBytes, manifest } = buildArchiveFromCollected(collected, keyEnc, {
format: BACKUP_FORMAT,
version: BACKUP_VERSION,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
logbook: { appVersion: getAppVersion(),
id: logbook.id, onProgress: options.onProgress
encryptedTitle: logbook.encryptedTitle, })
updatedAt: logbook.updatedAt,
isDemo: logbook.isDemo === 1
},
logbookKey: wrappedKey,
payloads,
counts: {
entries: payloads.entries.length,
photos: payloads.photos.length,
voiceMemos: payloads.voiceMemos?.length ?? 0,
crews: payloads.crews.length,
gpsTracks: payloads.gpsTracks.length,
hasYacht: !!payloads.yacht,
hasDeviation: !!payloads.deviation
}
}
const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle) const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle)
const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook' const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
const datePart = new Date().toISOString().slice(0, 10) const datePart = new Date().toISOString().slice(0, 10)
const filename = `${safeTitle}-${datePart}.daagbok.json` const filename = `${safeTitle}-${datePart}.daagbok`
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }) const blob = new Blob([zipBytes.slice()], { type: 'application/zip' })
return { blob, filename, backup } return { blob, filename, manifest }
} }
export async function parseLogbookBackupFile(file: File): Promise<LogbookBackupFile> { function detectLegacyJsonV1(text: string): boolean {
const text = await file.text() const trimmed = text.trimStart()
let parsed: unknown if (!trimmed.startsWith('{')) return false
try { try {
parsed = JSON.parse(text) const parsed = JSON.parse(trimmed) as { format?: string; version?: number }
return parsed.format === BACKUP_FORMAT && parsed.version === 1
} catch { } catch {
throw new Error('BACKUP_INVALID_JSON') return false
}
} }
if (!isBackupFile(parsed)) { export async function parseLogbookBackupFile(file: File): Promise<ParsedLogbookBackup> {
throw new Error('BACKUP_INVALID_FORMAT') const buffer = await file.arrayBuffer()
const bytes = new Uint8Array(buffer)
if (!isZipArchive(bytes)) {
const text = new TextDecoder().decode(bytes)
if (detectLegacyJsonV1(text)) {
throw new Error('BACKUP_VERSION_UNSUPPORTED')
}
throw new Error('BACKUP_INVALID_ARCHIVE')
} }
return { const files = unzipArchive(bytes)
...parsed, const manifest = readManifestFromArchive(files)
payloads: normalizeBackupPayloads(parsed.payloads), return { manifest, files }
counts: {
...parsed.counts,
voiceMemos: parsed.counts.voiceMemos ?? parsed.payloads.voiceMemos?.length ?? 0
}
}
} }
export async function previewLogbookBackup( export async function previewLogbookBackup(
backup: LogbookBackupFile, backup: ParsedLogbookBackup,
passphrase: string passphrase: string
): Promise<LogbookBackupPreview> { ): Promise<LogbookBackupPreview> {
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase) const logbookKey = await unwrapLogbookKeyFromEnc(
const parsed = JSON.parse(backup.logbook.encryptedTitle) readBinaryFile(backup.files, backup.manifest.files.key),
const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey) passphrase
)
const logbookMeta = JSON.parse(
readTextFile(backup.files, backup.manifest.files.logbook)
) as LogbookMetaJson
const parsed = JSON.parse(logbookMeta.encryptedTitle)
let title: string
try {
title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey)
} catch {
throw new Error('BACKUP_WRONG_PASSPHRASE')
}
return { return {
title, title,
exportedAt: backup.exportedAt, exportedAt: backup.manifest.exportedAt,
sourceLogbookId: backup.logbook.id, sourceLogbookId: backup.manifest.logbookId,
counts: backup.counts counts: backup.manifest.counts,
totalUncompressedBytes: backup.manifest.totalUncompressedBytes
} }
} }
@@ -604,7 +560,7 @@ export interface RestoreLogbookOptions {
} }
export async function restoreLogbookBackup( export async function restoreLogbookBackup(
backup: LogbookBackupFile, backup: ParsedLogbookBackup,
passphrase: string, passphrase: string,
options: RestoreLogbookOptions = {} options: RestoreLogbookOptions = {}
): Promise<{ logbookId: string; title: string }> { ): Promise<{ logbookId: string; title: string }> {
@@ -612,16 +568,22 @@ export async function restoreLogbookBackup(
throw new Error('BACKUP_NOT_AUTHENTICATED') throw new Error('BACKUP_NOT_AUTHENTICATED')
} }
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase) const logbookKey = await unwrapLogbookKeyFromEnc(
const parsedTitle = JSON.parse(backup.logbook.encryptedTitle) readBinaryFile(backup.files, backup.manifest.files.key),
const title = await decryptJson( passphrase
parsedTitle.ciphertext,
parsedTitle.iv,
parsedTitle.tag,
logbookKey
) )
const logbookMeta = JSON.parse(
readTextFile(backup.files, backup.manifest.files.logbook)
) as LogbookMetaJson
const parsedTitle = JSON.parse(logbookMeta.encryptedTitle)
let title: string
try {
title = await decryptJson(parsedTitle.ciphertext, parsedTitle.iv, parsedTitle.tag, logbookKey)
} catch {
throw new Error('BACKUP_WRONG_PASSPHRASE')
}
let targetId = backup.logbook.id let targetId = backup.manifest.logbookId
const existing = await db.logbooks.get(targetId) const existing = await db.logbooks.get(targetId)
if (existing && !options.overwrite && !options.assignNewId) { if (existing && !options.overwrite && !options.assignNewId) {
@@ -632,24 +594,29 @@ export async function restoreLogbookBackup(
await deleteLocalLogbookCache(targetId) await deleteLocalLogbookCache(targetId)
} }
let prepared = backup
if (options.assignNewId || (existing && !options.overwrite)) { if (options.assignNewId || (existing && !options.overwrite)) {
targetId = crypto.randomUUID() targetId = crypto.randomUUID()
prepared = remapParsedBackup(backup, targetId)
} }
const normalized = { const finalMeta = JSON.parse(
...backup, readTextFile(prepared.files, prepared.manifest.files.logbook)
payloads: normalizeBackupPayloads(backup.payloads) ) as LogbookMetaJson
}
const prepared = targetId === normalized.logbook.id
? normalized
: remapBackup(normalized, targetId)
await writeBackupToDexie(targetId, prepared, logbookKey) await writeBackupToDexie(
targetId,
finalMeta,
logbookKey,
prepared.manifest,
prepared.files
)
await queueRestoredLogbookForSync( await queueRestoredLogbookForSync(
targetId, targetId,
prepared.logbook.encryptedTitle, finalMeta.encryptedTitle,
logbookKey, logbookKey,
prepared.payloads prepared.manifest,
prepared.files
) )
if (navigator.onLine) { if (navigator.onLine) {
@@ -669,3 +636,13 @@ export function downloadBackupBlob(blob: Blob, filename: string): void {
anchor.click() anchor.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
/** Human-readable size for UI warnings. */
export function formatBackupBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export const BACKUP_SIZE_WARN_BYTES = 50_000_000
export const BACKUP_SIZE_CONFIRM_BYTES = 150_000_000
@@ -0,0 +1,355 @@
import { db } from '../db.js'
import { encBytesFromDexieFields, type DexieEncFields } from './encBlob.js'
import { buildZipArchive, utf8Bytes } from './zipArchive.js'
import {
BACKUP_FORMAT,
BACKUP_VERSION,
type BackupIndexedEntryFile,
type BackupIndexedPayloadFile,
type BackupIndexedTrackFile,
type BackupManifestCounts,
type BackupManifestFiles,
type BackupManifestV2,
type LogbookMetaJson
} from './manifest.js'
export interface CollectedBackupData {
logbookMeta: LogbookMetaJson
yacht: DexieEncFields | null
deviation: DexieEncFields | null
logbookCrewSelection: DexieEncFields | null
logbookVesselSelection: DexieEncFields | null
crews: Array<DexieEncFields & { payloadId: string; updatedAt: string }>
entries: Array<DexieEncFields & { payloadId: string; updatedAt: string }>
photos: Array<DexieEncFields & { payloadId: string; entryId: string; updatedAt: string }>
voiceMemos: Array<DexieEncFields & { payloadId: string; entryId: string; updatedAt: string }>
gpsTracks: Array<DexieEncFields & { entryId: string; updatedAt: string }>
nmeaArchives: Array<DexieEncFields & { entryId: string; updatedAt: string }>
}
function pickEnc(row: {
encryptedData: string
iv: string
tag: string
}): DexieEncFields {
return {
encryptedData: row.encryptedData,
iv: row.iv,
tag: row.tag
}
}
export async function collectLogbookBackupData(
logbookId: string
): Promise<CollectedBackupData> {
const [
logbook,
yacht,
deviation,
logbookCrewSelection,
logbookVesselSelection,
crews,
entries,
photos,
voiceMemos,
gpsTracks,
nmeaArchives
] = await Promise.all([
db.logbooks.get(logbookId),
db.yachts.get(logbookId),
db.deviations.get(logbookId),
db.logbookCrewSelections.get(logbookId),
db.logbookVesselSelections.get(logbookId),
db.crews.where({ logbookId }).toArray(),
db.entries.where({ logbookId }).toArray(),
db.photos.where({ logbookId }).toArray(),
db.voiceMemos.where({ logbookId }).toArray(),
db.gpsTracks.where({ logbookId }).toArray(),
db.nmeaArchives.where({ logbookId }).toArray()
])
if (!logbook) throw new Error('BACKUP_LOGBOOK_NOT_FOUND')
return {
logbookMeta: {
id: logbook.id,
encryptedTitle: logbook.encryptedTitle,
updatedAt: logbook.updatedAt,
isDemo: logbook.isDemo === 1
},
yacht: yacht ? pickEnc(yacht) : null,
deviation: deviation ? pickEnc(deviation) : null,
logbookCrewSelection: logbookCrewSelection ? pickEnc(logbookCrewSelection) : null,
logbookVesselSelection: logbookVesselSelection ? pickEnc(logbookVesselSelection) : null,
crews: crews.map((c) => ({ ...pickEnc(c), payloadId: c.payloadId, updatedAt: c.updatedAt })),
entries: entries.map((e) => ({
...pickEnc(e),
payloadId: e.payloadId,
updatedAt: e.updatedAt
})),
photos: photos.map((p) => ({
...pickEnc(p),
payloadId: p.payloadId,
entryId: p.entryId,
updatedAt: p.updatedAt
})),
voiceMemos: voiceMemos.map((v) => ({
...pickEnc(v),
payloadId: v.payloadId,
entryId: v.entryId,
updatedAt: v.updatedAt
})),
gpsTracks: gpsTracks.map((t) => ({
...pickEnc(t),
entryId: t.entryId,
updatedAt: t.updatedAt
})),
nmeaArchives: nmeaArchives.map((n) => ({
...pickEnc(n),
entryId: n.entryId,
updatedAt: n.updatedAt
}))
}
}
export type BackupProgressPhase = 'collect' | 'pack' | 'done'
export interface BackupExportProgress {
phase: BackupProgressPhase
current: number
total: number
bytesPacked: number
}
export interface BuiltArchive {
zipBytes: Uint8Array
manifest: BackupManifestV2
counts: BackupManifestCounts
totalUncompressedBytes: number
}
function addEncFile(
zipFiles: Record<string, Uint8Array>,
path: string,
fields: DexieEncFields
): number {
const bytes = encBytesFromDexieFields(fields)
zipFiles[path] = bytes
return bytes.byteLength
}
export function buildArchiveFromCollected(
collected: CollectedBackupData,
keyEnc: Uint8Array,
options: {
exportedAt: string
appVersion?: string
onProgress?: (progress: BackupExportProgress) => void
}
): BuiltArchive {
const zipFiles: Record<string, Uint8Array> = {}
let totalUncompressedBytes = 0
const logbookPath = 'logbook.meta.json'
zipFiles[logbookPath] = utf8Bytes(JSON.stringify(collected.logbookMeta))
totalUncompressedBytes += zipFiles[logbookPath].byteLength
zipFiles['key.enc'] = keyEnc
totalUncompressedBytes += keyEnc.byteLength
const files: BackupManifestFiles = {
key: 'key.enc',
logbook: logbookPath,
yacht: null,
deviation: null,
logbookCrewSelection: null,
logbookVesselSelection: null,
crews: [],
entries: [],
photos: [],
voiceMemos: [],
gpsTracks: [],
nmeaArchives: []
}
const packSteps: Array<() => void> = []
if (collected.yacht) {
packSteps.push(() => {
const path = 'payloads/yacht.enc'
const size = addEncFile(zipFiles, path, collected.yacht!)
files.yacht = path
totalUncompressedBytes += size
})
}
if (collected.deviation) {
packSteps.push(() => {
const path = 'payloads/deviation.enc'
const size = addEncFile(zipFiles, path, collected.deviation!)
files.deviation = path
totalUncompressedBytes += size
})
}
if (collected.logbookCrewSelection) {
packSteps.push(() => {
const path = 'payloads/logbook-crew.enc'
const size = addEncFile(zipFiles, path, collected.logbookCrewSelection!)
files.logbookCrewSelection = path
totalUncompressedBytes += size
})
}
if (collected.logbookVesselSelection) {
packSteps.push(() => {
const path = 'payloads/logbook-vessel.enc'
const size = addEncFile(zipFiles, path, collected.logbookVesselSelection!)
files.logbookVesselSelection = path
totalUncompressedBytes += size
})
}
for (const c of collected.crews) {
packSteps.push(() => {
const path = `payloads/crews/${c.payloadId}.enc`
const size = addEncFile(zipFiles, path, c)
const index: BackupIndexedPayloadFile = {
path,
payloadId: c.payloadId,
updatedAt: c.updatedAt,
bytes: size
}
files.crews.push(index)
totalUncompressedBytes += size
})
}
for (const e of collected.entries) {
packSteps.push(() => {
const path = `payloads/entries/${e.payloadId}.enc`
const size = addEncFile(zipFiles, path, e)
const index: BackupIndexedPayloadFile = {
path,
payloadId: e.payloadId,
updatedAt: e.updatedAt,
bytes: size
}
files.entries.push(index)
totalUncompressedBytes += size
})
}
for (const p of collected.photos) {
packSteps.push(() => {
const path = `payloads/photos/${p.payloadId}.enc`
const size = addEncFile(zipFiles, path, p)
const index: BackupIndexedEntryFile = {
path,
payloadId: p.payloadId,
entryId: p.entryId,
updatedAt: p.updatedAt,
bytes: size
}
files.photos.push(index)
totalUncompressedBytes += size
})
}
for (const v of collected.voiceMemos) {
packSteps.push(() => {
const path = `payloads/voice-memos/${v.payloadId}.enc`
const size = addEncFile(zipFiles, path, v)
const index: BackupIndexedEntryFile = {
path,
payloadId: v.payloadId,
entryId: v.entryId,
updatedAt: v.updatedAt,
bytes: size
}
files.voiceMemos.push(index)
totalUncompressedBytes += size
})
}
for (const t of collected.gpsTracks) {
packSteps.push(() => {
const path = `payloads/gps-tracks/${t.entryId}.enc`
const size = addEncFile(zipFiles, path, t)
const index: BackupIndexedTrackFile = {
path,
entryId: t.entryId,
updatedAt: t.updatedAt,
bytes: size
}
files.gpsTracks.push(index)
totalUncompressedBytes += size
})
}
for (const n of collected.nmeaArchives) {
packSteps.push(() => {
const path = `payloads/nmea-archives/${n.entryId}.enc`
const size = addEncFile(zipFiles, path, n)
const index: BackupIndexedTrackFile = {
path,
entryId: n.entryId,
updatedAt: n.updatedAt,
bytes: size
}
files.nmeaArchives.push(index)
totalUncompressedBytes += size
})
}
const total = packSteps.length
packSteps.forEach((step, i) => {
step()
options.onProgress?.({
phase: 'pack',
current: i + 1,
total,
bytesPacked: totalUncompressedBytes
})
})
const counts: BackupManifestCounts = {
entries: collected.entries.length,
photos: collected.photos.length,
voiceMemos: collected.voiceMemos.length,
crews: collected.crews.length,
gpsTracks: collected.gpsTracks.length,
nmeaArchives: collected.nmeaArchives.length,
hasYacht: !!collected.yacht,
hasDeviation: !!collected.deviation,
hasLogbookCrewSelection: !!collected.logbookCrewSelection,
hasLogbookVesselSelection: !!collected.logbookVesselSelection
}
const manifest: BackupManifestV2 = {
format: BACKUP_FORMAT,
version: BACKUP_VERSION,
exportedAt: options.exportedAt,
appVersion: options.appVersion,
compression: 'zip-deflate-6',
logbookId: collected.logbookMeta.id,
counts,
totalUncompressedBytes,
files
}
zipFiles['manifest.json'] = utf8Bytes(JSON.stringify(manifest))
totalUncompressedBytes += zipFiles['manifest.json'].byteLength
const zipBytes = buildZipArchive(zipFiles)
manifest.totalUncompressedBytes = totalUncompressedBytes
options.onProgress?.({
phase: 'done',
current: total,
total,
bytesPacked: totalUncompressedBytes
})
return { zipBytes, manifest, counts, totalUncompressedBytes }
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import {
dexieFieldsFromEncBytes,
encBytesFromDexieFields,
ENC_HEADER_SIZE
} from './encBlob.js'
function toB64(bytes: number[]): string {
return btoa(String.fromCharCode(...bytes))
}
describe('encBlob', () => {
it('round-trips dexie AES-GCM fields', () => {
const fields = {
encryptedData: toB64([9, 8, 7]),
iv: toB64(Array.from({ length: 12 }, (_, i) => i)),
tag: toB64(Array.from({ length: 16 }, (_, i) => i + 20))
}
const enc = encBytesFromDexieFields(fields)
expect(enc.byteLength).toBe(ENC_HEADER_SIZE + 3)
expect(dexieFieldsFromEncBytes(enc)).toEqual(fields)
})
it('rejects invalid magic', () => {
expect(() => dexieFieldsFromEncBytes(new Uint8Array(40))).toThrow('BACKUP_INVALID_ENC')
})
})
@@ -0,0 +1,45 @@
import { base64ToBuffer, bufferToBase64 } from '../crypto.js'
export const ENC_MAGIC = new Uint8Array([0x4b, 0x44, 0x41, 0x42]) // KDAB
export const ENC_FORMAT_VERSION = 1
export const ENC_HEADER_SIZE = 33 // 4 + 1 + 12 + 16
export interface DexieEncFields {
encryptedData: string
iv: string
tag: string
}
export function encBytesFromDexieFields(fields: DexieEncFields): Uint8Array {
const iv = new Uint8Array(base64ToBuffer(fields.iv))
const tag = new Uint8Array(base64ToBuffer(fields.tag))
const ciphertext = new Uint8Array(base64ToBuffer(fields.encryptedData))
if (iv.length !== 12) throw new Error('BACKUP_INVALID_ENC')
if (tag.length !== 16) throw new Error('BACKUP_INVALID_ENC')
const out = new Uint8Array(ENC_HEADER_SIZE + ciphertext.length)
out.set(ENC_MAGIC, 0)
out[4] = ENC_FORMAT_VERSION
out.set(iv, 5)
out.set(tag, 17)
out.set(ciphertext, 33)
return out
}
export function dexieFieldsFromEncBytes(bytes: Uint8Array): DexieEncFields {
if (bytes.length < ENC_HEADER_SIZE) throw new Error('BACKUP_INVALID_ENC')
for (let i = 0; i < 4; i++) {
if (bytes[i] !== ENC_MAGIC[i]) throw new Error('BACKUP_INVALID_ENC')
}
if (bytes[4] !== ENC_FORMAT_VERSION) throw new Error('BACKUP_INVALID_ENC')
const iv = bufferToBase64(bytes.slice(5, 17).buffer)
const tag = bufferToBase64(bytes.slice(17, 33).buffer)
const ciphertext = bufferToBase64(bytes.slice(33).buffer)
return { encryptedData: ciphertext, iv, tag }
}
export function encByteLength(fields: DexieEncFields): number {
const ct = base64ToBuffer(fields.encryptedData).byteLength
return ENC_HEADER_SIZE + ct
}
@@ -0,0 +1,97 @@
export const BACKUP_FORMAT = 'kapteins-daagbok-backup' as const
export const BACKUP_VERSION = 2 as const
export interface BackupIndexedFile {
path: string
updatedAt: string
bytes: number
}
export interface BackupIndexedPayloadFile extends BackupIndexedFile {
payloadId: string
}
export interface BackupIndexedEntryFile extends BackupIndexedPayloadFile {
entryId: string
}
export interface BackupIndexedTrackFile extends BackupIndexedFile {
entryId: string
}
export interface BackupManifestCounts {
entries: number
photos: number
voiceMemos: number
crews: number
gpsTracks: number
nmeaArchives: number
hasYacht: boolean
hasDeviation: boolean
hasLogbookCrewSelection: boolean
hasLogbookVesselSelection: boolean
}
export interface BackupManifestFiles {
key: string
logbook: string
yacht: string | null
deviation: string | null
logbookCrewSelection: string | null
logbookVesselSelection: string | null
crews: BackupIndexedPayloadFile[]
entries: BackupIndexedPayloadFile[]
photos: BackupIndexedEntryFile[]
voiceMemos: BackupIndexedEntryFile[]
gpsTracks: BackupIndexedTrackFile[]
nmeaArchives: BackupIndexedTrackFile[]
}
export interface BackupManifestV2 {
format: typeof BACKUP_FORMAT
version: typeof BACKUP_VERSION
exportedAt: string
appVersion?: string
compression: 'zip-deflate-6'
logbookId: string
counts: BackupManifestCounts
totalUncompressedBytes: number
files: BackupManifestFiles
}
export interface LogbookMetaJson {
id: string
encryptedTitle: string
updatedAt: string
isDemo?: boolean
}
export function parseManifestJson(text: string): BackupManifestV2 {
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
throw new Error('BACKUP_INVALID_FORMAT')
}
if (!isBackupManifestV2(parsed)) {
throw new Error('BACKUP_INVALID_FORMAT')
}
return parsed
}
export function isBackupManifestV2(value: unknown): value is BackupManifestV2 {
if (!value || typeof value !== 'object') return false
const obj = value as Partial<BackupManifestV2>
return (
obj.format === BACKUP_FORMAT &&
obj.version === BACKUP_VERSION &&
typeof obj.exportedAt === 'string' &&
typeof obj.logbookId === 'string' &&
!!obj.counts &&
!!obj.files
)
}
export function serializeManifest(manifest: BackupManifestV2): string {
return JSON.stringify(manifest)
}
@@ -0,0 +1,45 @@
import { strToU8, unzipSync, zipSync } from 'fflate'
import { parseManifestJson, type BackupManifestV2 } from './manifest.js'
const ZIP_LEVEL = 6
export function buildZipArchive(files: Record<string, Uint8Array>): Uint8Array {
return zipSync(files, { level: ZIP_LEVEL })
}
export function unzipArchive(data: Uint8Array): Record<string, Uint8Array> {
try {
return unzipSync(data)
} catch {
throw new Error('BACKUP_INVALID_ARCHIVE')
}
}
export function readManifestFromArchive(
files: Record<string, Uint8Array>
): BackupManifestV2 {
const raw = files['manifest.json']
if (!raw) throw new Error('BACKUP_INVALID_FORMAT')
const text = new TextDecoder().decode(raw)
return parseManifestJson(text)
}
export function readTextFile(files: Record<string, Uint8Array>, path: string): string {
const raw = files[path]
if (!raw) throw new Error('BACKUP_MISSING_BLOB')
return new TextDecoder().decode(raw)
}
export function readBinaryFile(files: Record<string, Uint8Array>, path: string): Uint8Array {
const raw = files[path]
if (!raw) throw new Error('BACKUP_MISSING_BLOB')
return raw
}
export function utf8Bytes(text: string): Uint8Array {
return strToU8(text)
}
export function isZipArchive(bytes: Uint8Array): boolean {
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b
}
+337
View File
@@ -0,0 +1,337 @@
# Backup-Format v2 — Design
**Status:** Implementiert in `feature/backup-format-v2` (`BACKUP_VERSION = 2`, Datei `*.daagbok`).
**Ziel:** Logbuch-Backups skalieren für viele Reisetage, Fotos, Voice-Memos und GPS-Tracks — ohne den gesamten Inhalt als eine große JSON-Datei im Browser-RAM zu halten.
**Ausgangslage:** v1 (`BACKUP_VERSION = 1`, Datei `*.daagbok.json`) serialisiert alle Payloads in ein einziges JSON-Objekt mit Pretty-Print. Binärdaten stecken doppelt als Base64-Strings in `encryptedData`. Import nutzt `file.text()` + `JSON.parse()` auf der vollen Datei.
**Entscheidung:** Keine Abwärtskompatibilität zu v1 — es gibt noch keine produktiven User-Backups. v1-Code und `-json`-Dateiendung wurden durch v2 ersetzt.
---
## 1. Anforderungen
### Funktional
| ID | Anforderung |
|----|-------------|
| B-01 | Export enthält **alle** lokalen Logbuch-Payloads: yacht, deviation, crews, entries, photos, voiceMemos, gpsTracks, **logbookCrewSelection**, **logbookVesselSelection**, **nmeaArchives**. |
| B-02 | E2E-Verschlüsselung bleibt: Logbuch-Key mit Backup-Passphrase (PBKDF2) gewrappt; Payload-Blobs unverändert wie in Dexie (AES-GCM über `encryptJson`). |
| B-03 | Medien (Fotos, Voice-Memos) als **binäre Blob-Dateien** im Archiv, nicht als Base64 in JSON. |
| B-04 | Strukturdaten (Manifest, kleine Metadaten) als **kompaktes JSON** (ein Zeile, kein Pretty-Print). |
| B-05 | Gesamtarchiv **DEFLATE-komprimiert** (ZIP). |
| B-06 | Preview (Titel, Counts, Export-Datum) ohne vollständiges Entpacken aller Medien — nur Manifest + Key-Entschlüsselung. |
| B-07 | Restore-Optionen wie heute: `overwrite`, `assignNewId`, Konflikt bei gleicher ID. |
| B-08 | Nach Restore: Sync-Queue wie heute befüllen, optional `syncLogbook` wenn online. |
| B-09 | Export vor Download optional mit **Fortschrittsanzeige** (Anzahl Blobs / Bytes). |
### Nicht-Ziele (v2)
- Inkrementelles / dedupliziertes Backup über mehrere Dateien.
- Backup auf dem Server (nur lokaler Download wie heute).
- Klartext-Manifest oder unverschlüsselte Medien.
- Account-weites Multi-Logbuch-Archiv (weiterhin **ein Logbuch pro Datei**).
### Akzeptanzkriterien (UAT)
1. Logbuch mit 50 Fotos + 20 Voice-Memos exportieren → Datei `.daagbok` deutlich kleiner als vergleichbares v1-JSON (Kompression + kein Pretty-Print + binäre Blobs).
2. Restore auf frischem Gerät (eingeloggt) → alle Einträge, Medien abspielbar, Crew/Vessel-Selection vorhanden.
3. Falsches Passphrase → `BACKUP_WRONG_PASSPHRASE` (wie heute).
4. Beschädigtes ZIP / fehlende Manifest → `BACKUP_INVALID_FORMAT`.
5. v1-Datei (`version: 1`) → `BACKUP_VERSION_UNSUPPORTED` mit Hinweis.
---
## 2. Container: ZIP-Archiv
| Eigenschaft | Wert |
|-------------|------|
| MIME-Typ (Download) | `application/vnd.kapteins-daagbok+zip` (Fallback: `application/zip`) |
| Dateiendung | `.daagbok` (kein `.json`) |
| Kompression | ZIP mit DEFLATE (Level 6 — Balance Größe/Geschwindigkeit) |
| Magic / Erkennung | ZIP-Signatur `PK\x03\x04` + `manifest.json` mit `format` + `version: 2` |
**Bibliothek (Client):** [`fflate`](https://github.com/101arrowz/fflate) (klein, ESM, ZIP sync/async). Als direkte `dependencies`-Eintrag in `client/package.json`, nicht nur transitiv.
**Warum ZIP und nicht nur gzip auf einer JSON-Datei?**
- Viele unabhängige Blobs → paralleles Entpacken, Preview ohne alle Medien zu lesen.
- Manifest bleibt klein (< 100 KB typisch).
- Standard-Tooling (optional manuelle Inspektion mit `unzip -l`).
---
## 3. Archiv-Layout
```
backup.daagbok (ZIP)
├── manifest.json # Klartext-Metadaten + Index (kompakt)
├── key.enc # Mit Backup-Passphrase gewrapptes Logbuch-Key (binär)
├── logbook.meta.json # encryptedTitle, id, updatedAt, isDemo (klein, JSON)
└── payloads/
├── yacht.enc
├── deviation.enc
├── logbook-crew.enc
├── logbook-vessel.enc
├── crews/
│ └── {payloadId}.enc
├── entries/
│ └── {payloadId}.enc
├── photos/
│ └── {payloadId}.enc # + sidecar optional: {payloadId}.meta.json
├── voice-memos/
│ └── {payloadId}.enc
├── gps-tracks/
│ └── {entryId}.enc
└── nmea-archives/
└── {entryId}.enc
```
### 3.1 `manifest.json` (Schema)
```json
{
"format": "kapteins-daagbok-backup",
"version": 2,
"exportedAt": "2026-06-03T12:00:00.000Z",
"appVersion": "0.1.0.109",
"compression": "zip-deflate-6",
"logbookId": "uuid",
"counts": {
"entries": 42,
"photos": 50,
"voiceMemos": 12,
"crews": 3,
"gpsTracks": 40,
"nmeaArchives": 2,
"hasYacht": true,
"hasDeviation": true,
"hasLogbookCrewSelection": true,
"hasLogbookVesselSelection": true
},
"totalUncompressedBytes": 125000000,
"files": {
"key": "key.enc",
"logbook": "logbook.meta.json",
"yacht": "payloads/yacht.enc",
"deviation": null,
"logbookCrewSelection": "payloads/logbook-crew.enc",
"logbookVesselSelection": "payloads/logbook-vessel.enc",
"crews": [
{ "payloadId": "…", "path": "payloads/crews/….enc", "updatedAt": "…" }
],
"entries": [ … ],
"photos": [
{ "payloadId": "…", "entryId": "…", "path": "…", "updatedAt": "…", "bytes": 183422 }
],
"voiceMemos": [ … ],
"gpsTracks": [ … ],
"nmeaArchives": [ … ]
}
}
```
- **`appVersion`:** optional, aus Client-Build (PWA-Version) — nur für Support/Debug.
- **`totalUncompressedBytes`:** Summe der Blob-Größen vor ZIP — für UI („~120 MB“) und Speicher-Warnung vor Import.
- **Keine** `encryptedData` / IV / Tag im Manifest für große Blobs — nur im Binärformat (siehe 3.2).
### 3.2 Binärformat `.enc` (einheitlich für alle Payloads)
Jede `.enc`-Datei ist **roh**, kein JSON:
```
Offset Size Inhalt
0 4 Magic ASCII "KDAB" (Kaptein's Daagbok)
4 1 Format version = 1
5 12 IV (AES-GCM, wie heute)
17 16 Auth tag (AES-GCM)
33 N Ciphertext (identisch mit heutigem decryptJson-Eingang:
Base64-decode von encryptedData + concat mit tag in decryptBuffer)
```
**Migration von Dexie:** Beim Export aus `encryptedData` (Base64-String) + `iv` + `tag` (Base64) → einmal decodieren → `.enc`-Datei schreiben. Beim Import umgekehrt → Dexie-Felder wie heute.
Vorteil: ~33 % weniger Speicher als Base64-in-JSON; Parser liest nur Header + Länge.
**`key.enc`:** Gleiches Binärformat, Inhalt = `encryptBuffer(logbookKey, passphraseDerivedKey)` — ersetzt das JSON-Objekt `logbookKey: { ciphertext, iv, tag }` aus v1.
**`logbook.meta.json`:** Unverändert kleines JSON (nur `encryptedTitle`, `updatedAt`, `isDemo`) — kein Binärbedarf.
### 3.3 Optionale Sidecars (Phase 2, nicht blocking v2)
Für Photos/Voice-Memos könnte `{id}.meta.json` nur `{ entryId, updatedAt }` enthalten, falls das Manifest zu groß wird (>10k Medien). v2 startet mit **allen Metadaten im Manifest** — ausreichend bis ~einige tausend Dateien.
---
## 4. Kryptographie (unverändert in der Semantik)
| Element | v1 | v2 |
|---------|----|----|
| Logbuch-Key im Backup | PBKDF2 + AES-GCM (`KapteinsDaagbokBackupFileSalt_v1`) | **Gleich** (Salt-String beibehalten für gleiche Passphrase → gleicher Key) |
| Payload-Verschlüsselung | `encryptJson` mit Logbuch-Key | **Byte-für-byte gleicher Ciphertext** in `.enc` |
| Passphrase-Mindestlänge | 8 Zeichen | 8 Zeichen |
Optional in v2.1 (nicht v2): PBKDF2-Iterationen erhöhen (z. B. 310_000) mit neuem Salt `…_v2` und Feld `keyWrap: "pbkdf2-v2"` in Manifest — nur wenn gewünscht.
---
## 5. Ablauf Export
```mermaid
sequenceDiagram
participant UI as LogbookBackupPanel
participant Svc as logbookBackupV2
participant DB as Dexie
participant ZIP as fflate ZIP
UI->>Svc: exportLogbookBackup(logbookId, passphrase)
Svc->>DB: collect all tables (batched)
loop each payload
Svc->>Svc: base64 → KDAB .enc bytes
Svc->>ZIP: add file (deflate)
end
Svc->>ZIP: manifest.json + key.enc + logbook.meta.json
Svc->>UI: Blob + filename.daagbok
```
### Implementierungsdetails
1. **Pre-sync:** wie heute `syncLogbook(logbookId)` wenn online.
2. **Sammlung:** `collectLogbookPayloadsV2()` — alle Tabellen aus Abschnitt B-01; Batches à 20 für Medien.
3. **ZIP-Erzeugung:** `fflate` `zipSync` oder `zip` mit Streaming-Callback; **nicht** das gesamte Archiv als ein Array im RAM, wenn `totalUncompressedBytes > 80_000_000` → Warnung in UI + ggf. `requestIdleCallback` zwischen Blobs.
4. **Fortschritt:** `onProgress({ phase, current, total, bytes })`.
5. **Download:** `downloadBackupBlob(blob, `${safeTitle}-${date}.daagbok`)`.
### Speicher-Richtwerte (UI-Warnung)
| Uncompressed | Empfehlung |
|--------------|------------|
| < 50 MB | Normal exportieren |
| 50150 MB | Hinweis: Import kann auf schwachen Geräten dauern |
| > 150 MB | Bestätigungsdialog; Export trotzdem erlauben |
ZIP reduziert typisch Medien-Anteil um **2040 %** (JPEG/WebM komprimieren schlecht in ZIP, aber Base64-Overhead entfällt).
---
## 6. Ablauf Import / Preview
### Preview (Passphrase-Check)
1. ZIP öffnen (nur zentrales Directory lesen — `fflate` `unzip`).
2. `manifest.json` parsen → `version === 2` prüfen.
3. `key.enc` laden → Passphrase → Logbuch-Key.
4. `logbook.meta.json` → Titel entschlüsseln.
5. Counts aus Manifest anzeigen — **keine** Medien-Blobs dekodieren.
### Restore
1. Vollständig entpacken in **temporäre Struktur** (Object-URLs / `Map<path, Uint8Array>`) — bei >150 MB Warnung.
2. `writeBackupToDexieV2()` — analog v1, aber aus `.enc` Bytes.
3. `queueRestoredLogbookForSync()` — unveränderte Sync-Queue-Semantik.
4. `listCache` auf Entries: nach Restore optional aus Entry-Payload neu ableiten (wie bei normalem Decrypt) oder beim Export mit speichern — **Design:** listCache beim Export **nicht** sichern (bleibt abgeleitetes Feld); nach Restore beim ersten Öffnen neu berechnen.
### Fehlercodes (neu/angepasst)
| Code | Bedeutung |
|------|-----------|
| `BACKUP_VERSION_UNSUPPORTED` | `version !== 2` (inkl. v1) |
| `BACKUP_INVALID_ARCHIVE` | Kein ZIP / kein manifest |
| `BACKUP_MISSING_BLOB` | Index verweist auf fehlende Datei |
| `BACKUP_INVALID_ENC` | Magic/ Länge ungültig |
| *(bestehend)* | `BACKUP_WRONG_PASSPHRASE`, `BACKUP_ID_CONFLICT`, … |
---
## 7. Code-Struktur (Implementierung)
| Datei | Aufgabe |
|-------|---------|
| [`client/src/services/logbookBackup/encBlob.ts`](client/src/services/logbookBackup/encBlob.ts) | `dexieRecordToEncBytes`, `encBytesToDexieFields` |
| [`client/src/services/logbookBackup/manifest.ts`](client/src/services/logbookBackup/manifest.ts) | Typen `BackupManifestV2`, Validierung |
| [`client/src/services/logbookBackup/zipArchive.ts`](client/src/services/logbookBackup/zipArchive.ts) | ZIP pack/unpack mit fflate |
| [`client/src/services/logbookBackup.ts`](client/src/services/logbookBackup.ts) | Öffentliche API: `exportLogbookBackup`, `parseLogbookBackupFile`, `preview…`, `restore…` — ruft v2 intern auf |
| [`client/src/components/LogbookBackupPanel.tsx`](client/src/components/LogbookBackupPanel.tsx) | `.daagbok`-Accept, Fortschrittsbalken, Größen-Warnung |
| i18n `settings.backup_*` | Texte für v2, `BACKUP_VERSION_UNSUPPORTED` |
| [`docs/plausible-events.md`](docs/plausible-events.md) | Properties `bytes`, `counts` bei Export/Restore |
**v1 entfernen:** `BACKUP_VERSION = 1`, `LogbookBackupFile`-Monolith, `JSON.stringify(…, null, 2)`, `normalizeBackupPayloads` für voiceMemos — löschen, nicht parallel halten.
---
## 8. Vergleich v1 → v2
| Aspekt | v1 | v2 |
|--------|----|----|
| Container | Eine JSON-Datei | ZIP `.daagbok` |
| Medien im Export | Base64 in JSON-Strings | Binäre `.enc`-Dateien |
| Manifest | Alles in einem Objekt | Schlankes `manifest.json` + Blobs |
| Kompression | Keine (+ Pretty-Print) | DEFLATE |
| RAM Import | `file.text()` + full parse | ZIP directory + gezieltes Entpacken; Preview nur Manifest |
| Fehlende Payloads | Kein crew/vessel selection, kein NMEA | Vollständig |
| Abwärtskompatibel | — | Nein (bewusst) |
### Größenbeispiel (Schätzung)
100 Fotos à ~250 KB verschlüsselt (≈190 KB Ciphertext):
- **v1 JSON:** ~100 × (190 KB × 4/3 Base64) ≈ **25 MB** nur Fotos-Ciphertext-Strings + JSON-Escaping + Pretty-Print → oft **35+ MB**
- **v2 ZIP:** ~100 × 190 KB + Kompression ≈ **1922 MB** Archiv
---
## 9. Implementierungsplan (Phasen)
### Phase 1 — Kern (MVP v2)
- [x] `encBlob` + `manifest` + `zipArchive` Module
- [x] Export/Import/Preview/Restore auf v2
- [x] Alle Dexie-Tabellen inkl. crew/vessel selection + nmeaArchives
- [x] UI: `.daagbok`, Fehler `BACKUP_VERSION_UNSUPPORTED` für v1-JSON
- [x] Tests: `encBlob` unit tests
### Phase 2 — UX & Robustheit
- [x] Export-Fortschritt
- [x] Größen-Warnung vor Import (>150 MB)
- [ ] `onProgress` während Restore
### Phase 3 — Optional
- [ ] Streaming-Export für sehr große Archive (fflate async + Chunk-Schreiben)
- [ ] PBKDF2 v2 mit höheren Iterationen
- [ ] Sidecar-Metadaten wenn Manifest > 2 MB
---
## 10. Testplan
| Test | Typ |
|------|-----|
| `encBlob`: round-trip Dexie-Felder ↔ Bytes | Unit |
| Manifest-Validator: version 2, fehlende paths | Unit |
| Export → unzip → Manifest-Counts = DB-Counts | Integration (Vitest + IndexedDB fake) |
| Restore → decrypt photo/voice → gültige Data-URL | Integration |
| v1-JSON-Datei → `BACKUP_VERSION_UNSUPPORTED` | Unit |
| Korruptes ZIP → `BACKUP_INVALID_ARCHIVE` | Unit |
---
## 11. Dokumentation & Analytics
- Nutzer-Texte: „Sicherungsdatei `.daagbok`“ statt `.daagbok.json`.
- Plausible **Backup Exported / Restored:** Properties `bytes`, `photos`, `voiceMemos` (Anzahlen, keine Inhalte).
- Deployment: **kein Server-Change** — Backup ist rein clientseitig.
---
## 12. Offene Punkte (für Review)
1. **Schwellwert Speicher-Warnung:** 150 MB uncompressed — anpassen nach ersten Real-Logbüchern?
2. **NMEA-Archive:** oft groß — eigenes Subdirectory ausreichend; später ggf. „NMEA nicht ins Backup“ als Opt-out?
3. **Geteilte Logbücher (`isShared === 1`):** Export weiterhin nur Owner — unverändert.
---
*Implementierung: [`client/src/services/logbookBackup/`](../client/src/services/logbookBackup/), API in [`client/src/services/logbookBackup.ts`](../client/src/services/logbookBackup.ts).*
+2 -2
View File
@@ -42,8 +42,8 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| Live Log Voice Uploaded | Sprachnotiz im Live-Journal gespeichert (`voiceAttachments.ts`, `analyticsContext`: `live_log`) | — | | Live Log Voice Uploaded | Sprachnotiz im Live-Journal gespeichert (`voiceAttachments.ts`, `analyticsContext`: `live_log`) | — |
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) | | OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
| AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — | | AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — |
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) | | Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) |
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` | | Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes`, `mode`: `same_id` \| `overwrite` \| `new_id` |
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — | | Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
| Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — | | Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — |
| Footer Link Clicked | Klick auf Autoren-Link im App-Footer (`AppFooter.tsx`) | — | | Footer Link Clicked | Klick auf Autoren-Link im App-Footer (`AppFooter.tsx`) | — |