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.
131 lines
5.1 KiB
Swift
131 lines
5.1 KiB
Swift
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
|
||
)
|
||
}
|
||
}
|