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.
@@ -0,0 +1,5 @@
|
||||
build/
|
||||
DerivedData/
|
||||
*.xcodeproj
|
||||
.DS_Store
|
||||
xcuserdata/
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 529 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 525 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 574 KiB |
@@ -0,0 +1 @@
|
||||
{"info":{"version":1,"author":"xcode"}}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||