378 lines
13 KiB
Swift
378 lines
13 KiB
Swift
//
|
|
// 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]
|
|
}
|
|
|
|
/// 有线传输照片预览上下文,保存可预览照片顺序和点击照片位置。
|
|
struct TravelAlbumOTGPhotoPreviewContext: Equatable, Sendable {
|
|
let photos: [TravelAlbumOTGPhotoItem]
|
|
let initialIndex: Int
|
|
}
|
|
|
|
/// 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 "失败"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// OTG 传输模式,决定新照片传入 App 后是否立即上传 OSS。
|
|
enum TravelAlbumOTGTransferMode: String, CaseIterable, Sendable {
|
|
case liveUpload
|
|
case postTransfer
|
|
|
|
/// 展示标题。
|
|
var title: String {
|
|
switch self {
|
|
case .liveUpload: return "边拍边传"
|
|
case .postTransfer: return "拍后传输"
|
|
}
|
|
}
|
|
|
|
/// 模式选择弹窗中的业务说明。
|
|
var detailText: String {
|
|
switch self {
|
|
case .liveUpload:
|
|
return "相机拍摄后,照片自动传输并上传到当前相册"
|
|
case .postTransfer:
|
|
return "拍摄完成后,再选择照片批量传输"
|
|
}
|
|
}
|
|
|
|
/// 是否在新照片导入 App 后自动上传。
|
|
var shouldAutoUploadNewImports: Bool {
|
|
self == .liveUpload
|
|
}
|
|
|
|
/// 根据展示标题解析传输模式。
|
|
static func option(title: String) -> TravelAlbumOTGTransferMode? {
|
|
allCases.first { $0.title == title }
|
|
}
|
|
}
|
|
|
|
/// OTG 指定上传弹窗选项。
|
|
enum TravelAlbumOTGSpecifyUploadOption: String, CaseIterable, Sendable {
|
|
case allPending
|
|
case todayCaptured
|
|
|
|
/// 展示标题。
|
|
var title: String {
|
|
switch self {
|
|
case .allPending: return "上传所有未上传的照片"
|
|
case .todayCaptured: return "上传所有今日拍摄的照片"
|
|
}
|
|
}
|
|
}
|
|
|
|
extension TravelAlbumOTGPhotoItem {
|
|
/// 将 Android 同款 `yyyy-MM-dd HH:mm:ss` 拍摄时间转为本地日期。
|
|
func capturedDate() -> Date? {
|
|
TravelAlbumOTGTimeGrouping.capturedDate(from: capturedAt)
|
|
}
|
|
}
|
|
|
|
extension Array where Element == TravelAlbumOTGPhotoItem {
|
|
/// 按当前列表顺序生成预览上下文,不可预览照片不会占用左右滑动页码。
|
|
func previewContext(startingWith photoID: String) -> TravelAlbumOTGPhotoPreviewContext? {
|
|
let previewablePhotos = filter { $0.thumbnailURL != nil }
|
|
guard let initialIndex = previewablePhotos.firstIndex(where: { $0.id == photoID }) else {
|
|
return nil
|
|
}
|
|
return TravelAlbumOTGPhotoPreviewContext(
|
|
photos: previewablePhotos,
|
|
initialIndex: initialIndex
|
|
)
|
|
}
|
|
|
|
/// 按 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<String> = ["jpg", "jpeg", "heif", "hif"]
|
|
private static let rawExtensions: Set<String> = [
|
|
"dng", "cr2", "cr3", "nef", "nrw", "arw", "srf", "sr2",
|
|
"orf", "rw2", "raw", "raf", "srw", "pef", "ptx", "rwl",
|
|
]
|
|
private static let jpegFormatCodes: Set<Int> = [0x3801, 0x3808]
|
|
private static let rawFormatCodes: Set<Int> = [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<String>()
|
|
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
|
|
)
|
|
}
|
|
}
|