Fix OTG album local path storage
This commit is contained in:
@ -71,15 +71,6 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
/// 是否仍可在页面展示。
|
||||
var isDisplayable: Bool {
|
||||
if status == .uploaded { return true }
|
||||
if !remoteUrl.isEmpty { return true }
|
||||
if !localPath.isEmpty, FileManager.default.fileExists(atPath: localPath) { return true }
|
||||
if !thumbnailPath.isEmpty, FileManager.default.fileExists(atPath: thumbnailPath) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。
|
||||
func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord {
|
||||
guard status == .transferring || status == .uploading else { return self }
|
||||
@ -129,7 +120,7 @@ final class TravelAlbumOTGPhotoRepository: PhotoRepositoryProtocol {
|
||||
let record = TravelAlbumOTGPhotoRecord(
|
||||
id: TravelAlbumOTGPhotoStore.photoId(for: fileURL.lastPathComponent, createdAt: createdAt),
|
||||
fileName: fileURL.lastPathComponent,
|
||||
localPath: localPath,
|
||||
localPath: storage.relativePath(for: fileURL, albumId: albumID),
|
||||
capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(createdAt),
|
||||
fileSizeBytes: size,
|
||||
status: .pending,
|
||||
@ -192,11 +183,10 @@ final class TravelAlbumOTGPhotoStore {
|
||||
let decoded = try? JSONDecoder().decode([TravelAlbumOTGPhotoRecord].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
let deletedIds = loadDeletedIds(albumId: albumId)
|
||||
return decoded
|
||||
.filter { $0.albumId == albumId && $0.userId == context.userId && !deletedIds.contains($0.id) }
|
||||
.filter { $0.albumId == albumId && $0.userId == context.userId }
|
||||
.map { $0.normalizedAfterInterruptedTransfer() }
|
||||
.filter(\.isDisplayable)
|
||||
.filter { isDisplayable($0, albumId: albumId) }
|
||||
.sorted { $0.capturedAt > $1.capturedAt }
|
||||
}
|
||||
|
||||
@ -205,6 +195,7 @@ final class TravelAlbumOTGPhotoStore {
|
||||
guard albumId > 0, !context.userId.isEmpty else { return }
|
||||
let normalized = records
|
||||
.filter { $0.albumId == albumId && $0.userId == context.userId }
|
||||
.map { recordByPersistingRelativePaths($0, albumId: albumId) }
|
||||
.sorted { $0.capturedAt > $1.capturedAt }
|
||||
do {
|
||||
try fileManager.createDirectory(at: albumRoot(albumId: albumId), withIntermediateDirectories: true)
|
||||
@ -227,7 +218,6 @@ final class TravelAlbumOTGPhotoStore {
|
||||
guard albumId > 0, !photoId.isEmpty else { return }
|
||||
let records = load(albumId: albumId)
|
||||
records.filter { $0.id == photoId }.forEach(deleteFiles)
|
||||
saveDeletedIds(loadDeletedIds(albumId: albumId).union([photoId]), albumId: albumId)
|
||||
save(records.filter { $0.id != photoId }, albumId: albumId)
|
||||
}
|
||||
|
||||
@ -238,15 +228,6 @@ final class TravelAlbumOTGPhotoStore {
|
||||
try? fileManager.removeItem(at: previewRoot(albumId: albumId))
|
||||
}
|
||||
|
||||
/// 返回已删除照片 ID。
|
||||
func loadDeletedIds(albumId: Int) -> Set<String> {
|
||||
guard let data = try? Data(contentsOf: deletedIndexURL(albumId: albumId)),
|
||||
let decoded = try? JSONDecoder().decode([String].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
return Set(decoded)
|
||||
}
|
||||
|
||||
/// 原片目录。
|
||||
func originalsDirectory(albumId: Int) throws -> URL {
|
||||
let url = albumRoot(albumId: albumId).appendingPathComponent("originals", isDirectory: true)
|
||||
@ -292,6 +273,58 @@ final class TravelAlbumOTGPhotoStore {
|
||||
return url
|
||||
}
|
||||
|
||||
/// 返回写入索引用的相对路径。
|
||||
func relativePath(for url: URL, albumId: Int) -> String {
|
||||
let path = url.standardizedFileURL.path
|
||||
let originalRoot = albumRoot(albumId: albumId)
|
||||
.appendingPathComponent("originals", isDirectory: true)
|
||||
.standardizedFileURL
|
||||
.path
|
||||
if let relative = pathComponent(path, relativeTo: originalRoot) {
|
||||
return "originals/\(relative)"
|
||||
}
|
||||
|
||||
let previewPath = previewRoot(albumId: albumId).standardizedFileURL.path
|
||||
if let relative = pathComponent(path, relativeTo: previewPath) {
|
||||
return "previews/\(relative)"
|
||||
}
|
||||
|
||||
return url.lastPathComponent
|
||||
}
|
||||
|
||||
/// 把索引中的相对路径解析到当前沙盒绝对 URL。
|
||||
func absoluteURL(for relativePath: String, albumId: Int) -> URL? {
|
||||
let trimmed = relativePath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, !trimmed.hasPrefix("/") else { return nil }
|
||||
if trimmed.hasPrefix("originals/") {
|
||||
return albumRoot(albumId: albumId).appendingPathComponent(trimmed)
|
||||
}
|
||||
if trimmed.hasPrefix("previews/") {
|
||||
return previewRoot(albumId: albumId)
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent(trimmed)
|
||||
}
|
||||
return albumRoot(albumId: albumId).appendingPathComponent(trimmed)
|
||||
}
|
||||
|
||||
/// 判断索引相对路径对应的文件是否存在。
|
||||
func fileExists(relativePath: String, albumId: Int) -> Bool {
|
||||
guard let url = absoluteURL(for: relativePath, albumId: albumId) else { return false }
|
||||
return fileManager.fileExists(atPath: url.path)
|
||||
}
|
||||
|
||||
/// 返回可直接用于文件读取的记录副本。
|
||||
func recordWithResolvedFilePaths(_ record: TravelAlbumOTGPhotoRecord, albumId: Int) -> TravelAlbumOTGPhotoRecord {
|
||||
var resolved = record
|
||||
if let localURL = absoluteURL(for: record.localPath, albumId: albumId) {
|
||||
resolved.localPath = localURL.path
|
||||
}
|
||||
if let thumbnailURL = absoluteURL(for: record.thumbnailPath, albumId: albumId) {
|
||||
resolved.thumbnailPath = thumbnailURL.path
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
/// 生成稳定照片 ID。
|
||||
static func photoId(for fileName: String, createdAt: Date) -> String {
|
||||
let timestamp = Int64(createdAt.timeIntervalSince1970)
|
||||
@ -339,27 +372,46 @@ final class TravelAlbumOTGPhotoStore {
|
||||
albumRoot(albumId: albumId).appendingPathComponent("records.json")
|
||||
}
|
||||
|
||||
private func deletedIndexURL(albumId: Int) -> URL {
|
||||
albumRoot(albumId: albumId).appendingPathComponent("deleted.json")
|
||||
}
|
||||
|
||||
private func saveDeletedIds(_ ids: Set<String>, albumId: Int) {
|
||||
do {
|
||||
try fileManager.createDirectory(at: albumRoot(albumId: albumId), withIntermediateDirectories: true)
|
||||
let data = try JSONEncoder().encode(Array(ids))
|
||||
try data.write(to: deletedIndexURL(albumId: albumId), options: .atomic)
|
||||
} catch {
|
||||
OTGLog.warning(.connection, "save deleted OTG ids failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteFiles(_ record: TravelAlbumOTGPhotoRecord) {
|
||||
[record.localPath, record.thumbnailPath].forEach { path in
|
||||
guard !path.isEmpty else { return }
|
||||
try? fileManager.removeItem(atPath: path)
|
||||
guard let url = absoluteURL(for: path, albumId: record.albumId) else { return }
|
||||
try? fileManager.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func isDisplayable(_ record: TravelAlbumOTGPhotoRecord, albumId: Int) -> Bool {
|
||||
if record.status == .uploaded { return true }
|
||||
if !record.remoteUrl.isEmpty { return true }
|
||||
if fileExists(relativePath: record.localPath, albumId: albumId) { return true }
|
||||
if fileExists(relativePath: record.thumbnailPath, albumId: albumId) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private func recordByPersistingRelativePaths(
|
||||
_ record: TravelAlbumOTGPhotoRecord,
|
||||
albumId: Int
|
||||
) -> TravelAlbumOTGPhotoRecord {
|
||||
var normalized = record
|
||||
if !record.localPath.isEmpty {
|
||||
normalized.localPath = record.localPath.hasPrefix("/")
|
||||
? relativePath(for: URL(fileURLWithPath: record.localPath), albumId: albumId)
|
||||
: record.localPath
|
||||
}
|
||||
if !record.thumbnailPath.isEmpty {
|
||||
normalized.thumbnailPath = record.thumbnailPath.hasPrefix("/")
|
||||
? relativePath(for: URL(fileURLWithPath: record.thumbnailPath), albumId: albumId)
|
||||
: record.thumbnailPath
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
private func pathComponent(_ path: String, relativeTo root: String) -> String? {
|
||||
let prefix = root.hasSuffix("/") ? root : "\(root)/"
|
||||
guard path.hasPrefix(prefix) else { return nil }
|
||||
return String(path.dropFirst(prefix.count))
|
||||
}
|
||||
|
||||
private func excludeFromBackup(_ url: URL) throws {
|
||||
var resourceValues = URLResourceValues()
|
||||
resourceValues.isExcludedFromBackup = true
|
||||
|
||||
Reference in New Issue
Block a user