// // TravelAlbumOTGTransferModels.swift // suixinkan // import Foundation /// OTG 传输页展示照片项。 struct TravelAlbumOTGPhotoItem: Hashable, Sendable { let id: String let sourceId: String let fileName: String let thumbnailURL: URL? let capturedAt: String let fileSizeText: String let fileSizeBytes: Int64 var status: TravelAlbumOTGUploadStatus var progress: Int var errorMessage: String? let localPath: String var remoteUrl: String /// 是否未上传完成。 var isNotUploaded: Bool { status != .uploaded } /// 是否可被选择上传。 var canSelectForUpload: Bool { status == .pending || status == .failed } } /// OTG 传输页 Tab。 enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable { case all case uploaded case failed var title: String { switch self { case .all: return "全部" case .uploaded: return "已上传" case .failed: return "失败" } } } /// 相机照片格式匹配工具,对齐 Android `OtgPhotoFormatMatcher`。 enum TravelAlbumOTGPhotoFormatMatcher { /// 相机照片类型。 enum PhotoKind: Equatable { case jpeg case raw case other } private static let jpegExtensions: Set = ["jpg", "jpeg", "heif", "hif"] private static let rawExtensions: Set = [ "dng", "cr2", "cr3", "nef", "nrw", "arw", "srf", "sr2", "orf", "rw2", "raw", "raf", "srw", "pef", "ptx", "rwl", ] private static let jpegFormatCodes: Set = [0x3801, 0x3808] private static let rawFormatCodes: Set = [0x380D, 0xB101, 0xB103, 0xB108] /// 判断文件类型。 static func classify(name: String, format: Int = 0, isVideo: Bool = false) -> PhotoKind { if isVideo { return .other } let ext = URL(fileURLWithPath: name).pathExtension.lowercased() if rawExtensions.contains(ext) || rawFormatCodes.contains(format) { return .raw } if jpegExtensions.contains(ext) || jpegFormatCodes.contains(format) { return .jpeg } return .other } /// 判断相机对象是否匹配当前上传格式。 static func matchesUploadKind(_ object: CameraObject, option: TravelAlbumOTGPhotoFormatOption) -> Bool { switch option { case .jpg: return classify(name: object.filename) == .jpeg case .raw: return classify(name: object.filename) == .raw case .jpegRaw: let kind = classify(name: object.filename) return kind == .jpeg || kind == .raw } } /// 根据格式偏好展开实际上传目标。 static func resolveUploadTargets( requested: [CameraObject], allObjects: [CameraObject], option: TravelAlbumOTGPhotoFormatOption ) -> [CameraObject] { guard !requested.isEmpty else { return [] } var ordered: [CameraObject] = [] var seen = Set() for object in requested { let peers = peers(of: object, allObjects: allObjects) let jpegs = peers.filter { classify(name: $0.filename) == .jpeg } let raws = peers.filter { classify(name: $0.filename) == .raw } let candidates: [CameraObject] switch option { case .jpg: candidates = Array(jpegs.prefix(1)) case .raw: candidates = Array(raws.prefix(1)) case .jpegRaw: candidates = jpegs + raws } candidates.forEach { candidate in if seen.insert(candidate.id).inserted { ordered.append(candidate) } } } return ordered } private static func peers(of object: CameraObject, allObjects: [CameraObject]) -> [CameraObject] { let stem = captureStem(object.filename) guard !stem.isEmpty else { return [object] } let matches = allObjects.filter { captureStem($0.filename) == stem } return matches.isEmpty ? [object] : matches } private static func captureStem(_ name: String) -> String { (name as NSString).deletingPathExtension.uppercased() } } extension TravelAlbumOTGPhotoRecord { /// 转为页面展示项。 func toPhotoItem() -> TravelAlbumOTGPhotoItem { let previewPath = [thumbnailPath, localPath, remoteUrl].first { path in guard !path.isEmpty else { return false } if path.hasPrefix("http") { return true } return FileManager.default.fileExists(atPath: path) } ?? "" let previewURL: URL? if previewPath.hasPrefix("http") { previewURL = URL(string: previewPath) } else if !previewPath.isEmpty { previewURL = URL(fileURLWithPath: previewPath) } else { previewURL = nil } return TravelAlbumOTGPhotoItem( id: id, sourceId: sourceId, fileName: fileName, thumbnailURL: previewURL, capturedAt: capturedAt, fileSizeText: TravelAlbumOTGPhotoStore.fileSizeText(fileSizeBytes), fileSizeBytes: fileSizeBytes, status: status, progress: progress, errorMessage: errorMessage, localPath: localPath, remoteUrl: remoteUrl ) } }