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:
@@ -41,6 +41,8 @@ export const PlausibleEvents = {
|
||||
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',
|
||||
LIVE_LOG_VOICE_UPLOADED: 'Live Log Voice Uploaded',
|
||||
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
|
||||
AI_SUMMARY_GENERATED: 'AI Summary Generated',
|
||||
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
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 })
|
||||
if (analyticsContext === 'live_log') {
|
||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_VOICE_UPLOADED)
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user