import Foundation /// Ein gemerkter Eintrag der Zwischenablage. /// /// Der **Inhalt steht nicht hier drin**, sondern in einer Datei daneben. Was /// hier steht, ist genug für die Liste — und wenig genug, dass die Indexdatei /// klein bleibt, auch bei fünfhundert Einträgen. public struct ClipboardItem: Identifiable, Equatable, Sendable, Codable { public enum Kind: String, Codable, Sendable { case text case image } public let id: UUID public let kind: Kind /// Was in der Kachel zu lesen ist. Bei Bildern der Dateiname oder das Maß. public let preview: String public let addedAt: Date /// Aus welchem Programm es kam — für die Ausschlussliste und die Anzeige. public let sourceBundleID: String? /// Erkennt Wiederholungen, ohne den ganzen Inhalt im Speicher zu halten. /// /// Dieselbe Zeile zweimal zu kopieren ist der Normalfall, nicht die /// Ausnahme — sie soll nach oben rutschen und nicht ein zweites Mal /// dastehen. public let fingerprint: String public init(id: UUID = UUID(), kind: Kind, preview: String, addedAt: Date, sourceBundleID: String?, fingerprint: String) { self.id = id self.kind = kind self.preview = preview self.addedAt = addedAt self.sourceBundleID = sourceBundleID self.fingerprint = fingerprint } } /// Die Regeln der Zwischenablage — ohne Pasteboard, ohne Dateisystem, ohne Uhr. /// /// Alles hier ist reine Rechnung und damit prüfbar. Was mit dem System zu tun /// hat, steht in ``ClipboardStore``. public enum ClipboardRules { /// Wie viele Einträge höchstens gemerkt werden. /// /// Eine Grenze muss es geben: ohne sie wächst der Verlauf, bis das Laden /// spürbar wird — und niemand scrollt durch tausend Einträge. public static let limit = 200 /// Wie lang eine Vorschauzeile höchstens ist. public static let previewLength = 120 /// Typen, bei denen ausdrücklich **nicht** gemerkt werden soll. /// /// Passwortmanager setzen `ConcealedType`, wenn sie ein Passwort ablegen; /// `TransientType` heißt „nur zum Durchreichen"; `AutoGeneratedType` setzen /// Werkzeuge, die etwas ohne Zutun des Menschen kopieren. Diese drei Marken /// sind eine Verabredung zwischen Programmen, kein Systemdienst — wer sie /// missachtet, sammelt fremde Passwörter ein. public static let ignoredTypes: Set = [ "org.nspasteboard.TransientType", "org.nspasteboard.ConcealedType", "org.nspasteboard.AutoGeneratedType", ] /// Ob eine Änderung überhaupt gemerkt werden darf. public static func shouldRecord(types: [String], sourceBundleID: String?, excludedApps: Set) -> Bool { guard types.allSatisfy({ !ignoredTypes.contains($0) }) else { return false } guard let sourceBundleID else { return true } return !excludedApps.contains(sourceBundleID) } /// Die eine Zeile, die in der Liste steht. /// /// Zeilenumbrüche werden zu Leerzeichen: ein mehrzeiliger Ausschnitt würde /// die Kachel sprengen, und die zweite Zeile sagt selten mehr als die /// erste. public static func preview(for text: String) -> String { let flat = text .replacingOccurrences(of: "\n", with: " ") .replacingOccurrences(of: "\r", with: " ") .replacingOccurrences(of: "\t", with: " ") let collapsed = flat.split(separator: " ", omittingEmptySubsequences: true).joined(separator: " ") guard collapsed.count > previewLength else { return collapsed } return String(collapsed.prefix(previewLength)) + "…" } /// Nimmt einen Eintrag auf: Wiederholungen rutschen nach oben, statt sich /// anzureihen, und über der Grenze fällt das Älteste heraus. /// /// Gibt zusätzlich zurück, welche Einträge dabei verdrängt wurden — deren /// Dateien muss der Aufrufer wegräumen. public static func merge(_ items: [ClipboardItem], adding item: ClipboardItem, limit: Int = limit) -> (items: [ClipboardItem], dropped: [ClipboardItem]) { var dropped = items.filter { $0.fingerprint == item.fingerprint } var kept = items.filter { $0.fingerprint != item.fingerprint } kept.insert(item, at: 0) if kept.count > limit { dropped.append(contentsOf: kept[limit...]) kept = Array(kept.prefix(limit)) } return (kept, dropped) } /// Was zu alt geworden ist. `nil` als Frist heißt: nichts verfällt. public static func expired(_ items: [ClipboardItem], olderThanDays days: Int?, now: Date) -> [ClipboardItem] { guard let days, days > 0 else { return [] } let cutoff = now.addingTimeInterval(-Double(days) * 86_400) return items.filter { $0.addedAt < cutoff } } /// Neuestes zuerst. /// /// Die Kennung als Zweitschlüssel, damit zwei im selben Augenblick /// gemerkte Einträge nicht bei jeder Anzeige die Plätze tauschen. public static func sorted(_ items: [ClipboardItem]) -> [ClipboardItem] { items.sorted { $0.addedAt == $1.addedAt ? $0.id.uuidString > $1.id.uuidString : $0.addedAt > $1.addedAt } } }