feat(desktop): two-way cloud sync with mobile and web
Rewrites the desktop mirror as services/sync/SyncEngine: a persistent outbox, per-account server-clock keyset cursors with paging, pulls that never overwrite unsent local edits, deletions both ways through sync_tombstones and per-row failure isolation. It now covers history titles and favorites, dictionary, every meeting's memos and documents, memo tags, user commands and dictation/meeting templates, and registers the desktop as a device that the phone can disconnect. Fixes shipped defects: the first pull after sign-in fetched nothing, only the first meeting's children were pushed, team meetings leaked into the personal database and lost team_id on re-push, and Realtime never connected because Electron's Node 20 has no global WebSocket (ws is now the transport). Anonymous local-mode records are imported into the first account that signs in. The settings sync section is translated and shows pending/rejected changes; synced screens reload on app:dataChanged.
This commit is contained in:
parent
b5c9ff9f31
commit
0a4f5aee64
49 changed files with 3940 additions and 1033 deletions
|
|
@ -211,6 +211,7 @@ class MeetingModeService extends EventEmitter {
|
|||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
|
||||
this._setState('idle')
|
||||
throw err
|
||||
|
|
@ -237,7 +238,7 @@ class MeetingModeService extends EventEmitter {
|
|||
// Phase 3.3: 부모 meeting row를 즉시 push (빈 상태로).
|
||||
// 자식 meeting_memos/meeting_documents의 RLS/FK가 부모 존재를 요구하므로,
|
||||
// 녹음 중에 들어오는 addMemo/generateDocument 푸시가 통과하려면 필수.
|
||||
void getCloudSyncService().pushOne('meetings', sessionId)
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
|
||||
return { sessionId }
|
||||
}
|
||||
|
|
@ -273,7 +274,7 @@ class MeetingModeService extends EventEmitter {
|
|||
logger.debug(`메모 추가: [${formatTime(memo.timestampMs)}] ${content}`)
|
||||
|
||||
// Phase 3.3: 자동 push (fire-and-forget)
|
||||
void getCloudSyncService().pushOne('meeting_memos', memo.id)
|
||||
getCloudSyncService().pushOne('meeting_memos', memo.id)
|
||||
|
||||
return memo
|
||||
}
|
||||
|
|
@ -317,6 +318,7 @@ class MeetingModeService extends EventEmitter {
|
|||
durationMs: this._sessionStartedAt ? now - this._sessionStartedAt : 0,
|
||||
updatedAt: now,
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
|
||||
logger.info(`회의 녹음 종료: sessionId=${sessionId}, segments=${this._segments.length}`)
|
||||
|
||||
|
|
@ -339,6 +341,7 @@ class MeetingModeService extends EventEmitter {
|
|||
status: 'processing',
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
|
||||
// Step 1: 전사 텍스트 합산
|
||||
this._sendProgress(sessionId, 'merging', 40)
|
||||
|
|
@ -355,9 +358,7 @@ class MeetingModeService extends EventEmitter {
|
|||
sttModel: configGet('sttModelId') as string | undefined,
|
||||
updatedAt: now,
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
|
||||
// Phase 3.3: 최종 save 후 자동 push (fire-and-forget)
|
||||
void getCloudSyncService().pushOne('meetings', sessionId)
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
|
||||
// Step 2.5: 오디오 WAV 파일 저장 (화자 구분용)
|
||||
if (this._audioBuffersForFile.length > 0) {
|
||||
|
|
@ -400,6 +401,7 @@ class MeetingModeService extends EventEmitter {
|
|||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
|
||||
this._resetSession()
|
||||
this._setState('idle')
|
||||
|
|
@ -478,7 +480,10 @@ class MeetingModeService extends EventEmitter {
|
|||
}
|
||||
|
||||
db.delete(meetingMemos).where(eq(meetingMemos.sessionId, sessionId)).run()
|
||||
db.delete(meetingDocuments).where(eq(meetingDocuments.sessionId, sessionId)).run()
|
||||
db.delete(meetingSessions).where(eq(meetingSessions.id, sessionId)).run()
|
||||
// 서버는 회의 삭제가 메모·문서로 cascade 된다 — 회의 하나만 지우면 된다.
|
||||
getCloudSyncService().pushDelete('meetings', sessionId)
|
||||
logger.info(`회의 세션 삭제: ${sessionId}`)
|
||||
}
|
||||
|
||||
|
|
@ -490,6 +495,7 @@ class MeetingModeService extends EventEmitter {
|
|||
}
|
||||
db.update(meetingSessions).set({ title, updatedAt: Date.now() })
|
||||
.where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
}
|
||||
|
||||
// ── 내보내기 ──
|
||||
|
|
@ -649,6 +655,7 @@ class MeetingModeService extends EventEmitter {
|
|||
editedTranscript,
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
logger.info(`전사 수정 저장: sessionId=${sessionId}`)
|
||||
}
|
||||
|
||||
|
|
@ -733,7 +740,7 @@ class MeetingModeService extends EventEmitter {
|
|||
logger.info(`문서 생성 완료: docId=${docId}, sessionId=${params.sessionId}`)
|
||||
|
||||
// Phase 3.3: 자동 push (fire-and-forget)
|
||||
void getCloudSyncService().pushOne('meeting_documents', docId)
|
||||
getCloudSyncService().pushOne('meeting_documents', docId)
|
||||
|
||||
return {
|
||||
id: docId,
|
||||
|
|
@ -779,6 +786,7 @@ class MeetingModeService extends EventEmitter {
|
|||
}
|
||||
db.update(meetingDocuments).set({ content, updatedAt: Date.now() })
|
||||
.where(eq(meetingDocuments.id, documentId)).run()
|
||||
getCloudSyncService().pushOne('meeting_documents', documentId)
|
||||
logger.info(`문서 수정: docId=${documentId}`)
|
||||
}
|
||||
|
||||
|
|
@ -789,6 +797,7 @@ class MeetingModeService extends EventEmitter {
|
|||
throw new D3ROError(ErrorCode.MeetingDocumentNotFound, `문서를 찾을 수 없습니다: ${documentId}`)
|
||||
}
|
||||
db.delete(meetingDocuments).where(eq(meetingDocuments.id, documentId)).run()
|
||||
getCloudSyncService().pushDelete('meeting_documents', documentId)
|
||||
logger.info(`문서 삭제: docId=${documentId}`)
|
||||
}
|
||||
|
||||
|
|
@ -1012,6 +1021,7 @@ class MeetingModeService extends EventEmitter {
|
|||
editedTranscript: result.text,
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
|
||||
logger.info(`Auto Polish 완료: sessionId=${sessionId}`)
|
||||
return result.text
|
||||
|
|
@ -1163,6 +1173,7 @@ ${transcript}`
|
|||
editedTranscript: labeledLines.join('\n'),
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
|
||||
logger.info(`pyannote 화자 구분 완료: ${diarResult.num_speakers}명, sessionId=${sessionId}`)
|
||||
} catch (err) {
|
||||
|
|
@ -1211,6 +1222,7 @@ ${speakerHint}
|
|||
editedTranscript: result.text,
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
}
|
||||
|
||||
private _buildPdfHtml(session: MeetingSessionDetail): string {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue