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>
84 lines
3.1 KiB
Swift
84 lines
3.1 KiB
Swift
import Foundation
|
|
|
|
/// Eine abgelegte Datei.
|
|
public struct ShelfItem: Identifiable, Equatable, Sendable {
|
|
public let url: URL
|
|
public let name: String
|
|
public let size: Int64
|
|
public let addedAt: Date
|
|
|
|
public var id: String { url.path }
|
|
|
|
public init(url: URL, name: String, size: Int64, addedAt: Date) {
|
|
self.url = url
|
|
self.name = name
|
|
self.size = size
|
|
self.addedAt = addedAt
|
|
}
|
|
|
|
/// Neuestes zuerst.
|
|
///
|
|
/// Der Name als Zweitschlüssel ist kein Detail: das Dateisystem sagt keine
|
|
/// Reihenfolge zu, und ohne festen zweiten Vergleich springen gleich alte
|
|
/// Dateien bei jeder Aktualisierung durcheinander.
|
|
public static func sorted(_ items: [ShelfItem]) -> [ShelfItem] {
|
|
items.sorted {
|
|
$0.addedAt == $1.addedAt ? $0.name < $1.name : $0.addedAt > $1.addedAt
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Findet freie Namen, ohne je etwas zu überschreiben.
|
|
public enum ShelfNaming {
|
|
|
|
/// Der erste freie Name in der Reihe `Vertrag.pdf`, `Vertrag 2.pdf`, …
|
|
///
|
|
/// Gleichnamige Dateien sind der Normalfall — `IMG_0001.jpg` aus zwei
|
|
/// verschiedenen Chats. Überschreiben wäre stiller Datenverlust.
|
|
public static func uniqueName(for name: String, taken: Set<String>) -> String {
|
|
let name = name.isEmpty ? "Datei" : name
|
|
guard taken.contains(name) else { return name }
|
|
|
|
let (base, ext) = split(name)
|
|
var counter = 2
|
|
while true {
|
|
let candidate = ext.isEmpty ? "\(base) \(counter)" : "\(base) \(counter).\(ext)"
|
|
if !taken.contains(candidate) { return candidate }
|
|
counter += 1
|
|
}
|
|
}
|
|
|
|
/// Zerlegt in Namensteil und Endung.
|
|
///
|
|
/// Ein führender Punkt zählt nicht als Endung — sonst würde aus
|
|
/// `.gitignore` ein ` 2.gitignore` mit führendem Leerzeichen. Und bei
|
|
/// `archiv.tar.gz` zählt nur die letzte Endung: `archiv 2.tar.gz` wäre
|
|
/// zwar hübscher, aber `archiv.tar 2.gz` ist die Regel, die auch bei
|
|
/// unbekannten Doppelendungen nichts kaputt macht.
|
|
private static func split(_ name: String) -> (base: String, ext: String) {
|
|
guard let dot = name.lastIndex(of: "."), dot != name.startIndex else {
|
|
return (name, "")
|
|
}
|
|
return (String(name[name.startIndex..<dot]),
|
|
String(name[name.index(after: dot)...]))
|
|
}
|
|
}
|
|
|
|
/// Räumt alte Dateien weg — wenn man es ausdrücklich möchte.
|
|
public enum ShelfCleanup {
|
|
|
|
/// Was über die Frist hinaus liegt.
|
|
///
|
|
/// `nil` und `0` heißen beide „gar nicht". Eine Ablage, die ungefragt
|
|
/// löscht, ist keine Ablage; und ein Fehlgriff im Einstellungsfeld darf
|
|
/// nicht der Verlust aller Dateien sein.
|
|
public static func expired(_ items: [ShelfItem], now: Date,
|
|
afterDays: Int?) -> [ShelfItem] {
|
|
guard let days = afterDays, days > 0 else { return [] }
|
|
let limit = now.addingTimeInterval(-Double(days) * 86_400)
|
|
// Streng älter: genau auf der Frist bleibt es liegen. Löschen ist
|
|
// unwiderruflich, einen Tag länger warten ist es nicht.
|
|
return items.filter { $0.addedAt < limit }
|
|
}
|
|
}
|