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
+5
View File
@@ -0,0 +1,5 @@
build/
DerivedData/
*.xcodeproj
.DS_Store
xcuserdata/
+3
View File
@@ -0,0 +1,3 @@
**July 2026**
First version: take quick text notes that remember where and when they were taken, see the most relevant ones resurface when you return to the same place, organize with tags, search everything, and sync across iPhone and Mac through iCloud
+4
View File
@@ -0,0 +1,4 @@
Copyright © 2026 rzen. All rights reserved.
This software and its source code are proprietary. No permission is granted to
use, copy, modify, or distribute this software without prior written consent.
+24
View File
@@ -0,0 +1,24 @@
import Foundation
import SwiftData
/// Owns all service objects; created asynchronously at launch and injected
/// into the SwiftUI environment.
@Observable
@MainActor
final class AppServices {
let modelContainer: ModelContainer
let syncEngine: SyncEngine
let contextService: ContextService
init() async {
let container = NotesModelContainer.make()
self.modelContainer = container
self.syncEngine = SyncEngine(modelContainer: container)
self.contextService = ContextService()
}
/// Slow startup work (iCloud container discovery) runs after the UI is up.
func startDeferredServices() async {
await syncEngine.connect()
}
}
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.282",
"green" : "0.208",
"red" : "0.886"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,74 @@
{
"images": [
{
"filename": "icon-ios-1024.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"filename": "icon-mac-16.png",
"idiom": "mac",
"size": "16x16",
"scale": "1x"
},
{
"filename": "[email protected]",
"idiom": "mac",
"size": "16x16",
"scale": "2x"
},
{
"filename": "icon-mac-32.png",
"idiom": "mac",
"size": "32x32",
"scale": "1x"
},
{
"filename": "[email protected]",
"idiom": "mac",
"size": "32x32",
"scale": "2x"
},
{
"filename": "icon-mac-128.png",
"idiom": "mac",
"size": "128x128",
"scale": "1x"
},
{
"filename": "[email protected]",
"idiom": "mac",
"size": "128x128",
"scale": "2x"
},
{
"filename": "icon-mac-256.png",
"idiom": "mac",
"size": "256x256",
"scale": "1x"
},
{
"filename": "[email protected]",
"idiom": "mac",
"size": "256x256",
"scale": "2x"
},
{
"filename": "icon-mac-512.png",
"idiom": "mac",
"size": "512x512",
"scale": "1x"
},
{
"filename": "[email protected]",
"idiom": "mac",
"size": "512x512",
"scale": "2x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 529 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

+1
View File
@@ -0,0 +1 @@
{"info":{"version":1,"author":"xcode"}}
+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)
}
}
}
+57
View File
@@ -0,0 +1,57 @@
import SwiftData
import SwiftUI
@main
struct NotesApp: App {
@State private var appServices: AppServices?
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
Group {
if let appServices {
ContentView()
.modelContainer(appServices.modelContainer)
.environment(appServices.syncEngine)
.environment(appServices.contextService)
} else {
ProgressView()
}
}
.task {
if appServices == nil {
let services = await AppServices()
appServices = services
await services.startDeferredServices()
}
}
.onChange(of: scenePhase) { _, phase in
guard phase == .active, let appServices else { return }
let syncEngine = appServices.syncEngine
let contextService = appServices.contextService
Task {
// Fresh context whenever the user comes back to the app
// recall ranking should reflect where they are right now.
await contextService.refresh()
}
Task {
switch syncEngine.iCloudStatus {
case .unavailable:
// The user typically fixes iCloud in Settings and
// switches straight back retry on foreground.
await syncEngine.connect()
case .available:
// Pull in records that synced down (and drop ones
// deleted elsewhere) while the app was backgrounded.
await syncEngine.reconcile()
case .checking:
break
}
}
}
}
#if os(macOS)
.defaultSize(width: 900, height: 700)
#endif
}
}
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Notes</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Notes remembers where each note was taken, so the right notes can resurface when you return to the same place.</string>
<key>NSUbiquitousContainers</key>
<dict>
<key>iCloud.dev.rzen.indie.Notes</key>
<dict>
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
<true/>
<key>NSUbiquitousContainerSupportedFolderLevels</key>
<string>Any</string>
<key>NSUbiquitousContainerName</key>
<string>Notes</string>
</dict>
</dict>
</dict>
</plist>
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Notes</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Notes remembers where each note was taken, so the right notes can resurface when you return to the same place.</string>
<key>NSLocationUsageDescription</key>
<string>Notes remembers where each note was taken, so the right notes can resurface when you return to the same place.</string>
<key>NSUbiquitousContainers</key>
<dict>
<key>iCloud.dev.rzen.indie.Notes</key>
<dict>
<key>NSUbiquitousContainerIsDocumentScopePublic</key>
<true/>
<key>NSUbiquitousContainerSupportedFolderLevels</key>
<string>Any</string>
<key>NSUbiquitousContainerName</key>
<string>Notes</string>
</dict>
</dict>
</dict>
</plist>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.dev.rzen.indie.Notes</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudDocuments</string>
</array>
<key>com.apple.developer.ubiquity-container-identifiers</key>
<array>
<string>iCloud.dev.rzen.indie.Notes</string>
</array>
</dict>
</plist>
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.dev.rzen.indie.Notes</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudDocuments</string>
</array>
<key>com.apple.developer.ubiquity-container-identifiers</key>
<array>
<string>iCloud.dev.rzen.indie.Notes</string>
</array>
</dict>
</plist>
+96
View File
@@ -0,0 +1,96 @@
import CoreLocation
import Foundation
import MapKit
/// Captures the "where am I, what time is it" snapshot used both when a
/// note is taken (stamped into the note) and when notes are recalled (fed to
/// the relevance ranking). Location is best-effort: denied or unavailable
/// location still yields a usable context (time zone, device).
@Observable
@MainActor
final class ContextService {
/// Latest context snapshot. Updated by `refresh()`.
private(set) var current: CaptureContext = .empty
/// Raw location behind `current`, for distance math at recall time.
private(set) var location: CLLocation?
private(set) var isRefreshing = false
private(set) var authorizationDenied = false
init() {
current.device = Self.deviceName
current.timeZoneID = TimeZone.current.identifier
}
static var deviceName: String {
#if os(macOS)
"Mac"
#else
"iPhone"
#endif
}
/// One-shot refresh: grab a current location (asking for when-in-use
/// authorization if needed), then reverse-geocode it into place names.
/// Bounded gives up after ~10 seconds rather than spinning.
func refresh() async {
guard !isRefreshing else { return }
isRefreshing = true
defer { isRefreshing = false }
current.device = Self.deviceName
current.timeZoneID = TimeZone.current.identifier
guard let location = await acquireLocation() else { return }
self.location = location
current.latitude = location.coordinate.latitude
current.longitude = location.coordinate.longitude
current.horizontalAccuracy = location.horizontalAccuracy
await reverseGeocode(location)
}
/// First fix from `CLLocationUpdate.liveUpdates()`, raced against a
/// 10-second deadline. `CLServiceSession` holds the when-in-use
/// authorization request open for the duration.
private func acquireLocation() async -> CLLocation? {
// CLServiceSession is unavailable on macOS; there liveUpdates itself
// triggers the authorization prompt.
#if os(iOS)
let session = CLServiceSession(authorization: .whenInUse)
defer { session.invalidate() }
#endif
let fix = Task {
for try await update in CLLocationUpdate.liveUpdates() {
if update.authorizationDenied || update.authorizationDeniedGlobally {
await MainActor.run { self.authorizationDenied = true }
return nil as CLLocation?
}
if let location = update.location {
return location
}
}
return nil
}
let deadline = Task {
try? await Task.sleep(for: .seconds(10))
fix.cancel()
}
defer { deadline.cancel() }
let location = (try? await fix.value) ?? nil
if location != nil { authorizationDenied = false }
return location
}
private func reverseGeocode(_ location: CLLocation) async {
guard let request = MKReverseGeocodingRequest(location: location) else { return }
guard let item = try? await request.mapItems.first else { return }
current.placeName = item.name
current.thoroughfare = item.address?.shortAddress
current.locality = item.addressRepresentations?.cityName
}
}
+130
View File
@@ -0,0 +1,130 @@
import CoreLocation
import Foundation
/// The signals relevance scoring reads from a note. A plain value type so
/// scoring is testable without SwiftData.
struct RelevanceSignals: Sendable, Equatable {
var latitude: Double?
var longitude: Double?
var placeName: String?
var locality: String?
var createdAt: Date
var timeZoneID: String?
}
/// The recall-time context a query scores against.
struct RelevanceQuery: Sendable {
var latitude: Double?
var longitude: Double?
var placeName: String?
var locality: String?
var date: Date = Date()
var timeZone: TimeZone = .current
}
/// Context-based ranking: given "where am I, what time is it" now, how likely
/// is this note the one the user wants at their fingertips?
///
/// The score is a sum of independent affinity terms, each decaying smoothly
/// from its ideal (same spot, same hour, just written). Weights are tuned so
/// physical proximity dominates being back at the coffee shop should
/// surface the barista's name even if the note is months old.
enum Relevance {
/// Score at or above which a note belongs in the "right here, right now"
/// shelf.
static let surfacingThreshold = 1.0
static func score(_ note: RelevanceSignals, query: RelevanceQuery) -> Double {
var score = 0.0
// Proximity: exponential decay with ~250 m scale full weight inside
// a venue, negligible a few kilometers away.
if let noteLat = note.latitude, let noteLon = note.longitude,
let hereLat = query.latitude, let hereLon = query.longitude {
let meters = CLLocation(latitude: noteLat, longitude: noteLon)
.distance(from: CLLocation(latitude: hereLat, longitude: hereLon))
score += 3.0 * exp(-meters / 250)
}
// Same named place (exact, case-insensitive) reverse geocoding is
// stable enough that this is a strong "you are back here" signal even
// when GPS wobbles.
if let notePlace = note.placeName, let herePlace = query.placeName,
!notePlace.isEmpty, notePlace.caseInsensitiveCompare(herePlace) == .orderedSame {
score += 1.5
} else if let noteCity = note.locality, let hereCity = query.locality,
!noteCity.isEmpty, noteCity.caseInsensitiveCompare(hereCity) == .orderedSame {
score += 0.3
}
// Time-of-day affinity: Gaussian over circular hour distance
// (σ 2.5 h) a "morning" note resurfaces in the morning.
let noteHour = localHour(of: note.createdAt, timeZoneID: note.timeZoneID)
let queryHour = Double(Calendar.current.component(.hour, from: query.date))
+ Double(Calendar.current.component(.minute, from: query.date)) / 60
let hourDelta = circularHourDistance(noteHour, queryHour)
score += 0.6 * exp(-(hourDelta * hourDelta) / (2 * 2.5 * 2.5))
// Same weekday: weak habitual signal (the Saturday farmers market).
var noteCalendar = Calendar.current
if let id = note.timeZoneID, let zone = TimeZone(identifier: id) {
noteCalendar.timeZone = zone
}
var queryCalendar = Calendar.current
queryCalendar.timeZone = query.timeZone
if noteCalendar.component(.weekday, from: note.createdAt)
== queryCalendar.component(.weekday, from: query.date) {
score += 0.2
}
// Recency: fresh notes float regardless of place (~45-day half-life
// feel; purely a tiebreaker against the location terms).
let ageDays = max(0, query.date.timeIntervalSince(note.createdAt)) / 86_400
score += 0.5 * exp(-ageDays / 45)
return score
}
/// Hour-of-day (fractional) of `date` in the zone the note was taken in,
/// falling back to the current zone.
private static func localHour(of date: Date, timeZoneID: String?) -> Double {
var calendar = Calendar.current
if let timeZoneID, let zone = TimeZone(identifier: timeZoneID) {
calendar.timeZone = zone
}
let hour = calendar.component(.hour, from: date)
let minute = calendar.component(.minute, from: date)
return Double(hour) + Double(minute) / 60
}
/// Distance between two hours on the 24-hour circle (0...12).
private static func circularHourDistance(_ a: Double, _ b: Double) -> Double {
let diff = abs(a - b).truncatingRemainder(dividingBy: 24)
return min(diff, 24 - diff)
}
}
extension RelevanceSignals {
init(entity: NoteEntity) {
self.init(
latitude: entity.latitude,
longitude: entity.longitude,
placeName: entity.placeName,
locality: entity.locality,
createdAt: entity.createdAt,
timeZoneID: entity.timeZoneID
)
}
}
extension RelevanceQuery {
@MainActor
init(context service: ContextService) {
self.init(
latitude: service.current.latitude,
longitude: service.current.longitude,
placeName: service.current.placeName,
locality: service.current.locality
)
}
}
+421
View File
@@ -0,0 +1,421 @@
import Foundation
import IndieSync
import SwiftData
enum ICloudStatus: Equatable, Sendable {
case checking
case available
case unavailable
}
/// Orchestrates iCloud JSON files (the sole source of truth) and the
/// SwiftData cache. Also exposes the domain operations the UI calls: create,
/// update, delete a note.
///
/// The file layer lives in the IndieSync package: `DocumentFileStore` for
/// coordinated, conflict-resolved, eviction-safe I/O; `TombstoneStore` for
/// soft-delete stubs; `MetadataObserver` for change events. Data flows one
/// way writes touch files only; the observers are the sole writers of the
/// cache, so a local save reaches it through the same pipeline as a change
/// from another device.
///
/// Layout inside the container (record paths in this engine are
/// Records-relative and get prefixed via `dataPath(_:)` at the store
/// boundary):
/// ```
/// Documents/
/// Records/YYYY/MM/<ULID>.json
/// Stubs/YYYY/MM/<ULID>.json soft-delete markers
/// ```
@Observable
@MainActor
final class SyncEngine {
private(set) var iCloudStatus: ICloudStatus = .checking
private(set) var isSyncing = false
/// Last non-fatal sync failure, surfaced for the UI to render. Clears on
/// the next successful write or rebuild.
private(set) var lastSyncError: String?
static let recordsDirectory = "Records"
static let stubsDirectory = "Stubs"
/// Store-rooted path of a record (record paths are Records-relative
/// everywhere inside this engine).
static func dataPath(_ recordsRelativePath: String) -> String {
"\(recordsDirectory)/\(recordsRelativePath)"
}
private let modelContainer: ModelContainer
private var store: DocumentFileStore?
private var tombstones: TombstoneStore?
private var dataMonitor: MetadataObserver?
private var stubMonitor: MetadataObserver?
private var dataMonitorTask: Task<Void, Never>?
private var stubMonitorTask: Task<Void, Never>?
private var cacheContext: ModelContext { modelContainer.mainContext }
init(modelContainer: ModelContainer) {
self.modelContainer = modelContainer
}
// MARK: - Connection
private var connectAttempt = 0
/// Resolves the ubiquity container and connects. Safe to call repeatedly
/// the gate's "Try Again" button and foreground reactivation reuse it.
func connect() async {
guard iCloudStatus != .available else { return }
connectAttempt += 1
let attempt = connectAttempt
iCloudStatus = .checking
// Blocking call never on the main thread.
let url = await Task.detached {
FileManager.default.url(forUbiquityContainerIdentifier: nil)
}.value
guard let containerURL = url else {
if attempt == connectAttempt {
iCloudStatus = .unavailable
}
return
}
let store = DocumentFileStore(
root: containerURL.appendingPathComponent("Documents", isDirectory: true))
// File ops inside the container can hang indefinitely while the
// iCloud account is in a bad state flip the gate to "unavailable"
// after a timeout, but let the attempt keep running and finish
// connecting if it eventually succeeds.
let timeout = Task { [weak self] in
try? await Task.sleep(for: .seconds(20))
guard let self, !Task.isCancelled else { return }
if self.iCloudStatus == .checking, attempt == self.connectAttempt {
self.iCloudStatus = .unavailable
}
}
await store.prepareDirectories([Self.recordsDirectory, Self.stubsDirectory])
timeout.cancel()
guard attempt == connectAttempt else { return } // superseded by a retry
self.store = store
self.tombstones = TombstoneStore(store: store, stubsDirectory: Self.stubsDirectory)
iCloudStatus = .available
startMonitoring()
await syncCacheOnConnect()
performLaunchMaintenance()
}
// MARK: - Domain operations
@discardableResult
func createNote(
text: String,
source: Note.Source = .typed,
tags: [String] = [],
context: CaptureContext
) async throws -> Note {
let note = Note.create(text: text, source: source, tags: tags, context: context)
try await save(note)
return note
}
func updateNote(_ note: Note) async throws {
var updated = note
updated.meta.modifiedAt = Date()
try await save(updated)
}
func deleteNote(id: ULID) async throws {
// Without the file layer only the cache row could go and the iCloud
// file would re-import on the next rebuild, resurrecting the note.
// Refuse instead.
guard let tombstones else {
report("Delete failed — iCloud not connected")
throw SyncError.iCloudUnavailable
}
let path = Note.relativePath(forID: id)
try await tombstones.softDelete(dataRelativePath: Self.dataPath(path), at: path)
lastSyncError = nil
// Cache cleanup happens via the monitors (file removal + stub
// appearance both drop the row), but do it eagerly too so the UI
// updates instantly even while iCloud event delivery lags.
removeFromCache(jsonRelativePath: path)
}
/// Reads the JSON file the authority so read-after-write flows never
/// act on a stale cache. Falls back to the near-lossless cache copy only
/// if the file is unreadable (offline or not yet downloaded).
func loadNote(id: ULID) async -> Note? {
let path = Note.relativePath(forID: id)
if let store,
let note = try? await store.read(Note.self, from: Self.dataPath(path)),
note.isReadable {
return migrateIfNeeded(note)
}
guard let entity = NoteMapper.fetch(id: id.stringValue, in: cacheContext) else { return nil }
return NoteMapper.note(from: entity)
}
// MARK: - Persistence plumbing
/// Writes touch the file layer ONLY. iCloud JSON is the source of truth:
/// with no file store the write would exist nowhere durable (a cache-only
/// copy is wiped by the next rebuild), so refuse the save. The UI gate is
/// a courtesy; this throw is the invariant.
private func save(_ note: Note) async throws {
guard let store else {
report("Note not saved — iCloud not connected")
throw SyncError.iCloudUnavailable
}
do {
_ = try await store.write(note, to: Self.dataPath(note.relativePath))
} catch {
report("Failed to save note", error)
throw error
}
lastSyncError = nil
}
/// Record a non-fatal sync failure: log it and publish it as
/// `lastSyncError` for the UI to render.
private func report(_ message: String, _ error: Error? = nil) {
let full = error.map { "\(message): \($0.localizedDescription)" } ?? message
print("[Sync] \(full)")
lastSyncError = full
}
private func removeFromCache(jsonRelativePath: String) {
let entities = NoteMapper.fetch(jsonRelativePath: jsonRelativePath, in: cacheContext)
guard !entities.isEmpty else { return }
entities.forEach(cacheContext.delete)
try? cacheContext.save()
}
private func removeFromCache(ids: Set<String>) {
for id in ids {
if let entity = NoteMapper.fetch(id: id, in: cacheContext) {
cacheContext.delete(entity)
}
}
try? cacheContext.save()
}
// MARK: - Monitoring
private func startMonitoring() {
guard let store else { return }
// Idempotent: tear down any existing monitors first so a reconnect
// can't leave two live NSMetadataQuery monitors feeding duplicate events.
stopMonitoring()
let recordsURL = store.rootURL.appendingPathComponent(Self.recordsDirectory, isDirectory: true)
let dataMonitor = MetadataObserver(documentsURL: recordsURL)
self.dataMonitor = dataMonitor
dataMonitor.start()
dataMonitorTask = Task { [weak self] in
for await batch in dataMonitor.events() {
for event in batch {
await self?.handleFileEvent(event)
}
}
}
let stubsURL = store.rootURL.appendingPathComponent(Self.stubsDirectory, isDirectory: true)
let stubMonitor = MetadataObserver(documentsURL: stubsURL)
self.stubMonitor = stubMonitor
stubMonitor.start()
stubMonitorTask = Task { [weak self] in
for await batch in stubMonitor.events() {
for event in batch {
if case .added(let path) = event {
await self?.handleStubAdded(relativePath: path)
}
}
}
}
}
private func stopMonitoring() {
dataMonitorTask?.cancel()
stubMonitorTask?.cancel()
dataMonitorTask = nil
stubMonitorTask = nil
dataMonitor?.stop()
stubMonitor?.stop()
dataMonitor = nil
stubMonitor = nil
}
private func handleFileEvent(_ event: FileChangeEvent) async {
guard let store, let tombstones else { return }
switch event {
case .added(let path), .modified(let path):
// Match stubs by document id, not path equality, so an evicted
// stub still vetoes resurrection.
let id = DocumentFileStore.documentID(fromRelativePath: path)
if await tombstones.stubExists(id: id) {
try? await store.remove(at: Self.dataPath(path))
return
}
await importRecord(at: path, using: store)
case .removed(let path):
removeFromCache(jsonRelativePath: path)
}
}
private func handleStubAdded(relativePath: String) async {
if let store {
try? await store.remove(at: Self.dataPath(relativePath))
}
removeFromCache(jsonRelativePath: relativePath)
}
private func importRecord(at path: String, using store: DocumentFileStore) async {
// The store's read resolves conflicts and force-downloads evicted
// files (bounded wait) before the coordinated read.
guard let note = try? await store.read(Note.self, from: Self.dataPath(path)) else { return }
// Forward gate: a document written by a newer app version is
// quarantined Codable would silently drop its unknown fields.
guard note.isReadable else {
print("[Sync] Skipping \(path) — schema \(note.schemaVersion) is newer than this app understands")
return
}
NoteMapper.upsert(migrateIfNeeded(note), relativePath: path, into: cacheContext)
try? cacheContext.save()
}
// MARK: - Cache rebuild / reconcile
/// Bring the cache in line with iCloud on connect: full rebuild when the
/// cache is empty (first launch, post-wipe), incremental reconcile
/// otherwise records synced down while the app was closed must appear.
private func syncCacheOnConnect() async {
guard store != nil else { return }
let count = (try? cacheContext.fetchCount(FetchDescriptor<NoteEntity>())) ?? 0
if count == 0 {
await rebuildCache()
} else {
await reconcile()
}
}
/// Diff iCloud's files against the cache and settle the difference:
/// import records that synced down while the app was closed/backgrounded,
/// drop rows whose file was deleted elsewhere, and clean up files a stub
/// has since tombstoned. Runs on every connect and foreground return.
func reconcile() async {
guard let store, let tombstones else { return }
let filePaths = await listRecordPaths(in: store)
let stubIDs = await tombstones.listStubIDs()
var livePathsByID: [String: String] = [:]
var orphanedPaths: [String] = []
for path in filePaths {
let id = DocumentFileStore.documentID(fromRelativePath: path)
if stubIDs.contains(id) {
orphanedPaths.append(path)
} else {
livePathsByID[id] = path
}
}
var descriptor = FetchDescriptor<NoteEntity>()
descriptor.propertiesToFetch = [\.id]
let cachedIDs = Set(((try? cacheContext.fetch(descriptor)) ?? []).map(\.id))
let toImport = Set(livePathsByID.keys).subtracting(cachedIDs)
let toRemove = cachedIDs.subtracting(livePathsByID.keys)
guard !toImport.isEmpty || !toRemove.isEmpty || !orphanedPaths.isEmpty else {
return // already in sync the common foreground case, kept cheap
}
print("[Sync] Reconcile: +\(toImport.count)/-\(toRemove.count) notes, \(orphanedPaths.count) orphaned files")
isSyncing = true
defer { isSyncing = false }
// Clean up files a stub has tombstoned (mirrors the rebuild path).
for path in orphanedPaths {
try? await store.remove(at: Self.dataPath(path))
}
removeFromCache(ids: toRemove)
// Import the missing records through the same conflict-resolved,
// eviction-safe, forward-gated path the monitors use.
for id in toImport.sorted() {
guard let path = livePathsByID[id] else { continue }
await importRecord(at: path, using: store)
}
}
func rebuildCache() async {
guard let store, let tombstones else { return }
isSyncing = true
defer { isSyncing = false }
await store.downloadEvictedFiles(inSubdirectory: Self.recordsDirectory)
for _ in 0..<30 {
if await store.countEvictedFiles(inSubdirectory: Self.recordsDirectory) == 0 { break }
try? await Task.sleep(for: .seconds(2))
}
do {
try cacheContext.delete(model: NoteEntity.self)
try cacheContext.save()
} catch {
report("Cache rebuild failed", error)
return
}
let stubIDs = await tombstones.listStubIDs()
for path in await listRecordPaths(in: store) {
if stubIDs.contains(DocumentFileStore.documentID(fromRelativePath: path)) {
try? await store.remove(at: Self.dataPath(path))
continue
}
await importRecord(at: path, using: store)
}
lastSyncError = nil
}
/// Records-relative paths of all live record files (placeholder-aware
/// includes evicted iCloud-only files).
private func listRecordPaths(in store: DocumentFileStore) async -> [String] {
let prefix = Self.recordsDirectory + "/"
return await store.list(inSubdirectory: Self.recordsDirectory)
.map { String($0.dropFirst(prefix.count)) }
.sorted()
}
// MARK: - Maintenance
private func performLaunchMaintenance() {
Task(priority: .utility) { [tombstones] in
_ = try? await tombstones?.prune()
}
}
// MARK: - Schema migration
/// Read-time upgrade for documents behind the current schema. A no-op
/// while `currentSchemaVersion` is 1, but wired into both read paths so
/// the first schema bump migrates instead of silently skipping. Write-back
/// happens lazily on the next save never from live monitor events.
private func migrateIfNeeded(_ note: Note) -> Note {
guard note.meta.schemaVersion < Note.currentSchemaVersion else { return note }
var migrated = note
migrated.meta.schemaVersion = Note.currentSchemaVersion
return migrated
}
}
+25
View File
@@ -0,0 +1,25 @@
import SwiftUI
struct ContentView: View {
@Environment(SyncEngine.self) private var syncEngine
var body: some View {
if syncEngine.iCloudStatus == .unavailable {
ICloudGateView()
} else {
TabView {
Tab("Notes", systemImage: "square.and.pencil") {
NotesListView()
}
Tab("Tags", systemImage: "tag") {
TagsView()
}
Tab("Settings", systemImage: "gearshape") {
SettingsView()
}
}
}
}
}
+28
View File
@@ -0,0 +1,28 @@
import SwiftUI
/// Blocking screen when the ubiquity container can't be obtained. The app
/// deliberately has no local-only fallback notes written without iCloud
/// would exist nowhere durable and be wiped by the next cache rebuild.
struct ICloudGateView: View {
@Environment(SyncEngine.self) private var syncEngine
var body: some View {
VStack(spacing: 16) {
Image(systemName: "icloud.slash")
.font(.system(size: 56))
.foregroundStyle(.secondary)
Text("iCloud Required")
.font(.title2.bold())
Text("Notes stores everything in your iCloud Drive so it's on all your devices. Sign in to iCloud and enable iCloud Drive for Notes in Settings, then try again.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 420)
Button("Try Again") {
Task { await syncEngine.connect() }
}
.buttonStyle(.borderedProminent)
}
.padding(32)
}
}
+155
View File
@@ -0,0 +1,155 @@
import IndieSync
import SwiftUI
/// Composer for a new note and editor for an existing one. On create it
/// snapshots the current context (refreshing location in the background);
/// on edit the original capture context is preserved untouched.
struct NoteEditorView: View {
enum Mode {
case create
case edit(NoteEntity)
}
let mode: Mode
@Environment(SyncEngine.self) private var syncEngine
@Environment(ContextService.self) private var contextService
@Environment(\.dismiss) private var dismiss
@State private var text = ""
@State private var tagsText = ""
@State private var saveError: String?
@FocusState private var textFocused: Bool
var body: some View {
NavigationStack {
Form {
Section {
TextEditor(text: $text)
.focused($textFocused)
.frame(minHeight: 160)
}
Section("Tags") {
TextField("coffee, people, ideas…", text: $tagsText)
.autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
}
Section("Context") {
if isCreating {
ContextSummaryRow(
context: contextService.current,
isRefreshing: contextService.isRefreshing
)
} else if case .edit(let note) = mode {
ContextSummaryRow(context: note.context, isRefreshing: false)
}
}
if let saveError {
Section {
Label(saveError, systemImage: "exclamationmark.icloud")
.foregroundStyle(.red)
.font(.footnote)
}
}
}
.formStyle(.grouped)
.navigationTitle(isCreating ? "New Note" : "Edit Note")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() }
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
.task {
switch mode {
case .create:
textFocused = true
await contextService.refresh()
case .edit(let note):
text = note.text
tagsText = note.tags.joined(separator: ", ")
}
}
#if os(macOS)
.frame(minWidth: 480, minHeight: 420)
#endif
}
private var isCreating: Bool {
if case .create = mode { return true }
return false
}
private var parsedTags: [String] {
tagsText
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
.filter { !$0.isEmpty }
}
private func save() {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
Task {
do {
switch mode {
case .create:
try await syncEngine.createNote(
text: trimmed,
tags: parsedTags,
context: contextService.current
)
case .edit(let entity):
guard let id = ULID(string: entity.id),
var note = await syncEngine.loadNote(id: id) else { return }
note.payload.text = trimmed
note.payload.tags = parsedTags
try await syncEngine.updateNote(note)
}
dismiss()
} catch {
saveError = "Couldn't save: \(error.localizedDescription)"
}
}
}
}
/// One-line "where and when" readout shown in the composer, so the user sees
/// what context is being stamped onto the note.
struct ContextSummaryRow: View {
let context: CaptureContext
let isRefreshing: Bool
var body: some View {
HStack {
Image(systemName: context.hasCoordinate ? "location.fill" : "location.slash")
.foregroundStyle(context.hasCoordinate ? Color.accentColor : .secondary)
if let summary = context.summary {
Text(summary)
} else if isRefreshing {
Text("Finding where you are…")
.foregroundStyle(.secondary)
} else if context.hasCoordinate {
Text("Location captured")
} else {
Text("No location")
.foregroundStyle(.secondary)
}
Spacer()
if isRefreshing {
ProgressView()
.controlSize(.small)
}
}
.font(.callout)
}
}
+32
View File
@@ -0,0 +1,32 @@
import SwiftUI
struct NoteRowView: View {
let note: NoteEntity
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(note.title.isEmpty ? "Untitled" : note.title)
.font(.body.weight(.medium))
.lineLimit(2)
HStack(spacing: 4) {
Text(note.createdAt, format: .dateTime.day().month().year())
if let place = note.context.summary {
Text("·")
Text(place)
.lineLimit(1)
}
}
.font(.caption)
.foregroundStyle(.secondary)
if !note.tags.isEmpty {
Text(note.tags.map { "#\($0)" }.joined(separator: " "))
.font(.caption)
.foregroundStyle(.tint)
.lineLimit(1)
}
}
.padding(.vertical, 2)
}
}
+117
View File
@@ -0,0 +1,117 @@
import IndieSync
import SwiftData
import SwiftUI
/// The main list: a "Right here, right now" shelf of context-relevant notes
/// on top, then everything else newest-first. Search filters text and tags.
struct NotesListView: View {
@Environment(SyncEngine.self) private var syncEngine
@Environment(ContextService.self) private var contextService
@Query(sort: \NoteEntity.createdAt, order: .reverse) private var notes: [NoteEntity]
@State private var searchText = ""
@State private var composing = false
@State private var editingNote: NoteEntity?
var body: some View {
NavigationStack {
List {
if let error = syncEngine.lastSyncError {
Section {
Label(error, systemImage: "exclamationmark.icloud")
.foregroundStyle(.red)
.font(.footnote)
}
}
if searchText.isEmpty, !relevantNotes.isEmpty {
Section("Right here, right now") {
ForEach(relevantNotes) { note in
row(note)
}
}
}
Section(searchText.isEmpty ? "All notes" : "Results") {
ForEach(filteredNotes) { note in
row(note)
}
}
}
.navigationTitle("Notes")
.searchable(text: $searchText, prompt: "Search notes and tags")
.overlay {
if notes.isEmpty {
ContentUnavailableView(
"No notes yet",
systemImage: "square.and.pencil",
description: Text("Jot something down — Notes remembers where and when, so it can resurface at the right moment.")
)
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
composing = true
} label: {
Label("New Note", systemImage: "square.and.pencil")
}
.keyboardShortcut("n", modifiers: .command)
}
}
.sheet(isPresented: $composing) {
NoteEditorView(mode: .create)
}
.sheet(item: $editingNote) { note in
NoteEditorView(mode: .edit(note))
}
}
}
private func row(_ note: NoteEntity) -> some View {
Button {
editingNote = note
} label: {
NoteRowView(note: note)
}
.buttonStyle(.plain)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
delete(note)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
/// Top context matches notes whose capture context resembles "here and
/// now" strongly enough to earn the shelf.
private var relevantNotes: [NoteEntity] {
let query = RelevanceQuery(context: contextService)
return notes
.filter { $0.isLive }
.map { (note: $0, score: Relevance.score(RelevanceSignals(entity: $0), query: query)) }
.filter { $0.score >= Relevance.surfacingThreshold }
.sorted { $0.score > $1.score }
.prefix(5)
.map(\.note)
}
private var filteredNotes: [NoteEntity] {
guard !searchText.isEmpty else { return notes }
let needle = searchText.lowercased()
return notes.filter { note in
note.text.lowercased().contains(needle)
|| note.tags.contains { $0.lowercased().contains(needle) }
|| (note.placeName?.lowercased().contains(needle) ?? false)
|| (note.locality?.lowercased().contains(needle) ?? false)
}
}
private func delete(_ note: NoteEntity) {
guard let id = ULID(string: note.id) else { return }
Task {
try? await syncEngine.deleteNote(id: id)
}
}
}
+56
View File
@@ -0,0 +1,56 @@
import SwiftUI
struct SettingsView: View {
@Environment(SyncEngine.self) private var syncEngine
@Environment(ContextService.self) private var contextService
var body: some View {
NavigationStack {
Form {
Section("iCloud") {
HStack {
Text("Status")
Spacer()
switch syncEngine.iCloudStatus {
case .checking:
Text("Checking…").foregroundStyle(.secondary)
case .available:
Label("Connected", systemImage: "checkmark.icloud")
.foregroundStyle(.green)
case .unavailable:
Label("Unavailable", systemImage: "xmark.icloud")
.foregroundStyle(.red)
}
}
if let error = syncEngine.lastSyncError {
Text(error)
.font(.footnote)
.foregroundStyle(.red)
}
Button("Rebuild Local Cache") {
Task { await syncEngine.rebuildCache() }
}
.disabled(syncEngine.iCloudStatus != .available || syncEngine.isSyncing)
}
Section("Context") {
ContextSummaryRow(
context: contextService.current,
isRefreshing: contextService.isRefreshing
)
if contextService.authorizationDenied {
Text("Location access is off. Notes can still be taken, but they won't resurface by place. Enable location for Notes in Settings.")
.font(.footnote)
.foregroundStyle(.secondary)
}
Button("Refresh Context") {
Task { await contextService.refresh() }
}
.disabled(contextService.isRefreshing)
}
}
.formStyle(.grouped)
.navigationTitle("Settings")
}
}
}
+75
View File
@@ -0,0 +1,75 @@
import SwiftData
import SwiftUI
/// Browse notes by tag the manual-organization counterpart to the
/// automatic context ranking.
struct TagsView: View {
@Query(sort: \NoteEntity.createdAt, order: .reverse) private var notes: [NoteEntity]
var body: some View {
NavigationStack {
List {
ForEach(tagCounts, id: \.tag) { entry in
NavigationLink(value: entry.tag) {
HStack {
Label(entry.tag, systemImage: "tag")
Spacer()
Text("\(entry.count)")
.foregroundStyle(.secondary)
.monospacedDigit()
}
}
}
}
.navigationTitle("Tags")
.navigationDestination(for: String.self) { tag in
TaggedNotesView(tag: tag)
}
.overlay {
if tagCounts.isEmpty {
ContentUnavailableView(
"No tags yet",
systemImage: "tag",
description: Text("Add tags when writing a note to organize by topic.")
)
}
}
}
}
private var tagCounts: [(tag: String, count: Int)] {
var counts: [String: Int] = [:]
for note in notes where note.isLive {
for tag in note.tags {
counts[tag, default: 0] += 1
}
}
return counts
.map { (tag: $0.key, count: $0.value) }
.sorted { $0.count == $1.count ? $0.tag < $1.tag : $0.count > $1.count }
}
}
struct TaggedNotesView: View {
let tag: String
@Query(sort: \NoteEntity.createdAt, order: .reverse) private var notes: [NoteEntity]
@State private var editingNote: NoteEntity?
var body: some View {
List {
ForEach(notes.filter { $0.isLive && $0.tags.contains(tag) }) { note in
Button {
editingNote = note
} label: {
NoteRowView(note: note)
}
.buttonStyle(.plain)
}
}
.navigationTitle("#\(tag)")
.sheet(item: $editingNote) { note in
NoteEditorView(mode: .edit(note))
}
}
}
+47
View File
@@ -0,0 +1,47 @@
import Foundation
import IndieSync
import Testing
@testable import Notes
struct NoteDocumentTests {
private func makeNote() -> Note {
var context = CaptureContext.empty
context.latitude = 37.8044
context.longitude = -122.2712
context.placeName = "Blue Bottle Coffee"
context.locality = "Oakland"
context.timeZoneID = "America/Los_Angeles"
context.device = "iPhone"
return Note.create(text: "Barista's name is Sam\nLikes cortados", tags: ["coffee", "people"], context: context)
}
@Test func codableRoundTrip() throws {
let note = makeNote()
let data = try DocumentCoder.encode(note)
let decoded = try DocumentCoder.decode(Note.self, from: data)
#expect(decoded.payload == note.payload)
#expect(decoded.meta.id == note.meta.id)
#expect(decoded.meta.schemaVersion == note.meta.schemaVersion)
// ISO-8601 encoding truncates sub-second precision.
#expect(abs(decoded.meta.createdAt.timeIntervalSince(note.meta.createdAt)) < 1)
#expect(abs(decoded.meta.modifiedAt.timeIntervalSince(note.meta.modifiedAt)) < 1)
}
@Test func forwardGateQuarantinesNewerSchema() {
var note = makeNote()
note.meta.schemaVersion = Note.currentSchemaVersion + 1
#expect(!note.isReadable)
#expect(makeNote().isReadable)
}
@Test func relativePathIsUTCBucketed() {
let note = makeNote()
let path = note.relativePath
#expect(path.hasSuffix("\(note.meta.id.stringValue).json"))
#expect(path == TimeBucketedLayout.relativePath(for: note.meta.id))
}
@Test func titleIsFirstLine() {
#expect(makeNote().title == "Barista's name is Sam")
}
}
+57
View File
@@ -0,0 +1,57 @@
import Foundation
import SwiftData
import Testing
@testable import Notes
@MainActor
struct NoteMapperTests {
/// Returns the container too `mainContext` does not keep its container
/// alive, and letting it deallocate mid-test crashes the process.
private func makeStore() throws -> (container: ModelContainer, context: ModelContext) {
let config = ModelConfiguration(isStoredInMemoryOnly: true, cloudKitDatabase: .none)
let container = try ModelContainer(for: NoteEntity.self, configurations: config)
return (container, container.mainContext)
}
private func makeNote(text: String = "Barista's name is Sam") -> Note {
var context = CaptureContext.empty
context.latitude = 37.8044
context.longitude = -122.2712
context.placeName = "Blue Bottle Coffee"
context.timeZoneID = "America/Los_Angeles"
return Note.create(text: text, tags: ["coffee"], context: context)
}
@Test func upsertInsertsThenUpdates() throws {
let (container, context) = try makeStore()
defer { withExtendedLifetime(container) {} }
let note = makeNote()
NoteMapper.upsert(note, relativePath: note.relativePath, into: context)
let inserted = NoteMapper.fetch(id: note.meta.id.stringValue, in: context)
#expect(inserted?.text == "Barista's name is Sam")
#expect(inserted?.placeName == "Blue Bottle Coffee")
var edited = note
edited.payload.text = "Barista's name is Sam — likes cortados"
edited.payload.tags = ["coffee", "people"]
NoteMapper.upsert(edited, relativePath: note.relativePath, into: context)
let all = try context.fetch(FetchDescriptor<NoteEntity>())
#expect(all.count == 1)
#expect(all.first?.text == "Barista's name is Sam — likes cortados")
#expect(all.first?.tags == ["coffee", "people"])
}
@Test func entityRoundTripsBackToNote() throws {
let (container, context) = try makeStore()
defer { withExtendedLifetime(container) {} }
let note = makeNote()
NoteMapper.upsert(note, relativePath: note.relativePath, into: context)
let entity = try #require(NoteMapper.fetch(id: note.meta.id.stringValue, in: context))
let restored = try #require(NoteMapper.note(from: entity))
#expect(restored.payload == note.payload)
#expect(restored.meta.id == note.meta.id)
}
}
+68
View File
@@ -0,0 +1,68 @@
import Foundation
import Testing
@testable import Notes
struct RelevanceTests {
private let coffeeShop = (lat: 37.8044, lon: -122.2712) // Oakland
private let farAway = (lat: 40.7128, lon: -74.0060) // NYC
private func signals(
lat: Double? = nil,
lon: Double? = nil,
place: String? = nil,
createdAt: Date = Date(timeIntervalSinceNow: -7 * 86_400)
) -> RelevanceSignals {
RelevanceSignals(
latitude: lat, longitude: lon, placeName: place, locality: nil,
createdAt: createdAt, timeZoneID: TimeZone.current.identifier
)
}
@Test func nearbyNoteOutscoresFarNote() {
let query = RelevanceQuery(latitude: coffeeShop.lat, longitude: coffeeShop.lon)
let near = Relevance.score(signals(lat: coffeeShop.lat, lon: coffeeShop.lon), query: query)
let far = Relevance.score(signals(lat: farAway.lat, lon: farAway.lon), query: query)
#expect(near > far)
#expect(near >= Relevance.surfacingThreshold)
}
@Test func farOldNoteStaysBelowThreshold() {
let query = RelevanceQuery(latitude: coffeeShop.lat, longitude: coffeeShop.lon)
let score = Relevance.score(
signals(lat: farAway.lat, lon: farAway.lon, createdAt: Date(timeIntervalSinceNow: -180 * 86_400)),
query: query
)
#expect(score < Relevance.surfacingThreshold)
}
@Test func samePlaceNameBoostsWithoutCoordinates() {
var query = RelevanceQuery()
query.placeName = "Blue Bottle Coffee"
let matching = Relevance.score(signals(place: "blue bottle coffee"), query: query)
let other = Relevance.score(signals(place: "Some Other Bar"), query: query)
#expect(matching > other)
}
@Test func sameHourOutscoresOppositeHour() {
let calendar = Calendar.current
let now = Date()
let sameHourYesterday = calendar.date(byAdding: .day, value: -1, to: now)!
let twelveHoursOff = calendar.date(byAdding: .hour, value: -12, to: sameHourYesterday)!
var query = RelevanceQuery()
query.date = now
let same = Relevance.score(signals(createdAt: sameHourYesterday), query: query)
let opposite = Relevance.score(signals(createdAt: twelveHoursOff), query: query)
#expect(same > opposite)
}
@Test func fresherNoteScoresHigher() {
let query = RelevanceQuery()
// Same clock time on different days isolates the recency term.
let calendar = Calendar.current
let recent = calendar.date(byAdding: .day, value: -7, to: query.date)!
let old = calendar.date(byAdding: .day, value: -98, to: query.date)!
let fresh = Relevance.score(signals(createdAt: recent), query: query)
let stale = Relevance.score(signals(createdAt: old), query: query)
#expect(fresh > stale)
}
}
+40
View File
@@ -0,0 +1,40 @@
# Notes
Context-aware note taking for iOS and macOS. Working name — bundle id `dev.rzen.indie.Notes`.
The idea: every note silently captures the context it was taken in — where you
were, what time it was — and that context is used to *recall* notes later.
Walk back into the coffee shop and the note with the barista's name bubbles to
the top of the list.
## Key features
- **Quick text notes** — jot something down in two taps; first line doubles as the title
- **Automatic context capture** — location, place name (reverse-geocoded), time zone, and device are stamped onto every note, best-effort and privacy-friendly (all data stays in your iCloud)
- **"Right here, right now" recall** — a ranked shelf of notes whose capture context matches your current place and time, above the regular newest-first list
- **Tags** — manual organization alongside the automatic context; browse notes by tag
- **Search** — full-text over note text, tags, and captured place names
- **iCloud sync** — notes live as JSON files in your iCloud Drive (visible in Files.app) and sync across iPhone and Mac; the local database is just a rebuildable cache
- **iOS + macOS** — same app, same data, both platforms
Planned: voice notes with transcription, richer context signals, smarter ranking.
## Architecture
- SwiftUI, iOS 26+ / macOS 26+, Swift 6 strict concurrency, XcodeGen (`xcodegen generate`)
- Storage per the icloud-sync-engine pattern: iCloud Drive JSON documents are
the sole source of truth (via the [IndieSync](https://git.rzen.dev/rzen/indie-sync)
package); a SwiftData cache is populated exclusively by metadata-observer
events and can be wiped/rebuilt at any time
- Context capture in `Services/ContextService.swift` (CoreLocation +
MapKit reverse geocoding); ranking in `Services/Relevance.swift`
## Build
```bash
xcodegen generate
xcodebuild build -project Notes.xcodeproj -scheme Notes \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro'
```
Requires `APPLE_TEAM_ID` in the environment for code signing.
+130
View File
@@ -0,0 +1,130 @@
name: Notes
options:
bundleIdPrefix: dev.rzen.indie
deploymentTarget:
iOS: "26.0"
macOS: "26.0"
xcodeVersion: "26.0"
defaultConfig: Debug
settings:
base:
SWIFT_VERSION: "6.0"
DEVELOPMENT_TEAM: ${APPLE_TEAM_ID}
MARKETING_VERSION: "0.1"
CURRENT_PROJECT_VERSION: "1"
ENABLE_USER_SCRIPT_SANDBOXING: "NO"
packages:
IndieSync:
url: https://git.rzen.dev/rzen/indie-sync.git
from: "0.1.0"
schemes:
Notes:
build:
targets:
Notes: all
NotesTests: [test]
run:
config: Debug
test:
config: Debug
targets:
- NotesTests
archive:
config: Release
targets:
NotesTests:
type: bundle.unit-test
platform: iOS
sources:
- NotesTests
dependencies:
- target: Notes
- package: IndieSync
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.NotesTests
GENERATE_INFOPLIST_FILE: true
SWIFT_STRICT_CONCURRENCY: complete
IPHONEOS_DEPLOYMENT_TARGET: "26.0"
Notes:
type: application
platform: iOS
sources:
- path: Notes
excludes:
- "Resources/Info-*.plist"
- "Resources/*.entitlements"
- path: CHANGELOG.md
buildPhase: resources
type: file
- path: README.md
buildPhase: resources
type: file
- path: LICENSE.md
buildPhase: resources
type: file
dependencies:
- package: IndieSync
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Notes
INFOPLIST_FILE: Notes/Resources/Info-iOS.plist
CODE_SIGN_ENTITLEMENTS: Notes/Resources/Notes-iOS.entitlements
GENERATE_INFOPLIST_FILE: false
SWIFT_STRICT_CONCURRENCY: complete
IPHONEOS_DEPLOYMENT_TARGET: "26.0"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
TARGETED_DEVICE_FAMILY: "1,2"
postBuildScripts:
- script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"'
name: Update Build Info
shell: /bin/sh
basedOnDependencyAnalysis: false
inputFiles:
- $(TARGET_BUILD_DIR)/$(INFOPLIST_PATH)
- $(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist
NotesMac:
type: application
platform: macOS
sources:
- path: Notes
excludes:
- "Resources/Info-*.plist"
- "Resources/*.entitlements"
- path: CHANGELOG.md
buildPhase: resources
type: file
- path: README.md
buildPhase: resources
type: file
- path: LICENSE.md
buildPhase: resources
type: file
dependencies:
- package: IndieSync
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Notes
PRODUCT_NAME: Notes
INFOPLIST_FILE: Notes/Resources/Info-macOS.plist
CODE_SIGN_ENTITLEMENTS: Notes/Resources/Notes-macOS.entitlements
GENERATE_INFOPLIST_FILE: false
SWIFT_STRICT_CONCURRENCY: complete
MACOSX_DEPLOYMENT_TARGET: "26.0"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
postBuildScripts:
- script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"'
name: Update Build Info
shell: /bin/sh
basedOnDependencyAnalysis: false
inputFiles:
- $(TARGET_BUILD_DIR)/$(INFOPLIST_PATH)
- $(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist