// // 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 传输页半小时维度时间槽。 struct TravelAlbumOTGTimeSlot: Hashable, Sendable { let id: String let dateLabel: String let slotStartLabel: String let photoCount: Int } /// OTG 传输页左侧时间导航日期分组。 struct TravelAlbumOTGDateGroup: Hashable, Sendable { let dateLabel: String let slots: [TravelAlbumOTGTimeSlot] } /// OTG 传输页照片列表半小时分段。 struct TravelAlbumOTGPhotoSection: Hashable, Sendable { let slotId: String let headerTitle: String let photos: [TravelAlbumOTGPhotoItem] } /// 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 "失败" } } } extension TravelAlbumOTGPhotoItem { /// 将 Android 同款 `yyyy-MM-dd HH:mm:ss` 拍摄时间转为本地日期。 func capturedDate() -> Date? { TravelAlbumOTGTimeGrouping.capturedDate(from: capturedAt) } } extension Array where Element == TravelAlbumOTGPhotoItem { /// 按 Android OTG 页规则构造半小时照片分段。 func buildPhotoSections() -> [TravelAlbumOTGPhotoSection] { let pairs = compactMap { photo -> (slotStart: Date, photo: TravelAlbumOTGPhotoItem)? in guard let date = photo.capturedDate() else { return nil } return (TravelAlbumOTGTimeGrouping.halfHourSlotStart(for: date), photo) } let grouped = Dictionary(grouping: pairs, by: \.slotStart) return grouped.keys .sorted(by: >) .map { slotStart in let photos = (grouped[slotStart] ?? []) .map(\.photo) .sorted { lhs, rhs in (lhs.capturedDate() ?? .distantPast) > (rhs.capturedDate() ?? .distantPast) } return TravelAlbumOTGPhotoSection( slotId: TravelAlbumOTGTimeGrouping.slotId(for: slotStart), headerTitle: TravelAlbumOTGTimeGrouping.sectionHeaderTitle(for: slotStart), photos: photos ) } } /// 按 Android OTG 页规则构造左侧时间快速导航分组。 func buildSidebarGroups() -> [TravelAlbumOTGDateGroup] { var slotCounts: [Date: Int] = [:] for photo in self { guard let date = photo.capturedDate() else { continue } let slotStart = TravelAlbumOTGTimeGrouping.halfHourSlotStart(for: date) slotCounts[slotStart, default: 0] += 1 } let groupedByDay = Dictionary(grouping: slotCounts.keys) { slotStart in TravelAlbumOTGTimeGrouping.dayStart(for: slotStart) } return groupedByDay.keys .sorted(by: >) .map { dayStart in let slots = (groupedByDay[dayStart] ?? []) .sorted(by: >) .map { slotStart in TravelAlbumOTGTimeSlot( id: TravelAlbumOTGTimeGrouping.slotId(for: slotStart), dateLabel: TravelAlbumOTGTimeGrouping.dateLabel(for: slotStart), slotStartLabel: TravelAlbumOTGTimeGrouping.timeLabel(for: slotStart), photoCount: slotCounts[slotStart] ?? 0 ) } return TravelAlbumOTGDateGroup( dateLabel: TravelAlbumOTGTimeGrouping.dateLabel(for: dayStart), slots: slots ) } } } /// OTG 传输页时间分组格式化工具,对齐 Android 的半小时槽规则。 enum TravelAlbumOTGTimeGrouping { private static let calendar = Calendar(identifier: .gregorian) /// 解析 Android OTG 页面使用的拍摄时间字符串。 static func capturedDate(from text: String) -> Date? { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" return formatter.date(from: text) } /// 计算半小时槽起点。 static func halfHourSlotStart(for date: Date) -> Date { let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date) let minute = ((components.minute ?? 0) / 30) * 30 return calendar.date( from: DateComponents( year: components.year, month: components.month, day: components.day, hour: components.hour, minute: minute ) ) ?? date } /// 计算日期起点。 static func dayStart(for date: Date) -> Date { calendar.startOfDay(for: date) } /// 时间槽唯一标识。 static func slotId(for date: Date) -> String { format(date, pattern: "yyyy-MM-dd'T'HH:mm", locale: Locale(identifier: "en_US_POSIX")) } /// 主列表分段标题。 static func sectionHeaderTitle(for date: Date) -> String { let end = calendar.date(byAdding: .minute, value: 30, to: date) ?? date return "\(dateLabel(for: date)) \(timeLabel(for: date)) - \(timeLabel(for: end))" } /// 日期标签。 static func dateLabel(for date: Date) -> String { format(date, pattern: "MM月dd日", locale: Locale(identifier: "zh_CN")) } /// 时间标签。 static func timeLabel(for date: Date) -> String { format(date, pattern: "HH:mm", locale: Locale(identifier: "en_US_POSIX")) } private static func format(_ date: Date, pattern: String, locale: Locale) -> String { let formatter = DateFormatter() formatter.locale = locale formatter.dateFormat = pattern return formatter.string(from: date) } } /// 相机照片格式匹配工具,对齐 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(storage: TravelAlbumOTGPhotoStore, albumId: Int) -> TravelAlbumOTGPhotoItem { let previewPath = [thumbnailPath, localPath, remoteUrl].first { path in guard !path.isEmpty else { return false } if path.hasPrefix("http") { return true } return storage.fileExists(relativePath: path, albumId: albumId) } ?? "" let previewURL: URL? if previewPath.hasPrefix("http") { previewURL = URL(string: previewPath) } else if !previewPath.isEmpty { previewURL = storage.absoluteURL(for: previewPath, albumId: albumId) } 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 ) } }