Files
suixinkan_uikit/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift
2026-07-10 11:02:00 +08:00

350 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 "失败"
}
}
}
/// OTG App OSS
enum TravelAlbumOTGTransferMode: String, CaseIterable, Sendable {
case liveUpload
case postTransfer
///
var title: 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 {
/// 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
)
}
}