Calendarr liefert Termine über den Gruppencontainer. Streng lesend: keine eigene Anmeldung, kein Netz, kein Schreiben zurück. Die Abdeckung ist der Kern und kein Detail. Der Schnappschuss reicht 49 Tage nach vorne, und das steht nirgends in der Datei — es ist zugesagtes Verhalten des Schreibers. Außerhalb liegt keine Information vor, nicht keine Termine; das Monatsraster graut solche Tage aus, statt „frei" zu behaupten. Verlockend wäre, die Abdeckung aus dem frühesten Termin abzuleiten. Das ist falsch, und ein Test hält es fest: mehrtägige Termine ragen ins Fenster hinein und werden mitgeschrieben, obwohl ihr Anfang nicht abgedeckt ist. Bei „beide Quellen" ist die Abdeckung der Schnitt, nicht die Vereinigung. Ein Tag gilt nur als bekannt, wenn ihn jede Quelle abdeckt. Umgekehrt darf ein Ausfall einer Quelle die andere nicht mitreißen: Calendarr nie geöffnet zu haben knipst den Apple-Kalender nicht aus. Die Ablage kopiert, statt zu verweisen. Anhänge aus Mail oder WhatsApp liegen in temporären Verzeichnissen — ein Verweis wäre in Stunden tot, und die Ablage wäre leer, wenn man sie braucht. Aus demselben Grund nimmt die Fläche UTType.item statt public.file-url an: solche Anhänge kommen als Dateiversprechen, und loadFileRepresentation löst beides ein. Die übergebene Datei existiert nur innerhalb des Rückrufs, also wird sie dort sofort gesichert statt später auf dem MainActor. Gelöscht wird in den Papierkorb. Automatisches Aufräumen ist standardmäßig aus — eine Ablage, die ungefragt löscht, ist ein Papierkorb mit Zeitschaltuhr. Nebenbei: „mixer.on" und „network.vpn.active" standen als rohe Schlüssel in der Oberfläche. Label(_:systemImage:) kennt kein Bundle und sucht immer im Hauptbundle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
138 lines
5.5 KiB
Swift
138 lines
5.5 KiB
Swift
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])
|
|
}
|
|
}
|