Compare commits

...

16 Commits

Author SHA1 Message Date
elpatron 91cf2674f7 chore: release v0.1.0.111 2026-06-03 15:51:52 +02:00
elpatron b7a9df6ae0 refactor: drop redundant Live Log photo/voice Plausible events
Live-journal uploads are tracked only via Photo Uploaded and Voice Memo Uploaded with context live_log.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 15:40:22 +02:00
elpatron 7bc3c25ba4 feat: add discreet Ko-fi support badge in app footer
Let users support project development and running costs via ko-fi.com/kapteinsdaagbok, with i18n tooltips and Plausible tracking.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 15:33:56 +02:00
elpatron e2fa036b9c chore: release v0.1.0.110 2026-06-03 15:19:07 +02:00
elpatron 89f0f52841 Disable backup export until passphrases match.
The download button stays inactive until both fields agree and meet
the minimum length, so users cannot start export with a mismatch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 15:18:03 +02:00
elpatron 6f28ea0b16 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>
2026-06-03 15:13:51 +02:00
elpatron 975c7a2e40 Add voice memos to live journal and event log.
Record short E2E-encrypted audio attachments from the live log, link them to events via __live:voice markers, and play them back in the stream and chronological event table.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 14:52:12 +02:00
elpatron f83d67b527 Fix TypeScript null check when preserving AI summary on crew save.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 12:51:40 +02:00
elpatron 6c48085904 chore: release v0.1.0.109 2026-06-03 12:50:09 +02:00
elpatron 07de51be22 chore: release v0.1.0.108 2026-06-03 12:42:26 +02:00
elpatron d654aad937 Allow crew to read AI summaries without losing them on save.
Preserve existing summaries when crew edits entries and show a read-only hint in the editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 12:42:17 +02:00
elpatron dd111ce01f chore: release v0.1.0.107 2026-06-03 11:50:37 +02:00
elpatron 978e132c70 Remove unused decryptJson import after crew decrypt fix.
Fixes client TypeScript build blocked by pre-deploy checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:50:01 +02:00
elpatron 1ecebc5dbb chore: release v0.1.0.106 2026-06-03 11:49:23 +02:00
elpatron caf85ad9eb Fix shared logbook access for crew after AI summary sync.
Correct owner detection while the logbook loads, preserve AI summaries on
live-log saves, skip corrupt entry decrypts, and never regenerate keys for
shared logbooks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:48:45 +02:00
elpatron d637fbea16 Show clear offline messages for OWM weather and AI summaries.
Users see localized feedback when OpenWeatherMap or travel-day summary
features are used without connectivity, instead of generic API errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 11:43:10 +02:00
48 changed files with 2670 additions and 382 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.0.106
0.1.0.112
+1
View File
@@ -12,6 +12,7 @@
"bip39": "^3.1.0",
"dexie": "^4.4.2",
"dexie-react-hooks": "^4.4.0",
"fflate": "^0.8.3",
"i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1",
"jspdf": "^4.2.1",
+3 -2
View File
@@ -22,15 +22,16 @@
"bip39": "^3.1.0",
"dexie": "^4.4.2",
"dexie-react-hooks": "^4.4.0",
"fflate": "^0.8.3",
"i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1",
"jspdf": "^4.2.1",
"leaflet": "^1.9.4",
"lucide-react": "^1.16.0",
"qrcode": "^1.5.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8",
"qrcode": "^1.5.4"
"react-i18next": "^17.0.8"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+99
View File
@@ -3642,6 +3642,84 @@ html.theme-cupertino .events-scroll-container {
margin-top: 16px;
}
.live-log-summary-block {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.live-voice-modal .live-voice-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.live-voice-modal .live-voice-modal-header h3 {
margin: 0;
}
.live-voice-record-btn,
.live-voice-stop-btn {
width: 100%;
justify-content: center;
}
.live-voice-recording-indicator {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 12px;
font-weight: 600;
color: #f87171;
}
.live-voice-recording-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #ef4444;
animation: live-voice-pulse 1s ease-in-out infinite;
}
@keyframes live-voice-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.85); }
}
.live-voice-caption-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
font-size: 13px;
}
.voice-memo-player {
width: 100%;
max-width: 280px;
height: 32px;
}
.voice-memo-player--compact {
max-width: 220px;
}
.voice-memo-player-unavailable {
font-size: 12px;
color: #94a3b8;
font-style: italic;
}
.event-remarks-cell {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
}
@media (max-width: 720px) {
.live-log-layout {
grid-template-columns: 1fr;
@@ -5394,6 +5472,27 @@ html.theme-cupertino .events-scroll-container {
text-decoration: underline;
}
.kofi-footer-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
color: #94a3b8;
text-decoration: none;
background: rgba(255, 94, 91, 0.08);
border: 1px solid rgba(255, 94, 91, 0.18);
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
}
.kofi-footer-badge:hover {
color: #fecaca;
background: rgba(255, 94, 91, 0.14);
border-color: rgba(255, 94, 91, 0.32);
}
.demo-badge {
display: inline-flex;
align-items: center;
+3 -2
View File
@@ -103,7 +103,7 @@ function App() {
[activeLogbookId]
)
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>('OWNER')
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>(null)
useEffect(() => {
if (!activeLogbookId) {
@@ -574,7 +574,8 @@ function App() {
const logbookReadOnly =
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
const isLogbookOwner =
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
activeAccessRole === 'OWNER' ||
(activeLogbookRecord != null && activeLogbookRecord.isShared !== 1)
if (showUserProfile) {
return (
+20
View File
@@ -1,8 +1,13 @@
import { Coffee } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
const KOFI_URL = 'https://ko-fi.com/kapteinsdaagbok'
export default function AppFooter() {
const { t } = useTranslation()
return (
<footer className="app-version-footer">
<span className="app-version-footer__version">v{APP_VERSION}</span>
@@ -18,6 +23,21 @@ export default function AppFooter() {
Markus F.J. Busche
</a>
</span>
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a
className="kofi-footer-badge"
href={KOFI_URL}
target="_blank"
rel="noopener noreferrer"
title={t('footer.kofi_title')}
aria-label={t('footer.kofi_title')}
onClick={() => trackPlausibleEvent(PlausibleEvents.KOFI_LINK_CLICKED)}
>
<Coffee size={12} aria-hidden="true" />
<span>{t('footer.kofi_label')}</span>
</a>
</footer>
)
}
+1
View File
@@ -156,6 +156,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
preloadedYacht={yacht}
preloadedEntries={entries}
preloadedPhotos={photos}
preloadedVoiceMemos={[]}
preloadedGpsTracks={gpsTracks}
controlledSelectedEntryId={tourSelectedEntryId}
onSelectedEntryIdChange={setTourSelectedEntryId}
@@ -0,0 +1,40 @@
import { useTranslation } from 'react-i18next'
import type { LogEventPayload } from '../utils/logEntryPayload.js'
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
import { formatEventSummary } from '../utils/formatEventSummary.js'
import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
interface EventRemarksCellProps {
event: LogEventPayload
logbookId: string
voiceMemoLookup?: Map<string, PreloadedVoiceMemo>
}
export default function EventRemarksCell({
event,
logbookId,
voiceMemoLookup
}: EventRemarksCellProps) {
const { t } = useTranslation()
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
const preloaded = voiceId ? voiceMemoLookup?.get(voiceId) : undefined
let summary = formatEventSummary(event, t)
if (voiceId && preloaded?.caption) {
summary = t('logs.live_voice_entry', { caption: preloaded.caption })
}
return (
<div className="event-remarks-cell">
<span>{summary}</span>
{voiceId && (
<VoiceMemoPlayer
audioId={voiceId}
logbookId={logbookId}
preloaded={preloaded}
compact
/>
)}
</div>
)
}
+133 -9
View File
@@ -15,6 +15,7 @@ import {
MapPin,
MessageSquare,
Camera,
Mic,
Radio,
Sailboat,
Undo2,
@@ -40,6 +41,8 @@ import {
liveCommentRemark,
liveFuelRemark,
livePhotoRemark,
liveVoiceRemark,
parseLiveVoiceRemark,
livePrecipRemark,
liveSailsRemark,
liveSogRemark,
@@ -64,8 +67,13 @@ import {
import { useDialog } from './ModalDialog.tsx'
import CourseDialInput from './CourseDialInput.tsx'
import LiveCameraCapture from './LiveCameraCapture.tsx'
import LiveVoiceCapture from './LiveVoiceCapture.tsx'
import VoiceMemoPlayer from './VoiceMemoPlayer.tsx'
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import { blobToAudioDataUrl } from '../utils/audioBlob.js'
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
interface LiveLogViewProps {
logbookId: string
@@ -90,6 +98,7 @@ type LiveModal =
| 'stw'
| 'fix'
| 'photo'
| 'voice'
const AUTO_POSITION_INTERVAL_MS = 3 * 60 * 60 * 1000
const AUTO_POSITION_CHECK_MS = 60_000
@@ -152,6 +161,7 @@ export default function LiveLogView({
const [modal, setModal] = useState<LiveModal>('none')
const [weatherExpanded, setWeatherExpanded] = useState(false)
const [weatherOwmLoading, setWeatherOwmLoading] = useState(false)
const [isOnline, setIsOnline] = useState(navigator.onLine)
const [commentText, setCommentText] = useState('')
const [valueInput, setValueInput] = useState('')
const [valueInputSecondary, setValueInputSecondary] = useState('')
@@ -163,10 +173,13 @@ export default function LiveLogView({
const [fixGpsUnavailable, setFixGpsUnavailable] = useState(false)
const [photoCaption, setPhotoCaption] = useState('')
const [photoSaving, setPhotoSaving] = useState(false)
const [undoHint, setUndoHint] = useState<'event' | 'photo'>('event')
const [voiceCaption, setVoiceCaption] = useState('')
const [voiceSaving, setVoiceSaving] = useState(false)
const [undoHint, setUndoHint] = useState<'event' | 'photo' | 'voice'>('event')
const streamEndRef = useRef<HTMLDivElement | null>(null)
const undoPhotoIdRef = useRef<string | null>(null)
const undoVoiceIdRef = useRef<string | null>(null)
const undoTimerRef = useRef<number | null>(null)
const autoPositionBusyRef = useRef(false)
const busyRef = useRef(busy)
@@ -193,6 +206,7 @@ export default function LiveLogView({
() => (date ? getLatestPositionFix(events, date) != null : false),
[events, date]
)
const voiceMemoLookup = useEntryVoiceMemos(logbookId, entryId)
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
@@ -207,7 +221,7 @@ export default function LiveLogView({
applyLoadedEntry(loaded)
}, [logbookId, applyLoadedEntry])
const showUndo = useCallback((hint: 'event' | 'photo' = 'event') => {
const showUndo = useCallback((hint: 'event' | 'photo' | 'voice' = 'event') => {
setUndoHint(hint)
setUndoVisible(true)
if (undoTimerRef.current) window.clearTimeout(undoTimerRef.current)
@@ -215,6 +229,7 @@ export default function LiveLogView({
setUndoVisible(false)
undoTimerRef.current = null
undoPhotoIdRef.current = null
undoVoiceIdRef.current = null
}, UNDO_TIMEOUT_MS)
}, [])
@@ -269,6 +284,17 @@ export default function LiveLogView({
}
}, [logbookId, applyLoadedEntry, t])
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
useEffect(() => {
void runInit()
return () => {
@@ -503,6 +529,10 @@ export default function LiveLogView({
const handleFetchOwmWeather = () => {
if (!entryId || busy || weatherOwmLoading) return
if (!isOnline) {
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
return
}
const position = getLastPositionFixWithin(
events,
@@ -533,6 +563,10 @@ export default function LiveLogView({
{ analyticsSource: 'live_log' }
)
} catch (err) {
if (err instanceof WeatherApiError && err.code === 'OFFLINE') {
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
return
}
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
return
@@ -590,8 +624,10 @@ export default function LiveLogView({
const handleUndo = () => {
if (!entryId || busy) return
const photoId = undoPhotoIdRef.current
const voiceId = undoVoiceIdRef.current
setUndoVisible(false)
undoPhotoIdRef.current = null
undoVoiceIdRef.current = null
if (undoTimerRef.current) {
window.clearTimeout(undoTimerRef.current)
undoTimerRef.current = null
@@ -600,6 +636,9 @@ export default function LiveLogView({
if (photoId) {
await deleteEntryPhoto(logbookId, photoId)
}
if (voiceId) {
await deleteEntryVoiceMemo(logbookId, voiceId)
}
await removeLastEvent(logbookId, entryId)
}, 'undo', false)
}
@@ -615,6 +654,56 @@ export default function LiveLogView({
setPhotoCaption('')
}
const openVoiceModal = () => {
setVoiceCaption('')
setModal('voice')
}
const closeVoiceModal = () => {
if (voiceSaving) return
setModal('none')
setVoiceCaption('')
}
const handleVoiceSave = (blob: Blob, mimeType: string, durationSec: number) => {
if (!entryId || voiceSaving) return
const caption = voiceCaption.trim()
setVoiceSaving(true)
void (async () => {
try {
const audioDataUrl = await blobToAudioDataUrl(blob)
const voiceId = await saveEntryVoiceMemo({
logbookId,
entryId,
audioDataUrl,
mimeType,
durationSec,
caption,
analyticsContext: 'live_log'
})
await appendQuickEvent(logbookId, entryId, {
remarks: liveVoiceRemark(voiceId)
})
await refreshEntry(entryId)
undoVoiceIdRef.current = voiceId
setModal('none')
setVoiceCaption('')
showUndo('voice')
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'voice' })
} catch (err: unknown) {
console.error('Live log voice save failed:', err)
const msg = err instanceof Error && err.message === 'VOICE_MEMO_TOO_LARGE'
? t('logs.live_voice_too_large')
: err instanceof Error
? err.message
: t('logs.live_voice_error')
void showAlert(msg, t('logs.live_voice_btn'))
} finally {
setVoiceSaving(false)
}
})()
}
const handlePhotoCapture = (blob: Blob) => {
if (!entryId || photoSaving) return
const caption = photoCaption.trim()
@@ -959,6 +1048,10 @@ export default function LiveLogView({
<Camera size={18} />
{t('logs.live_photo_btn')}
</button>
<button type="button" className="live-log-action-btn" onClick={openVoiceModal} disabled={busy || voiceSaving}>
<Mic size={18} />
{t('logs.live_voice_btn')}
</button>
</aside>
<section className="live-log-stream-panel" aria-label={t('logs.live_stream_label')}>
@@ -967,12 +1060,30 @@ export default function LiveLogView({
<p className="live-log-empty">{t('logs.live_no_events')}</p>
) : (
<ol className="live-log-stream">
{events.map((event, index) => (
<li key={`${event.time}-${index}`} className="live-log-entry">
<time className="live-log-time">{event.time}</time>
<span className="live-log-summary">{formatEventSummary(event, t)}</span>
</li>
))}
{events.map((event, index) => {
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
const voicePreloaded = voiceId ? voiceMemoLookup.get(voiceId) : undefined
let summary = formatEventSummary(event, t)
if (voiceId && voicePreloaded?.caption) {
summary = t('logs.live_voice_entry', { caption: voicePreloaded.caption })
}
return (
<li key={`${event.time}-${index}`} className="live-log-entry">
<time className="live-log-time">{event.time}</time>
<div className="live-log-summary-block">
<span className="live-log-summary">{summary}</span>
{voiceId && (
<VoiceMemoPlayer
audioId={voiceId}
logbookId={logbookId}
preloaded={voicePreloaded}
compact
/>
)}
</div>
</li>
)
})}
<div ref={streamEndRef} />
</ol>
)}
@@ -986,7 +1097,11 @@ export default function LiveLogView({
<div className="live-log-undo-bar" role="status">
<div className="live-log-undo-bar-inner">
<span>
{undoHint === 'photo' ? t('logs.live_undo_photo_hint') : t('logs.live_undo_hint')}
{undoHint === 'photo'
? t('logs.live_undo_photo_hint')
: undoHint === 'voice'
? t('logs.live_undo_voice_hint')
: t('logs.live_undo_hint')}
</span>
<button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}>
<Undo2 size={16} />
@@ -1238,6 +1353,15 @@ export default function LiveLogView({
onClose={closePhotoModal}
onCapture={handlePhotoCapture}
/>
<LiveVoiceCapture
open={modal === 'voice'}
busy={voiceSaving}
caption={voiceCaption}
onCaptionChange={setVoiceCaption}
onClose={closeVoiceModal}
onSave={handleVoiceSave}
/>
</>,
document.body
)}
+280
View File
@@ -0,0 +1,280 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Mic, Square, X } from 'lucide-react'
import {
assertVoiceMemoBlobSize,
formatVoiceDuration,
pickMediaRecorderMimeType,
VOICE_MEMO_MAX_DURATION_SEC
} from '../utils/audioBlob.js'
interface LiveVoiceCaptureProps {
open: boolean
busy?: boolean
caption?: string
onCaptionChange?: (value: string) => void
onClose: () => void
onSave: (blob: Blob, mimeType: string, durationSec: number) => void
}
type Phase = 'idle' | 'recording' | 'preview'
export default function LiveVoiceCapture({
open,
busy = false,
caption = '',
onCaptionChange,
onClose,
onSave
}: LiveVoiceCaptureProps) {
const { t } = useTranslation()
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const chunksRef = useRef<Blob[]>([])
const previewUrlRef = useRef<string | null>(null)
const startedAtRef = useRef<number>(0)
const timerRef = useRef<number | null>(null)
const [phase, setPhase] = useState<Phase>('idle')
const [micError, setMicError] = useState<string | null>(null)
const [elapsedSec, setElapsedSec] = useState(0)
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewMime, setPreviewMime] = useState('audio/webm')
const [previewDurationSec, setPreviewDurationSec] = useState(0)
const [saving, setSaving] = useState(false)
const stopStream = useCallback(() => {
for (const track of streamRef.current?.getTracks() ?? []) {
track.stop()
}
streamRef.current = null
}, [])
const clearPreview = useCallback(() => {
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current)
previewUrlRef.current = null
}
setPreviewUrl(null)
setPreviewBlob(null)
}, [])
const clearTimer = useCallback(() => {
if (timerRef.current != null) {
window.clearInterval(timerRef.current)
timerRef.current = null
}
}, [])
const resetAll = useCallback(() => {
if (mediaRecorderRef.current?.state === 'recording') {
mediaRecorderRef.current.stop()
}
mediaRecorderRef.current = null
chunksRef.current = []
clearTimer()
stopStream()
clearPreview()
setPhase('idle')
setMicError(null)
setElapsedSec(0)
setSaving(false)
}, [stopStream, clearPreview, clearTimer])
useEffect(() => {
if (!open) {
resetAll()
}
}, [open, resetAll])
useEffect(() => {
return () => {
resetAll()
}
}, [resetAll])
const finishRecording = useCallback((blob: Blob, mimeType: string, durationSec: number) => {
clearPreview()
const url = URL.createObjectURL(blob)
previewUrlRef.current = url
setPreviewBlob(blob)
setPreviewUrl(url)
setPreviewMime(mimeType)
setPreviewDurationSec(durationSec)
setPhase('preview')
}, [clearPreview])
const stopRecording = useCallback(() => {
const recorder = mediaRecorderRef.current
if (!recorder || recorder.state !== 'recording') return
recorder.stop()
clearTimer()
stopStream()
}, [clearTimer, stopStream])
const startRecording = async () => {
setMicError(null)
chunksRef.current = []
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
streamRef.current = stream
const mimeType = pickMediaRecorderMimeType()
const recorder = mimeType
? new MediaRecorder(stream, { mimeType })
: new MediaRecorder(stream)
mediaRecorderRef.current = recorder
const resolvedMime = recorder.mimeType || mimeType || 'audio/webm'
recorder.ondataavailable = (ev) => {
if (ev.data.size > 0) chunksRef.current.push(ev.data)
}
recorder.onstop = () => {
const durationSec = Math.min(
VOICE_MEMO_MAX_DURATION_SEC,
Math.max(1, Math.round((Date.now() - startedAtRef.current) / 1000))
)
const blob = new Blob(chunksRef.current, { type: resolvedMime })
chunksRef.current = []
try {
assertVoiceMemoBlobSize(blob)
finishRecording(blob, resolvedMime, durationSec)
} catch {
setMicError(t('logs.live_voice_too_large'))
setPhase('idle')
}
}
recorder.onerror = () => {
setMicError(t('logs.live_voice_record_failed'))
resetAll()
}
startedAtRef.current = Date.now()
recorder.start(200)
setPhase('recording')
setElapsedSec(0)
timerRef.current = window.setInterval(() => {
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000)
setElapsedSec(sec)
if (sec >= VOICE_MEMO_MAX_DURATION_SEC) {
stopRecording()
}
}, 250)
} catch {
setMicError(t('logs.live_voice_mic_denied'))
stopStream()
}
}
const handleSave = async () => {
if (!previewBlob || saving || busy) return
setSaving(true)
try {
onSave(previewBlob, previewMime, previewDurationSec)
} finally {
setSaving(false)
}
}
if (!open) return null
return (
<div
className="live-log-modal-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget && !busy && !saving && phase !== 'recording') onClose()
}}
>
<div className="live-log-modal live-voice-modal" onClick={(e) => e.stopPropagation()}>
<div className="live-voice-modal-header">
<h3>{t('logs.live_voice_btn')}</h3>
<button
type="button"
className="btn-icon"
onClick={onClose}
disabled={busy || saving || phase === 'recording'}
aria-label={t('logs.confirm_no')}
>
<X size={18} />
</button>
</div>
{micError && <p className="live-log-modal-hint auth-error">{micError}</p>}
{phase === 'idle' && (
<>
<p className="live-log-modal-hint">{t('logs.live_voice_hint')}</p>
<button
type="button"
className="btn primary live-voice-record-btn"
onClick={() => void startRecording()}
disabled={busy || saving}
>
<Mic size={18} />
{t('logs.live_voice_record')}
</button>
</>
)}
{phase === 'recording' && (
<>
<p className="live-voice-recording-indicator" role="status" aria-live="polite">
<span className="live-voice-recording-dot" aria-hidden />
{t('logs.live_voice_recording', { time: formatVoiceDuration(elapsedSec) })}
</p>
<button
type="button"
className="btn primary live-voice-stop-btn"
onClick={stopRecording}
>
<Square size={16} fill="currentColor" />
{t('logs.live_voice_stop')}
</button>
</>
)}
{phase === 'preview' && previewUrl && (
<>
<audio className="voice-memo-player" controls src={previewUrl} preload="auto" />
{onCaptionChange && (
<label className="live-voice-caption-field">
<span>{t('logs.live_voice_caption_label')}</span>
<input
type="text"
className="input-text"
value={caption}
onChange={(e) => onCaptionChange(e.target.value)}
placeholder={t('logs.live_voice_caption_placeholder')}
disabled={busy || saving}
/>
</label>
)}
<div className="live-log-modal-actions">
<button
type="button"
className="btn secondary"
onClick={() => {
clearPreview()
setPhase('idle')
}}
disabled={busy || saving}
>
{t('logs.live_voice_retake')}
</button>
<button
type="button"
className="btn primary"
onClick={() => void handleSave()}
disabled={busy || saving}
>
{saving ? t('logs.live_voice_saving') : t('logs.live_voice_save')}
</button>
</div>
</>
)}
</div>
</div>
)
}
+7 -4
View File
@@ -3,13 +3,13 @@ import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { decryptJson, encryptJson } from '../services/crypto.js'
import { encryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js'
import { findTodayEntryId } from '../services/quickEventLog.js'
import { findTodayEntryId, tryDecryptEntryPayload } from '../services/quickEventLog.js'
import LogEntryEditor from './LogEntryEditor.tsx'
import LiveLogView from './LiveLogView.tsx'
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
@@ -39,6 +39,7 @@ interface LogEntriesListProps {
preloadedYacht?: any
preloadedEntries?: any[]
preloadedPhotos?: any[]
preloadedVoiceMemos?: import('./VoiceMemoPlayer.tsx').PreloadedVoiceMemo[]
preloadedGpsTracks?: any[]
controlledSelectedEntryId?: string | null
onSelectedEntryIdChange?: (id: string | null) => void
@@ -63,6 +64,7 @@ export default function LogEntriesList({
preloadedYacht,
preloadedEntries,
preloadedPhotos,
preloadedVoiceMemos,
preloadedGpsTracks,
controlledSelectedEntryId,
onSelectedEntryIdChange,
@@ -136,7 +138,7 @@ export default function LogEntriesList({
}
await forEachInBatches(needsDecrypt, 8, async (entry) => {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (!decrypted) return
const listCache = await buildEntryListCache(decrypted as Record<string, unknown>)
@@ -266,7 +268,7 @@ export default function LogEntriesList({
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
for (const entry of localEntries) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
}
@@ -403,6 +405,7 @@ export default function LogEntriesList({
readOnly={readOnly}
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
preloadedPhotos={preloadedPhotos}
preloadedVoiceMemos={preloadedVoiceMemos}
preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
/>
)
+72 -6
View File
@@ -10,6 +10,11 @@ import { getErrorMessage } from '../utils/errors.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp, Sparkles } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx'
import EventRemarksCell from './EventRemarksCell.tsx'
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
import { deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
import type { PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
import SignatureSection from './SignatureSection.tsx'
import EntryCrewSection from './EntryCrewSection.tsx'
import { emptyEntryCrewFields, type EntryCrewFields } from '../types/person.js'
@@ -43,6 +48,7 @@ import {
generateTravelDaySummary,
TravelDaySummaryApiError
} from '../services/aiSummary.js'
import { tryDecryptEntryPayload } from '../services/quickEventLog.js'
import {
getDecryptedTrack,
saveUploadedTrack,
@@ -155,6 +161,7 @@ interface LogEntryEditorProps {
readOnly?: boolean
preloadedEntry?: any
preloadedPhotos?: any[]
preloadedVoiceMemos?: PreloadedVoiceMemo[]
preloadedTrack?: any
preloadedYacht?: any
}
@@ -168,6 +175,7 @@ export default function LogEntryEditor({
readOnly = false,
preloadedEntry,
preloadedPhotos,
preloadedVoiceMemos,
preloadedTrack,
preloadedYacht
}: LogEntryEditorProps) {
@@ -225,6 +233,7 @@ export default function LogEntryEditor({
// Events list state
const [events, setEvents] = useState<LogEvent[]>([])
const voiceMemoLookup = useEntryVoiceMemos(logbookId, entryId, preloadedVoiceMemos)
// Add Event Form State
const [evTime, setEvTime] = useState(() => currentLocalTimeHHMM())
@@ -457,13 +466,30 @@ export default function LogEntryEditor({
const eventsOverride = normalized.eventsOverride
const skipperToSave = normalized.signSkipper !== undefined ? normalized.signSkipper : signSkipper
const crewToSave = normalized.signCrew !== undefined ? normalized.signCrew : signCrew
const summaryToSave = normalized.aiSummary !== undefined ? normalized.aiSummary : aiSummary
const summaryAtToSave =
let summaryToSave = normalized.aiSummary !== undefined ? normalized.aiSummary : aiSummary
let summaryAtToSave =
normalized.aiSummaryGeneratedAt !== undefined ? normalized.aiSummaryGeneratedAt : aiSummaryGeneratedAt
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
// Crew edits must not drop the skipper's AI summary when it is not loaded into editor state.
if (!summaryToSave.trim()) {
const local = await db.entries.get(entryId)
if (local) {
const decrypted = await tryDecryptEntryPayload(local, masterKey)
if (decrypted) {
const existing =
typeof decrypted.aiSummary === 'string' ? decrypted.aiSummary.trim() : ''
if (existing) {
summaryToSave = existing
summaryAtToSave =
typeof decrypted.aiSummaryGeneratedAt === 'string' ? decrypted.aiSummaryGeneratedAt : ''
}
}
}
}
const entryData: Record<string, unknown> = {
...buildPayloadForSigning(eventsOverride),
signSkipper: normalizedSerializedSignature(skipperToSave),
@@ -527,6 +553,8 @@ export default function LogEntryEditor({
}, [])
useEffect(() => {
setCanSignSkipper(false)
setCanSignCrew(false)
getLogbookAccess(logbookId).then((access) => {
if (!access) return
setCanSignSkipper(access.isOwner)
@@ -538,7 +566,7 @@ export default function LogEntryEditor({
}, [logbookId])
useEffect(() => {
if (!canSignSkipper || readOnly) {
if (!canSignSkipper || readOnly || !isOnline) {
setAiSummaryRemaining(null)
return
}
@@ -553,7 +581,7 @@ export default function LogEntryEditor({
console.warn('Failed to load AI summary usage:', err)
})
return () => { cancelled = true }
}, [canSignSkipper, readOnly, logbookId, entryId])
}, [canSignSkipper, readOnly, isOnline, logbookId, entryId])
useEffect(() => {
const seq = ++entryHashSeqRef.current
@@ -986,6 +1014,10 @@ export default function LogEntryEditor({
showAlert('GPS capturing failed, and no location name is entered in "Ort / Hafen" or "Start-Hafen" to look up coordinates.')
return
}
if (!isOnline) {
showAlert(t('logs.weather_offline'))
return
}
try {
const data = await fetchOpenWeatherCurrent(
@@ -999,6 +1031,10 @@ export default function LogEntryEditor({
showAlert(`Coordinates loaded for "${locationQuery}" via OpenWeatherMap.`)
}
} catch (e) {
if (e instanceof WeatherApiError && e.code === 'OFFLINE') {
showAlert(t('logs.weather_offline'))
return
}
if (e instanceof WeatherApiError && e.code === 'NO_KEY') {
showAlert(t('settings.no_key'))
return
@@ -1025,6 +1061,11 @@ export default function LogEntryEditor({
}
const handleFetchWeather = async () => {
if (!isOnline) {
showAlert(t('logs.weather_offline'))
return
}
const localToday = new Date()
const todayStr = `${localToday.getFullYear()}-${String(localToday.getMonth() + 1).padStart(2, '0')}-${String(localToday.getDate()).padStart(2, '0')}`
@@ -1066,6 +1107,10 @@ export default function LogEntryEditor({
showAlert(t('settings.weather_success'))
} catch (err) {
if (err instanceof WeatherApiError && err.code === 'OFFLINE') {
showAlert(t('logs.weather_offline'))
return
}
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
showAlert(t('settings.no_key'))
return
@@ -1079,6 +1124,10 @@ export default function LogEntryEditor({
const handleGenerateAiSummary = async () => {
if (!canSignSkipper || readOnly || aiSummaryLoading) return
if (!isOnline) {
setAiSummaryError(t('logs.ai_summary_offline'))
return
}
if (aiSummaryRemaining === 0) {
setAiSummaryError(t('logs.ai_summary_error_rate_limited'))
return
@@ -1135,7 +1184,9 @@ export default function LogEntryEditor({
})
} catch (err) {
if (err instanceof TravelDaySummaryApiError) {
if (err.code === 'NO_KEY') {
if (err.code === 'OFFLINE') {
setAiSummaryError(t('logs.ai_summary_offline'))
} else if (err.code === 'NO_KEY') {
setAiSummaryError(t('logs.ai_summary_error_no_key'))
} else if (err.code === 'RATE_LIMITED') {
setAiSummaryError(t('logs.ai_summary_error_rate_limited'))
@@ -1301,6 +1352,7 @@ export default function LogEntryEditor({
const handleDeleteEvent = async (index: number) => {
if (readOnly) return
const voiceId = parseLiveVoiceRemark(events[index]?.remarks?.trim() ?? '')
const hadSkipperSignature = !!signSkipper
markSkipperSignatureClearedForEventChange()
const nextEvents = events.filter((_, idx) => idx !== index)
@@ -1318,6 +1370,9 @@ export default function LogEntryEditor({
}
try {
if (voiceId && !readOnly) {
await deleteEntryVoiceMemo(logbookId, voiceId)
}
await persistEntryToDb(nextEvents)
} catch (err: any) {
console.error('Failed to auto-save after event delete:', err)
@@ -1526,6 +1581,11 @@ export default function LogEntryEditor({
<Sparkles size={20} className="form-icon" />
<h3>{t('logs.ai_summary_title')}</h3>
</div>
{aiSummary.trim() && !canSignSkipper && (
<p style={{ margin: '0 0 12px', fontSize: '0.9rem', opacity: 0.8 }}>
{t('logs.ai_summary_read_only')}
</p>
)}
{aiSummary.trim() ? (
<p style={{ whiteSpace: 'pre-wrap', margin: '0 0 16px', lineHeight: 1.5 }}>{aiSummary}</p>
) : (
@@ -1751,7 +1811,13 @@ export default function LogEntryEditor({
<td className="font-mono text-sm">
{ev.gpsLat && ev.gpsLng ? `${ev.gpsLat}, ${ev.gpsLng}` : '—'}
</td>
<td className="remarks-td">{ev.remarks}</td>
<td className="remarks-td">
<EventRemarksCell
event={ev}
logbookId={logbookId}
voiceMemoLookup={voiceMemoLookup}
/>
</td>
{!readOnly && (
<td className="events-actions-td">
<button
+62 -10
View File
@@ -5,10 +5,12 @@ import { useDialog } from './ModalDialog.tsx'
import {
downloadBackupBlob,
exportLogbookBackup,
formatBackupBytes,
parseLogbookBackupFile,
previewLogbookBackup,
restoreLogbookBackup,
type LogbookBackupFile,
BACKUP_SIZE_CONFIRM_BYTES,
type ParsedLogbookBackup,
type LogbookBackupPreview
} from '../services/logbookBackup.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')
case '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':
return t('settings.backup_invalid_format')
case 'BACKUP_NOT_AUTHENTICATED':
@@ -53,12 +61,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
const [importPassphrase, setImportPassphrase] = useState('')
const [importFile, setImportFile] = useState<File | 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 [previewing, setPreviewing] = useState(false)
const [exportProgress, setExportProgress] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const exportPassphrasesMatch =
exportPassphrase.length >= 8 && exportPassphrase === exportConfirm
const handleExportSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await handleExport()
@@ -83,21 +95,36 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
}
setExporting(true)
setExportProgress(null)
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)
setSuccess(t('settings.backup_export_success', { count: backup.counts.entries }))
setSuccess(t('settings.backup_export_success', { count: manifest.counts.entries }))
setExportPassphrase('')
setExportConfirm('')
trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, {
entries: backup.counts.entries,
photos: backup.counts.photos
entries: manifest.counts.entries,
photos: manifest.counts.photos,
voiceMemos: manifest.counts.voiceMemos,
bytes: manifest.totalUncompressedBytes
})
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
setError(mapBackupError(message, t))
} finally {
setExporting(false)
setExportProgress(null)
}
}
@@ -138,6 +165,18 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
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)
setError(null)
try {
@@ -149,8 +188,10 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
setParsedBackup(null)
if (fileInputRef.current) fileInputRef.current.value = ''
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
entries: parsedBackup.counts.entries,
photos: parsedBackup.counts.photos,
entries: parsedBackup.manifest.counts.entries,
photos: parsedBackup.manifest.counts.photos,
voiceMemos: parsedBackup.manifest.counts.voiceMemos,
bytes: parsedBackup.manifest.totalUncompressedBytes,
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
})
onRestored?.(result.logbookId, result.title)
@@ -253,11 +294,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
<button
type="submit"
className="btn primary"
disabled={exporting || !exportPassphrase || !exportConfirm}
disabled={exporting || !exportPassphrasesMatch}
>
<Download size={16} />
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
</button>
{exportProgress && (
<p className="text-muted backup-export-progress" role="status">
{exportProgress}
</p>
)}
</form>
</section>
@@ -275,7 +321,7 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
id="backup-import-file"
ref={fileInputRef}
type="file"
accept=".daagbok.json,application/json"
accept=".daagbok,application/zip"
className="input-text"
onChange={handleFileChange}
disabled={importing}
@@ -330,8 +376,14 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
<ul className="backup-preview-stats">
<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_voice', { count: importPreview.counts.voiceMemos })}</li>
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</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>
<p className="text-muted backup-preview-date">
{t('settings.backup_exported_at', {
+19
View File
@@ -46,6 +46,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
const [legacyCrews, setLegacyCrews] = useState<any[]>([])
const [entries, setEntries] = useState<any[]>([])
const [photos, setPhotos] = useState<any[]>([])
const [voiceMemos, setVoiceMemos] = useState<any[]>([])
const [gpsTracks, setGpsTracks] = useState<any[]>([])
useEffect(() => {
@@ -174,6 +175,23 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
}
setPhotos(decPhotos)
const decVoiceMemos = []
if (data.voiceMemos) {
for (const v of data.voiceMemos) {
const dec = await decryptJson(v.encryptedData, v.iv, v.tag, keyBuffer)
if (dec) {
decVoiceMemos.push({
payloadId: v.payloadId,
audio: dec.audio,
mimeType: dec.mimeType,
durationSec: dec.durationSec,
caption: dec.caption || ''
})
}
}
}
setVoiceMemos(decVoiceMemos)
// Decrypt GPS Tracks
const decGpsTracks = []
if (data.gpsTracks) {
@@ -282,6 +300,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
preloadedYacht={yacht}
preloadedEntries={entries}
preloadedPhotos={photos}
preloadedVoiceMemos={voiceMemos}
preloadedGpsTracks={gpsTracks}
/>
)}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { decryptJson } from '../services/crypto.js'
export interface PreloadedVoiceMemo {
payloadId: string
audio: string
mimeType?: string
durationSec?: number
caption?: string
}
interface VoiceMemoPlayerProps {
audioId: string
logbookId: string
preloaded?: PreloadedVoiceMemo | null
compact?: boolean
}
export default function VoiceMemoPlayer({
audioId,
logbookId,
preloaded,
compact = false
}: VoiceMemoPlayerProps) {
const { t } = useTranslation()
const [src, setSrc] = useState<string | null>(preloaded?.audio ?? null)
const [error, setError] = useState(false)
useEffect(() => {
if (preloaded?.audio) {
setSrc(preloaded.audio)
setError(false)
return
}
let cancelled = false
void (async () => {
try {
const record = await db.voiceMemos.get(audioId)
if (!record || cancelled) return
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey || cancelled) return
const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
if (!decrypted?.audio || cancelled) {
setError(true)
return
}
setSrc(String(decrypted.audio))
setError(false)
} catch {
if (!cancelled) setError(true)
}
})()
return () => {
cancelled = true
}
}, [audioId, logbookId, preloaded?.audio])
if (error || !src) {
return (
<span className="voice-memo-player-unavailable">
{t('logs.live_voice_unavailable')}
</span>
)
}
return (
<audio
className={compact ? 'voice-memo-player voice-memo-player--compact' : 'voice-memo-player'}
controls
preload="none"
src={src}
/>
)
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { decryptJson } from '../services/crypto.js'
import type { PreloadedVoiceMemo } from '../components/VoiceMemoPlayer.tsx'
export function useEntryVoiceMemos(
logbookId: string,
entryId: string | null,
preloaded?: PreloadedVoiceMemo[]
): Map<string, PreloadedVoiceMemo> {
const localMemos = useLiveQuery(
() => (entryId ? db.voiceMemos.where({ entryId }).toArray() : []),
[entryId]
)
const [lookup, setLookup] = useState<Map<string, PreloadedVoiceMemo>>(new Map())
useEffect(() => {
if (preloaded && preloaded.length > 0) {
const map = new Map<string, PreloadedVoiceMemo>()
for (const m of preloaded) {
map.set(m.payloadId, m)
}
setLookup(map)
return
}
if (!entryId || !localMemos) {
setLookup(new Map())
return
}
let cancelled = false
void (async () => {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey || cancelled) return
const map = new Map<string, PreloadedVoiceMemo>()
for (const row of localMemos) {
try {
const decrypted = await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)
if (!decrypted?.audio) continue
map.set(row.payloadId, {
payloadId: row.payloadId,
audio: String(decrypted.audio),
mimeType: decrypted.mimeType ? String(decrypted.mimeType) : undefined,
durationSec: typeof decrypted.durationSec === 'number' ? decrypted.durationSec : undefined,
caption: decrypted.caption ? String(decrypted.caption) : ''
})
} catch {
// skip corrupt memo
}
}
if (!cancelled) setLookup(map)
})()
return () => {
cancelled = true
}
}, [localMemos, entryId, logbookId, preloaded])
return lookup
}
+35 -4
View File
@@ -6,6 +6,10 @@
"beta": "Beta",
"beta_hint": "Betaversion - funktioner kan stadig ændres"
},
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Støt projektet, videreudvikling og driftsomkostninger på Ko-fi"
},
"languages": {
"de": "Deutsch",
"en": "English",
@@ -266,6 +270,24 @@
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget",
"live_undo_photo_hint": "Foto gemt",
"live_voice_btn": "Stemmenotat",
"live_voice_hint": "Optag en kort stemmenotat (maks. 60 sekunder).",
"live_voice_record": "Start optagelse",
"live_voice_stop": "Stop optagelse",
"live_voice_recording": "Optager {{time}}",
"live_voice_save": "Gem",
"live_voice_saving": "Gemmer…",
"live_voice_retake": "Optag igen",
"live_voice_mic_denied": "Mikrofonadgang nægtet eller utilgængelig.",
"live_voice_record_failed": "Optagelse mislykkedes. Prøv igen.",
"live_voice_unavailable": "Stemmenotat utilgængelig",
"live_voice_too_large": "Optagelsen er for stor. Optag venligst kortere.",
"live_voice_error": "Kunne ikke gemme stemmenotat.",
"live_voice_entry": "Stemmenotat: {{caption}}",
"live_voice_entry_plain": "Stemmenotat",
"live_voice_caption_label": "Billedtekst (valgfrit)",
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnemester",
"live_undo_voice_hint": "Stemmenotat gemt",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Indtast tekst…",
"live_comment_confirm": "Indtast",
@@ -358,6 +380,7 @@
"event_remarks": "Bemærkninger / hændelser",
"gps_btn": "Hent GPS-koordinater",
"weather_btn": "OpenWeatherMap Kald vejret op",
"weather_offline": "OpenWeatherMap kræver internetforbindelse. Du er offline lige nu.",
"event_wind_pressure": "Lufttryk (hPa)",
"event_heel": "Krængning (°)",
"event_sails": "Sejlhåndtering/motor",
@@ -372,6 +395,7 @@
"export_pdf": "Download PDF.",
"exporting_pdf": "PDF er genereret...",
"ai_summary_title": "AI-resumé",
"ai_summary_read_only": "Oprettet af skipperen — kun læsning for besætningen.",
"ai_summary_empty": "Intet resumé endnu.",
"ai_summary_generate": "Generér resumé",
"ai_summary_regenerate": "Generér igen",
@@ -381,6 +405,7 @@
"ai_summary_error_no_key": "Ingen OpenRouter API-nøgle konfigureret på serveren.",
"ai_summary_error_rate_limited": "Maksimalt antal genereringer nået for denne rejsedag.",
"ai_summary_error_forbidden": "Kun skipperen må generere AI-resuméer.",
"ai_summary_offline": "AI-resumé kræver internetforbindelse. Du er offline lige nu.",
"photos_title": "Vedhæftede billeder (E2E-krypteret)",
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen",
@@ -474,7 +499,7 @@
"new_logbook_placeholder": "Navn på logbog eller yacht",
"logout": "Log ud",
"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!",
"loading": "Logbøgerne er fyldt op...",
"status_synced": "Synkroniseret",
@@ -753,7 +778,7 @@
"delete_account_confirm_yes": "Ja, slet konto og alle data",
"delete_account_confirm_no": "Annuller",
"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...",
"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.",
@@ -764,7 +789,7 @@
"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_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_desc": "Gendanner en sikkerhedskopi til din nuværende konto - selv efter registrering af en ny konto.",
"backup_passphrase": "Backup-passphrase",
@@ -776,7 +801,13 @@
"backup_export_btn": "Download backup",
"backup_exporting": "Sikkerhedskopien er oprettet...",
"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_previewing": "Tjek...",
"backup_restore_btn": "Gendan",
+36 -5
View File
@@ -6,6 +6,10 @@
"beta": "Beta",
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
},
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Projekt, Weiterentwicklung und Betriebskosten auf Ko-fi unterstützen"
},
"languages": {
"de": "Deutsch",
"en": "English",
@@ -266,6 +270,24 @@
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto aufgenommen",
"live_undo_photo_hint": "Foto gespeichert",
"live_voice_btn": "Sprachnotiz",
"live_voice_hint": "Kurze Sprachnotiz aufnehmen (max. 60 Sekunden).",
"live_voice_record": "Aufnahme starten",
"live_voice_stop": "Aufnahme beenden",
"live_voice_recording": "Aufnahme {{time}}",
"live_voice_save": "Speichern",
"live_voice_saving": "Wird gespeichert…",
"live_voice_retake": "Neu aufnehmen",
"live_voice_mic_denied": "Mikrofonzugriff verweigert oder nicht verfügbar.",
"live_voice_record_failed": "Aufnahme fehlgeschlagen. Bitte erneut versuchen.",
"live_voice_unavailable": "Sprachnotiz nicht verfügbar",
"live_voice_too_large": "Aufnahme ist zu groß. Bitte kürzer aufnehmen.",
"live_voice_error": "Sprachnotiz konnte nicht gespeichert werden.",
"live_voice_entry": "Sprachnotiz: {{caption}}",
"live_voice_entry_plain": "Sprachnotiz",
"live_voice_caption_label": "Beschriftung (optional)",
"live_voice_caption_placeholder": "z. B. Funkverkehr mit Hafenmeister",
"live_undo_voice_hint": "Sprachnotiz gespeichert",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Freitext eingeben…",
"live_comment_confirm": "Eintragen",
@@ -358,6 +380,7 @@
"event_remarks": "Bemerkungen / Vorkommnisse",
"gps_btn": "GPS-Koordinaten abrufen",
"weather_btn": "OpenWeatherMap Wetter abrufen",
"weather_offline": "OpenWeatherMap erfordert eine Internetverbindung. Du bist derzeit offline.",
"event_wind_pressure": "Luftdruck (hPa)",
"event_heel": "Krängung (°)",
"event_sails": "Segelführung / Motor",
@@ -372,6 +395,7 @@
"export_pdf": "PDF herunterladen",
"exporting_pdf": "PDF wird generiert...",
"ai_summary_title": "KI-Zusammenfassung",
"ai_summary_read_only": "Vom Skipper erstellt — nur lesbar für die Crew.",
"ai_summary_empty": "Noch keine Zusammenfassung vorhanden.",
"ai_summary_generate": "Zusammenfassung generieren",
"ai_summary_regenerate": "Neu generieren",
@@ -381,6 +405,7 @@
"ai_summary_error_no_key": "Kein OpenRouter API-Schlüssel auf dem Server konfiguriert.",
"ai_summary_error_rate_limited": "Maximale Anzahl an Generierungen für diesen Reisetag erreicht.",
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
"photos_title": "Foto-Anhänge (E2E-verschlüsselt)",
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
@@ -474,7 +499,7 @@
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
"logout": "Abmelden",
"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!",
"loading": "Logbücher werden geladen...",
"status_synced": "Synchronisiert",
@@ -753,7 +778,7 @@
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
"delete_account_confirm_no": "Abbrechen",
"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…",
"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.",
@@ -762,9 +787,9 @@
"invite_push_prompt_later": "Später",
"invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
"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_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_desc": "Stellt ein Backup in deinem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
"backup_passphrase": "Backup-Passphrase",
@@ -776,7 +801,13 @@
"backup_export_btn": "Backup herunterladen",
"backup_exporting": "Backup wird erstellt…",
"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_previewing": "Prüfe…",
"backup_restore_btn": "Wiederherstellen",
+36 -5
View File
@@ -6,6 +6,10 @@
"beta": "Beta",
"beta_hint": "Beta release — features may still change"
},
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Support the project, development, and running costs on Ko-fi"
},
"languages": {
"de": "Deutsch",
"en": "English",
@@ -266,6 +270,24 @@
"live_photo_entry": "Photo: {{caption}}",
"live_photo_entry_plain": "Photo captured",
"live_undo_photo_hint": "Photo saved",
"live_voice_btn": "Voice memo",
"live_voice_hint": "Record a short voice memo (max. 60 seconds).",
"live_voice_record": "Start recording",
"live_voice_stop": "Stop recording",
"live_voice_recording": "Recording {{time}}",
"live_voice_save": "Save",
"live_voice_saving": "Saving…",
"live_voice_retake": "Record again",
"live_voice_mic_denied": "Microphone access denied or unavailable.",
"live_voice_record_failed": "Recording failed. Please try again.",
"live_voice_unavailable": "Voice memo unavailable",
"live_voice_too_large": "Recording is too large. Please record a shorter memo.",
"live_voice_error": "Could not save voice memo.",
"live_voice_entry": "Voice memo: {{caption}}",
"live_voice_entry_plain": "Voice memo",
"live_voice_caption_label": "Caption (optional)",
"live_voice_caption_placeholder": "e.g. radio call with harbour master",
"live_undo_voice_hint": "Voice memo saved",
"live_comment_btn": "Comment",
"live_comment_placeholder": "Enter text…",
"live_comment_confirm": "Log entry",
@@ -358,6 +380,7 @@
"event_remarks": "Remarks / Events",
"gps_btn": "Get GPS Location",
"weather_btn": "Fetch OpenWeatherMap Weather",
"weather_offline": "OpenWeatherMap requires an internet connection. You are currently offline.",
"event_wind_pressure": "Barometer (hPa)",
"event_heel": "Heel Angle (°)",
"event_sails": "Sails / Motor Status",
@@ -372,6 +395,7 @@
"export_pdf": "Download PDF",
"exporting_pdf": "Generating PDF...",
"ai_summary_title": "AI Summary",
"ai_summary_read_only": "Created by the skipper — read-only for crew.",
"ai_summary_empty": "No summary yet.",
"ai_summary_generate": "Generate summary",
"ai_summary_regenerate": "Regenerate",
@@ -381,6 +405,7 @@
"ai_summary_error_no_key": "No OpenRouter API key configured on the server.",
"ai_summary_error_rate_limited": "Maximum number of generations reached for this travel day.",
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.",
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
"photos_title": "Photo Attachments (E2E Encrypted)",
"photo_caption_label": "Photo Caption / Label (Optional)",
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
@@ -474,7 +499,7 @@
"new_logbook_placeholder": "Logbook or Yacht Name",
"logout": "Logout",
"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!",
"loading": "Loading logbooks...",
"status_synced": "Synced",
@@ -753,7 +778,7 @@
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
"delete_account_confirm_no": "Cancel",
"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…",
"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.",
@@ -762,9 +787,9 @@
"invite_push_prompt_later": "Later",
"invite_push_prompt_success": "Push notifications are active on this device.",
"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_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_desc": "Restores a backup into your current account — including after registering a new account.",
"backup_passphrase": "Backup passphrase",
@@ -776,7 +801,13 @@
"backup_export_btn": "Download backup",
"backup_exporting": "Creating backup…",
"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_previewing": "Verifying…",
"backup_restore_btn": "Restore",
+35 -4
View File
@@ -6,6 +6,10 @@
"beta": "Beta",
"beta_hint": "Betaversjon - funksjoner kan fortsatt endres"
},
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Støtt prosjektet, videreutvikling og driftskostnader på Ko-fi"
},
"languages": {
"de": "Deutsch",
"en": "English",
@@ -266,6 +270,24 @@
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto tatt",
"live_undo_photo_hint": "Foto lagret",
"live_voice_btn": "Talemelding",
"live_voice_hint": "Ta opp en kort talemelding (maks. 60 sekunder).",
"live_voice_record": "Start opptak",
"live_voice_stop": "Stopp opptak",
"live_voice_recording": "Tar opp {{time}}",
"live_voice_save": "Lagre",
"live_voice_saving": "Lagrer…",
"live_voice_retake": "Ta opp på nytt",
"live_voice_mic_denied": "Mikrofontilgang nektet eller utilgjengelig.",
"live_voice_record_failed": "Opptak mislyktes. Prøv igjen.",
"live_voice_unavailable": "Talemelding utilgjengelig",
"live_voice_too_large": "Opptaket er for stort. Ta et kortere opptak.",
"live_voice_error": "Kunne ikke lagre talemelding.",
"live_voice_entry": "Talemelding: {{caption}}",
"live_voice_entry_plain": "Talemelding",
"live_voice_caption_label": "Bildetekst (valgfritt)",
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnesjef",
"live_undo_voice_hint": "Talemelding lagret",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Skriv inn tekst…",
"live_comment_confirm": "Loggfør",
@@ -358,6 +380,7 @@
"event_remarks": "Merknader / hendelser",
"gps_btn": "Hent GPS-koordinater",
"weather_btn": "OpenWeatherMap Ring opp været",
"weather_offline": "OpenWeatherMap krever internettforbindelse. Du er frakoblet.",
"event_wind_pressure": "Lufttrykk (hPa)",
"event_heel": "Helning (°)",
"event_sails": "Seilhåndtering / motor",
@@ -372,6 +395,7 @@
"export_pdf": "Last ned PDF",
"exporting_pdf": "PDF genereres...",
"ai_summary_title": "AI-sammendrag",
"ai_summary_read_only": "Opprettet av skipperen — kun lesbar for mannskapet.",
"ai_summary_empty": "Ingen sammendrag ennå.",
"ai_summary_generate": "Generer sammendrag",
"ai_summary_regenerate": "Generer på nytt",
@@ -381,6 +405,7 @@
"ai_summary_error_no_key": "Ingen OpenRouter API-nøkkel konfigurert på serveren.",
"ai_summary_error_rate_limited": "Maksimalt antall genereringer nådd for denne reisedagen.",
"ai_summary_error_forbidden": "Kun skipperen kan generere AI-sammendrag.",
"ai_summary_offline": "AI-sammendrag krever internettforbindelse. Du er frakoblet.",
"photos_title": "Bildevedlegg (E2E-kryptert)",
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
@@ -474,7 +499,7 @@
"new_logbook_placeholder": "Navn på loggboken eller båten",
"logout": "Logg ut",
"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!",
"loading": "Loggbøker er lastet...",
"status_synced": "Synkronisert",
@@ -753,7 +778,7 @@
"delete_account_confirm_yes": "Ja, slett konto og alle data",
"delete_account_confirm_no": "Avbryt",
"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...",
"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.",
@@ -764,7 +789,7 @@
"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_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_desc": "Gjenoppretter en sikkerhetskopi til din nåværende konto - selv etter at du har registrert en ny konto.",
"backup_passphrase": "Passord for sikkerhetskopiering",
@@ -776,7 +801,13 @@
"backup_export_btn": "Last ned sikkerhetskopi",
"backup_exporting": "Sikkerhetskopien er opprettet...",
"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_previewing": "Sjekk...",
"backup_restore_btn": "Gjenopprett",
+35 -4
View File
@@ -6,6 +6,10 @@
"beta": "Beta",
"beta_hint": "Betaversion - funktioner kan fortfarande ändras"
},
"footer": {
"kofi_label": "Ko-fi",
"kofi_title": "Stöd projektet, vidareutveckling och driftskostnader på Ko-fi"
},
"languages": {
"de": "Deutsch",
"en": "English",
@@ -266,6 +270,24 @@
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget",
"live_undo_photo_hint": "Foto sparat",
"live_voice_btn": "Röstanteckning",
"live_voice_hint": "Spela in en kort röstanteckning (max 60 sekunder).",
"live_voice_record": "Starta inspelning",
"live_voice_stop": "Stoppa inspelning",
"live_voice_recording": "Spelar in {{time}}",
"live_voice_save": "Spara",
"live_voice_saving": "Sparar…",
"live_voice_retake": "Spela in igen",
"live_voice_mic_denied": "Mikrofonåtkomst nekad eller ej tillgänglig.",
"live_voice_record_failed": "Inspelning misslyckades. Försök igen.",
"live_voice_unavailable": "Röstanteckning ej tillgänglig",
"live_voice_too_large": "Inspelningen är för stor. Spela in kortare.",
"live_voice_error": "Kunde inte spara röstanteckning.",
"live_voice_entry": "Röstanteckning: {{caption}}",
"live_voice_entry_plain": "Röstanteckning",
"live_voice_caption_label": "Bildtext (valfritt)",
"live_voice_caption_placeholder": "t.ex. radiokontakt med hamnmästare",
"live_undo_voice_hint": "Röstanteckning sparad",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Ange text…",
"live_comment_confirm": "Logga",
@@ -358,6 +380,7 @@
"event_remarks": "Anmärkningar / incidenter",
"gps_btn": "Hämta GPS-koordinater",
"weather_btn": "OpenWeatherMap Ring upp väder",
"weather_offline": "OpenWeatherMap kräver internetanslutning. Du är offline.",
"event_wind_pressure": "Lufttryck (hPa)",
"event_heel": "Krängning (°)",
"event_sails": "Segelhantering / motor",
@@ -372,6 +395,7 @@
"export_pdf": "Hämta PDF.",
"exporting_pdf": "PDF genereras...",
"ai_summary_title": "AI-sammanfattning",
"ai_summary_read_only": "Skapad av skepparen — endast läsning för besättningen.",
"ai_summary_empty": "Ingen sammanfattning ännu.",
"ai_summary_generate": "Generera sammanfattning",
"ai_summary_regenerate": "Generera igen",
@@ -381,6 +405,7 @@
"ai_summary_error_no_key": "Ingen OpenRouter API-nyckel konfigurerad på servern.",
"ai_summary_error_rate_limited": "Maximalt antal genereringar nått för denna resedag.",
"ai_summary_error_forbidden": "Endast skepparen får generera AI-sammanfattningar.",
"ai_summary_offline": "AI-sammanfattning kräver internetanslutning. Du är offline.",
"photos_title": "Fotobilagor (E2E-krypterade)",
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
@@ -474,7 +499,7 @@
"new_logbook_placeholder": "Loggbokens eller båtens namn",
"logout": "Logga ut",
"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!",
"loading": "Loggböckerna är fulla...",
"status_synced": "Synkroniserad",
@@ -753,7 +778,7 @@
"delete_account_confirm_yes": "Ja, radera konto och all data",
"delete_account_confirm_no": "Avbryt",
"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...",
"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.",
@@ -764,7 +789,7 @@
"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_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_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",
@@ -776,7 +801,13 @@
"backup_export_btn": "Ladda ner backup",
"backup_exporting": "Säkerhetskopian skapas...",
"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_previewing": "Check...",
"backup_restore_btn": "Återställ",
+6 -2
View File
@@ -5,11 +5,11 @@ import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayl
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
export class TravelDaySummaryApiError extends Error {
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'REQUEST_FAILED'
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'OFFLINE' | 'REQUEST_FAILED'
constructor(
message: string,
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'REQUEST_FAILED' = 'REQUEST_FAILED'
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED'
) {
super(message)
this.name = 'TravelDaySummaryApiError'
@@ -146,6 +146,10 @@ export async function generateTravelDaySummary(params: {
language: string
context: TravelDaySummaryContext
}): Promise<{ summary: string; remainingAttempts: number; maxAttempts: number }> {
if (!navigator.onLine) {
throw new TravelDaySummaryApiError('Offline', 'OFFLINE')
}
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), SUMMARY_FETCH_TIMEOUT_MS)
+2 -1
View File
@@ -26,6 +26,7 @@ export const PlausibleEvents = {
PUSH_ENABLED: 'Push Enabled',
PUSH_DISABLED: 'Push Disabled',
FOOTER_LINK_CLICKED: 'Footer Link Clicked',
KOFI_LINK_CLICKED: 'Ko-fi Link Clicked',
PROFILE_OPENED: 'Profile Opened',
PASSKEY_ADDED: 'Passkey Added',
PASSKEY_REMOVED: 'Passkey Removed',
@@ -40,7 +41,7 @@ export const PlausibleEvents = {
NMEA_UPLOADED: 'NMEA Uploaded',
LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
AI_SUMMARY_GENERATED: 'AI Summary Generated',
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
+1
View File
@@ -556,6 +556,7 @@ export async function deleteAccount(): Promise<boolean> {
db.deviations.clear(),
db.entries.clear(),
db.photos.clear(),
db.voiceMemos.clear(),
db.gpsTracks.clear(),
db.syncQueue.clear(),
db.logbookKeys.clear(),
+31
View File
@@ -65,6 +65,16 @@ export interface LocalPhoto {
updatedAt: string
}
export interface LocalVoiceMemo {
payloadId: string
entryId: string
logbookId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalGpsTrack {
entryId: string // one track per daily journal entry
logbookId: string
@@ -132,6 +142,7 @@ export interface SyncQueueItem {
| 'entry'
| 'logbook'
| 'photo'
| 'voiceMemo'
| 'gpsTrack'
| 'logbookCrew'
| 'logbookVessel'
@@ -166,6 +177,7 @@ class DaagboxDatabase extends Dexie {
deviations!: Table<LocalDeviation>
entries!: Table<LocalEntry>
photos!: Table<LocalPhoto>
voiceMemos!: Table<LocalVoiceMemo>
gpsTracks!: Table<LocalGpsTrack>
nmeaArchives!: Table<LocalNmeaArchive>
logbookKeys!: Table<LocalLogbookKey>
@@ -289,6 +301,25 @@ class DaagboxDatabase extends Dexie {
userSyncQueue: '++id, action, type, payloadId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
this.version(10).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
voiceMemos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId',
personPool: 'payloadId, updatedAt',
vesselPool: 'payloadId, updatedAt',
logbookCrewSelections: 'logbookId, updatedAt',
logbookVesselSelections: 'logbookId, updatedAt',
userSyncQueue: '++id, action, type, payloadId',
entryDrafts: '[logbookId+entryId], updatedAt'
})
}
}
+1
View File
@@ -283,6 +283,7 @@ export async function deleteLocalLogbookCache(id: string): Promise<void> {
await db.deviations.where({ logbookId: id }).delete()
await db.entries.where({ logbookId: id }).delete()
await db.photos.where({ logbookId: id }).delete()
await db.voiceMemos.where({ logbookId: id }).delete()
await db.gpsTracks.where({ logbookId: id }).delete()
await db.syncQueue.where({ logbookId: id }).delete()
await db.logbookKeys.where({ logbookId: id }).delete()
+358 -311
View File
@@ -9,89 +9,54 @@ import { decryptLogbookTitle, deleteLocalLogbookCache } from './logbook.js'
import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js'
import { syncLogbook } from './sync.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 const BACKUP_VERSION = 1 as const
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
}>
gpsTracks: Array<{
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
}
counts: {
entries: number
photos: number
crews: number
gpsTracks: number
hasYacht: boolean
hasDeviation: boolean
}
}
export { BACKUP_FORMAT, BACKUP_VERSION }
export type { BackupExportProgress, BackupManifestCounts, BackupManifestV2 }
export interface LogbookBackupPreview {
title: string
exportedAt: 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> {
const encoder = new TextEncoder()
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(
'raw',
@@ -120,26 +85,17 @@ async function wrapLogbookKey(logbookKey: ArrayBuffer, passphrase: string) {
return encryptBuffer(logbookKey, key)
}
async function unwrapLogbookKey(
wrapped: LogbookBackupFile['logbookKey'],
async function unwrapLogbookKeyFromEnc(
keyEnc: Uint8Array,
passphrase: string
): Promise<ArrayBuffer> {
const key = await deriveBackupPassphraseKey(passphrase)
return decryptBuffer(wrapped.ciphertext, wrapped.iv, wrapped.tag, key)
}
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
)
try {
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 encryptedPayloadData(
@@ -156,96 +112,12 @@ function encryptedPayloadData(
})
}
async function collectLogbookPayloads(logbookId: string): Promise<LogbookBackupFile['payloads']> {
const [yacht, deviation, crews, entries, photos, 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.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
})),
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 })),
gpsTracks: backup.payloads.gpsTracks.map((t) => ({ ...t }))
}
}
}
async function queueRestoredLogbookForSync(
logbookId: string,
encryptedTitle: string,
logbookKey: ArrayBuffer,
payloads: LogbookBackupFile['payloads']
manifest: BackupManifestV2,
files: Record<string, Uint8Array>
): Promise<void> {
const masterKey = getActiveMasterKey()
if (!masterKey) throw new Error('Master key not found')
@@ -276,78 +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({
action: 'update',
type: 'yacht',
payloadId: logbookId,
logbookId,
data: encryptedPayloadData(
payloads.yacht.encryptedData,
payloads.yacht.iv,
payloads.yacht.tag
),
updatedAt: payloads.yacht.updatedAt
data: encryptedPayloadData(yacht.encryptedData, yacht.iv, yacht.tag),
updatedAt: now
})
}
if (payloads.deviation) {
const deviation = readFields(manifest.files.deviation)
if (deviation) {
items.push({
action: 'update',
type: 'deviation',
payloadId: logbookId,
logbookId,
data: encryptedPayloadData(
payloads.deviation.encryptedData,
payloads.deviation.iv,
payloads.deviation.tag
),
updatedAt: payloads.deviation.updatedAt
data: encryptedPayloadData(deviation.encryptedData, deviation.iv, deviation.tag),
updatedAt: now
})
}
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({
action: 'create',
type: 'crew',
payloadId: crew.payloadId,
logbookId,
data: encryptedPayloadData(crew.encryptedData, crew.iv, crew.tag),
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
updatedAt: crew.updatedAt
})
}
for (const entry of payloads.entries) {
for (const entry of manifest.files.entries) {
const f = readFields(entry.path)
items.push({
action: 'create',
type: 'entry',
payloadId: entry.payloadId,
logbookId,
data: encryptedPayloadData(entry.encryptedData, entry.iv, entry.tag),
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
updatedAt: entry.updatedAt
})
}
for (const photo of payloads.photos) {
for (const photo of manifest.files.photos) {
const f = readFields(photo.path)
items.push({
action: 'create',
type: 'photo',
payloadId: photo.payloadId,
logbookId,
data: encryptedPayloadData(photo.encryptedData, photo.iv, photo.tag, {
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, {
entryId: photo.entryId
}),
updatedAt: photo.updatedAt
})
}
for (const track of payloads.gpsTracks) {
for (const voice of manifest.files.voiceMemos) {
const f = readFields(voice.path)
items.push({
action: 'create',
type: 'voiceMemo',
payloadId: voice.payloadId,
logbookId,
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, {
entryId: voice.entryId
}),
updatedAt: voice.updatedAt
})
}
for (const track of manifest.files.gpsTracks) {
const f = readFields(track.path)
items.push({
action: 'create',
type: 'gpsTrack',
payloadId: track.entryId,
logbookId,
data: encryptedPayloadData(track.encryptedData, track.iv, track.tag),
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
updatedAt: track.updatedAt
})
}
@@ -357,101 +274,190 @@ async function queueRestoredLogbookForSync(
async function writeBackupToDexie(
logbookId: string,
backup: LogbookBackupFile,
logbookKey: ArrayBuffer
logbookMeta: LogbookMetaJson,
logbookKey: ArrayBuffer,
manifest: BackupManifestV2,
files: Record<string, Uint8Array>
): Promise<void> {
const { logbook, payloads } = backup
await db.logbooks.put({
id: logbookId,
encryptedTitle: logbook.encryptedTitle,
updatedAt: logbook.updatedAt,
encryptedTitle: logbookMeta.encryptedTitle,
updatedAt: logbookMeta.updatedAt,
isSynced: 0,
isShared: 0,
isDemo: logbook.isDemo ? 1 : 0
isDemo: logbookMeta.isDemo ? 1 : 0
})
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({
logbookId,
encryptedData: payloads.yacht.encryptedData,
iv: payloads.yacht.iv,
tag: payloads.yacht.tag,
updatedAt: payloads.yacht.updatedAt
encryptedData: yacht.encryptedData,
iv: yacht.iv,
tag: yacht.tag,
updatedAt: logbookMeta.updatedAt
})
}
if (payloads.deviation) {
const deviation = readFields(manifest.files.deviation)
if (deviation) {
await db.deviations.put({
logbookId,
encryptedData: payloads.deviation.encryptedData,
iv: payloads.deviation.iv,
tag: payloads.deviation.tag,
updatedAt: payloads.deviation.updatedAt
encryptedData: deviation.encryptedData,
iv: deviation.iv,
tag: deviation.tag,
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(
payloads.crews.map((c) => ({
payloadId: c.payloadId,
logbookId,
encryptedData: c.encryptedData,
iv: c.iv,
tag: c.tag,
updatedAt: c.updatedAt
}))
manifest.files.crews.map((c) => {
const f = dexieFieldsFromEncBytes(readBinaryFile(files, c.path))
return {
payloadId: c.payloadId,
logbookId,
encryptedData: f.encryptedData,
iv: f.iv,
tag: f.tag,
updatedAt: c.updatedAt
}
})
)
}
if (payloads.entries.length > 0) {
if (manifest.files.entries.length > 0) {
await db.entries.bulkPut(
payloads.entries.map((e) => ({
payloadId: e.payloadId,
logbookId,
encryptedData: e.encryptedData,
iv: e.iv,
tag: e.tag,
updatedAt: e.updatedAt
}))
manifest.files.entries.map((e) => {
const f = dexieFieldsFromEncBytes(readBinaryFile(files, e.path))
return {
payloadId: e.payloadId,
logbookId,
encryptedData: f.encryptedData,
iv: f.iv,
tag: f.tag,
updatedAt: e.updatedAt
}
})
)
}
if (payloads.photos.length > 0) {
if (manifest.files.photos.length > 0) {
await db.photos.bulkPut(
payloads.photos.map((p) => ({
payloadId: p.payloadId,
entryId: p.entryId,
logbookId,
encryptedData: p.encryptedData,
iv: p.iv,
tag: p.tag,
caption: '',
updatedAt: p.updatedAt
}))
manifest.files.photos.map((p) => {
const f = dexieFieldsFromEncBytes(readBinaryFile(files, p.path))
return {
payloadId: p.payloadId,
entryId: p.entryId,
logbookId,
encryptedData: f.encryptedData,
iv: f.iv,
tag: f.tag,
caption: '',
updatedAt: p.updatedAt
}
})
)
}
if (payloads.gpsTracks.length > 0) {
await db.gpsTracks.bulkPut(
payloads.gpsTracks.map((t) => ({
entryId: t.entryId,
logbookId,
encryptedData: t.encryptedData,
iv: t.iv,
tag: t.tag,
updatedAt: t.updatedAt
}))
if (manifest.files.voiceMemos.length > 0) {
await db.voiceMemos.bulkPut(
manifest.files.voiceMemos.map((v) => {
const f = dexieFieldsFromEncBytes(readBinaryFile(files, v.path))
return {
payloadId: v.payloadId,
entryId: v.entryId,
logbookId,
encryptedData: f.encryptedData,
iv: f.iv,
tag: f.tag,
updatedAt: v.updatedAt
}
})
)
}
if (manifest.files.gpsTracks.length > 0) {
await db.gpsTracks.bulkPut(
manifest.files.gpsTracks.map((t) => {
const f = dexieFieldsFromEncBytes(readBinaryFile(files, t.path))
return {
entryId: t.entryId,
logbookId,
encryptedData: f.encryptedData,
iv: f.iv,
tag: f.tag,
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(
logbookId: string,
passphrase: string
): Promise<{ blob: Blob; filename: string; backup: LogbookBackupFile }> {
passphrase: string,
options: ExportLogbookBackupOptions = {}
): Promise<{ blob: Blob; filename: string; manifest: BackupManifestV2 }> {
if (!passphrase.trim() || passphrase.length < 8) {
throw new Error('BACKUP_PASSPHRASE_TOO_SHORT')
}
@@ -467,70 +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 payloads = await collectLogbookPayloads(logbookId)
const wrappedKey = await wrapLogbookKey(logbookKey, passphrase)
const wrapped = await wrapLogbookKey(logbookKey, passphrase)
const keyEnc = encBytesFromDexieFields({
encryptedData: wrapped.ciphertext,
iv: wrapped.iv,
tag: wrapped.tag
})
const backup: LogbookBackupFile = {
format: BACKUP_FORMAT,
version: BACKUP_VERSION,
const { zipBytes, manifest } = buildArchiveFromCollected(collected, keyEnc, {
exportedAt: new Date().toISOString(),
logbook: {
id: logbook.id,
encryptedTitle: logbook.encryptedTitle,
updatedAt: logbook.updatedAt,
isDemo: logbook.isDemo === 1
},
logbookKey: wrappedKey,
payloads,
counts: {
entries: payloads.entries.length,
photos: payloads.photos.length,
crews: payloads.crews.length,
gpsTracks: payloads.gpsTracks.length,
hasYacht: !!payloads.yacht,
hasDeviation: !!payloads.deviation
}
}
appVersion: getAppVersion(),
onProgress: options.onProgress
})
const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle)
const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
const datePart = new Date().toISOString().slice(0, 10)
const filename = `${safeTitle}-${datePart}.daagbok.json`
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' })
const filename = `${safeTitle}-${datePart}.daagbok`
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> {
const text = await file.text()
let parsed: unknown
function detectLegacyJsonV1(text: string): boolean {
const trimmed = text.trimStart()
if (!trimmed.startsWith('{')) return false
try {
parsed = JSON.parse(text)
const parsed = JSON.parse(trimmed) as { format?: string; version?: number }
return parsed.format === BACKUP_FORMAT && parsed.version === 1
} catch {
throw new Error('BACKUP_INVALID_JSON')
return false
}
}
export async function parseLogbookBackupFile(file: File): Promise<ParsedLogbookBackup> {
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')
}
if (!isBackupFile(parsed)) {
throw new Error('BACKUP_INVALID_FORMAT')
}
return parsed
const files = unzipArchive(bytes)
const manifest = readManifestFromArchive(files)
return { manifest, files }
}
export async function previewLogbookBackup(
backup: LogbookBackupFile,
backup: ParsedLogbookBackup,
passphrase: string
): Promise<LogbookBackupPreview> {
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
const parsed = JSON.parse(backup.logbook.encryptedTitle)
const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey)
const logbookKey = await unwrapLogbookKeyFromEnc(
readBinaryFile(backup.files, backup.manifest.files.key),
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 {
title,
exportedAt: backup.exportedAt,
sourceLogbookId: backup.logbook.id,
counts: backup.counts
exportedAt: backup.manifest.exportedAt,
sourceLogbookId: backup.manifest.logbookId,
counts: backup.manifest.counts,
totalUncompressedBytes: backup.manifest.totalUncompressedBytes
}
}
@@ -540,7 +560,7 @@ export interface RestoreLogbookOptions {
}
export async function restoreLogbookBackup(
backup: LogbookBackupFile,
backup: ParsedLogbookBackup,
passphrase: string,
options: RestoreLogbookOptions = {}
): Promise<{ logbookId: string; title: string }> {
@@ -548,16 +568,22 @@ export async function restoreLogbookBackup(
throw new Error('BACKUP_NOT_AUTHENTICATED')
}
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
const parsedTitle = JSON.parse(backup.logbook.encryptedTitle)
const title = await decryptJson(
parsedTitle.ciphertext,
parsedTitle.iv,
parsedTitle.tag,
logbookKey
const logbookKey = await unwrapLogbookKeyFromEnc(
readBinaryFile(backup.files, backup.manifest.files.key),
passphrase
)
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)
if (existing && !options.overwrite && !options.assignNewId) {
@@ -568,18 +594,29 @@ export async function restoreLogbookBackup(
await deleteLocalLogbookCache(targetId)
}
let prepared = backup
if (options.assignNewId || (existing && !options.overwrite)) {
targetId = crypto.randomUUID()
prepared = remapParsedBackup(backup, targetId)
}
const prepared = targetId === backup.logbook.id ? backup : remapBackup(backup, targetId)
const finalMeta = JSON.parse(
readTextFile(prepared.files, prepared.manifest.files.logbook)
) as LogbookMetaJson
await writeBackupToDexie(targetId, prepared, logbookKey)
await writeBackupToDexie(
targetId,
finalMeta,
logbookKey,
prepared.manifest,
prepared.files
)
await queueRestoredLogbookForSync(
targetId,
prepared.logbook.encryptedTitle,
finalMeta.encryptedTitle,
logbookKey,
prepared.payloads
prepared.manifest,
prepared.files
)
if (navigator.onLine) {
@@ -599,3 +636,13 @@ export function downloadBackupBlob(blob: Blob, filename: string): void {
anchor.click()
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
}
+12
View File
@@ -91,6 +91,7 @@ export function clearLogbookKeysCache() {
export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer> {
const localLb = await db.logbooks.get(logbookId)
const encryptedTitle = localLb ? localLb.encryptedTitle : ''
const isShared = localLb?.isShared === 1
const masterKey = getActiveMasterKey()
let key = await getLogbookKey(logbookId)
@@ -103,6 +104,11 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
// Key works, return it
return key
} catch (err) {
if (isShared) {
throw new Error(
'Shared logbook encryption key is missing or invalid. Please go online and refresh your logbooks.'
)
}
console.warn('Stored logbook key failed to decrypt title. Testing if master key works (legacy migration)...')
try {
const parsed = JSON.parse(encryptedTitle)
@@ -145,6 +151,12 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
// If no logbook key exists yet
if (!key) {
if (isShared) {
throw new Error(
'Shared logbook encryption key not found. Please go online and refresh your logbooks.'
)
}
if (encryptedTitle && masterKey) {
try {
// Check if title is already decryptable using masterKey (meaning it is a legacy logbook)
-3
View File
@@ -55,9 +55,6 @@ export async function saveEntryPhoto(options: {
})
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: analyticsContext })
if (analyticsContext === 'live_log') {
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
}
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
return photoId
}
+12 -1
View File
@@ -124,11 +124,22 @@ function buildEncryptedPayload(
})
const clear = options.clearSignatures
return {
const entryData: Record<string, unknown> = {
...payload,
signSkipper: clear ? '' : (data.signSkipper ?? ''),
signCrew: clear ? '' : (data.signCrew ?? '')
}
const summary = typeof data.aiSummary === 'string' ? data.aiSummary.trim() : ''
if (summary) {
entryData.aiSummary = summary
entryData.aiSummaryGeneratedAt =
typeof data.aiSummaryGeneratedAt === 'string' && data.aiSummaryGeneratedAt
? data.aiSummaryGeneratedAt
: new Date().toISOString()
}
return entryData
}
export async function loadEntry(logbookId: string, entryId: string): Promise<LoadedEntry | null> {
+38 -1
View File
@@ -61,6 +61,8 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
return !!(await db.entries.get(item.payloadId))
case 'photo':
return !!(await db.photos.get(item.payloadId))
case 'voiceMemo':
return !!(await db.voiceMemos.get(item.payloadId))
case 'gpsTrack':
return !!(await db.gpsTracks.get(item.payloadId))
case 'logbookCrew':
@@ -230,6 +232,7 @@ type PulledServerPayload = {
crews?: Array<{ payloadId: string; updatedAt: string }>
entries?: Array<{ payloadId: string; updatedAt: string }>
photos?: Array<{ payloadId: string; updatedAt: string }>
voiceMemos?: Array<{ payloadId: string; updatedAt: string }>
gpsTracks?: Array<{ entryId: string; updatedAt: string }>
}
@@ -253,6 +256,7 @@ async function pruneAcknowledgedQueueItems(
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
for (const v of server.voiceMemos ?? []) serverTimes.set('voiceMemo:' + v.payloadId, v.updatedAt)
for (const gt of server.gpsTracks ?? []) serverTimes.set('gpsTrack:' + gt.entryId, gt.updatedAt)
const localLogbook = await db.logbooks.get(logbookId)
@@ -299,7 +303,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
return false
}
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, gpsTracks } =
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, voiceMemos, gpsTracks } =
await response.json()
// Large pull payloads block on JSON.parse — yield before applying to IndexedDB.
@@ -313,6 +317,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
crews,
entries,
photos,
voiceMemos,
gpsTracks
}
@@ -471,6 +476,38 @@ async function pullChanges(logbookId: string): Promise<boolean> {
}
}
// 5b. Sync Voice Memos
const serverVoiceMap = new Map<string, any>()
if (voiceMemos && Array.isArray(voiceMemos)) {
await forEachInBatches(voiceMemos, 20, async (v) => {
serverVoiceMap.set(v.payloadId, v)
const local = await db.voiceMemos.get(v.payloadId)
if (!local || isNewer(v.updatedAt, local.updatedAt)) {
await db.voiceMemos.put({
payloadId: v.payloadId,
entryId: v.entryId,
logbookId,
encryptedData: v.encryptedData,
iv: v.iv,
tag: v.tag,
updatedAt: v.updatedAt
})
}
})
}
const localVoiceMemos = await db.voiceMemos.where({ logbookId }).toArray()
for (const lv of localVoiceMemos) {
if (!serverVoiceMap.has(lv.payloadId)) {
const pendingCreate = await db.syncQueue
.where({ payloadId: lv.payloadId, action: 'create' })
.first()
if (!pendingCreate) {
await db.voiceMemos.delete(lv.payloadId)
}
}
}
// 6. Sync GPS Tracks
const serverGpsTrackMap = new Map<string, any>()
if (gpsTracks && Array.isArray(gpsTracks)) {
+100
View File
@@ -0,0 +1,100 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
async function getEncryptionKey(logbookId: string): Promise<ArrayBuffer> {
const key = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!key) throw new Error('Encryption key not found. Please log in.')
return key
}
export async function saveEntryVoiceMemo(options: {
logbookId: string
entryId: string
audioDataUrl: string
mimeType: string
durationSec: number
caption?: string
analyticsContext?: string
}): Promise<string> {
const {
logbookId,
entryId,
audioDataUrl,
mimeType,
durationSec,
caption = '',
analyticsContext = 'logbook'
} = options
const masterKey = await getEncryptionKey(logbookId)
const voiceId = window.crypto.randomUUID()
const voicePayload = {
audio: audioDataUrl,
mimeType,
durationSec,
caption: caption.trim()
}
const encrypted = await encryptJson(voicePayload, masterKey)
const now = new Date().toISOString()
await db.voiceMemos.put({
payloadId: voiceId,
entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'create',
type: 'voiceMemo',
payloadId: voiceId,
logbookId,
data: JSON.stringify({
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
entryId
}),
updatedAt: now
})
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: analyticsContext })
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
return voiceId
}
export async function deleteEntryVoiceMemo(logbookId: string, voiceId: string): Promise<void> {
const now = new Date().toISOString()
await db.voiceMemos.delete(voiceId)
await db.syncQueue.put({
action: 'delete',
type: 'voiceMemo',
payloadId: voiceId,
logbookId,
data: '',
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
/** Deletes the newest voice memo for an entry; returns its id or null. */
export async function removeLastVoiceMemoForEntry(
logbookId: string,
entryId: string
): Promise<string | null> {
const memos = await db.voiceMemos.where({ entryId }).toArray()
if (memos.length === 0) return null
memos.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
)
const lastId = memos[0].payloadId
await deleteEntryVoiceMemo(logbookId, lastId)
return lastId
}
+11
View File
@@ -44,6 +44,17 @@ describe('fetchOpenWeatherCurrent', () => {
})
})
it('throws OFFLINE when navigator.onLine is false', async () => {
vi.stubGlobal('navigator', { ...navigator, onLine: false })
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
expect(err).toBeInstanceOf(WeatherApiError)
expect((err as InstanceType<typeof WeatherApiError>).code).toBe('OFFLINE')
expect(apiFetch).not.toHaveBeenCalled()
})
it('does not track when the API request fails', async () => {
apiFetch.mockResolvedValue({
ok: false,
+6 -2
View File
@@ -7,9 +7,9 @@ import {
} from './analytics.js'
export class WeatherApiError extends Error {
code: 'NO_KEY' | 'REQUEST_FAILED'
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED'
constructor(message: string, code: 'NO_KEY' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
constructor(message: string, code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
super(message)
this.name = 'WeatherApiError'
this.code = code
@@ -26,6 +26,10 @@ export async function fetchOpenWeatherCurrent(
},
options?: { analyticsSource: OwmAnalyticsSource }
): Promise<Record<string, unknown>> {
if (!navigator.onLine) {
throw new WeatherApiError('Offline', 'OFFLINE')
}
const searchParams = new URLSearchParams()
if (params.lat && params.lon) {
+39
View File
@@ -0,0 +1,39 @@
export const VOICE_MEMO_MAX_DURATION_SEC = 60
export const VOICE_MEMO_MAX_BLOB_BYTES = 800_000
const MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus'
]
export function pickMediaRecorderMimeType(): string | undefined {
if (typeof MediaRecorder === 'undefined') return undefined
for (const mime of MIME_CANDIDATES) {
if (MediaRecorder.isTypeSupported(mime)) return mime
}
return undefined
}
export function blobToAudioDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(new Error('audio_read_failed'))
reader.readAsDataURL(blob)
})
}
export function formatVoiceDuration(seconds: number): string {
const s = Math.max(0, Math.floor(seconds))
const m = Math.floor(s / 60)
const r = s % 60
return `${m}:${String(r).padStart(2, '0')}`
}
export function assertVoiceMemoBlobSize(blob: Blob): void {
if (blob.size > VOICE_MEMO_MAX_BLOB_BYTES) {
throw new Error('VOICE_MEMO_TOO_LARGE')
}
}
@@ -7,6 +7,8 @@ import {
liveSogRemark,
parseLiveCommentRemark,
livePhotoRemark,
liveVoiceRemark,
parseLiveVoiceRemark,
parseLiveSailsRemark
} from './liveEventCodes.js'
import { formatEventSummary } from './formatEventSummary.js'
@@ -28,6 +30,7 @@ const t = (key: string, opts?: Record<string, unknown>) => {
'logs.live_wind_entry': `Wind ${opts?.value}`,
'logs.live_photo_entry': `Photo: ${opts?.caption}`,
'logs.live_photo_entry_plain': 'Photo captured',
'logs.live_voice_entry_plain': 'Voice memo',
'logs.live_course_entry': `Course ${opts?.course}`,
'logs.live_sog_entry': `SOG ${opts?.speed} kn`,
'logs.live_stw_entry': `STW ${opts?.speed} kn`,
@@ -59,6 +62,12 @@ describe('liveEventCodes', () => {
expect(parseLiveSailsRemark(liveSailsRemark('Main + Genoa'))).toBe('Main + Genoa')
expect(parseLiveCommentRemark(liveCommentRemark('Wind dreht'))).toBe('Wind dreht')
})
it('parses voice remark with uuid', () => {
const id = 'a1b2c3d4-e5f6-4789-a012-3456789abcde'
expect(parseLiveVoiceRemark(liveVoiceRemark(id))).toBe(id)
expect(parseLiveVoiceRemark('__live:voice:not-a-uuid')).toBeNull()
})
})
describe('formatEventSummary', () => {
@@ -130,4 +139,10 @@ describe('formatEventSummary', () => {
})
expect(formatEventSummary(captioned, t)).toBe('Photo: Mastbruch')
})
it('formats voice memo entry', () => {
const id = 'a1b2c3d4-e5f6-4789-a012-3456789abcde'
const event = normalizeLogEvent({ time: '12:00', remarks: liveVoiceRemark(id) })
expect(formatEventSummary(event, t)).toBe('Voice memo')
})
})
+6
View File
@@ -5,6 +5,7 @@ import {
parseLiveCommentRemark,
parseLiveFuelRemark,
parseLivePhotoRemark,
parseLiveVoiceRemark,
parseLivePrecipRemark,
parseLiveSailsRemark,
parseLiveSogRemark,
@@ -34,6 +35,11 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
: t('logs.live_photo_entry_plain')
}
const voiceId = parseLiveVoiceRemark(code)
if (voiceId) {
return t('logs.live_voice_entry_plain')
}
const temp = parseLiveTempRemark(code)
if (temp) return t('logs.live_temp_entry', { temp })
+15
View File
@@ -50,6 +50,21 @@ export function parseLivePhotoRemark(remarks: string): string | null {
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
const VOICE_UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
export function liveVoiceRemark(audioId: string): string {
return `__live:voice:${audioId}`
}
export function parseLiveVoiceRemark(remarks: string): string | null {
const trimmed = remarks.trim()
const prefix = '__live:voice:'
if (!trimmed.startsWith(prefix)) return null
const id = trimmed.slice(prefix.length)
return VOICE_UUID_RE.test(id) ? id : null
}
export function liveSogRemark(speedKn: string): string {
return `__live:sog:${speedKn}`
}
+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).*
+8 -5
View File
@@ -37,14 +37,15 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
| Live Log Photo Uploaded | Foto im Live-Journal per Kamera gespeichert (`photoAttachments.ts`, `analyticsContext`: `live_log`) | — |
| Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` |
| 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`) | — |
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) |
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` |
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) |
| 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 Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — |
| Footer Link Clicked | Klick auf Autoren-Link im App-Footer (`AppFooter.tsx`) | — |
| Ko-fi Link Clicked | Klick auf Ko-fi-Unterstützen-Badge im App-Footer (`AppFooter.tsx`) | — |
| Profile Opened | Profilseite geöffnet (`UserProfilePage.tsx`, einmal pro Mount) | — |
| Passkey Added | Passkey erfolgreich registriert (`UserProfilePage.tsx`) | `labeled`: `true` \| `false` (optionaler Name gesetzt) |
| Passkey Removed | Passkey entfernt, mindestens ein Key verbleibt (`UserProfilePage.tsx`) | — |
@@ -85,6 +86,7 @@ Property `action` bei **Live Log Event Logged** — stabile englische Schlüssel
| `sea_state` | Seegang |
| `fix` | GPS-Fix (manuell) |
| `comment` | Kommentar |
| `voice` | Sprachnotiz (Modal gespeichert) |
| `undo` | Letztes Ereignis rückgängig |
### OWM-Quellen
@@ -135,7 +137,7 @@ Empfohlene Goal-Ketten für Auswertung (nur Business!):
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback)
9. **NMEA-Import:** NMEA Uploaded → NMEA Imported (Modus, `events`, optional Track; Upload-Funnel vs. Abbruch)
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `fix`, `course`, `motor_start`) → Live Log Photo Uploaded
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `fix`, `course`, `motor_start`) → Photo Uploaded / Voice Memo Uploaded (Filter `context`: `live_log`)
11. **OpenWeatherMap:** OWM Weather Fetched (Verteilung `source`; Live-Journal vs. Reisetag-Editor)
12. **PWA-Stabilitaet:** PWA Boot Watchdog Soft → PWA Boot Watchdog Hard → PWA Boot Watchdog Fallback → PWA Boot Watchdog Manual Repair
@@ -148,7 +150,8 @@ trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'live_log' })
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: 'live_log' })
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' })
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
+17
View File
@@ -93,6 +93,7 @@ model Logbook {
deviations DeviationPayload[]
entries EntryPayload[]
photos PhotoPayload[]
voiceMemos VoiceMemoPayload[]
gpsTracks GpsTrackPayload[]
collaborators Collaboration[]
invitations Invitation[]
@@ -240,6 +241,22 @@ model PhotoPayload {
@@index([entryId])
}
model VoiceMemoPayload {
id String @id @default(uuid())
logbookId String
payloadId String
entryId String
encryptedData String
iv String
tag String
updatedAt DateTime @updatedAt
logbook Logbook @relation(fields: [logbookId], references: [id], onDelete: Cascade)
@@unique([logbookId, payloadId])
@@index([logbookId])
@@index([entryId])
}
model GpsTrackPayload {
id String @id @default(uuid())
logbookId String
+2
View File
@@ -83,6 +83,7 @@ router.get('/share-pull', async (req: any, res) => {
const logbookVesselSelection = await findLogbookVesselSelectionSafe(logbookId)
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
const voiceMemos = await prisma.voiceMemoPayload.findMany({ where: { logbookId } })
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
return res.json({
@@ -94,6 +95,7 @@ router.get('/share-pull', async (req: any, res) => {
logbookVesselSelection,
entries,
photos,
voiceMemos,
gpsTracks
})
} catch (error: unknown) {
+20
View File
@@ -143,6 +143,8 @@ router.post('/push', async (req: any, res) => {
await prisma.entryPayload.deleteMany({ where: { logbookId, payloadId } })
} else if (type === 'photo') {
await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } })
} else if (type === 'voiceMemo') {
await prisma.voiceMemoPayload.deleteMany({ where: { logbookId, payloadId } })
} else if (type === 'gpsTrack') {
await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
} else if (type === 'logbookCrew') {
@@ -234,6 +236,22 @@ router.post('/push', async (req: any, res) => {
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
}
} else if (type === 'voiceMemo') {
{
const existing = await prisma.voiceMemoPayload.findUnique({
where: { logbookId_payloadId: { logbookId, payloadId } }
})
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
continue
}
const entryId = parsed.entryId || ''
await prisma.voiceMemoPayload.upsert({
where: { logbookId_payloadId: { logbookId, payloadId } },
create: { logbookId, payloadId, entryId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
}
} else if (type === 'gpsTrack') {
{
const existing = await prisma.gpsTrackPayload.findUnique({
@@ -365,6 +383,7 @@ router.get('/pull', async (req: any, res) => {
const crews = await prisma.crewPayload.findMany({ where: { logbookId } })
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
const voiceMemos = await prisma.voiceMemoPayload.findMany({ where: { logbookId } })
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
const { findLogbookCrewSelectionSafe, findLogbookVesselSelectionSafe } =
await import('../utils/crewPoolSchema.js')
@@ -379,6 +398,7 @@ router.get('/pull', async (req: any, res) => {
logbookVesselSelection,
entries,
photos,
voiceMemos,
gpsTracks
})
} catch (error: any) {