import Foundation import SwiftData @Model final class CelestiaSession { var id: UUID var title: String var startTime: Date var endTime: Date? var localAudioPath: String? var isSynced: Bool var cloudSessionId: String? var ownerUserID: String? // Defaults must live on the persisted properties so SwiftData can backfill // existing rows during a lightweight migration. Initializer defaults only // apply to newly-created model instances. var serverRevision: Int64 = 0 var syncStateRaw: String = SessionSyncState.localOnly.rawValue var lastSyncError: String? var lastSyncedAt: Date? @Relationship(deleteRule: .cascade, inverse: \CelestiaTimelineEvent.session) var events: [CelestiaTimelineEvent] @Relationship(deleteRule: .cascade, inverse: \AudioChunk.session) var audioChunks: [AudioChunk] var durationMs: Int64 { guard let end = endTime else { return 0 } return Int64(end.timeIntervalSince(startTime) * 1000) } var durationFormatted: String { guard let end = endTime else { return "正在记录..." } let interval = end.timeIntervalSince(startTime) let hours = Int(interval) / 3600 let minutes = (Int(interval) % 3600) / 60 let seconds = Int(interval) % 60 if hours > 0 { return String(format: "%d:%02d:%02d", hours, minutes, seconds) } return String(format: "%02d:%02d", minutes, seconds) } var photoCount: Int { events.filter { $0.eventType == "PHOTO" }.count } var noteCount: Int { events.filter { $0.eventType == "NOTE" || ($0.eventType == "MARKER" && !$0.isContinuationMarker) }.count } var syncState: SessionSyncState { get { SessionSyncState(rawValue: syncStateRaw) ?? .localOnly } set { syncStateRaw = newValue.rawValue } } /// A server-confirmed sync requires both the legacy completion flag and a /// remote identifier. This remains compatible with records created before /// `syncStateRaw` was added, whose migrated state defaults to `localOnly`. var hasConfirmedCloudSync: Bool { isSynced && cloudSessionId != nil } var needsCloudSync: Bool { !hasConfirmedCloudSync } func markContentModified() { isSynced = false syncState = .pending lastSyncError = nil } /// The revision represented by the current local content. /// Unsynced edits form the next revision based on the last known server revision. var localRevision: Int64 { if hasConfirmedCloudSync { return max(serverRevision, 1) } return max(serverRevision + 1, 1) } init(id: UUID = UUID(), title: String, localAudioPath: String? = nil, startTime: Date = Date()) { self.id = id self.title = title self.startTime = startTime self.endTime = nil self.localAudioPath = localAudioPath self.isSynced = false self.cloudSessionId = nil self.ownerUserID = nil self.serverRevision = 0 self.syncStateRaw = SessionSyncState.localOnly.rawValue self.lastSyncError = nil self.lastSyncedAt = nil self.events = [] self.audioChunks = [] } } enum SessionSyncState: String, Codable { case localOnly case pending case syncing case synced case failed case conflict }