From 15924afc044792c89a1264826c454c7f1a39f6ad Mon Sep 17 00:00:00 2001 From: yoshikazu Date: Mon, 27 Jul 2026 15:42:07 +0000 Subject: [PATCH] Add scan view model --- .../Photochk/ViewModels/ScanViewModel.swift | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 Sources/Photochk/ViewModels/ScanViewModel.swift diff --git a/Sources/Photochk/ViewModels/ScanViewModel.swift b/Sources/Photochk/ViewModels/ScanViewModel.swift new file mode 100644 index 0000000..ac158fc --- /dev/null +++ b/Sources/Photochk/ViewModels/ScanViewModel.swift @@ -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 = [] + 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 + } + } +}