240 lines
9.1 KiB
Swift
240 lines
9.1 KiB
Swift
import Foundation
|
|
|
|
enum JSONValue: Codable, Sendable, Equatable {
|
|
case string(String)
|
|
case number(Double)
|
|
case bool(Bool)
|
|
case object([String: JSONValue])
|
|
case array([JSONValue])
|
|
case null
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.singleValueContainer()
|
|
if container.decodeNil() { self = .null; return }
|
|
if let value = try? container.decode(Bool.self) { self = .bool(value); return }
|
|
if let value = try? container.decode(Double.self) { self = .number(value); return }
|
|
if let value = try? container.decode(String.self) { self = .string(value); return }
|
|
if let value = try? container.decode([String: JSONValue].self) { self = .object(value); return }
|
|
if let value = try? container.decode([JSONValue].self) { self = .array(value); return }
|
|
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.singleValueContainer()
|
|
switch self {
|
|
case .string(let value): try container.encode(value)
|
|
case .number(let value): try container.encode(value)
|
|
case .bool(let value): try container.encode(value)
|
|
case .object(let value): try container.encode(value)
|
|
case .array(let value): try container.encode(value)
|
|
case .null: try container.encodeNil()
|
|
}
|
|
}
|
|
|
|
static func object(_ values: [String: Any]) -> JSONValue {
|
|
.object(values.reduce(into: [:]) { result, item in result[item.key] = JSONValue(any: item.value) })
|
|
}
|
|
|
|
init(any value: Any) {
|
|
switch value {
|
|
case let value as String: self = .string(value)
|
|
case let value as Int: self = .number(Double(value))
|
|
case let value as Int64: self = .number(Double(value))
|
|
case let value as Double: self = .number(value)
|
|
case let value as Bool: self = .bool(value)
|
|
case let value as [Any]: self = .array(value.map(JSONValue.init(any:)))
|
|
case let value as [String: Any]: self = .object(value.reduce(into: [:]) { $0[$1.key] = JSONValue(any: $1.value) })
|
|
default: self = .null
|
|
}
|
|
}
|
|
|
|
var objectValue: [String: JSONValue]? { if case .object(let value) = self { return value }; return nil }
|
|
var arrayValue: [JSONValue]? { if case .array(let value) = self { return value }; return nil }
|
|
var stringValue: String? { if case .string(let value) = self { return value }; return nil }
|
|
var doubleValue: Double? { if case .number(let value) = self { return value }; return nil }
|
|
var intValue: Int? { doubleValue.map(Int.init) }
|
|
var int64Value: Int64? { doubleValue.map(Int64.init) }
|
|
|
|
subscript(_ key: String) -> JSONValue? { objectValue?[key] }
|
|
|
|
func firstString(_ keys: [String]) -> String? {
|
|
for key in keys where self[key]?.stringValue != nil { return self[key]?.stringValue }
|
|
return nil
|
|
}
|
|
|
|
// API responses are not consistent about whether payload fields are nested in `data`.
|
|
func firstStringDeep(_ keys: [String]) -> String? {
|
|
if let value = firstString(keys) { return value }
|
|
guard let object = objectValue else { return nil }
|
|
let preferredKeys = ["data", "result", "user", "profile", "payload"]
|
|
for key in preferredKeys {
|
|
if let value = object[key]?.firstStringDeep(keys) { return value }
|
|
}
|
|
for (key, value) in object where !preferredKeys.contains(key) {
|
|
if let found = value.firstStringDeep(keys) { return found }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func firstInt(_ keys: [String]) -> Int? {
|
|
for key in keys where self[key]?.intValue != nil { return self[key]?.intValue }
|
|
return nil
|
|
}
|
|
|
|
func firstIntDeep(_ keys: [String]) -> Int? {
|
|
if let value = firstInt(keys) { return value }
|
|
guard let object = objectValue else { return nil }
|
|
let preferredKeys = ["data", "result", "user", "profile", "payload"]
|
|
for key in preferredKeys {
|
|
if let value = object[key]?.firstIntDeep(keys) { return value }
|
|
}
|
|
for (key, value) in object where !preferredKeys.contains(key) {
|
|
if let found = value.firstIntDeep(keys) { return found }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func firstInt64Deep(_ keys: [String]) -> Int64? {
|
|
if let value = keys.compactMap({ self[$0]?.int64Value }).first { return value }
|
|
guard let object = objectValue else { return nil }
|
|
let preferredKeys = ["data", "result", "user", "profile", "payload"]
|
|
for key in preferredKeys {
|
|
if let value = object[key]?.firstInt64Deep(keys) { return value }
|
|
}
|
|
for (key, value) in object where !preferredKeys.contains(key) {
|
|
if let found = value.firstInt64Deep(keys) { return found }
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func formatFileDate(_ epoch: Int64) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.locale = Locale(identifier: "zh_CN")
|
|
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
|
return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(epoch)))
|
|
}
|
|
|
|
struct CloudFile: Identifiable, Hashable, Sendable {
|
|
let id: String
|
|
let name: String
|
|
let isDirectory: Bool
|
|
var size: Int64?
|
|
var gcid: String?
|
|
var modifiedAt: String
|
|
let fileType: Int
|
|
|
|
var icon: String {
|
|
if isDirectory { return "folder.fill" }
|
|
switch fileType {
|
|
case 1: return "photo.fill"
|
|
case 2: return "play.rectangle.fill"
|
|
case 3: return "music.note"
|
|
case 4: return "doc.text.fill"
|
|
case 5, 9: return "archivebox.fill"
|
|
default: return "doc.fill"
|
|
}
|
|
}
|
|
|
|
var typeName: String {
|
|
if isDirectory { return "文件夹" }
|
|
return [1: "图片", 2: "视频", 3: "音频", 4: "文档", 5: "压缩包", 9: "BT种子"][fileType] ?? "文件"
|
|
}
|
|
|
|
var formattedSize: String {
|
|
guard let size else { return "--" }
|
|
let formatter = ByteCountFormatter()
|
|
formatter.countStyle = .file
|
|
return formatter.string(fromByteCount: size)
|
|
}
|
|
|
|
init?(json: JSONValue) {
|
|
guard let object = json.objectValue,
|
|
let name = json.firstString(["name", "fileName", "resName"]) else { return nil }
|
|
id = json.firstString(["fileId", "id", "resId"]) ?? UUID().uuidString
|
|
self.name = name
|
|
let resourceType = json.firstInt(["resType"])
|
|
let type = json.firstInt(["fileType", "type"]) ?? 0
|
|
if let explicitDirectory = json.firstInt(["isDir", "dir", "directoryType"]) {
|
|
isDirectory = explicitDirectory == 1
|
|
} else if let resourceType {
|
|
// dirType == 1 is also present on ordinary resources; resType == 2 identifies folders.
|
|
isDirectory = resourceType == 2
|
|
} else {
|
|
isDirectory = type == 0 && (object["dirName"] != nil || object["children"] != nil)
|
|
}
|
|
size = json.firstInt64Deep(["size", "fileSize", "resSize", "totalSize", "dirSize", "folderSize"])
|
|
gcid = json.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"])
|
|
modifiedAt = json.firstString(["updateTime", "updatedAt", "modifyTime", "createTime"]) ?? json.firstInt64Deep(["utime", "ctime"]).map(formatFileDate) ?? ""
|
|
fileType = type
|
|
}
|
|
}
|
|
|
|
struct DuplicateGroup: Identifiable, Sendable {
|
|
let id: String
|
|
let files: [CloudFile]
|
|
}
|
|
|
|
struct TMDBMatch: Identifiable, Sendable {
|
|
let id: Int
|
|
let title: String
|
|
let mediaType: String
|
|
let releaseDate: String
|
|
let overview: String
|
|
|
|
init?(json: JSONValue) {
|
|
guard let id = json.firstIntDeep(["id"]), let title = json.firstStringDeep(["title", "name"]) else { return nil }
|
|
self.id = id
|
|
self.title = title
|
|
mediaType = json.firstStringDeep(["media_type"]) ?? "movie"
|
|
releaseDate = json.firstStringDeep(["release_date", "first_air_date"]) ?? ""
|
|
overview = json.firstStringDeep(["overview"]) ?? ""
|
|
}
|
|
}
|
|
|
|
struct FolderPath: Identifiable, Hashable, Sendable {
|
|
let id: String
|
|
let name: String
|
|
}
|
|
|
|
struct UserProfile: Sendable {
|
|
let name: String
|
|
let phone: String
|
|
let avatarURL: URL?
|
|
|
|
init(json: JSONValue) {
|
|
name = json.firstStringDeep(["nickname", "name", "username", "phone_number"]) ?? "光鸭用户"
|
|
phone = json.firstStringDeep(["phone", "phoneNumber", "phone_number", "username"]) ?? ""
|
|
avatarURL = json.firstStringDeep(["avatar", "avatarUrl", "avatar_url"]).flatMap(URL.init(string:))
|
|
}
|
|
}
|
|
|
|
struct AuthTokens: Sendable {
|
|
let accessToken: String
|
|
let refreshToken: String?
|
|
let expiresIn: Double?
|
|
}
|
|
|
|
enum WorkspaceSection: String, CaseIterable, Identifiable {
|
|
case files = "我的文件"
|
|
case photos = "图片"
|
|
case videos = "视频"
|
|
case documents = "文档"
|
|
case cloud = "云下载"
|
|
case shares = "我的分享"
|
|
case recycle = "回收站"
|
|
|
|
var id: String { rawValue }
|
|
var icon: String {
|
|
switch self {
|
|
case .files: return "folder"
|
|
case .photos: return "photo"
|
|
case .videos: return "play.rectangle"
|
|
case .documents: return "doc.text"
|
|
case .cloud: return "arrow.down.circle"
|
|
case .shares: return "square.and.arrow.up"
|
|
case .recycle: return "trash"
|
|
}
|
|
}
|
|
}
|