Drei neue Module nach dem Bauplan von `ShelfProvider`: reine Regeln getrennt vom Zustand, versionierte Indexdatei, Inhalte in Dateien statt in `UserDefaults` — was dort steht, liest jedes Programm mit `defaults read` mit. Die Zwischenablage achtet `ConcealedType`, `TransientType` und `AutoGeneratedType`. Diese Marken sind eine Verabredung zwischen Programmen und kein Systemdienst; wer sie missachtet, sammelt fremde Passwörter ein. Der Gesichtsvergleich stand im Plan als der eine echte Unsicherheitsfaktor. Ein Spike hat ihn erledigt: `VNGenerateFaceprintRequest` gibt es nicht, aber `VNGenerateImageFeaturePrintRequest` ist öffentlich, liefert 768 Elemente und ein Abstandsmaß — Faktor 3,5 Trennschärfe. Kein eigenes CoreML-Modell nötig. `FaceMatcher` entscheidet über mehrere Posen, mehrere Einzelbilder und eine Sperre nach Fehlversuchen. Die Schwelle liegt bewusst nicht mittig: ein falsches Ja ist ein Fremder am entsperrten Rechner, ein falsches Nein nur ein Passwort, das man ohnehin kennt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
78 lines
2.6 KiB
Swift
78 lines
2.6 KiB
Swift
import Foundation
|
|
import Observation
|
|
import os
|
|
|
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Notes")
|
|
|
|
/// Die Notizen und ihre Ablage.
|
|
///
|
|
/// Eine Datei für alles: Notizen sind kurz, und fünfzig davon sind ein paar
|
|
/// Kilobyte. Eine Datei je Notiz wäre der Ordnung wegen — nicht der Sache.
|
|
///
|
|
/// **Nicht in `UserDefaults`.** Was dort steht, liest jedes Programm des
|
|
/// Benutzers mit `defaults read` mit, und eine Notiz ist oft genau das, was man
|
|
/// sich merkt, weil man es nicht im Kopf behalten will.
|
|
@MainActor
|
|
@Observable
|
|
public final class NotesStore {
|
|
|
|
public private(set) var notes: [Note] = []
|
|
|
|
@ObservationIgnored private let fileURL: URL
|
|
|
|
private struct Archive: Codable {
|
|
var version = 1
|
|
var notes: [Note]
|
|
}
|
|
|
|
public init(directory: URL? = nil) {
|
|
let folder = directory ?? Self.defaultDirectory
|
|
try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
|
fileURL = folder.appendingPathComponent("notes.json")
|
|
load()
|
|
}
|
|
|
|
public static var defaultDirectory: URL {
|
|
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
.appendingPathComponent("Onyx/Notes", isDirectory: true)
|
|
}
|
|
|
|
/// Legt eine leere Notiz an und gibt sie zurück, damit die Anzeige den
|
|
/// Schreibcursor hineinsetzen kann.
|
|
///
|
|
/// Sie steht noch **nicht** in der Liste: eine leere Notiz ist keine, und
|
|
/// wer auf „Neu" drückt und es sich anders überlegt, soll keine Leerzeile
|
|
/// hinterlassen. Erst der erste Buchstabe macht sie zur Notiz.
|
|
public func makeDraft() -> Note { Note() }
|
|
|
|
public func update(_ note: Note) {
|
|
var updated = note
|
|
updated.updatedAt = Date()
|
|
notes = NoteRules.merge(notes, updating: updated)
|
|
save()
|
|
}
|
|
|
|
public func remove(_ note: Note) {
|
|
notes.removeAll { $0.id == note.id }
|
|
save()
|
|
}
|
|
|
|
private func load() {
|
|
guard let data = try? Data(contentsOf: fileURL) else { return }
|
|
guard let archive = try? JSONDecoder().decode(Archive.self, from: data),
|
|
archive.version == 1 else {
|
|
log.notice("Unbekannte Fassung der Notizdatei — sie bleibt unangetastet")
|
|
return
|
|
}
|
|
notes = NoteRules.sorted(archive.notes)
|
|
}
|
|
|
|
private func save() {
|
|
do {
|
|
try JSONEncoder().encode(Archive(notes: notes)).write(to: fileURL, options: .atomic)
|
|
} catch {
|
|
log.error("Notizen nicht schreibbar: \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
}
|