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:
2026-07-14 20:30:46 -04:00
commit 9b231a1978
42 changed files with 2126 additions and 0 deletions
+97
View File
@@ -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) ?? ""
}
}
+85
View File
@@ -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 }
}
+59
View File
@@ -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
)
)
}
}
+79
View File
@@ -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)
}
}
}