Initial scaffold: context-aware notes app for iOS + macOS
XcodeGen project (Notes / NotesMac / NotesTests), Swift 6 strict concurrency, iCloud-document storage via IndieSync with a SwiftData cache, automatic capture context (location, place, time zone, device) on every note, context-based relevance ranking with a 'Right here, right now' shelf, tags, search, and the square.and.pencil-on-red app icon.
This commit is contained in:
@@ -0,0 +1,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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user