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>
This commit is contained in:
2026-06-03 14:52:12 +02:00
parent f83d67b527
commit 975c7a2e40
30 changed files with 1155 additions and 14 deletions
+73 -3
View File
@@ -63,6 +63,14 @@ export interface LogbookBackupFile {
tag: string
updatedAt: string
}>
voiceMemos: Array<{
payloadId: string
entryId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}>
gpsTracks: Array<{
entryId: string
encryptedData: string
@@ -74,6 +82,7 @@ export interface LogbookBackupFile {
counts: {
entries: number
photos: number
voiceMemos: number
crews: number
gpsTracks: number
hasYacht: boolean
@@ -128,6 +137,15 @@ async function unwrapLogbookKey(
return decryptBuffer(wrapped.ciphertext, wrapped.iv, wrapped.tag, key)
}
function normalizeBackupPayloads(
payloads: LogbookBackupFile['payloads']
): LogbookBackupFile['payloads'] {
return {
...payloads,
voiceMemos: payloads.voiceMemos ?? []
}
}
function isBackupFile(value: unknown): value is LogbookBackupFile {
if (!value || typeof value !== 'object') return false
const obj = value as Partial<LogbookBackupFile>
@@ -157,12 +175,13 @@ function encryptedPayloadData(
}
async function collectLogbookPayloads(logbookId: string): Promise<LogbookBackupFile['payloads']> {
const [yacht, deviation, crews, entries, photos, gpsTracks] = await Promise.all([
const [yacht, deviation, crews, entries, photos, voiceMemos, gpsTracks] = await Promise.all([
db.yachts.get(logbookId),
db.deviations.get(logbookId),
db.crews.where({ logbookId }).toArray(),
db.entries.where({ logbookId }).toArray(),
db.photos.where({ logbookId }).toArray(),
db.voiceMemos.where({ logbookId }).toArray(),
db.gpsTracks.where({ logbookId }).toArray()
])
@@ -205,6 +224,14 @@ async function collectLogbookPayloads(logbookId: string): Promise<LogbookBackupF
tag: p.tag,
updatedAt: p.updatedAt
})),
voiceMemos: voiceMemos.map((v) => ({
payloadId: v.payloadId,
entryId: v.entryId,
encryptedData: v.encryptedData,
iv: v.iv,
tag: v.tag,
updatedAt: v.updatedAt
})),
gpsTracks: gpsTracks.map((t) => ({
entryId: t.entryId,
encryptedData: t.encryptedData,
@@ -236,6 +263,7 @@ function remapBackup(
crews: backup.payloads.crews.map((c) => ({ ...c })),
entries: backup.payloads.entries.map((e) => ({ ...e })),
photos: backup.payloads.photos.map((p) => ({ ...p })),
voiceMemos: (backup.payloads.voiceMemos ?? []).map((v) => ({ ...v })),
gpsTracks: backup.payloads.gpsTracks.map((t) => ({ ...t }))
}
}
@@ -341,6 +369,19 @@ async function queueRestoredLogbookForSync(
})
}
for (const voice of payloads.voiceMemos ?? []) {
items.push({
action: 'create',
type: 'voiceMemo',
payloadId: voice.payloadId,
logbookId,
data: encryptedPayloadData(voice.encryptedData, voice.iv, voice.tag, {
entryId: voice.entryId
}),
updatedAt: voice.updatedAt
})
}
for (const track of payloads.gpsTracks) {
items.push({
action: 'create',
@@ -434,6 +475,21 @@ async function writeBackupToDexie(
)
}
const voiceMemosToRestore = payloads.voiceMemos ?? []
if (voiceMemosToRestore.length > 0) {
await db.voiceMemos.bulkPut(
voiceMemosToRestore.map((v) => ({
payloadId: v.payloadId,
entryId: v.entryId,
logbookId,
encryptedData: v.encryptedData,
iv: v.iv,
tag: v.tag,
updatedAt: v.updatedAt
}))
)
}
if (payloads.gpsTracks.length > 0) {
await db.gpsTracks.bulkPut(
payloads.gpsTracks.map((t) => ({
@@ -486,6 +542,7 @@ export async function exportLogbookBackup(
counts: {
entries: payloads.entries.length,
photos: payloads.photos.length,
voiceMemos: payloads.voiceMemos?.length ?? 0,
crews: payloads.crews.length,
gpsTracks: payloads.gpsTracks.length,
hasYacht: !!payloads.yacht,
@@ -515,7 +572,14 @@ export async function parseLogbookBackupFile(file: File): Promise<LogbookBackupF
throw new Error('BACKUP_INVALID_FORMAT')
}
return parsed
return {
...parsed,
payloads: normalizeBackupPayloads(parsed.payloads),
counts: {
...parsed.counts,
voiceMemos: parsed.counts.voiceMemos ?? parsed.payloads.voiceMemos?.length ?? 0
}
}
}
export async function previewLogbookBackup(
@@ -572,7 +636,13 @@ export async function restoreLogbookBackup(
targetId = crypto.randomUUID()
}
const prepared = targetId === backup.logbook.id ? backup : remapBackup(backup, targetId)
const normalized = {
...backup,
payloads: normalizeBackupPayloads(backup.payloads)
}
const prepared = targetId === normalized.logbook.id
? normalized
: remapBackup(normalized, targetId)
await writeBackupToDexie(targetId, prepared, logbookKey)
await queueRestoredLogbookForSync(