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 = [] var errorMessage: String? private var scanTask: Task? 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 = [] 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) { 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 } } }