| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735 |
- import Foundation
- import SwiftData
- import UniformTypeIdentifiers
- import Network
- import CryptoKit
- // Remote contract: Docs/RemoteAPI.openapi.yaml
- struct RemoteAsset: Decodable {
- let id: String
- let clientId: String
- let kind: String
- let fileName: String
- let mimeType: String
- let sizeBytes: Int64
- let sha256: String
- let createdAt: Date?
- }
- struct AssetUploadResult {
- let asset: RemoteAsset
- let sessionRevision: Int64?
- }
- struct StorageQuota: Decodable {
- let totalBytes: Int64
- let usedBytes: Int64
- let remainingBytes: Int64
- }
- struct RemoteEvent: Decodable {
- let id: String
- let clientId: String?
- let relativeTimeMs: Int64
- let eventType: String
- let textContent: String?
- let voiceStartOffsetMs: Int64?
- let voiceEndOffsetMs: Int64?
- }
- struct RemoteSession: Decodable {
- let id: String
- let clientId: String?
- let title: String
- let startTime: Date
- let endTime: Date?
- let durationMs: Int64
- let revision: Int64
- let deletedAt: Date?
- let events: [RemoteEvent]?
- let assets: [RemoteAsset]?
- }
- private struct SessionUpload: Encodable {
- let clientId: String
- let title: String
- let startTime: Date
- let endTime: Date?
- let durationMs: Int64
- let events: [EventUpload]
- let baseRevision: Int64?
- let deletedEventClientIds: [String]
- }
- private struct EventUpload: Encodable {
- let clientId: String
- let relativeTimeMs: Int64
- let eventType: String
- let textContent: String?
- let voiceStartOffsetMs: Int64?
- let voiceEndOffsetMs: Int64?
- }
- private struct SyncCheckpoint: Decodable {
- let syncedAt: Date
- }
- private struct AssetUploadPayload: Decodable {
- let asset: RemoteAsset
- let sessionRevision: Int64?
- private enum CodingKeys: String, CodingKey {
- case asset
- case sessionRevision
- }
- init(from decoder: Decoder) throws {
- if let container = try? decoder.container(keyedBy: CodingKeys.self),
- container.contains(.asset) {
- asset = try container.decode(RemoteAsset.self, forKey: .asset)
- sessionRevision = try container.decodeIfPresent(Int64.self, forKey: .sessionRevision)
- } else {
- asset = try RemoteAsset(from: decoder)
- sessionRevision = nil
- }
- }
- }
- private struct ChunkUploadInitBody: Encodable {
- let clientId: String
- let kind: String
- let fileName: String
- let mimeType: String
- let fileSize: Int64
- let chunkSize: Int
- }
- private struct ChunkUploadInitResponse: Decodable {
- let uploadId: String
- let totalChunks: Int
- let chunkSize: Int
- }
- private struct ChunkUploadProgressResponse: Decodable {
- let uploadedChunks: Int
- let totalChunks: Int
- }
- private struct ChunkUploadCompleteBody: Encodable {
- let uploadId: String
- let totalChunks: Int
- }
- final class RemoteNetworkService: NetworkServiceProtocol {
- private static let chunkedUploadThreshold: Int64 = 16 * 1024 * 1024
- private static let preferredChunkSize = 8 * 1024 * 1024
- private let client: APIClient
- init(client: APIClient = .shared) {
- self.client = client
- }
- func fetchSessions() async throws -> [RemoteSession] {
- try await client.request("sessions?includeDeleted=true", authenticated: true)
- }
- func syncSession(
- _ session: CelestiaSession,
- deletedEventClientIDs: [String]
- ) async throws -> RemoteSession {
- let payload = SessionUpload(
- clientId: session.id.uuidString,
- title: session.title,
- startTime: session.startTime,
- endTime: session.endTime,
- durationMs: session.durationMs,
- events: session.events.map {
- EventUpload(
- clientId: $0.id.uuidString,
- relativeTimeMs: $0.relativeTimeMs,
- eventType: $0.eventType,
- textContent: $0.textContent,
- voiceStartOffsetMs: $0.voiceStartOffsetMs,
- voiceEndOffsetMs: $0.voiceEndOffsetMs
- )
- },
- baseRevision: session.serverRevision > 0 ? session.serverRevision : nil,
- deletedEventClientIds: deletedEventClientIDs
- )
- let encoder = JSONEncoder()
- encoder.dateEncodingStrategy = .iso8601
- return try await client.request("sessions", method: .post, body: try encoder.encode(payload), authenticated: true)
- }
- func fetchStorageQuota() async throws -> StorageQuota {
- try await client.request("storage/quota", authenticated: true)
- }
- func uploadAsset(
- sessionID: String,
- clientID: String,
- kind: String,
- fileURL: URL,
- progress: @escaping @Sendable (Double) -> Void
- ) async throws -> AssetUploadResult {
- let mimeType = UTType(filenameExtension: fileURL.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
- let size = try fileSize(of: fileURL)
- if size >= Self.chunkedUploadThreshold {
- return try await uploadAssetInChunks(
- sessionID: sessionID,
- clientID: clientID,
- kind: kind,
- fileURL: fileURL,
- fileSize: size,
- mimeType: mimeType,
- progress: progress
- )
- }
- let payload: AssetUploadPayload = try await client.upload(
- "sessions/\(sessionID)/assets",
- fileURL: fileURL,
- fileName: fileURL.lastPathComponent,
- mimeType: mimeType,
- fields: ["clientId": clientID, "kind": kind],
- progress: progress
- )
- return AssetUploadResult(asset: payload.asset, sessionRevision: payload.sessionRevision)
- }
- func downloadAsset(sessionID: String, asset: RemoteAsset, destinationURL: URL) async throws {
- try await client.download("sessions/\(sessionID)/assets/\(asset.id)", to: destinationURL)
- }
- func recordSyncCheckpoint() async throws {
- let _: SyncCheckpoint = try await client.request("sync/trigger", method: .post, authenticated: true)
- }
- private func uploadAssetInChunks(
- sessionID: String,
- clientID: String,
- kind: String,
- fileURL: URL,
- fileSize: Int64,
- mimeType: String,
- progress: @escaping @Sendable (Double) -> Void
- ) async throws -> AssetUploadResult {
- let body = ChunkUploadInitBody(
- clientId: clientID,
- kind: kind,
- fileName: fileURL.lastPathComponent,
- mimeType: mimeType,
- fileSize: fileSize,
- chunkSize: Self.preferredChunkSize
- )
- let encoder = JSONEncoder()
- let upload: ChunkUploadInitResponse = try await client.request(
- "sessions/\(sessionID)/assets/init",
- method: .post,
- body: try encoder.encode(body),
- authenticated: true
- )
- let input = try FileHandle(forReadingFrom: fileURL)
- defer { try? input.close() }
- for chunkIndex in 0..<upload.totalChunks {
- let offset = UInt64(chunkIndex) * UInt64(upload.chunkSize)
- try input.seek(toOffset: offset)
- guard let data = try input.read(upToCount: upload.chunkSize), !data.isEmpty else {
- throw APIError.transport("读取上传分片失败")
- }
- let temporaryURL = FileManager.default.temporaryDirectory
- .appendingPathComponent("celestia-\(upload.uploadId)-\(chunkIndex).chunk")
- try data.write(to: temporaryURL, options: .atomic)
- defer { try? FileManager.default.removeItem(at: temporaryURL) }
- let chunkStart = Double(offset) / Double(fileSize)
- let chunkSpan = Double(data.count) / Double(fileSize)
- let _: ChunkUploadProgressResponse = try await client.upload(
- "sessions/\(sessionID)/assets/chunk",
- fileURL: temporaryURL,
- fileName: "chunk-\(chunkIndex)",
- mimeType: "application/octet-stream",
- fields: [
- "uploadId": upload.uploadId,
- "chunkIndex": String(chunkIndex)
- ]
- ) { fraction in
- progress(min(max(chunkStart + chunkSpan * fraction, 0), 1))
- }
- }
- let completeBody = ChunkUploadCompleteBody(
- uploadId: upload.uploadId,
- totalChunks: upload.totalChunks
- )
- let payload: AssetUploadPayload = try await client.request(
- "sessions/\(sessionID)/assets/complete",
- method: .post,
- body: try encoder.encode(completeBody),
- authenticated: true
- )
- progress(1)
- return AssetUploadResult(asset: payload.asset, sessionRevision: payload.sessionRevision)
- }
- private func fileSize(of url: URL) throws -> Int64 {
- let values = try url.resourceValues(forKeys: [.fileSizeKey])
- guard let size = values.fileSize else {
- throw APIError.transport("无法读取待上传文件大小")
- }
- return Int64(size)
- }
- }
- struct RemoteBoundDevice: Decodable {
- let id: String
- let name: String
- let peripheralUUID: String
- }
- private struct BindDeviceBody: Encodable {
- let name: String
- let peripheralUUID: String
- let hardwareMAC: String?
- let firmwareVersion: String?
- let batteryLevel: Int?
- let freeStorageMB: Int?
- let totalStorageMB: Int?
- }
- private struct UpdateDeviceBody: Encodable {
- let name: String?
- let batteryLevel: Int?
- let isConnected: Bool?
- let firmwareVersion: String?
- let freeStorageMB: Int?
- let totalStorageMB: Int?
- }
- final class DeviceCloudService {
- static let shared = DeviceCloudService()
- private let client: APIClient
- init(client: APIClient = .shared) {
- self.client = client
- }
- func register(_ device: BoundDevice) async throws -> RemoteBoundDevice {
- let body = BindDeviceBody(
- name: device.name,
- peripheralUUID: device.peripheralUUID,
- hardwareMAC: device.hardwareMAC,
- firmwareVersion: device.firmwareVersion,
- batteryLevel: device.batteryLevel,
- freeStorageMB: device.freeStorageMB,
- totalStorageMB: device.totalStorageMB
- )
- return try await client.request("devices", method: .post, body: try JSONEncoder().encode(body), authenticated: true)
- }
- func update(_ device: BoundDevice) async throws -> RemoteBoundDevice {
- guard let cloudID = device.cloudID else { return try await register(device) }
- let body = UpdateDeviceBody(
- name: device.name,
- batteryLevel: device.batteryLevel,
- isConnected: device.isConnected,
- firmwareVersion: device.firmwareVersion,
- freeStorageMB: device.freeStorageMB,
- totalStorageMB: device.totalStorageMB
- )
- return try await client.request("devices/\(cloudID)", method: .put, body: try JSONEncoder().encode(body), authenticated: true)
- }
- func remove(cloudID: String) async throws {
- try await client.requestVoid("devices/\(cloudID)", method: .delete, authenticated: true)
- }
- }
- final class NetworkStatusMonitor: ObservableObject, @unchecked Sendable {
- static let shared = NetworkStatusMonitor()
- @Published private(set) var isConnected = true
- @Published private(set) var isWiFi = false
- @Published private(set) var isCellular = false
- private let monitor = NWPathMonitor()
- private let queue = DispatchQueue(label: "com.celestia.trace.network-path")
- private init() {
- monitor.pathUpdateHandler = { [weak self] path in
- let connected = path.status == .satisfied
- let usesWiFi = path.usesInterfaceType(.wifi)
- let usesCellular = path.usesInterfaceType(.cellular)
- DispatchQueue.main.async {
- self?.isConnected = connected
- self?.isWiFi = usesWiFi
- self?.isCellular = usesCellular
- }
- }
- monitor.start(queue: queue)
- }
- var connectionName: String {
- if isWiFi { return "Wi-Fi" }
- if isCellular { return "蜂窝网络" }
- return isConnected ? "当前网络" : "网络"
- }
- }
- @MainActor
- final class SyncManager: ObservableObject {
- static let shared = SyncManager()
- @Published private(set) var isSyncing = false
- @Published private(set) var lastSyncDate: Date?
- @Published private(set) var lastErrorMessage: String?
- @Published private(set) var completedCount = 0
- @Published private(set) var totalCount = 0
- @Published private(set) var activeSessionID: UUID?
- @Published private(set) var syncProgress: Double = 0
- private let service: NetworkServiceProtocol
- private var activeSyncTask: Task<Bool, Never>?
- init(service: NetworkServiceProtocol = RemoteNetworkService()) {
- self.service = service
- lastSyncDate = UserDefaults.standard.object(forKey: "com.celestia.trace.last_server_sync") as? Date
- }
- func estimatedUploadBytes(for session: CelestiaSession) -> Int64 {
- var urls: [URL] = []
- if let url = AudioPathHelper.resolveURL(for: session.localAudioPath) {
- urls.append(url)
- }
- urls.append(contentsOf: session.events.compactMap { event in
- guard event.eventType == "PHOTO" else { return nil }
- return AudioPathHelper.resolveURL(for: event.localFilePath)
- })
- return urls.reduce(into: 0) { total, url in
- let values = try? url.resourceValues(forKeys: [.fileSizeKey])
- total += Int64(values?.fileSize ?? 0)
- }
- }
- func sync(
- sessions: [CelestiaSession],
- modelContext: ModelContext,
- userID: String
- ) async -> Bool {
- guard !isSyncing else { return false }
- isSyncing = true
- let task = Task { @MainActor [self] in
- await performSync(
- sessions: sessions,
- modelContext: modelContext,
- userID: userID
- )
- }
- activeSyncTask = task
- let result = await withTaskCancellationHandler {
- await task.value
- } onCancel: {
- task.cancel()
- }
- activeSyncTask = nil
- isSyncing = false
- activeSessionID = nil
- return result
- }
- func pauseSync(sessionID: UUID) {
- guard isSyncing, activeSessionID == sessionID else { return }
- activeSyncTask?.cancel()
- }
- private func performSync(
- sessions: [CelestiaSession],
- modelContext: ModelContext,
- userID: String
- ) async -> Bool {
- lastErrorMessage = nil
- completedCount = 0
- activeSessionID = nil
- syncProgress = 0
- totalCount = sessions.filter {
- ($0.ownerUserID == nil || $0.ownerUserID == userID) && (!$0.isSynced || $0.cloudSessionId == nil)
- }.count
- do {
- let remoteSessions = try await service.fetchSessions()
- try Task.checkCancellation()
- let mergedSessions = merge(remoteSessions, into: sessions, modelContext: modelContext, userID: userID)
- for (remote, local) in mergedSessions {
- try await downloadMissingAssets(for: local, remote: remote)
- try Task.checkCancellation()
- }
- try modelContext.save()
- let eligible = sessions.filter {
- ($0.ownerUserID == nil || $0.ownerUserID == userID)
- && (!$0.isSynced || $0.cloudSessionId == nil)
- && $0.syncState != .conflict
- }
- var failures: [String] = []
- let eligibleCount = max(eligible.count, 1)
- for (index, session) in eligible.enumerated() {
- activeSessionID = session.id
- let sessionStartProgress = Double(index) / Double(eligibleCount)
- let sessionProgressSpan = 1.0 / Double(eligibleCount)
- syncProgress = sessionStartProgress
- session.ownerUserID = userID
- session.syncState = .syncing
- session.lastSyncError = nil
- do {
- let previousRemote = remoteSessions.first {
- $0.id == session.cloudSessionId
- || $0.clientId?.caseInsensitiveCompare(session.id.uuidString) == .orderedSame
- }
- let localEventIDs = Set(session.events.map { $0.id.uuidString.lowercased() })
- let deletedEventClientIDs = previousRemote?.events?
- .compactMap(\.clientId)
- .filter { !localEventIDs.contains($0.lowercased()) } ?? []
- let remote = try await service.syncSession(
- session,
- deletedEventClientIDs: deletedEventClientIDs
- )
- try Task.checkCancellation()
- syncProgress = sessionStartProgress + sessionProgressSpan * 0.12
- session.cloudSessionId = remote.id
- session.serverRevision = remote.revision
- try await uploadLocalAssets(for: session, remote: remote) { [weak self] assetProgress in
- Task { @MainActor [weak self] in
- self?.syncProgress = sessionStartProgress
- + sessionProgressSpan * (0.12 + assetProgress * 0.83)
- }
- }
- try Task.checkCancellation()
- session.isSynced = true
- session.syncState = .synced
- session.lastSyncedAt = Date()
- completedCount += 1
- syncProgress = sessionStartProgress + sessionProgressSpan
- } catch where Task.isCancelled {
- session.isSynced = false
- session.syncState = .pending
- session.lastSyncError = nil
- syncProgress = 0
- try? modelContext.save()
- return false
- } catch APIError.server(let code, _) where code == 409 {
- session.isSynced = false
- session.syncState = .conflict
- session.lastSyncError = "本地版本 \(session.serverRevision) 与云端版本不一致"
- } catch {
- session.isSynced = false
- session.syncState = .failed
- session.lastSyncError = error.localizedDescription
- failures.append("\(session.title):\(error.localizedDescription)")
- }
- try modelContext.save()
- }
- let conflicts = sessions.filter { $0.syncState == .conflict }
- if !conflicts.isEmpty {
- failures.append(contentsOf: conflicts.map {
- "\($0.title):本地和云端版本不一致"
- })
- }
- guard failures.isEmpty else {
- lastErrorMessage = failures.joined(separator: "\n")
- return false
- }
- try await service.recordSyncCheckpoint()
- let now = Date()
- lastSyncDate = now
- UserDefaults.standard.set(now, forKey: "com.celestia.trace.last_server_sync")
- return true
- } catch where Task.isCancelled {
- syncProgress = 0
- return false
- } catch {
- lastErrorMessage = error.localizedDescription
- return false
- }
- }
- private func uploadLocalAssets(
- for session: CelestiaSession,
- remote: RemoteSession,
- progress: @escaping @Sendable (Double) -> Void
- ) async throws {
- guard let cloudID = session.cloudSessionId else { throw APIError.missingData }
- let existingIDs = Set((remote.assets ?? []).map(\.clientId))
- var uploads: [(clientID: String, kind: String, url: URL, size: Int64)] = []
- if let path = session.localAudioPath {
- guard let url = AudioPathHelper.resolveURL(for: path) else {
- throw APIError.transport("本地录音文件不存在:\(path)")
- }
- let sha256 = try fileSHA256(of: url)
- let hasMatchingAudio = (remote.assets ?? []).contains {
- $0.kind.uppercased() == "AUDIO"
- && $0.sha256.caseInsensitiveCompare(sha256) == .orderedSame
- }
- if !hasMatchingAudio {
- let clientID = "\(session.id.uuidString)-audio-\(sha256.prefix(16))"
- uploads.append((clientID, "AUDIO", url, fileSize(of: url)))
- }
- }
- for event in session.events where event.eventType == "PHOTO" {
- guard let path = event.localFilePath else { continue }
- guard let url = AudioPathHelper.resolveURL(for: path) else {
- throw APIError.transport("照片文件不存在:\(path)")
- }
- let clientID = "\(event.id.uuidString)-photo"
- if !existingIDs.contains(clientID) {
- uploads.append((clientID, "PHOTO", url, fileSize(of: url)))
- }
- }
- let totalBytes = max(uploads.reduce(Int64(0)) { $0 + $1.size }, 1)
- let requiredBytes = uploads.reduce(Int64(0)) { $0 + $1.size }
- if requiredBytes > 0 {
- let quota = try await service.fetchStorageQuota()
- guard requiredBytes <= quota.remainingBytes else {
- throw APIError.storageQuotaExceeded(
- requiredBytes: requiredBytes,
- remainingBytes: quota.remainingBytes
- )
- }
- }
- var completedBytes: Int64 = 0
- progress(uploads.isEmpty ? 1 : 0)
- for upload in uploads {
- let bytesBeforeUpload = completedBytes
- let result = try await service.uploadAsset(
- sessionID: cloudID,
- clientID: upload.clientID,
- kind: upload.kind,
- fileURL: upload.url
- ) { fraction in
- let sentBytes = Double(bytesBeforeUpload) + Double(upload.size) * fraction
- progress(min(max(sentBytes / Double(totalBytes), 0), 1))
- }
- if let revision = result.sessionRevision {
- session.serverRevision = revision
- }
- completedBytes += upload.size
- progress(Double(completedBytes) / Double(totalBytes))
- }
- }
- private func fileSize(of url: URL) -> Int64 {
- let values = try? url.resourceValues(forKeys: [.fileSizeKey])
- return Int64(values?.fileSize ?? 0)
- }
- private func fileSHA256(of url: URL) throws -> String {
- let input = try FileHandle(forReadingFrom: url)
- defer { try? input.close() }
- var hasher = SHA256()
- while let data = try input.read(upToCount: 1_024 * 1_024),
- !data.isEmpty {
- hasher.update(data: data)
- }
- return hasher.finalize().map { String(format: "%02x", $0) }.joined()
- }
- private func downloadMissingAssets(for session: CelestiaSession, remote: RemoteSession) async throws {
- let assets = remote.assets ?? []
- if let audio = assets
- .filter({ $0.kind.uppercased() == "AUDIO" })
- .max(by: {
- ($0.createdAt ?? .distantPast) < ($1.createdAt ?? .distantPast)
- }) {
- let localURL = AudioPathHelper.resolveURL(for: session.localAudioPath)
- let localHash = localURL.flatMap { try? fileSHA256(of: $0) }
- let needsDownload = localURL == nil
- || (session.isSynced
- && localHash?.caseInsensitiveCompare(audio.sha256) != .orderedSame)
- if needsDownload {
- let destination = downloadDestination(for: audio)
- try await service.downloadAsset(sessionID: remote.id, asset: audio, destinationURL: destination)
- session.localAudioPath = AudioPathHelper.relativePath(from: destination.path)
- }
- }
- let photoAssets = Dictionary(uniqueKeysWithValues: assets
- .filter { $0.kind.uppercased() == "PHOTO" }
- .map { ($0.clientId.lowercased(), $0) })
- for event in session.events where event.eventType == "PHOTO" {
- guard event.localFilePath == nil || AudioPathHelper.resolveURL(for: event.localFilePath) == nil else { continue }
- let key = "\(event.id.uuidString)-photo".lowercased()
- guard let asset = photoAssets[key] else { continue }
- let destination = downloadDestination(for: asset)
- try await service.downloadAsset(sessionID: remote.id, asset: asset, destinationURL: destination)
- event.localFilePath = AudioPathHelper.relativePath(from: destination.path)
- }
- }
- private func downloadDestination(for asset: RemoteAsset) -> URL {
- let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
- let fileExtension = (asset.fileName as NSString).pathExtension
- let suffix = fileExtension.isEmpty ? "" : ".\(fileExtension.lowercased())"
- return documents.appendingPathComponent("cloud_\(asset.id)\(suffix)")
- }
- private func merge(
- _ remoteSessions: [RemoteSession],
- into localSessions: [CelestiaSession],
- modelContext: ModelContext,
- userID: String
- ) -> [(RemoteSession, CelestiaSession)] {
- var byClientID = Dictionary(uniqueKeysWithValues: localSessions.map { ($0.id.uuidString.lowercased(), $0) })
- var merged: [(RemoteSession, CelestiaSession)] = []
- for remote in remoteSessions {
- guard let clientID = remote.clientId?.lowercased() else { continue }
- if let local = byClientID[clientID] {
- if remote.deletedAt != nil, local.isSynced {
- modelContext.delete(local)
- continue
- }
- if !local.isSynced,
- remote.revision > local.serverRevision {
- local.syncState = .conflict
- local.lastSyncError = "本地版本 \(local.serverRevision) 与云端版本 \(remote.revision) 不一致"
- continue
- }
- if local.isSynced, remote.revision > local.serverRevision {
- apply(remote, to: local, userID: userID)
- }
- if remote.deletedAt == nil {
- merged.append((remote, local))
- }
- } else if remote.deletedAt == nil {
- let id = UUID(uuidString: clientID) ?? UUID()
- let session = CelestiaSession(id: id, title: remote.title, startTime: remote.startTime)
- apply(remote, to: session, userID: userID)
- modelContext.insert(session)
- byClientID[clientID] = session
- merged.append((remote, session))
- }
- }
- try? modelContext.save()
- return merged
- }
- private func apply(_ remote: RemoteSession, to local: CelestiaSession, userID: String) {
- local.title = remote.title
- local.startTime = remote.startTime
- local.endTime = remote.endTime
- local.cloudSessionId = remote.id
- local.ownerUserID = userID
- local.serverRevision = remote.revision
- local.isSynced = true
- local.syncState = .synced
- local.lastSyncError = nil
- local.events.removeAll()
- for remoteEvent in remote.events ?? [] {
- let id = remoteEvent.clientId.flatMap(UUID.init(uuidString:)) ?? UUID()
- let event = CelestiaTimelineEvent(id: id, relativeTimeMs: remoteEvent.relativeTimeMs, eventType: remoteEvent.eventType)
- event.textContent = remoteEvent.textContent
- event.voiceStartOffsetMs = remoteEvent.voiceStartOffsetMs
- event.voiceEndOffsetMs = remoteEvent.voiceEndOffsetMs
- local.events.append(event)
- }
- }
- }
|