diff --git a/apps/desktop/src/main/ipc/cloud-sync-handlers.ts b/apps/desktop/src/main/ipc/cloud-sync-handlers.ts index 2864595..7bb494b 100644 --- a/apps/desktop/src/main/ipc/cloud-sync-handlers.ts +++ b/apps/desktop/src/main/ipc/cloud-sync-handlers.ts @@ -6,6 +6,9 @@ import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { ipcSuccess, ipcError, ErrorCode, D3ROError } from '@d3ro/core/errors' import { getCloudSyncService } from '../services/CloudSyncService' import { getLogger } from '../services/LoggerService' +import { onConfigChanged } from '../services/ConfigService' +import { isApplyingRemoteSettings } from '../services/sync/settings-sync' +import { reapplyThemeToAllPopups } from '../windows/WindowManager' const logger = getLogger('cloud-sync-handlers') @@ -115,6 +118,13 @@ export function registerCloudSyncHandlers(): void { broadcast(IPC_CHANNELS.CLOUD_SYNC.SYNC_ERROR, payload) }) // 다른 기기의 변경이 반영되면 앱 공통 새로고침 신호로 알린다 (이력·사전·회의·명령 화면이 구독). + // 동기화가 메인에서 바꾼 설정(언어·테마 등)을 화면에도 알린다 — IPC로 바꿀 때만 방송되던 경로. + onConfigChanged((event) => { + if (!isApplyingRemoteSettings()) return + broadcast(IPC_CHANNELS.CONFIG.CHANGED, event) + if (event.key === 'theme') reapplyThemeToAllPopups() + }) + sync.on('data-changed', (payload) => { broadcast(IPC_CHANNELS.APP.DATA_CHANGED, { type: 'cloud-sync', entities: payload.entities }) }) diff --git a/apps/desktop/src/main/ipc/history-handlers.ts b/apps/desktop/src/main/ipc/history-handlers.ts index 0142169..4298b01 100644 --- a/apps/desktop/src/main/ipc/history-handlers.ts +++ b/apps/desktop/src/main/ipc/history-handlers.ts @@ -3,15 +3,22 @@ import { ipcMain } from 'electron' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors' +import fs from 'fs' import { getHistoryService } from '../services/HistoryService' +import { getCloudSyncService } from '../services/CloudSyncService' +import { audioMimeType } from '../services/sync/audio-sync' import type { HistoryQueryParams, HistoryGetByIdParams, HistoryDeleteParams, HistorySearchParams, - HistorySetFavoriteParams + HistorySetFavoriteParams, + HistoryAudioSource } from '@d3ro/core/types' +/** 렌더러로 넘기는 로컬 녹음 상한. 넘으면 재생하지 않는다(IPC 복사 비용). */ +const MAX_LOCAL_AUDIO_BYTES = 100 * 1024 * 1024 + export function registerHistoryHandlers(): void { ipcMain.handle(IPC_CHANNELS.HISTORY.GET_ALL, async (_event, params: HistoryQueryParams) => { try { @@ -67,6 +74,27 @@ export function registerHistoryHandlers(): void { } }) + ipcMain.handle(IPC_CHANNELS.HISTORY.GET_AUDIO, async (_event, params: HistoryGetByIdParams) => { + try { + const entry = getHistoryService().getById(params.id) + if (!entry) return ipcError(ErrorCode.HistoryNotFound, `History entry not found: ${params.id}`) + const localPath = entry.audioLocalPath + if (localPath && fs.existsSync(localPath) && fs.statSync(localPath).size <= MAX_LOCAL_AUDIO_BYTES) { + const source: HistoryAudioSource = { + kind: 'local', + bytes: new Uint8Array(fs.readFileSync(localPath)), + mimeType: audioMimeType(localPath) ?? 'audio/wav', + } + return ipcSuccess(source) + } + const url = await getCloudSyncService().createHistoryAudioUrl(params.id) + return ipcSuccess(url ? ({ kind: 'remote', url } satisfies HistoryAudioSource) : null) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return ipcError(ErrorCode.DBQueryFailed, `Failed to load audio: ${message}`) + } + }) + ipcMain.handle(IPC_CHANNELS.HISTORY.SEARCH, async (_event, params: HistorySearchParams) => { try { return ipcSuccess(getHistoryService().search(params)) diff --git a/apps/desktop/src/main/services/CloudSyncService.ts b/apps/desktop/src/main/services/CloudSyncService.ts index 100579e..021b505 100644 --- a/apps/desktop/src/main/services/CloudSyncService.ts +++ b/apps/desktop/src/main/services/CloudSyncService.ts @@ -13,7 +13,7 @@ import { type RealtimeChannel } from '@supabase/supabase-js' import { getLogger } from './LoggerService' -import { configGet, configSet } from './ConfigService' +import { configGet, configSet, onConfigChanged } from './ConfigService' import { SUPABASE_URL, SUPABASE_ANON_KEY } from '@d3ro/core/supabase-config' import { openForUser, openLocal, closeCurrent, getCurrentUserId, importLocalModeData } from '../db' import { D3ROError, ErrorCode } from '@d3ro/core/errors' @@ -24,6 +24,8 @@ import { enqueueChange, getSyncState, setSyncState } from './sync/sync-outbox' import { checkInDesktopDevice, currentDeviceInfo, unregisterDesktopDevice } from './sync/device-registration' import type { SyncEntity, SyncRunResult } from './sync/sync-types' import { nodeRealtimeTransport } from './sync/realtime-transport' +import { SETTINGS_ROW_ID, SYNCED_CONFIG_KEYS, isApplyingRemoteSettings } from './sync/settings-sync' +import { AUDIO_BUCKET, listLocalAudioOwners } from './sync/audio-sync' const logger = getLogger('CloudSyncService') @@ -46,6 +48,8 @@ const REALTIME_TABLES = [ 'memo_tags', 'custom_instructions', 'user_templates', + 'knowledge_documents', + 'user_settings', 'sync_tombstones', ] as const @@ -125,6 +129,23 @@ class CloudSyncService extends EventEmitter { this._lastSyncAt = (configGet('cloudSyncLastAt') as number | undefined) ?? null + // 언어·테마·자동 다듬기·활성 명령이 바뀌면 모바일 user_settings 로 올린다(원격 반영 중엔 제외). + onConfigChanged((event) => { + if (isApplyingRemoteSettings()) return + if ((SYNCED_CONFIG_KEYS as readonly string[]).includes(event.key)) { + this.pushOne('user_settings', SETTINGS_ROW_ID) + } + // 녹음 동기화를 켜면 그동안 올리지 않은 녹음을 올린다. + if (event.key === 'cloudSyncAudio' && event.value === true && this._engine) { + try { + for (const id of listLocalAudioOwners('history')) this.pushOne('history_audio', id) + for (const id of listLocalAudioOwners('meeting')) this.pushOne('meeting_audio', id) + } catch (err) { + logger.warn(`Audio re-queue failed: ${err instanceof Error ? err.message : String(err)}`) + } + } + }) + // 저장된 refresh token 복원 const stored = this._loadStoredRefreshToken() if (stored) { @@ -913,6 +934,29 @@ class CloudSyncService extends EventEmitter { } } + /** + * 다른 기기(모바일·웹)에서 녹음한 기록의 재생 URL(5분 서명). 로컬 파일이 없을 때만 쓴다. + * 로그인 전이거나 서버에 녹음이 없으면 null. + */ + async createHistoryAudioUrl(historyId: string): Promise { + const client = this._client + const session = this._session + if (!client || !session) return null + const { data, error } = await client + .from('audio_files') + .select('storage_key,upload_status') + .eq('user_id', session.user.id) + .eq('history_id', historyId) + .eq('upload_status', 'uploaded') + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle() + if (error || !data || typeof data.storage_key !== 'string') return null + if (!data.storage_key.startsWith(`${session.user.id}/`)) return null + const signed = await client.storage.from(AUDIO_BUCKET).createSignedUrl(data.storage_key, 300) + return signed.error ? null : signed.data.signedUrl + } + getSyncStatus(): SyncStatus | null { if (!this._engine) return null try { diff --git a/apps/desktop/src/main/services/ConfigService.ts b/apps/desktop/src/main/services/ConfigService.ts index ab944a4..eac6561 100644 --- a/apps/desktop/src/main/services/ConfigService.ts +++ b/apps/desktop/src/main/services/ConfigService.ts @@ -158,6 +158,7 @@ const CONFIG_DEFAULTS: AppConfig = { supabaseAnonKey: '', cloudSyncLastAt: null, deviceInstallationId: null, + cloudSyncAudio: true, onboardingCompleted: false, // Phase 6/10+: AppConfig 키 기본값 (WS2 SSOT 강화 대응). // as never 제거 후 configGet이 이 키들을 반환 — 기존 사용자 config(0.1.x)에 diff --git a/apps/desktop/src/main/services/HistoryService.ts b/apps/desktop/src/main/services/HistoryService.ts index ad0f23f..ec84590 100644 --- a/apps/desktop/src/main/services/HistoryService.ts +++ b/apps/desktop/src/main/services/HistoryService.ts @@ -41,6 +41,7 @@ class HistoryService { // Phase 3.3: 로그인 상태면 자동 push (fire-and-forget, 로컬 write는 차단하지 않음) void getCloudSyncService().pushOne('history', id) + if (input.audioLocalPath) getCloudSyncService().pushOne('history_audio', id) // 비동기로 LLM 타이틀 자동 생성 (fire-and-forget) this.generateTitle(id).catch(() => { /* ignore */ }) diff --git a/apps/desktop/src/main/services/MeetingModeService.ts b/apps/desktop/src/main/services/MeetingModeService.ts index 86eaf1b..581cc99 100644 --- a/apps/desktop/src/main/services/MeetingModeService.ts +++ b/apps/desktop/src/main/services/MeetingModeService.ts @@ -369,6 +369,7 @@ class MeetingModeService extends EventEmitter { if (!fs.existsSync(audioDir)) fs.mkdirSync(audioDir, { recursive: true }) const wavPath = path.join(audioDir, `${sessionId}.wav`) this._saveWav(wavPath, Buffer.concat(this._audioBuffersForFile)) + getCloudSyncService().pushOne('meeting_audio', sessionId) logger.info(`회의 오디오 저장: ${wavPath}`) } catch (err) { logger.warn(`오디오 저장 실패: ${err instanceof Error ? err.message : String(err)}`) diff --git a/apps/desktop/src/main/services/RAGService.ts b/apps/desktop/src/main/services/RAGService.ts index 65f2463..1b7a6d8 100644 --- a/apps/desktop/src/main/services/RAGService.ts +++ b/apps/desktop/src/main/services/RAGService.ts @@ -4,13 +4,15 @@ import { EventEmitter } from 'events' import path from 'path' -import { eq } from 'drizzle-orm' +import fs from 'fs' +import { asc, eq } from 'drizzle-orm' import { getLogger } from './LoggerService' import { getPremiumLLMService } from './PremiumLLMService' import { getOllamaServerUrl } from './LocalLLMService' import { getDatabase } from '../db' import { ragDocuments, ragChunks } from '../db/schema' import { getMainWindow } from '../windows/WindowManager' +import { getCloudSyncService } from './CloudSyncService' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { @@ -133,8 +135,12 @@ class RAGService extends EventEmitter { addedAt: Date.now(), }).run() + // 청크 원문을 먼저 저장한다 — 임베딩이 실패해도 원문은 남아 재색인·기기 간 동기화가 가능하다. + this._storeChunks(docId, chunks) + getCloudSyncService().pushOne('knowledge_documents', docId) + // 비동기 인덱싱 (임베딩 생성) - this._indexDocument(docId, fileName, chunks).catch((err) => { + this._embedStoredChunks(docId, fileName).catch((err) => { logger.error(`Indexing failed for ${fileName}:`, err) }) @@ -154,10 +160,59 @@ class RAGService extends EventEmitter { * 문서 제거 (청크 포함) */ removeDocument(documentId: string): void { + this.removeRemote(documentId) + getCloudSyncService().pushDelete('knowledge_documents', documentId) + logger.info(`RAG document removed: ${documentId}`) + } + + /** 동기화: 다른 기기에서 지운 문서를 지운다(outbox에 넣지 않는다). */ + removeRemote(documentId: string): boolean { const db = getDatabase() db.delete(ragChunks).where(eq(ragChunks.documentId, documentId)).run() - db.delete(ragDocuments).where(eq(ragDocuments.id, documentId)).run() - logger.info(`RAG document removed: ${documentId}`) + return db.delete(ragDocuments).where(eq(ragDocuments.id, documentId)).run().changes > 0 + } + + /** + * 동기화: 다른 기기(모바일·웹)의 지식 문서를 원문 청크로 받아 저장하고, 이 기기의 임베딩 모델로 색인한다. + * 임베딩 공간이 기기마다 달라 벡터는 옮기지 않는다. 원본 파일은 없으므로 filePath는 비워 둔다. + */ + applyRemoteDocument(doc: { + id: string + fileName: string + fileType: RAGDocument['fileType'] + chunks: string[] + addedAt: number + }): boolean { + const db = getDatabase() + if (db.select({ id: ragDocuments.id }).from(ragDocuments).where(eq(ragDocuments.id, doc.id)).get()) return false + const chunks = doc.chunks.filter((c) => c.trim().length > 0) + if (chunks.length === 0) return false + db.insert(ragDocuments).values({ + id: doc.id, + fileName: doc.fileName, + filePath: '', + fileType: doc.fileType, + chunkCount: chunks.length, + indexed: false, + indexedAt: null, + addedAt: doc.addedAt, + }).run() + this._storeChunks(doc.id, chunks) + this._embedStoredChunks(doc.id, doc.fileName).catch((err) => { + logger.warn(`Synced document ${doc.fileName} is stored but not embedded yet:`, err) + }) + return true + } + + /** 저장된 청크 원문(chunkIndex 순) — 동기화 업로드용 */ + getStoredChunks(documentId: string): string[] { + return getDatabase() + .select({ content: ragChunks.content }) + .from(ragChunks) + .where(eq(ragChunks.documentId, documentId)) + .orderBy(asc(ragChunks.chunkIndex)) + .all() + .map((r) => r.content) } /** @@ -171,8 +226,11 @@ class RAGService extends EventEmitter { } const doc = rows[0] - // 기존 청크 삭제 - db.delete(ragChunks).where(eq(ragChunks.documentId, documentId)).run() + // 원본 파일이 없으면(다른 기기에서 동기화된 문서 등) 저장된 원문 청크로 다시 임베딩한다. + if (!doc.filePath || !fs.existsSync(doc.filePath)) { + await this._embedStoredChunks(documentId, doc.fileName, { force: true }) + return + } // 텍스트 재추출 + 재인덱싱 const content = await this._extractText(doc.filePath, doc.fileType as RAGDocument['fileType']) @@ -183,7 +241,9 @@ class RAGService extends EventEmitter { .where(eq(ragDocuments.id, documentId)) .run() - await this._indexDocument(documentId, doc.fileName, chunks) + this._storeChunks(documentId, chunks) + getCloudSyncService().pushOne('knowledge_documents', documentId) + await this._embedStoredChunks(documentId, doc.fileName) } /** @@ -194,7 +254,8 @@ class RAGService extends EventEmitter { try { const db = getDatabase() - const allChunks = db.select().from(ragChunks).all() + // 원문만 있고 아직 임베딩되지 않은 청크는 검색 대상이 아니다. + const allChunks = db.select().from(ragChunks).all().filter((c) => c.embedding.length > 0) if (allChunks.length === 0) { throw new D3ROError(ErrorCode.RAGQueryFailed, 'No indexed chunks to query') } @@ -245,9 +306,37 @@ ${context}` // ── 내부 메서드 ── - private async _indexDocument(docId: string, fileName: string, chunks: string[]): Promise { + /** 청크 원문을 (다시) 저장한다. 임베딩은 비워 두고 _embedStoredChunks 가 채운다. */ + private _storeChunks(docId: string, chunks: string[]): void { + const db = getDatabase() + db.transaction((tx) => { + tx.delete(ragChunks).where(eq(ragChunks.documentId, docId)).run() + chunks.forEach((content, index) => { + tx.insert(ragChunks).values({ + id: crypto.randomUUID(), + documentId: docId, + content, + embedding: '', + chunkIndex: index, + }).run() + }) + }) + } + + /** 저장된 청크 중 임베딩이 없는 것(force면 전부)을 이 기기의 임베딩 모델로 채운다. */ + private async _embedStoredChunks( + docId: string, + fileName: string, + options: { force?: boolean } = {} + ): Promise { this._state = 'indexing' const db = getDatabase() + const chunks = db + .select() + .from(ragChunks) + .where(eq(ragChunks.documentId, docId)) + .orderBy(asc(ragChunks.chunkIndex)) + .all() logger.info(`RAG indexing started: ${fileName} (${chunks.length} chunks)`) @@ -262,21 +351,21 @@ ${context}` let successCount = 0 for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i] + if (chunk.embedding.length > 0 && !options.force) { + successCount++ + continue + } try { - const embedding = await this._embed(chunks[i]) - - db.insert(ragChunks).values({ - id: crypto.randomUUID(), - documentId: docId, - content: chunks[i], - embedding: JSON.stringify(embedding), - chunkIndex: i, - }).run() - + const embedding = await this._embed(chunk.content) + db.update(ragChunks) + .set({ embedding: JSON.stringify(embedding) }) + .where(eq(ragChunks.id, chunk.id)) + .run() successCount++ } catch (err) { logger.warn(`RAG embedding failed for chunk ${i}/${chunks.length} of ${fileName}:`, err) - // 개별 청크 실패는 건너뛰고 계속 진행 + // 개별 청크 실패는 건너뛰고 계속 진행 — 원문은 남아 있어 재색인할 수 있다 } const progress: RAGIndexProgress = { @@ -307,9 +396,9 @@ ${context}` ) } - // 인덱싱 완료 표시 + // 인덱싱 완료 표시 (chunkCount는 원문 청크 수 — 서버·모바일과 같은 기준) db.update(ragDocuments) - .set({ indexed: true, indexedAt: Date.now(), chunkCount: successCount }) + .set({ indexed: true, indexedAt: Date.now(), chunkCount: chunks.length }) .where(eq(ragDocuments.id, docId)) .run() diff --git a/apps/desktop/src/main/services/sync/SyncEngine.ts b/apps/desktop/src/main/services/sync/SyncEngine.ts index dac6cc9..8dd3cb1 100644 --- a/apps/desktop/src/main/services/sync/SyncEngine.ts +++ b/apps/desktop/src/main/services/sync/SyncEngine.ts @@ -18,6 +18,8 @@ import { pushMemoTagRemovals, reconcileMemoTags, } from './memo-tag-sync' +import { fetchRemoteAudioOwners, isAudioSyncEnabled, listLocalAudioOwners, pushAudio } from './audio-sync' +import { SETTINGS_ROW_ID, applyRemoteSettings, fetchRemoteSettings, pushSettings } from './settings-sync' import { completeEntry, dropEntry, @@ -215,6 +217,25 @@ export class SyncEngine extends EventEmitter { } } } + const ctx: PushContext = { remote: this.remote, userId: this.userId } + // 설정: 서버에 행이 없을 때만 로컬 값을 올린다. 있으면 다른 기기의 선택이 우선(pull이 반영). + if (!(await fetchRemoteSettings(ctx))) { + enqueueChange('user_settings', SETTINGS_ROW_ID, 'upsert', now) + queued++ + } + if (isAudioSyncEnabled()) { + for (const owner of ['history', 'meeting'] as const) { + const local = listLocalAudioOwners(owner) + if (local.length === 0) continue + const remote = await fetchRemoteAudioOwners(this.remote, this.userId, owner) + for (const id of local) { + if (!remote.has(id)) { + enqueueChange(owner === 'history' ? 'history_audio' : 'meeting_audio', id, 'upsert', now) + queued++ + } + } + } + } setSyncState(BACKFILL_FLAG, 'done', now) logger.info(`Backfill queued ${queued} local change(s) for ${this.userId}`) } @@ -269,31 +290,51 @@ export class SyncEngine extends EventEmitter { 'meeting_memos', 'meeting_documents', 'custom_instructions', + 'user_settings', 'user_templates', + 'knowledge_documents', 'memo_tags', + 'history_audio', + 'meeting_audio', ] let networkDown = false for (const entity of upsertOrder) { const bucket = byEntity.get(entity) if (!bucket || bucket.upserts.length === 0 || networkDown) continue - const outcomes = - entity === 'memo_tags' - ? await pushMemoTagAdds(ctx, bucket.upserts.map((e) => e.rowId)) - : await this.requireAdapter(entity).push(ctx, bucket.upserts.map((e) => e.rowId)) + const ids = bucket.upserts.map((e) => e.rowId) + const outcomes = await this.pushUpserts(ctx, entity, ids) networkDown = this.settle(bucket.upserts, outcomes, result, entity) } // 자식 먼저 delete (서버 cascade가 있어도 순서를 지켜 FK 오류를 피한다) for (const entity of [...order].reverse()) { const bucket = byEntity.get(entity) if (!bucket || bucket.deletes.length === 0 || networkDown) continue + const ids = bucket.deletes.map((e) => e.rowId) const outcomes = entity === 'memo_tags' - ? await pushMemoTagRemovals(ctx, bucket.deletes.map((e) => e.rowId)) - : await this.requireAdapter(entity).pushDeletes(ctx, bucket.deletes.map((e) => e.rowId)) + ? await pushMemoTagRemovals(ctx, ids) + : adapterFor(entity) + ? await this.requireAdapter(entity).pushDeletes(ctx, ids) + : ids.map((id) => ({ id, error: null })) networkDown = this.settle(bucket.deletes, outcomes, result, entity) } } + private async pushUpserts(ctx: PushContext, entity: SyncEntity, ids: string[]): Promise { + switch (entity) { + case 'memo_tags': + return pushMemoTagAdds(ctx, ids) + case 'user_settings': + return [{ id: SETTINGS_ROW_ID, error: await pushSettings(ctx) }] + case 'history_audio': + return pushAudio(ctx, 'history', ids) + case 'meeting_audio': + return pushAudio(ctx, 'meeting', ids) + default: + return this.requireAdapter(entity).push(ctx, ids) + } + } + /** outbox를 결과대로 정리. 네트워크 계열 실패가 있었으면 true(이번 flush 중단). */ private settle(entries: OutboxEntry[], outcomes: PushOutcome[], result: SyncRunResult, entity: SyncEntity): boolean { const byId = new Map(outcomes.map((o) => [o.id, o.error])) @@ -361,6 +402,20 @@ export class SyncEngine extends EventEmitter { logger.warn(`Reconcile memo_tags failed: ${message}`) } + try { + if (!pendingOps('user_settings').has(SETTINGS_ROW_ID)) { + const row = await fetchRemoteSettings({ remote: this.remote, userId: this.userId }) + if (row && applyRemoteSettings(row)) { + changed.add('user_settings') + result.pulled++ + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + result.errors.push(`user_settings: ${message}`) + logger.warn(`Pull user_settings failed: ${message}`) + } + result.changed = [...changed] if (result.errors.length === 0) setSyncState('lastPullAt', String(this.now())) } @@ -389,7 +444,7 @@ export class SyncEngine extends EventEmitter { const id = typeof row.id === 'string' ? row.id : null if (id && !pending.has(id)) { try { - if (adapter.applyRemote(row)) applied++ + if (await adapter.applyRemote(row, { remote: this.remote, userId: this.userId })) applied++ } catch (err) { logger.warn( `Apply ${adapter.entity}/${id} failed: ${err instanceof Error ? err.message : String(err)}` diff --git a/apps/desktop/src/main/services/sync/audio-sync.ts b/apps/desktop/src/main/services/sync/audio-sync.ts new file mode 100644 index 0000000..4214276 --- /dev/null +++ b/apps/desktop/src/main/services/sync/audio-sync.ts @@ -0,0 +1,219 @@ +// src/main/services/sync/audio-sync.ts +// 데스크톱 녹음 파일을 모바일과 같은 저장소 계약(`audio` 버킷, `{userId}/...` 경로, audio_files 행)으로 올린다. +// 모바일은 audio_files 로 재생 URL을 만들고, 데스크톱은 반대로 모바일 녹음을 재생할 때 서명 URL을 받는다. + +import fs from 'fs' +import path from 'path' +import { createHash } from 'crypto' +import { app } from 'electron' +import { eq, isNotNull } from 'drizzle-orm' +import { getDatabase } from '../../db' +import { history, meetingSessions } from '../../db/schema' +import { configGet } from '../ConfigService' +import { getLogger } from '../LoggerService' +import { isUuid, type PushContext } from './sync-adapters' +import { toSyncRemoteError, type PushOutcome, type RemoteCursor, type SyncRemote } from './sync-types' + +const logger = getLogger('AudioSync') + +export const AUDIO_BUCKET = 'audio' +/** Supabase Storage 파일 한도(config.toml storage 50MiB). 넘는 파일은 올리지 않는다. */ +export const MAX_AUDIO_BYTES = 50 * 1024 * 1024 + +export type AudioOwner = 'history' | 'meeting' + +const OWNER_COLUMN: Record = { + history: 'history_id', + meeting: 'meeting_id', +} + +/** audio 버킷이 허용하는 형식만(서버 allowed_mime_types). 그 밖의 파일은 올리지 않는다. */ +const AUDIO_MIME: Record = { + '.wav': 'audio/wav', + '.webm': 'audio/webm', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/x-m4a', + '.aac': 'audio/aac', + '.ogg': 'audio/ogg', + '.flac': 'audio/flac', + '.mp4': 'video/mp4', + '.mov': 'video/quicktime', +} + +export function audioMimeType(filePath: string): string | null { + return AUDIO_MIME[path.extname(filePath).toLowerCase()] ?? null +} + +export function audioStorageKey(userId: string, owner: AudioOwner, id: string, extension = '.wav'): string { + return `${userId}/desktop/${owner}/${id}${extension.toLowerCase()}` +} + +export function isAudioSyncEnabled(): boolean { + return configGet('cloudSyncAudio') !== false +} + +function meetingAudioPath(id: string): string { + return path.join(app.getPath('userData'), 'meeting-audio', `${id}.wav`) +} + +/** 로컬 녹음 파일 경로. 파일이 없으면 null */ +export function localAudioPath(owner: AudioOwner, id: string): string | null { + let candidate: string | null + if (owner === 'history') { + const row = getDatabase() + .select({ audioLocalPath: history.audioLocalPath }) + .from(history) + .where(eq(history.id, id)) + .get() + candidate = row?.audioLocalPath ?? null + } else { + candidate = meetingAudioPath(id) + } + return candidate && fs.existsSync(candidate) ? candidate : null +} + +/** 녹음이 있는 로컬 항목 id (최초 대조용) */ +export function listLocalAudioOwners(owner: AudioOwner): string[] { + if (owner === 'history') { + return getDatabase() + .select({ id: history.id, audioLocalPath: history.audioLocalPath }) + .from(history) + .where(isNotNull(history.audioLocalPath)) + .all() + .filter((r) => isUuid(r.id) && r.audioLocalPath !== null && fs.existsSync(r.audioLocalPath)) + .map((r) => r.id) + } + return getDatabase() + .select({ id: meetingSessions.id }) + .from(meetingSessions) + .all() + .filter((r) => isUuid(r.id) && fs.existsSync(meetingAudioPath(r.id))) + .map((r) => r.id) +} + +function durationMs(owner: AudioOwner, id: string): number | null { + if (owner === 'history') { + const row = getDatabase().select({ duration: history.duration }).from(history).where(eq(history.id, id)).get() + return row ? Math.round(row.duration * 1000) : null + } + const row = getDatabase() + .select({ durationMs: meetingSessions.durationMs }) + .from(meetingSessions) + .where(eq(meetingSessions.id, id)) + .get() + return row?.durationMs ?? null +} + +export async function pushAudio(ctx: PushContext, owner: AudioOwner, ids: string[]): Promise { + const outcomes: PushOutcome[] = [] + const column = OWNER_COLUMN[owner] + for (const id of ids) { + const filePath = isAudioSyncEnabled() && isUuid(id) ? localAudioPath(owner, id) : null + const mimeType = filePath ? audioMimeType(filePath) : null + if (!filePath || !mimeType) { + outcomes.push({ id, error: null }) + continue + } + try { + const size = fs.statSync(filePath).size + if (size > MAX_AUDIO_BYTES) { + logger.info(`Audio ${owner}/${id} is ${size} bytes — over the storage limit, kept local only`) + outcomes.push({ id, error: null }) + continue + } + const bytes = fs.readFileSync(filePath) + const sha256 = createHash('sha256').update(bytes).digest('hex') + const existing = await ctx.remote.selectWhere( + 'audio_files', + ctx.userId, + [ + { column, op: 'eq', value: id }, + { column: 'sha256', op: 'eq', value: sha256 }, + { column: 'upload_status', op: 'eq', value: 'uploaded' }, + ], + 'id' + ) + if (existing.length > 0) { + outcomes.push({ id, error: null }) + continue + } + const key = audioStorageKey(ctx.userId, owner, id, path.extname(filePath)) + await ctx.remote.uploadObject(AUDIO_BUCKET, key, new Uint8Array(bytes), mimeType) + await ctx.remote.upsert( + 'audio_files', + [ + { + user_id: ctx.userId, + [column]: id, + source: 'recording', + original_name: path.basename(filePath), + storage_key: key, + mime_type: mimeType, + size_bytes: size, + duration_ms: durationMs(owner, id), + sha256, + upload_status: 'uploaded', + }, + ], + 'user_id,sha256,storage_key' + ) + outcomes.push({ id, error: null }) + } catch (err) { + outcomes.push({ id, error: toSyncRemoteError(err) }) + } + } + return outcomes +} + +/** 서버에 이미 올라간 녹음의 주인 id (최초 대조용) */ +export async function fetchRemoteAudioOwners(remote: SyncRemote, userId: string, owner: AudioOwner): Promise> { + const column = OWNER_COLUMN[owner] + const owners = new Set() + let after: RemoteCursor | null = null + for (;;) { + const page = await remote.fetchPage({ + table: 'audio_files', + userId, + cursorColumn: 'updated_at', + after, + limit: 1000, + columns: `id,${column},updated_at`, + filters: [{ column: 'upload_status', op: 'eq', value: 'uploaded' }], + }) + for (const row of page) { + const value = row[column] + if (isUuid(value)) owners.add(value) + } + if (page.length < 1000) break + const last = page[page.length - 1] + if (typeof last.updated_at !== 'string' || typeof last.id !== 'string') break + after = { ts: last.updated_at, id: last.id } + } + return owners +} + +/** + * 지운 기록·회의의 녹음을 저장소와 audio_files 에서 지운다. + * 부모 행이 지워지면 audio_files 는 SET NULL 로 고아가 되므로 부모보다 먼저 지운다. 실패해도 부모 삭제는 진행한다. + */ +export async function purgeRemoteAudio(ctx: PushContext, column: 'history_id' | 'meeting_id', ids: string[]): Promise { + for (const id of ids.filter(isUuid)) { + try { + const rows = await ctx.remote.selectWhere( + 'audio_files', + ctx.userId, + [{ column, op: 'eq', value: id }], + 'id,storage_key' + ) + const keys = rows.map((r) => r.storage_key).filter((k): k is string => typeof k === 'string') + await ctx.remote.removeObjects(AUDIO_BUCKET, keys) + await ctx.remote.deleteByIds( + 'audio_files', + ctx.userId, + rows.map((r) => r.id).filter(isUuid) + ) + } catch (err) { + logger.warn(`Audio cleanup for ${column}=${id} failed: ${err instanceof Error ? err.message : String(err)}`) + } + } +} diff --git a/apps/desktop/src/main/services/sync/settings-sync.ts b/apps/desktop/src/main/services/sync/settings-sync.ts new file mode 100644 index 0000000..38eb411 --- /dev/null +++ b/apps/desktop/src/main/services/sync/settings-sync.ts @@ -0,0 +1,169 @@ +// src/main/services/sync/settings-sync.ts +// 모바일 user_settings(한 행, revision 낙관적 동시성)와 데스크톱 설정 중 뜻이 같은 것만 맞춘다. +// locale ↔ language (모바일이 받는 ko/en만) +// theme_mode ↔ theme (system/light/dark 만 — 데스크톱 전용 테마는 로컬에 둔다) +// auto_polish_enabled ↔ defaultLLMAction ('none' 이면 꺼짐) +// active_instruction_id ↔ activeInstructionId (양쪽에 있는 사용자 명령일 때만) + +import type { LLMActionSelection, ThemeMode } from '@d3ro/core/types' +import { configGet, configSet } from '../ConfigService' +import { getCustomInstructionService } from '../CustomInstructionService' +import { isUuid, type PushContext } from './sync-adapters' +import { toSyncRemoteError, type RemoteRow, type SyncRemoteError } from './sync-types' + +export const SETTINGS_ROW_ID = 'self' + +/** 이 키들이 바뀌면 설정 동기화 대상이다 */ +export const SYNCED_CONFIG_KEYS = ['language', 'theme', 'defaultLLMAction', 'activeInstructionId'] as const + +const SETTINGS_COLUMNS = 'user_id,locale,theme_mode,auto_polish_enabled,active_instruction_id,revision' + +let applyingRemote = false + +/** 원격 설정을 반영하는 중인지 — 이 동안의 설정 변경은 다시 올리지 않는다(에코 방지) */ +export function isApplyingRemoteSettings(): boolean { + return applyingRemote +} + +export interface LocalSettings { + language: string + theme: ThemeMode + defaultLLMAction: LLMActionSelection + activeInstructionId: string +} + +export interface RemoteSettingsPatch { + locale?: 'ko' | 'en' + theme_mode?: 'system' | 'light' | 'dark' + auto_polish_enabled: boolean +} + +export function readLocalSettings(): LocalSettings { + return { + language: String(configGet('language') ?? 'ko'), + theme: (configGet('theme') ?? 'auto') as ThemeMode, + defaultLLMAction: (configGet('defaultLLMAction') ?? 'refine') as LLMActionSelection, + activeInstructionId: String(configGet('activeInstructionId') ?? ''), + } +} + +export function toRemoteSettings(local: LocalSettings): RemoteSettingsPatch { + const patch: RemoteSettingsPatch = { auto_polish_enabled: local.defaultLLMAction !== 'none' } + if (local.language === 'ko' || local.language === 'en') patch.locale = local.language + if (local.theme === 'auto') patch.theme_mode = 'system' + else if (local.theme === 'light' || local.theme === 'dark') patch.theme_mode = local.theme + return patch +} + +/** 원격 행이 요구하는 로컬 변경만 돌려준다(같으면 빈 객체). */ +export function remoteToLocalPatch( + row: RemoteRow, + local: LocalSettings, + userInstructionIds: ReadonlySet +): Partial { + const patch: Partial = {} + if ((row.locale === 'ko' || row.locale === 'en') && row.locale !== local.language) patch.language = row.locale + + const sharedTheme = local.theme === 'auto' || local.theme === 'light' || local.theme === 'dark' + if (sharedTheme) { + const theme: ThemeMode | null = + row.theme_mode === 'system' ? 'auto' : row.theme_mode === 'light' || row.theme_mode === 'dark' ? row.theme_mode : null + if (theme && theme !== local.theme) patch.theme = theme + } + + if (row.auto_polish_enabled === false && local.defaultLLMAction !== 'none') patch.defaultLLMAction = 'none' + if (row.auto_polish_enabled === true && local.defaultLLMAction === 'none') patch.defaultLLMAction = 'refine' + + const remoteActive = row.active_instruction_id + if (isUuid(remoteActive)) { + if (userInstructionIds.has(remoteActive) && remoteActive !== local.activeInstructionId) { + patch.activeInstructionId = remoteActive + } + } else if (remoteActive === null && userInstructionIds.has(local.activeInstructionId)) { + // 사용자 명령이 해제됐다. 데스크톱 프리셋(builtin-*)이 켜져 있으면 모바일이 알 수 없으므로 둔다. + patch.activeInstructionId = '' + } + return patch +} + +function userInstructionIds(): Set { + return new Set( + getCustomInstructionService() + .getAll() + .filter((i) => !i.isBuiltin && isUuid(i.id)) + .map((i) => i.id) + ) +} + +export async function fetchRemoteSettings(ctx: PushContext): Promise { + const rows = await ctx.remote.selectWhere('user_settings', ctx.userId, [], SETTINGS_COLUMNS) + return rows[0] ?? null +} + +/** 로컬 설정을 올린다. 모바일과 같은 revision 규칙(재조회 → revision 조건부 수정, 최대 3회). */ +export async function pushSettings(ctx: PushContext): Promise { + const local = readLocalSettings() + const desired = toRemoteSettings(local) + try { + let remote: RemoteRow | null = null + let saved = false + for (let attempt = 0; attempt < 3 && !saved; attempt++) { + remote = await fetchRemoteSettings(ctx) + if (!remote) { + try { + await ctx.remote.insert('user_settings', [{ user_id: ctx.userId, ...desired }]) + saved = true + } catch (err) { + const error = toSyncRemoteError(err) + if (error.code !== '23505') throw error + } + continue + } + const revision = Number(remote.revision ?? 1) + // 이미 같으면 쓰지 않는다 — revision만 올리면 다른 기기에 가짜 변경이 전파된다. + const current = remote + if ((Object.keys(desired) as Array).every((k) => current[k] === desired[k])) { + saved = true + break + } + const changed = await ctx.remote.updateMatching( + 'user_settings', + ctx.userId, + { revision }, + { ...desired, revision: revision + 1 } + ) + saved = changed > 0 + } + if (!saved) return toSyncRemoteError({ code: '40001', message: 'user_settings changed repeatedly on another device' }) + + // 활성 명령: 양쪽에 있는 사용자 명령이거나 '없음'일 때만 올린다(프리셋은 모바일에 없다). + const ids = userInstructionIds() + const active = local.activeInstructionId + const remoteActive = remote?.active_instruction_id ?? null + if (active === '' && remoteActive !== null) { + await ctx.remote.rpc('set_active_custom_instruction', { instruction_id: null }) + } else if (ids.has(active) && remoteActive !== active) { + await ctx.remote.rpc('set_active_custom_instruction', { instruction_id: active }) + } + return null + } catch (err) { + return toSyncRemoteError(err) + } +} + +/** 원격 설정을 로컬에 반영한다. 바꾼 것이 있으면 true */ +export function applyRemoteSettings(row: RemoteRow): boolean { + const patch = remoteToLocalPatch(row, readLocalSettings(), userInstructionIds()) + const keys = Object.keys(patch) as Array + if (keys.length === 0) return false + applyingRemote = true + try { + if (patch.language !== undefined) configSet('language', patch.language) + if (patch.theme !== undefined) configSet('theme', patch.theme) + if (patch.defaultLLMAction !== undefined) configSet('defaultLLMAction', patch.defaultLLMAction) + if (patch.activeInstructionId !== undefined) configSet('activeInstructionId', patch.activeInstructionId) + } finally { + applyingRemote = false + } + return true +} diff --git a/apps/desktop/src/main/services/sync/supabase-sync-remote.ts b/apps/desktop/src/main/services/sync/supabase-sync-remote.ts index 57c94be..fce6e72 100644 --- a/apps/desktop/src/main/services/sync/supabase-sync-remote.ts +++ b/apps/desktop/src/main/services/sync/supabase-sync-remote.ts @@ -76,9 +76,66 @@ export class SupabaseSyncRemote implements SyncRemote { return rowsOf(data) } - async upsert(table: string, rows: RemoteRow[]): Promise { + async selectChildren( + table: string, + parentColumn: string, + parentId: string, + columns: string, + orderBy: string + ): Promise { + const { data, error } = await this.client + .from(table) + .select(columns) + .eq(parentColumn, parentId) + .order(orderBy, { ascending: true }) + if (error) throw toSyncRemoteError(error) + return rowsOf(data) + } + + async upsert(table: string, rows: RemoteRow[], onConflict = 'id'): Promise { if (rows.length === 0) return - const { error } = await this.client.from(table).upsert(rows, { onConflict: 'id' }) + const { error } = await this.client.from(table).upsert(rows, { onConflict }) + if (error) throw toSyncRemoteError(error) + } + + async insert(table: string, rows: RemoteRow[]): Promise { + if (rows.length === 0) return + const { error } = await this.client.from(table).insert(rows) + if (error) throw toSyncRemoteError(error) + } + + async updateMatching( + table: string, + userId: string, + match: Record, + patch: RemoteRow + ): Promise { + let query = this.client.from(table).update(patch).eq('user_id', userId) + for (const [column, value] of Object.entries(match)) query = query.eq(column, value) + const { data, error } = await query.select('user_id') + if (error) throw toSyncRemoteError(error) + return rowsOf(data).length + } + + async deleteChildren(table: string, parentColumn: string, parentId: string): Promise { + const { error } = await this.client.from(table).delete().eq(parentColumn, parentId) + if (error) throw toSyncRemoteError(error) + } + + async invokeFunction(name: string, body: Record): Promise { + const { data, error } = await this.client.functions.invoke(name, { body }) + if (error) throw toSyncRemoteError(error) + return data + } + + async uploadObject(bucket: string, key: string, bytes: Uint8Array, contentType: string): Promise { + const { error } = await this.client.storage.from(bucket).upload(key, bytes, { contentType, upsert: true }) + if (error) throw toSyncRemoteError(error) + } + + async removeObjects(bucket: string, keys: string[]): Promise { + if (keys.length === 0) return + const { error } = await this.client.storage.from(bucket).remove(keys) if (error) throw toSyncRemoteError(error) } diff --git a/apps/desktop/src/main/services/sync/sync-adapters.ts b/apps/desktop/src/main/services/sync/sync-adapters.ts index 448c305..57099c2 100644 --- a/apps/desktop/src/main/services/sync/sync-adapters.ts +++ b/apps/desktop/src/main/services/sync/sync-adapters.ts @@ -10,11 +10,14 @@ import { meetingMemos, meetingSessions, memoTags, + ragDocuments, } from '../../db/schema' import type { CustomInstruction, DictationTemplate, MeetingDocTemplate, TemplateField } from '@d3ro/core/types' import { getCustomInstructionService } from '../CustomInstructionService' import { getDictationTemplateService } from '../DictationTemplateService' import { getMeetingDocTemplateService } from '../MeetingDocTemplateService' +import { getRAGService } from '../RAGService' +import { purgeRemoteAudio } from './audio-sync' import { dropEntry } from './sync-outbox' import { SyncRemoteError, @@ -43,8 +46,8 @@ export interface SyncAdapter { listLocalVersions(): LocalVersion[] push(ctx: PushContext, ids: string[]): Promise pushDeletes(ctx: PushContext, ids: string[]): Promise - /** 원격 행을 로컬에 반영. 반영했으면 true */ - applyRemote(row: RemoteRow): boolean + /** 원격 행을 로컬에 반영. 반영했으면 true. 자식 행을 더 읽어야 하는 엔티티는 비동기다 */ + applyRemote(row: RemoteRow, ctx: PushContext): boolean | Promise /** 원격 삭제를 로컬에 반영. 지운 행이 있으면 true */ deleteLocal(id: string): boolean } @@ -209,7 +212,9 @@ const historyAdapter: SyncAdapter = { ) return [...outcomes, ...missingAsDone(ids, found)] }, - pushDeletes(ctx, ids) { + async pushDeletes(ctx, ids) { + // 지운 기록의 녹음 파일도 저장소에서 지운다(행은 SET NULL로 남아 고아가 된다). + await purgeRemoteAudio(ctx, 'history_id', ids) return deleteRemote(ctx, 'history', ids) }, applyRemote(row) { @@ -296,11 +301,9 @@ async function adoptRemoteDictionaryRow( const match = matches.find((m) => isUuid(m.id) && m.id !== localId) if (!match || !isUuid(match.id)) return error const remoteId = match.id - db.transaction((tx) => { - tx.delete(dictionary).where(eq(dictionary.id, localId)).run() - }) + db.delete(dictionary).where(eq(dictionary.id, localId)).run() dropEntry('dictionary', localId) - dictionaryAdapter.applyRemote(match) + void dictionaryAdapter.applyRemote(match, ctx) const merged = db.select().from(dictionary).where(eq(dictionary.id, remoteId)).get() if (merged && local.usageCount > merged.usageCount) { db.update(dictionary) @@ -440,7 +443,8 @@ const meetingsAdapter: SyncAdapter = { ) return [...outcomes, ...missingAsDone(ids, found)] }, - pushDeletes(ctx, ids) { + async pushDeletes(ctx, ids) { + await purgeRemoteAudio(ctx, 'meeting_id', ids) return deleteRemote(ctx, 'meetings', ids) }, applyRemote(row) { @@ -841,6 +845,95 @@ const userTemplatesAdapter: SyncAdapter = { }, } +// ── knowledge_documents ───────────────────────────────── +// 원문 청크만 옮긴다. 임베딩은 기기마다 모델·차원이 달라(데스크톱 nomic 768 vs 서버 1536) 각자 만든다. + +const KNOWLEDGE_TYPES = ['txt', 'md', 'pdf', 'docx'] as const +const CHUNK_BATCH = 200 + +async function pushKnowledgeDocument(ctx: PushContext, id: string): Promise { + const db = getDatabase() + const doc = db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get() + if (!doc) return null + const chunks = getRAGService().getStoredChunks(id) + if (chunks.length === 0) return null + try { + await ctx.remote.upsert('knowledge_documents', [ + { + id: doc.id, + user_id: ctx.userId, + title: doc.fileName.slice(0, 300), + file_name: doc.fileName, + file_type: doc.fileType, + chunk_count: chunks.length, + indexed: false, + indexed_at: null, + created_at: iso(doc.addedAt), + }, + ]) + await ctx.remote.deleteChildren('knowledge_chunks', 'document_id', doc.id) + for (let i = 0; i < chunks.length; i += CHUNK_BATCH) { + await ctx.remote.insert( + 'knowledge_chunks', + chunks.slice(i, i + CHUNK_BATCH).map((content, offset) => ({ + document_id: doc.id, + chunk_index: i + offset, + content, + })) + ) + } + } catch (err) { + return toSyncRemoteError(err) + } + // 모바일·웹 검색용 서버 임베딩. 공급자 키가 없거나 한도에 걸려도 원문은 이미 올라갔다 — + // 모바일에서 "색인" 을 다시 누를 수 있으므로 여기서 실패로 되돌리지 않는다. + try { + await ctx.remote.invokeFunction('embed-chunks', { document_id: doc.id }) + } catch { + // 의도적 무시(위 주석) + } + return null +} + +const knowledgeAdapter: SyncAdapter = { + entity: 'knowledge_documents', + pull: { columns: 'id,title,file_name,file_type,chunk_count,created_at,updated_at' }, + listLocalVersions() { + return getDatabase() + .select({ id: ragDocuments.id, addedAt: ragDocuments.addedAt }) + .from(ragDocuments) + .all() + .filter((r) => isUuid(r.id)) + .map((r) => ({ id: r.id, updatedAt: r.addedAt })) + }, + async push(ctx, ids) { + const outcomes: PushOutcome[] = [] + for (const id of ids) outcomes.push({ id, error: await pushKnowledgeDocument(ctx, id) }) + return outcomes + }, + pushDeletes(ctx, ids) { + return deleteRemote(ctx, 'knowledge_documents', ids) + }, + async applyRemote(row, ctx) { + const id = row.id + if (!isUuid(id)) return false + const exists = getDatabase().select({ id: ragDocuments.id }).from(ragDocuments).where(eq(ragDocuments.id, id)).get() + if (exists) return false + const chunkRows = await ctx.remote.selectChildren('knowledge_chunks', 'document_id', id, 'chunk_index,content', 'chunk_index') + const chunks = chunkRows.map((c) => str(c.content)).filter((c): c is string => c !== null) + return getRAGService().applyRemoteDocument({ + id, + fileName: str(row.file_name) ?? str(row.title) ?? 'document', + fileType: oneOf(row.file_type, KNOWLEDGE_TYPES) ?? 'txt', + chunks, + addedAt: ms(row.created_at, Date.now()), + }) + }, + deleteLocal(id) { + return getRAGService().removeRemote(id) + }, +} + /** push 순서: 부모(이력·회의)가 자식(태그·메모·문서)보다 먼저. 삭제는 역순. */ export const TABLE_ADAPTERS: readonly SyncAdapter[] = [ historyAdapter, @@ -850,6 +943,7 @@ export const TABLE_ADAPTERS: readonly SyncAdapter[] = [ meetingDocumentsAdapter, customInstructionsAdapter, userTemplatesAdapter, + knowledgeAdapter, ] export function adapterFor(entity: SyncEntity): SyncAdapter | null { diff --git a/apps/desktop/src/main/services/sync/sync-types.ts b/apps/desktop/src/main/services/sync/sync-types.ts index 1b32c17..5b24fa6 100644 --- a/apps/desktop/src/main/services/sync/sync-types.ts +++ b/apps/desktop/src/main/services/sync/sync-types.ts @@ -11,6 +11,12 @@ export const SYNC_ENTITIES = [ 'memo_tags', 'custom_instructions', 'user_templates', + 'knowledge_documents', + /** 단일 행(row_id 'self'): 언어·테마·자동 다듬기·활성 명령 */ + 'user_settings', + /** 녹음 파일 업로드(row_id = history id / meeting id) */ + 'history_audio', + 'meeting_audio', ] as const export type SyncEntity = (typeof SYNC_ENTITIES)[number] @@ -54,9 +60,18 @@ export interface RemotePageRequest { export interface SyncRemote { fetchPage(request: RemotePageRequest): Promise selectWhere(table: string, userId: string, filters: RemoteFilter[], columns?: string): Promise - upsert(table: string, rows: RemoteRow[]): Promise + /** user_id 소유 검사가 없는 자식 테이블(knowledge_chunks) 조회 — RLS가 부모 소유로 거른다 */ + selectChildren(table: string, parentColumn: string, parentId: string, columns: string, orderBy: string): Promise + upsert(table: string, rows: RemoteRow[], onConflict?: string): Promise + insert(table: string, rows: RemoteRow[]): Promise + /** user_id + match 조건에 맞는 행을 patch로 수정하고 수정된 행 수를 돌려준다(낙관적 동시성용) */ + updateMatching(table: string, userId: string, match: Record, patch: RemoteRow): Promise deleteByIds(table: string, userId: string, ids: string[]): Promise + deleteChildren(table: string, parentColumn: string, parentId: string): Promise rpc(name: string, params: Record): Promise + invokeFunction(name: string, body: Record): Promise + uploadObject(bucket: string, key: string, bytes: Uint8Array, contentType: string): Promise + removeObjects(bucket: string, keys: string[]): Promise } export class SyncRemoteError extends Error { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index d5dc00f..946133d 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -67,6 +67,7 @@ import type { HistoryDeleteParams, HistorySearchParams, HistorySetFavoriteParams, + HistoryAudioSource, DictionaryQueryParams, DictionaryPage, DictionaryEntry, @@ -411,6 +412,8 @@ const electronAPI = { deleteAll: () => invoke(IPC_CHANNELS.HISTORY.DELETE_ALL), setFavorite: (params: HistorySetFavoriteParams) => invoke(IPC_CHANNELS.HISTORY.SET_FAVORITE, params), + getAudio: (params: HistoryGetByIdParams) => + invoke(IPC_CHANNELS.HISTORY.GET_AUDIO, params), search: (params: HistorySearchParams) => invoke(IPC_CHANNELS.HISTORY.SEARCH, params), onAdded: (cb: (e: HistoryEntry) => void): Unsubscribe => diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 897a4ce..8714681 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -9,7 +9,7 @@ import { useState, useEffect, useMemo, useRef } from 'react' import { ThemeProvider, CssBaseline, useMediaQuery } from '@mui/material' import { getTheme } from '@d3ro/ui/theme' -import { I18nProvider, type I18nStorage, type Locale } from '@d3ro/i18n' +import { I18nProvider, useI18n, type I18nStorage, type Locale } from '@d3ro/i18n' import { AppLayout } from './components/AppLayout' import { UpgradePromptModal } from './components/UpgradePromptModal' import { startSystemAudioCapture, stopSystemAudioCapture } from './utils/systemAudioCapture' @@ -26,6 +26,21 @@ const electronI18nStorage: I18nStorage = { } } +/** 다른 기기(모바일)에서 바꾼 언어가 동기화로 들어오면 화면 언어도 바꾼다. */ +function SyncedLocale(): null { + const { locale, setLocale } = useI18n() + useEffect( + () => + window.electronAPI.config.onChanged((e: ConfigChangedEvent) => { + if (e.key === 'language' && typeof e.value === 'string' && e.value !== locale) { + setLocale(e.value as Locale) + } + }), + [locale, setLocale], + ) + return null +} + export function App(): React.ReactElement { const [themeMode, setThemeMode] = useState('auto') const prefersDark = useMediaQuery('(prefers-color-scheme: dark)') @@ -76,6 +91,7 @@ export function App(): React.ReactElement { return ( + diff --git a/apps/desktop/src/renderer/components/CloudSyncSection.tsx b/apps/desktop/src/renderer/components/CloudSyncSection.tsx index caf957f..1da7197 100644 --- a/apps/desktop/src/renderer/components/CloudSyncSection.tsx +++ b/apps/desktop/src/renderer/components/CloudSyncSection.tsx @@ -3,7 +3,7 @@ // 로그인 상태 / 지금 동기화 / 마지막 동기화 / 올릴 변경·거부된 변경 건수 import { useCallback, useEffect, useState } from 'react' -import { Box, Button, Stack, Alert, CircularProgress } from '@mui/material' +import { Box, Button, Stack, Alert, CircularProgress, FormControlLabel, Switch } from '@mui/material' import { Cloud, CloudCheck, Globe, GitBranch, RefreshCw } from 'lucide-react' import { d3roPalette, typoSx } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' @@ -32,6 +32,7 @@ export function CloudSyncSection(): React.ReactElement { const [error, setError] = useState(null) const [info, setInfo] = useState(null) const [busy, setBusy] = useState(false) + const [audioSync, setAudioSync] = useState(true) const refreshState = useCallback(async () => { const r = await window.electronAPI.cloudSync.getState() @@ -41,6 +42,9 @@ export function CloudSyncSection(): React.ReactElement { // 초기 상태 로드 + 이벤트 구독 useEffect(() => { void refreshState() + void window.electronAPI.config.get({ key: 'cloudSyncAudio' }).then((r) => { + if (r.success) setAudioSync(r.data !== false) + }) const unsubAuth = window.electronAPI.cloudSync.onAuthChanged(() => { void refreshState() @@ -60,6 +64,11 @@ export function CloudSyncSection(): React.ReactElement { } }, [refreshState]) + function handleAudioSyncChange(enabled: boolean): void { + setAudioSync(enabled) + void window.electronAPI.config.set({ key: 'cloudSyncAudio', value: enabled }) + } + async function handleSignIn(provider: 'google' | 'github'): Promise { setError(null) setBusy(true) @@ -174,6 +183,16 @@ export function CloudSyncSection(): React.ReactElement { )} + + handleAudioSyncChange(e.target.checked)} />} + label={t('cloudSync.audio')} + /> + + {t('cloudSync.audioDesc')} + + +