Add scan view model
This commit is contained in:
@@ -0,0 +1,243 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// 各グループで「どの1枚を残すか」の基準。
|
||||||
|
enum KeepRule: String, CaseIterable, Identifiable {
|
||||||
|
case highestResolution
|
||||||
|
case newest
|
||||||
|
case oldest
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .highestResolution: return "解像度が最も高い1枚を残す"
|
||||||
|
case .newest: return "最も新しい1枚を残す"
|
||||||
|
case .oldest: return "最も古い1枚を残す"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class ScanViewModel {
|
||||||
|
private(set) var folder: URL?
|
||||||
|
private(set) var groups: [SimilarityGroup] = []
|
||||||
|
private(set) var progress: ScanProgress?
|
||||||
|
private(set) var hasScanned = false
|
||||||
|
|
||||||
|
var settings = ScanSettings() {
|
||||||
|
didSet { if oldValue != settings, folder != nil { rescan() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedGroupID: SimilarityGroup.ID?
|
||||||
|
var markedForDeletion: Set<URL> = []
|
||||||
|
var errorMessage: String?
|
||||||
|
|
||||||
|
private var scanTask: Task<Void, Never>?
|
||||||
|
private let cache = FingerprintCache()
|
||||||
|
private let folderAccess = FolderAccess()
|
||||||
|
|
||||||
|
var isScanning: Bool { progress != nil }
|
||||||
|
|
||||||
|
var selectedGroup: SimilarityGroup? {
|
||||||
|
guard let selectedGroupID else { return nil }
|
||||||
|
return groups.first { $0.id == selectedGroupID }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - フォルダ
|
||||||
|
|
||||||
|
func restorePreviousFolder() {
|
||||||
|
guard folder == nil, let url = folderAccess.restorePrevious() else { return }
|
||||||
|
folder = url
|
||||||
|
rescan()
|
||||||
|
}
|
||||||
|
|
||||||
|
func chooseFolder() {
|
||||||
|
guard let url = folderAccess.choose(startingAt: folder) else { return }
|
||||||
|
folder = url
|
||||||
|
rescan()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - スキャン
|
||||||
|
|
||||||
|
func rescan() {
|
||||||
|
guard let folder else { return }
|
||||||
|
|
||||||
|
scanTask?.cancel()
|
||||||
|
groups = []
|
||||||
|
selectedGroupID = nil
|
||||||
|
markedForDeletion = []
|
||||||
|
errorMessage = nil
|
||||||
|
progress = ScanProgress(phase: .scanning)
|
||||||
|
|
||||||
|
let settings = settings
|
||||||
|
let cache = cache
|
||||||
|
scanTask = Task { [weak self] in
|
||||||
|
do {
|
||||||
|
let result = try await SimilarityGrouper.run(
|
||||||
|
root: folder,
|
||||||
|
settings: settings,
|
||||||
|
cache: cache,
|
||||||
|
progress: { [weak self] update in
|
||||||
|
Task { @MainActor in
|
||||||
|
// キャンセル後に遅れて届いた更新は捨てる
|
||||||
|
guard let self, self.isScanning else { return }
|
||||||
|
self.progress = update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
try Task.checkCancellation()
|
||||||
|
self?.finishScan(with: result)
|
||||||
|
} catch is CancellationError {
|
||||||
|
self?.progress = nil
|
||||||
|
} catch {
|
||||||
|
self?.errorMessage = error.localizedDescription
|
||||||
|
self?.progress = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelScan() {
|
||||||
|
scanTask?.cancel()
|
||||||
|
scanTask = nil
|
||||||
|
progress = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishScan(with result: [SimilarityGroup]) {
|
||||||
|
groups = result
|
||||||
|
selectedGroupID = result.first?.id
|
||||||
|
progress = nil
|
||||||
|
hasScanned = true
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// テスト専用。実際にスキャンせずに結果を差し込む。
|
||||||
|
func setGroupsForTesting(_ groups: [SimilarityGroup]) {
|
||||||
|
self.groups = groups
|
||||||
|
self.selectedGroupID = groups.first?.id
|
||||||
|
self.hasScanned = true
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - 選択
|
||||||
|
|
||||||
|
/// そのグループで最後の1枚を消すことになる場合は選べない。
|
||||||
|
func canMark(_ url: URL, in group: SimilarityGroup) -> Bool {
|
||||||
|
if markedForDeletion.contains(url) { return true }
|
||||||
|
let remaining = group.members.filter { !markedForDeletion.contains($0.id) }
|
||||||
|
return remaining.count > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleMark(_ url: URL, in group: SimilarityGroup) {
|
||||||
|
if markedForDeletion.contains(url) {
|
||||||
|
markedForDeletion.remove(url)
|
||||||
|
} else if canMark(url, in: group) {
|
||||||
|
markedForDeletion.insert(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyKeepRule(_ rule: KeepRule) {
|
||||||
|
var marks: Set<URL> = []
|
||||||
|
for group in groups {
|
||||||
|
guard let keeper = keeper(for: rule, in: group) else { continue }
|
||||||
|
for member in group.members where member.id != keeper {
|
||||||
|
marks.insert(member.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
markedForDeletion = marks
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearMarks() {
|
||||||
|
markedForDeletion = []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func keeper(for rule: KeepRule, in group: SimilarityGroup) -> URL? {
|
||||||
|
switch rule {
|
||||||
|
case .highestResolution:
|
||||||
|
return group.members.max {
|
||||||
|
($0.asset.pixelCount, $0.asset.fileSize) < ($1.asset.pixelCount, $1.asset.fileSize)
|
||||||
|
}?.id
|
||||||
|
case .newest:
|
||||||
|
return group.members.max {
|
||||||
|
sortDate($0.asset) < sortDate($1.asset)
|
||||||
|
}?.id
|
||||||
|
case .oldest:
|
||||||
|
return group.members.min {
|
||||||
|
sortDate($0.asset) < sortDate($1.asset)
|
||||||
|
}?.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sortDate(_ asset: PhotoAsset) -> Date {
|
||||||
|
asset.captureDate ?? asset.modificationDate
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 集計
|
||||||
|
|
||||||
|
var markedCount: Int { markedForDeletion.count }
|
||||||
|
|
||||||
|
var markedBytes: Int64 {
|
||||||
|
groups
|
||||||
|
.flatMap(\.members)
|
||||||
|
.filter { markedForDeletion.contains($0.id) }
|
||||||
|
.reduce(0) { $0 + $1.asset.fileSize }
|
||||||
|
}
|
||||||
|
|
||||||
|
var markedBytesText: String {
|
||||||
|
ByteCountFormatter.string(fromByteCount: markedBytes, countStyle: .file)
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalReclaimableText: String {
|
||||||
|
let bytes = groups.reduce(0) { $0 + $1.reclaimableBytes }
|
||||||
|
return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalDuplicateCount: Int {
|
||||||
|
groups.reduce(0) { $0 + $1.members.count - 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ゴミ箱へ
|
||||||
|
|
||||||
|
func moveMarkedToTrash() async {
|
||||||
|
let targets = Array(markedForDeletion)
|
||||||
|
guard !targets.isEmpty else { return }
|
||||||
|
|
||||||
|
let outcome = await TrashService.moveToTrash(targets)
|
||||||
|
let trashed = Set(outcome.trashed)
|
||||||
|
guard !trashed.isEmpty || !outcome.failed.isEmpty else { return }
|
||||||
|
|
||||||
|
markedForDeletion.subtract(trashed)
|
||||||
|
removeFromGroups(trashed)
|
||||||
|
|
||||||
|
if !outcome.failed.isEmpty {
|
||||||
|
let names = outcome.failed.prefix(5).map(\.lastPathComponent).joined(separator: "\n")
|
||||||
|
let more = outcome.failed.count > 5 ? "\nほか \(outcome.failed.count - 5) 件" : ""
|
||||||
|
errorMessage = """
|
||||||
|
\(outcome.failed.count) 件をゴミ箱へ移動できませんでした。
|
||||||
|
\(names)\(more)
|
||||||
|
\(outcome.errorDescription ?? "")
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ゴミ箱に入れたファイルを一覧から取り除き、1枚だけになったグループは畳む。
|
||||||
|
private func removeFromGroups(_ removed: Set<URL>) {
|
||||||
|
var rebuilt: [SimilarityGroup] = []
|
||||||
|
for group in groups {
|
||||||
|
let survivors = group.members.filter { !removed.contains($0.id) }
|
||||||
|
guard survivors.count > 1 else { continue }
|
||||||
|
|
||||||
|
let representativeID = survivors.contains { $0.id == group.representativeID }
|
||||||
|
? group.representativeID
|
||||||
|
: survivors[0].id
|
||||||
|
rebuilt.append(
|
||||||
|
SimilarityGroup(members: survivors, representativeID: representativeID)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
groups = rebuilt
|
||||||
|
if let selectedGroupID, !rebuilt.contains(where: { $0.id == selectedGroupID }) {
|
||||||
|
self.selectedGroupID = rebuilt.first?.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user