import Foundation import AppKit import OSLog private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Shelf") /// Die Ablage: ein Ordner, in dem Dateien zwischenlagern. /// /// Kopiert wird, nicht verwiesen. Anhänge aus Mail oder WhatsApp liegen in /// temporären Verzeichnissen, die das System aufräumt — ein Verweis darauf /// wäre in Stunden tot, und die Ablage wäre genau dann leer, wenn man sie /// braucht. @MainActor @Observable public final class ShelfStore { public private(set) var items: [ShelfItem] = [] public private(set) var lastError: String? /// Automatisches Aufräumen nach N Tagen. `nil` = aus, und das ist Standard. public var autoCleanupDays: Int? { didSet { defaults.set(autoCleanupDays ?? 0, forKey: Self.cleanupKey) cleanUp() } } private static let cleanupKey = "shelf.cleanupDays" public let directory: URL private let defaults: UserDefaults public init(defaults: UserDefaults = .standard, directory: URL? = nil) { self.defaults = defaults self.directory = directory ?? FileManager.default .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("Onyx/Shelf", isDirectory: true) let stored = defaults.integer(forKey: Self.cleanupKey) self.autoCleanupDays = stored > 0 ? stored : nil try? FileManager.default.createDirectory(at: self.directory, withIntermediateDirectories: true) reload() cleanUp() } // MARK: - Lesen public func reload() { let keys: [URLResourceKey] = [.fileSizeKey, .addedToDirectoryDateKey, .contentModificationDateKey, .isDirectoryKey] guard let urls = try? FileManager.default.contentsOfDirectory( at: directory, includingPropertiesForKeys: keys, options: [.skipsHiddenFiles]) else { items = [] return } items = ShelfItem.sorted(urls.compactMap { url in let values = try? url.resourceValues(forKeys: Set(keys)) return ShelfItem( url: url, name: url.lastPathComponent, size: Int64(values?.fileSize ?? 0), // Wann die Datei **hier** ankam, nicht wann sie erzeugt wurde. // Ein zehn Jahre altes Foto von gestern soll nicht sofort // ablaufen, nur weil die Kamera ein altes Datum trägt. addedAt: values?.addedToDirectoryDate ?? values?.contentModificationDate ?? Date()) }) } // MARK: - Hinzufügen /// Legt Kopien in der Ablage an und meldet, was tatsächlich ankam. @discardableResult public func add(_ urls: [URL]) -> [ShelfItem] { var added: [ShelfItem] = [] var taken = Set(items.map(\.name)) for source in urls { // Dateien aus fremden Apps kommen als sicherheitsbereichte URLs. // Ohne das Paar aus Start und Stop scheitert das Kopieren mit // „Berechtigung verweigert" — und zwar erst bei manchen Quellen, // was den Fehler besonders unangenehm macht. let scoped = source.startAccessingSecurityScopedResource() defer { if scoped { source.stopAccessingSecurityScopedResource() } } let name = ShelfNaming.uniqueName(for: source.lastPathComponent, taken: taken) let target = directory.appendingPathComponent(name) do { try FileManager.default.copyItem(at: source, to: target) taken.insert(name) let size = (try? target.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 added.append(ShelfItem(url: target, name: name, size: Int64(size), addedAt: Date())) } catch { log.error("Ablage: \(source.lastPathComponent, privacy: .public) nicht kopiert: \(error.localizedDescription, privacy: .public)") lastError = error.localizedDescription } } if !added.isEmpty { reload() } return added } // MARK: - Entfernen public func remove(_ item: ShelfItem) { // In den Papierkorb statt endgültig: die Ablage ist ein Zwischenlager, // und ein Fehlklick darf keine Datei vernichten. NSWorkspace.shared.recycle([item.url]) { [weak self] _, error in if let error { log.error("Ablage: nicht gelöscht: \(error.localizedDescription, privacy: .public)") } Task { @MainActor in self?.reload() } } } public func removeAll() { guard !items.isEmpty else { return } NSWorkspace.shared.recycle(items.map(\.url)) { [weak self] _, _ in Task { @MainActor in self?.reload() } } } /// Räumt ab, was über die eingestellte Frist hinaus liegt. public func cleanUp(now: Date = Date()) { let expired = ShelfCleanup.expired(items, now: now, afterDays: autoCleanupDays) guard !expired.isEmpty else { return } log.notice("Ablage: \(expired.count, privacy: .public) Dateien abgelaufen") NSWorkspace.shared.recycle(expired.map(\.url)) { [weak self] _, _ in Task { @MainActor in self?.reload() } } } /// Öffnet den Ablageordner im Finder — der Notausgang, wenn die Oberfläche /// nicht das kann, was man gerade braucht. public func revealInFinder() { NSWorkspace.shared.activateFileViewerSelecting([directory]) } }