Initial scaffold: context-aware notes app for iOS + macOS
XcodeGen project (Notes / NotesMac / NotesTests), Swift 6 strict concurrency, iCloud-document storage via IndieSync with a SwiftData cache, automatic capture context (location, place, time zone, device) on every note, context-based relevance ranking with a 'Right here, right now' shelf, tags, search, and the square.and.pencil-on-red app icon.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import IndieSync
|
||||
|
||||
/// The context snapshot captured at the moment a note is taken — the raw
|
||||
/// material the relevance ranking later matches against "where am I, what
|
||||
/// time is it" at recall time. Every field is optional: capture is
|
||||
/// best-effort and a note taken with location off is still a note.
|
||||
struct CaptureContext: Codable, Sendable, Equatable {
|
||||
var latitude: Double?
|
||||
var longitude: Double?
|
||||
var horizontalAccuracy: Double?
|
||||
|
||||
/// Point-of-interest or venue name from reverse geocoding ("Blue Bottle
|
||||
/// Coffee"). Stored as text so recall works even when geocoding is
|
||||
/// unavailable later.
|
||||
var placeName: String?
|
||||
var thoroughfare: String?
|
||||
var locality: String?
|
||||
var administrativeArea: String?
|
||||
var country: String?
|
||||
|
||||
/// Time zone the note was taken in; local hour / weekday are derived
|
||||
/// from `meta.createdAt` under this zone rather than stored.
|
||||
var timeZoneID: String?
|
||||
|
||||
/// "iPhone" / "Mac" — which device captured the note.
|
||||
var device: String?
|
||||
|
||||
static let empty = CaptureContext()
|
||||
|
||||
var hasCoordinate: Bool { latitude != nil && longitude != nil }
|
||||
|
||||
/// Short human summary for rows and the composer ("Blue Bottle Coffee · Oakland").
|
||||
var summary: String? {
|
||||
let parts = [placeName, locality].compactMap { $0 }
|
||||
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
/// One note = one JSON file in the iCloud container, filed under
|
||||
/// `Records/YYYY/MM/<ULID>.json` (UTC-bucketed by the ULID's timestamp).
|
||||
struct Note: SyncDocument, VersionedDocument, Equatable {
|
||||
static let currentSchemaVersion = 1
|
||||
|
||||
struct Meta: Codable, Sendable, Equatable {
|
||||
var id: ULID
|
||||
var schemaVersion: Int
|
||||
var createdAt: Date
|
||||
var modifiedAt: Date
|
||||
}
|
||||
|
||||
/// How the note's text came to be. `transcribed` is reserved for the
|
||||
/// planned voice-capture path.
|
||||
enum Source: String, Codable, Sendable {
|
||||
case typed
|
||||
case transcribed
|
||||
}
|
||||
|
||||
struct Payload: Codable, Sendable, Equatable {
|
||||
var text: String
|
||||
var source: Source
|
||||
var tags: [String]
|
||||
var context: CaptureContext
|
||||
}
|
||||
|
||||
var meta: Meta
|
||||
var payload: Payload
|
||||
|
||||
var id: ULID { meta.id }
|
||||
var schemaVersion: Int { meta.schemaVersion }
|
||||
|
||||
var relativePath: String { Self.relativePath(forID: meta.id) }
|
||||
|
||||
static func relativePath(forID id: ULID) -> String {
|
||||
TimeBucketedLayout.relativePath(for: id)
|
||||
}
|
||||
|
||||
static func create(
|
||||
text: String,
|
||||
source: Source = .typed,
|
||||
tags: [String] = [],
|
||||
context: CaptureContext
|
||||
) -> Note {
|
||||
let now = Date()
|
||||
return Note(
|
||||
meta: Meta(id: ULID(), schemaVersion: currentSchemaVersion, createdAt: now, modifiedAt: now),
|
||||
payload: Payload(text: text, source: source, tags: tags, context: context)
|
||||
)
|
||||
}
|
||||
|
||||
/// First line of the text, used as the display title.
|
||||
var title: String {
|
||||
payload.text
|
||||
.split(separator: "\n", omittingEmptySubsequences: true)
|
||||
.first.map(String.init) ?? ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Rebuildable cache row for one note. Holds nothing that isn't also, more
|
||||
/// authoritatively, in the note's JSON file — the metadata observer (via
|
||||
/// `NoteMapper`) is the only writer.
|
||||
@Model
|
||||
final class NoteEntity {
|
||||
@Attribute(.unique) var id: String = ""
|
||||
var text: String = ""
|
||||
var sourceRaw: String = Note.Source.typed.rawValue
|
||||
var tags: [String] = []
|
||||
var createdAt: Date = Date()
|
||||
var modifiedAt: Date = Date()
|
||||
|
||||
/// Back-pointer to the authoritative file, so a "removed" event can find
|
||||
/// the row to delete by path when it lacks the id.
|
||||
var jsonRelativePath: String = ""
|
||||
|
||||
// Flattened CaptureContext — @Model can't store a nested Codable struct
|
||||
// as one column; call sites use the `context` computed property instead.
|
||||
var latitude: Double?
|
||||
var longitude: Double?
|
||||
var horizontalAccuracy: Double?
|
||||
var placeName: String?
|
||||
var thoroughfare: String?
|
||||
var locality: String?
|
||||
var administrativeArea: String?
|
||||
var country: String?
|
||||
var timeZoneID: String?
|
||||
var device: String?
|
||||
|
||||
init(id: String, jsonRelativePath: String) {
|
||||
self.id = id
|
||||
self.jsonRelativePath = jsonRelativePath
|
||||
}
|
||||
}
|
||||
|
||||
extension NoteEntity {
|
||||
var context: CaptureContext {
|
||||
get {
|
||||
CaptureContext(
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
horizontalAccuracy: horizontalAccuracy,
|
||||
placeName: placeName,
|
||||
thoroughfare: thoroughfare,
|
||||
locality: locality,
|
||||
administrativeArea: administrativeArea,
|
||||
country: country,
|
||||
timeZoneID: timeZoneID,
|
||||
device: device
|
||||
)
|
||||
}
|
||||
set {
|
||||
latitude = newValue.latitude
|
||||
longitude = newValue.longitude
|
||||
horizontalAccuracy = newValue.horizontalAccuracy
|
||||
placeName = newValue.placeName
|
||||
thoroughfare = newValue.thoroughfare
|
||||
locality = newValue.locality
|
||||
administrativeArea = newValue.administrativeArea
|
||||
country = newValue.country
|
||||
timeZoneID = newValue.timeZoneID
|
||||
device = newValue.device
|
||||
}
|
||||
}
|
||||
|
||||
var source: Note.Source {
|
||||
Note.Source(rawValue: sourceRaw) ?? .typed
|
||||
}
|
||||
|
||||
/// First line of the text, used as the display title.
|
||||
var title: String {
|
||||
text.split(separator: "\n", omittingEmptySubsequences: true)
|
||||
.first.map(String.init) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
extension PersistentModel {
|
||||
/// Safe-to-read guard for objects reached through `@Query` or a
|
||||
/// relationship during a rebuild/sync burst. `isDeleted` alone only
|
||||
/// covers the unsaved window.
|
||||
var isLive: Bool { modelContext != nil && !isDeleted }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
import IndieSync
|
||||
import SwiftData
|
||||
|
||||
/// Document ↔ cache-entity mapping. Nothing else in the app knows both
|
||||
/// shapes. `upsert` is used only by the metadata observer and cache
|
||||
/// rebuilds — app code never writes the cache directly.
|
||||
enum NoteMapper {
|
||||
static func fetch(id: String, in context: ModelContext) -> NoteEntity? {
|
||||
var descriptor = FetchDescriptor<NoteEntity>(predicate: #Predicate { $0.id == id })
|
||||
descriptor.fetchLimit = 1
|
||||
return try? context.fetch(descriptor).first
|
||||
}
|
||||
|
||||
static func fetch(jsonRelativePath path: String, in context: ModelContext) -> [NoteEntity] {
|
||||
let descriptor = FetchDescriptor<NoteEntity>(predicate: #Predicate { $0.jsonRelativePath == path })
|
||||
return (try? context.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
static func upsert(_ note: Note, relativePath: String, into context: ModelContext) {
|
||||
let idString = note.meta.id.stringValue
|
||||
let entity: NoteEntity
|
||||
if let existing = fetch(id: idString, in: context) {
|
||||
entity = existing
|
||||
} else {
|
||||
entity = NoteEntity(id: idString, jsonRelativePath: relativePath)
|
||||
context.insert(entity)
|
||||
}
|
||||
// Always overwrite, not just on insert — the document just read is at
|
||||
// least as fresh as whatever the cache last held.
|
||||
entity.text = note.payload.text
|
||||
entity.sourceRaw = note.payload.source.rawValue
|
||||
entity.tags = note.payload.tags
|
||||
entity.createdAt = note.meta.createdAt
|
||||
entity.modifiedAt = note.meta.modifiedAt
|
||||
entity.jsonRelativePath = relativePath
|
||||
entity.context = note.payload.context
|
||||
}
|
||||
|
||||
/// Near-lossless reconstruction from the cache, used only as the offline
|
||||
/// fallback when the authoritative file can't be read.
|
||||
static func note(from entity: NoteEntity) -> Note? {
|
||||
guard let id = ULID(string: entity.id) else { return nil }
|
||||
return Note(
|
||||
meta: Note.Meta(
|
||||
id: id,
|
||||
schemaVersion: Note.currentSchemaVersion,
|
||||
createdAt: entity.createdAt,
|
||||
modifiedAt: entity.modifiedAt
|
||||
),
|
||||
payload: Note.Payload(
|
||||
text: entity.text,
|
||||
source: entity.source,
|
||||
tags: entity.tags,
|
||||
context: entity.context
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Creates the SwiftData cache container. The store is disposable: schema
|
||||
/// changes and iCloud account changes wipe it, and `SyncEngine` rebuilds it
|
||||
/// from the files (an empty cache is the entire contract between the two).
|
||||
enum NotesModelContainer {
|
||||
/// Bump whenever the cache schema changes — wipes and rebuilds from files.
|
||||
static let cacheSchemaVersion = 1
|
||||
private static let cacheSchemaVersionKey = "Notes.cacheSchemaVersion"
|
||||
private static let identityTokenKey = "Notes.iCloudIdentityToken"
|
||||
|
||||
private static var storeURL: URL {
|
||||
URL.applicationSupportDirectory.appending(path: "Notes.store")
|
||||
}
|
||||
|
||||
static func make(inMemory: Bool = false) -> ModelContainer {
|
||||
let schema = Schema([NoteEntity.self])
|
||||
|
||||
if !inMemory {
|
||||
try? FileManager.default.createDirectory(
|
||||
at: storeURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
wipeIfNeeded()
|
||||
wipeIfAccountChanged()
|
||||
|
||||
do {
|
||||
// `cloudKitDatabase: .none` is load-bearing: the parameter
|
||||
// defaults to `.automatic`, which — because the app is
|
||||
// iCloud-entitled — would silently mirror the cache to
|
||||
// CloudKit on top of the file-based sync.
|
||||
let config = ModelConfiguration(schema: schema, url: storeURL, cloudKitDatabase: .none)
|
||||
let container = try ModelContainer(for: schema, configurations: [config])
|
||||
UserDefaults.standard.set(cacheSchemaVersion, forKey: cacheSchemaVersionKey)
|
||||
return container
|
||||
} catch {
|
||||
print("Notes: ModelContainer creation failed at \(storeURL.path): \(error). Falling back to in-memory.")
|
||||
}
|
||||
}
|
||||
|
||||
// In-memory: screenshot/test mode, or last resort so the app still
|
||||
// launches (degraded, cache-only for the session) rather than crashing.
|
||||
do {
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)
|
||||
return try ModelContainer(for: schema, configurations: [config])
|
||||
} catch {
|
||||
fatalError("Notes: could not create even an in-memory ModelContainer: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func wipeIfNeeded() {
|
||||
let stored = UserDefaults.standard.integer(forKey: cacheSchemaVersionKey)
|
||||
guard stored < cacheSchemaVersion else { return }
|
||||
wipeStore()
|
||||
}
|
||||
|
||||
/// Wipes when the signed-in iCloud account's ubiquity identity token
|
||||
/// differs from the one the cache was built for. A nil token is not a
|
||||
/// change — it's legitimately nil for the same account early in launch.
|
||||
private static func wipeIfAccountChanged() {
|
||||
guard let current = currentIdentityTokenData() else { return }
|
||||
let stored = UserDefaults.standard.data(forKey: identityTokenKey)
|
||||
guard current != stored else { return }
|
||||
wipeStore()
|
||||
UserDefaults.standard.set(current, forKey: identityTokenKey)
|
||||
}
|
||||
|
||||
private static func currentIdentityTokenData() -> Data? {
|
||||
guard let token = FileManager.default.ubiquityIdentityToken else { return nil }
|
||||
return try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: false)
|
||||
}
|
||||
|
||||
/// Removes the on-disk store and its SQLite WAL/SHM sidecars. Safe
|
||||
/// because the store is a rebuildable cache.
|
||||
private static func wipeStore() {
|
||||
for url in [storeURL, URL(fileURLWithPath: storeURL.path + "-wal"), URL(fileURLWithPath: storeURL.path + "-shm")] {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user