import Foundation import Combine /// 按日分组的相机照片区块。 struct CameraPhotoSection: Equatable, Identifiable { var id: String { dayKey } let dayKey: String let title: String let photos: [CameraObject] } /// 相机历史导入页状态机。 enum CameraImportState: Equatable { case idle case loading case loaded(sections: [CameraPhotoSection]) case importing(current: Int, total: Int) case finished(importedCount: Int) case failed(message: String) } /// 相机 SD 卡历史照片多选导入 ViewModel。 @MainActor final class CameraHistoryImportViewModel: ObservableObject { @Published private(set) var state: CameraImportState = .idle @Published private(set) var selectedPhotoIDs: Set = [] @Published private(set) var shouldShowSonyMTPHelp = false @Published private(set) var sections: [CameraPhotoSection] = [] let albumId: Int let accountKey: String private weak var driver: (any CameraDriver)? private let photoSink: OTGPhotoSink private let onImported: () -> Void private var allPhotos: [CameraObject] = [] private var existingAlbumPhotoIDs: Set = [] private var thumbnailCache: [String: Data] = [:] /// 初始化历史导入 ViewModel。 init( albumId: Int, accountKey: String, driver: any CameraDriver, photoSink: OTGPhotoSink, onImported: @escaping () -> Void ) { self.albumId = albumId self.accountKey = accountKey self.driver = driver self.photoSink = photoSink self.onImported = onImported } /// 通过 Driver 拉取相机内照片并按日分组。 func loadPhotos() { guard let driver else { state = .failed(message: "相机未连接") return } state = .loading Task { do { existingAlbumPhotoIDs = await photoSink.existingCameraObjectIDs() let objects = try await driver.listObjects() allPhotos = objects let sections = Self.groupByDay(objects) if sections.isEmpty { self.sections = [] state = .failed(message: "相机中没有可导入的照片") } else { self.sections = sections state = .loaded(sections: sections) } updateSonyMTPHelpVisibility() } catch { state = .failed(message: error.localizedDescription) updateSonyMTPHelpVisibility() } } } /// 判断照片是否已在当前相册。 func isPhotoAlreadyInAlbum(id: String) -> Bool { existingAlbumPhotoIDs.contains(id) } /// 切换单张照片选中状态。 func togglePhoto(id: String) { guard !isPhotoAlreadyInAlbum(id: id) else { return } if selectedPhotoIDs.contains(id) { selectedPhotoIDs.remove(id) } else { selectedPhotoIDs.insert(id) } } /// 切换分组全选。 func toggleSection(_ section: CameraPhotoSection) { let importableIDs = Set(section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.map(\.id)) guard !importableIDs.isEmpty else { return } if importableIDs.isSubset(of: selectedPhotoIDs) { selectedPhotoIDs.subtract(importableIDs) } else { selectedPhotoIDs.formUnion(importableIDs) } } func isPhotoSelected(id: String) -> Bool { selectedPhotoIDs.contains(id) } func isSectionFullySelected(_ section: CameraPhotoSection) -> Bool { let ids = section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.map(\.id) return !ids.isEmpty && ids.allSatisfy { selectedPhotoIDs.contains($0) } } func hasImportablePhotos(in section: CameraPhotoSection) -> Bool { section.photos.contains { !isPhotoAlreadyInAlbum(id: $0.id) } } /// 加载相机缩略图。 func thumbnailData(for photo: CameraObject, maxPixelSize: Int) async -> Data? { if let cached = thumbnailCache[photo.id] { return cached } guard let driver else { return nil } guard let data = await driver.requestThumbnailData(for: photo, maxPixelSize: maxPixelSize) else { return nil } thumbnailCache[photo.id] = data return data } /// 顺序下载选中项并写入本地存储。 func importSelected() { guard let driver else { return } let selected = allPhotos.filter { selectedPhotoIDs.contains($0.id) && !isPhotoAlreadyInAlbum(id: $0.id) } guard !selected.isEmpty else { return } state = .importing(current: 0, total: selected.count) Task { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("camera-history-import", isDirectory: true) try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) var imported = 0 for (index, object) in selected.enumerated() { do { let downloadedURL = try await driver.downloadObject(object, to: tempDir) _ = try await photoSink.saveDownloadedFile( at: downloadedURL, filename: object.filename, capturedAt: object.capturedAt, cameraObjectID: object.id ) imported += 1 state = .importing(current: index + 1, total: selected.count) } catch { OTGLog.error(.connection, "import failed: \(object.filename) \(error.localizedDescription)") } } selectedPhotoIDs.removeAll() state = .finished(importedCount: imported) onImported() } } private func updateSonyMTPHelpVisibility() { guard driver?.platform == .sony else { shouldShowSonyMTPHelp = false return } switch state { case .loaded(let sections): shouldShowSonyMTPHelp = sections.isEmpty case .failed: shouldShowSonyMTPHelp = allPhotos.isEmpty default: shouldShowSonyMTPHelp = false } } private static func groupByDay(_ photos: [CameraObject]) -> [CameraPhotoSection] { let calendar = Calendar.current let formatter = DateFormatter() formatter.locale = Locale(identifier: "zh_CN") formatter.dateFormat = "yyyy年M月d日" let grouped = Dictionary(grouping: photos) { photo in calendar.startOfDay(for: photo.capturedAt) } return grouped.keys .sorted(by: >) .map { day in let items = grouped[day]!.sorted { $0.capturedAt > $1.capturedAt } let dayKey = dayKeyString(for: day, calendar: calendar) let title = sectionTitle(for: day, formatter: formatter, calendar: calendar) return CameraPhotoSection(dayKey: dayKey, title: title, photos: items) } } private static func dayKeyString(for day: Date, calendar: Calendar) -> String { let components = calendar.dateComponents([.year, .month, .day], from: day) return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) } private static func sectionTitle(for day: Date, formatter: DateFormatter, calendar: Calendar) -> String { if calendar.isDateInToday(day) { return "今天" } if calendar.isDateInYesterday(day) { return "昨天" } return formatter.string(from: day) } }