// // TravelAlbumCameraImportViewModel.swift // suixinkan // import Foundation /// 相机历史照片导入分组。 struct TravelAlbumCameraImportSection: Equatable { let dayKey: String let title: String let photos: [CameraObject] } /// 相机历史照片导入页状态。 enum TravelAlbumCameraImportState: Equatable { case idle case loading case loaded(sections: [TravelAlbumCameraImportSection]) case importing(current: Int, total: Int) case finished(importedCount: Int, failedCount: Int, importedPhotoIds: [String]) case failed(message: String) } /// 旅拍相册相机历史照片导入页 ViewModel,负责相机枚举、多选和本地导入。 @MainActor final class TravelAlbumCameraImportViewModel { let albumId: Int let albumTitle: String private(set) var state: TravelAlbumCameraImportState = .idle { didSet { onStateUpdated?(state) onSonyMTPHelpVisibilityChanged?(shouldShowSonyMTPHelp) } } private(set) var selectedPhotoIds: Set = [] { didSet { onSelectionUpdated?(selectedPhotoIds.count) } } private(set) var failedPhotoIds: Set = [] var onStateUpdated: ((TravelAlbumCameraImportState) -> Void)? var onSelectionUpdated: ((Int) -> Void)? var onSonyMTPHelpVisibilityChanged: ((Bool) -> Void)? private let driver: CameraDriver private let storage: TravelAlbumOTGPhotoStore private let appStore: AppStore private var allPhotos: [CameraObject] = [] private var existingPhotoIds: Set = [] private var thumbnailCache: [String: Data] = [:] /// 初始化旅拍相册相机历史照片导入页 ViewModel。 init( albumId: Int, albumTitle: String, driver: CameraDriver, storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(), appStore: AppStore = .shared ) { self.albumId = albumId self.albumTitle = albumTitle self.driver = driver self.storage = storage self.appStore = appStore } /// 索尼相机且目录为空时展示 MTP 帮助入口。 var shouldShowSonyMTPHelp: Bool { guard driver.platform == .sony else { return false } switch state { case .loaded(let sections): return sections.isEmpty case .failed: return allPhotos.isEmpty default: return false } } /// 从相机读取历史照片并按日期分组。 func loadPhotos() { state = .loading Task { do { reloadExistingPhotoIds() let objects = try await driver.listObjects() allPhotos = objects let sections = Self.groupByDay(objects) if sections.isEmpty { state = .failed(message: "相机中没有可导入的照片") } else { state = .loaded(sections: sections) } } catch { state = .failed(message: error.localizedDescription) } } } /// 是否已经导入当前相册。 func isPhotoAlreadyInAlbum(id: String) -> Bool { existingPhotoIds.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: TravelAlbumCameraImportSection) { let ids = Set(section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.map(\.id)) guard !ids.isEmpty else { return } if ids.isSubset(of: selectedPhotoIds) { selectedPhotoIds.subtract(ids) } else { selectedPhotoIds.formUnion(ids) } } /// 是否选中单张照片。 func isPhotoSelected(id: String) -> Bool { selectedPhotoIds.contains(id) } /// 是否整组全选。 func isSectionFullySelected(_ section: TravelAlbumCameraImportSection) -> Bool { let ids = section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.map(\.id) return !ids.isEmpty && ids.allSatisfy { selectedPhotoIds.contains($0) } } /// 是否整组半选。 func isSectionPartiallySelected(_ section: TravelAlbumCameraImportSection) -> Bool { let ids = section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.map(\.id) let selectedCount = ids.filter { selectedPhotoIds.contains($0) }.count return selectedCount > 0 && selectedCount < ids.count } /// 分组是否还有可导入照片。 func hasImportablePhotos(in section: TravelAlbumCameraImportSection) -> Bool { section.photos.contains { !isPhotoAlreadyInAlbum(id: $0.id) } } /// 分组可导入照片数。 func importablePhotoCount(in section: TravelAlbumCameraImportSection) -> Int { section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.count } /// 分组已导入照片数。 func importedPhotoCount(in section: TravelAlbumCameraImportSection) -> Int { section.photos.filter { isPhotoAlreadyInAlbum(id: $0.id) }.count } /// 加载相机缩略图。 func thumbnailData(for photo: CameraObject, maxPixelSize: Int) async -> Data? { if let cached = thumbnailCache[photo.id] { return cached } guard let data = await driver.requestThumbnailData(for: photo, maxPixelSize: maxPixelSize) else { return nil } thumbnailCache[photo.id] = data return data } /// 将已选照片导入当前相册本地缓存。 func importSelected() { let selected = allPhotos.filter { selectedPhotoIds.contains($0.id) && !isPhotoAlreadyInAlbum(id: $0.id) } guard !selected.isEmpty else { return } state = .importing(current: 0, total: selected.count) failedPhotoIds = [] Task { let directory = try? storage.originalsDirectory(albumId: albumId) var importedCount = 0 var importedPhotoIds: [String] = [] for (index, object) in selected.enumerated() { defer { state = .importing(current: index + 1, total: selected.count) } guard let directory else { failedPhotoIds.insert(object.id) continue } do { let downloadedURL = try await driver.downloadObject(object, to: directory) let record = TravelAlbumOTGPhotoRecord( id: object.id, sourceId: object.id, fileName: downloadedURL.lastPathComponent, localPath: storage.relativePath(for: downloadedURL, albumId: albumId), thumbnailPath: "", capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(object.capturedAt), fileSizeBytes: object.fileSize, status: .pending, progress: 0, albumId: albumId, userId: appStore.session.userId ) storage.upsert(record, albumId: albumId) existingPhotoIds.insert(object.id) importedPhotoIds.append(record.id) importedCount += 1 } catch { failedPhotoIds.insert(object.id) OTGLog.error(.connection, "history import failed: \(object.filename) \(error.localizedDescription)") } } selectedPhotoIds.subtract(existingPhotoIds) state = .finished( importedCount: importedCount, failedCount: failedPhotoIds.count, importedPhotoIds: importedPhotoIds ) } } private func reloadExistingPhotoIds() { existingPhotoIds = Set( storage.load(albumId: albumId).flatMap { record in [record.id, record.sourceId].filter { !$0.isEmpty } } ) } private static func groupByDay(_ photos: [CameraObject]) -> [TravelAlbumCameraImportSection] { 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 TravelAlbumCameraImportSection( dayKey: dayKeyString(for: day, calendar: calendar), title: sectionTitle(for: day, formatter: formatter, calendar: calendar), photos: grouped[day]!.sorted { $0.capturedAt > $1.capturedAt } ) } } 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) } }