Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Blackbook.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion Blackbook/App/BlackbookApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ struct BlackbookApp: App {
ContactRelationship.self,
Reminder.self,
Activity.self,
RejectedCalendarEvent.self
RejectedCalendarEvent.self,
AppNotification.self
])

// Wipe the store when the schema version changes so SwiftData never
Expand Down
86 changes: 86 additions & 0 deletions Blackbook/Models/AppNotification.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import Foundation
import SwiftData

/// A persisted, synced notification / suggested action shown in the Overview "Notifications" chiclet.
/// References its target contact by id (no SwiftData relationship — the id is enough to navigate and
/// keeps the model a simple leaf that syncs without dependency ordering).
@Model
final class AppNotification {
var id: UUID
var kindRaw: String
var title: String
var message: String
/// The contact this notification points to (for tap-to-navigate). Nil for non-contact notifications.
var contactId: UUID?
var createdAt: Date
var isRead: Bool
var isDismissed: Bool

var updatedAt: Date = Date()
var syncStatus: String = SyncStatus.pending.rawValue
var lastSyncedAt: Date?

// MARK: - Source-device provenance

var createdByDeviceId: String?
var createdByPlatform: String?
var createdByDeviceName: String?
var lastEditedByDeviceId: String?
var lastEditedByPlatform: String?
var lastEditedByDeviceName: String?

/// The category of this notification, driving its icon and any inline action.
var kind: AppNotificationKind {
get { AppNotificationKind(rawValue: kindRaw) ?? .fadingRelationship }
set { kindRaw = newValue.rawValue }
}

init(
kind: AppNotificationKind,
title: String,
message: String,
contactId: UUID? = nil,
createdAt: Date = Date()
) {
self.id = UUID()
self.kindRaw = kind.rawValue
self.title = title
self.message = message
self.contactId = contactId
self.createdAt = createdAt
self.isRead = false
self.isDismissed = false
self.updatedAt = Date()
self.createdByDeviceId = DeviceIdentity.installId
self.createdByPlatform = DeviceIdentity.platform
self.createdByDeviceName = DeviceIdentity.deviceName
self.lastEditedByDeviceId = DeviceIdentity.installId
self.lastEditedByPlatform = DeviceIdentity.platform
self.lastEditedByDeviceName = DeviceIdentity.deviceName
}

func markLocallyEdited() {
updatedAt = Date()
if syncStatus != SyncStatus.deleted.rawValue {
syncStatus = SyncStatus.pending.rawValue
}
lastEditedByDeviceId = DeviceIdentity.installId
lastEditedByPlatform = DeviceIdentity.platform
lastEditedByDeviceName = DeviceIdentity.deviceName
}
}

/// The source/category of an `AppNotification`.
enum AppNotificationKind: String, Codable, CaseIterable {
/// A relationship whose score has dropped — suggest reaching out.
case fadingRelationship
/// A previously-imported contact no longer found in the address book — suggest archiving.
case archiveSuggestion

var icon: String {
switch self {
case .fadingRelationship: return "arrow.down.right.circle.fill"
case .archiveSuggestion: return "archivebox.fill"
}
}
}
9 changes: 9 additions & 0 deletions Blackbook/Models/Contact.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,15 @@ final class Contact {
}
}

extension Sequence where Element == Contact {
/// Contacts eligible to appear in any list, picker, or search surface — excludes hidden and
/// merged-away contacts. Single chokepoint for the CLAUDE.md rule "Hidden contacts must never
/// appear outside Settings > Hidden Contacts." Use everywhere contacts are offered for selection.
var selectable: [Contact] {
filter { !$0.isHidden && !$0.isMergedAway }
}
}

/// Buckets for relationship health based on numeric score thresholds.
enum ScoreCategory: String, Codable {
case strong = "Strong"
Expand Down
66 changes: 66 additions & 0 deletions Blackbook/Services/ContactSuggestionEngine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import Foundation

/// Ranks contacts to surface as "suggested" records when picking a contact for a relationship field
/// (e.g. "Introduced to", "Met via"). Suggestions are a function of contextual similarity to the
/// subject — shared tags, groups, and locations — plus a per-field signal, so the user usually finds
/// the right person without typing. Falls back to highest relationship score when there's no overlap,
/// so the picker can *always* offer suggestions.
enum ContactSuggestionEngine {

/// The field a suggestion is being made for. Each field weights the similarity signals differently.
enum Field {
/// People the subject introduced you to (`metViaBacklinks`).
case introducedTo
/// The person who introduced you to the subject (`metVia`).
case metVia
}

/// Returns up to `limit` suggested contacts for `subject` in `field`, ranked by similarity.
///
/// - Parameters:
/// - subject: the contact whose field is being edited.
/// - field: which relationship field the suggestions are for.
/// - candidates: the pool to draw from (hidden / merged-away are filtered out here).
/// - excluding: contact IDs to omit (e.g. already-selected, the subject itself).
/// - limit: maximum number of suggestions (default 3).
static func suggestions(
for subject: Contact,
field: Field,
from candidates: [Contact],
excluding: Set<UUID> = [],
limit: Int = 3
) -> [Contact] {
let subjectTags = Set(subject.tags.map(\.id))
let subjectGroups = Set(subject.groups.map(\.id))
let subjectLocations = Set(subject.locations.map(\.id))

func similarity(_ c: Contact) -> Double {
var score = 0.0
score += Double(Set(c.tags.map(\.id)).intersection(subjectTags).count) * 3
score += Double(Set(c.groups.map(\.id)).intersection(subjectGroups).count) * 2
score += Double(Set(c.locations.map(\.id)).intersection(subjectLocations).count) * 2
switch field {
case .metVia:
// A likely connector shares the same introducer as the subject.
if let mv = c.metVia?.id, mv == subject.metVia?.id { score += 1 }
case .introducedTo:
// People you'd introduce tend to be already linked to the subject either way.
if c.metVia?.id == subject.id || subject.metVia?.id == c.id { score += 1 }
}
return score
}

let pool = candidates.selectable.filter { $0.id != subject.id && !excluding.contains($0.id) }
return pool
.map { (contact: $0, score: similarity($0)) }
.sorted { lhs, rhs in
if lhs.score != rhs.score { return lhs.score > rhs.score }
if lhs.contact.relationshipScore != rhs.contact.relationshipScore {
return lhs.contact.relationshipScore > rhs.contact.relationshipScore
}
return lhs.contact.displayName.localizedCaseInsensitiveCompare(rhs.contact.displayName) == .orderedAscending
}
.prefix(limit)
.map(\.contact)
}
}
48 changes: 40 additions & 8 deletions Blackbook/Services/ContactSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,16 @@ final class ContactSyncService {
return
}

// Run synchronously on the main thread to keep ModelContext safe.
importContacts(into: modelContext)
startObservingChanges()
// Defer the launch import by one second so the first frame renders before the import
// runs. Combined with the background-context apply in importContacts, this prevents the
// launch-time re-import from faulting a live @Query mid-insert — the crash loop that
// left the app unable to reopen after an "Import All" (work_log: import crash).
Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(1))
guard let self else { return }
self.importContacts(into: modelContext)
self.startObservingChanges()
}
}

func startObservingChanges() {
Expand Down Expand Up @@ -95,9 +102,15 @@ final class ContactSyncService {
let started = Date()
do {
let cnContacts = try fetchAllSystemContacts()
let outcome = try mergeOrInsert(cnContacts, into: modelContext)
try modelContext.save()
// Apply on a background context with a single settled commit so the main context's
// @Queries (ContactListView, etc.) never observe the partial-state insert window —
// the SwiftData inverse fault that crashed "Import All" (mirrors the PR #43 pull fix).
let bgContext = ModelContext(modelContext.container)
bgContext.autosaveEnabled = false
let outcome = try mergeOrInsert(cnContacts, into: bgContext, detectArchives: true)
try bgContext.save()
lastSyncDate = Date()
NotificationCenter.default.post(name: .blackbookSyncDidComplete, object: nil)
logger.info("Contact sync completed — processed=\(cnContacts.count) updated=\(outcome.identifierMatched) reattached=\(outcome.reattached) inserted=\(outcome.inserted)")
let durationMs = Int(Date().timeIntervalSince(started) * 1000)
Log.action("contacts.sync", metadata: [
Expand Down Expand Up @@ -153,9 +166,14 @@ final class ContactSyncService {
do {
let allSystem = fetchSystemContacts()
let selected = allSystem.filter { identifiers.contains($0.identifier) }
let outcome = try mergeOrInsert(selected, into: modelContext)
try modelContext.save()
// Background-context apply + single commit, as in importContacts (avoids the
// partial-state @Query fault during bulk insert).
let bgContext = ModelContext(modelContext.container)
bgContext.autosaveEnabled = false
let outcome = try mergeOrInsert(selected, into: bgContext)
try bgContext.save()
lastSyncDate = Date()
NotificationCenter.default.post(name: .blackbookSyncDidComplete, object: nil)
logger.info("Selective import completed — \(selected.count) contacts imported")
let durationMs = Int(Date().timeIntervalSince(started) * 1000)
Log.action("contacts.sync.selective", metadata: [
Expand Down Expand Up @@ -205,7 +223,7 @@ final class ContactSyncService {
/// Resolves each CNContact to an existing Blackbook Contact (reattaching by name +
/// email/phone if the cnContactIdentifier no longer matches), or inserts a new one.
/// Returns counts of each outcome.
private func mergeOrInsert(_ cnContacts: [CNContact], into modelContext: ModelContext) throws -> MergeOutcome {
private func mergeOrInsert(_ cnContacts: [CNContact], into modelContext: ModelContext, detectArchives: Bool = false) throws -> MergeOutcome {
var outcome = MergeOutcome()

let allActive = try modelContext.fetch(
Expand Down Expand Up @@ -270,6 +288,20 @@ final class ContactSyncService {
"cnIdentifier": cnContact.identifier
])
}

// After a full import, flag contacts that were imported before but are no longer in the
// address book (their cnContactIdentifier is gone from the live set) as archive suggestions.
if detectArchives {
for c in allActive where !c.isHidden {
guard let cn = c.cnContactIdentifier, !liveIdentifiers.contains(cn) else { continue }
if NotificationService.suggestArchive(contactId: c.id, displayName: Self.displayName(c), context: modelContext) {
Log.action("contact.import.archiveSuggested", metadata: [
"contactId": c.id.uuidString,
"displayName": Self.displayName(c)
])
}
}
}
return outcome
}

Expand Down
22 changes: 21 additions & 1 deletion Blackbook/Services/LocalServerSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ final class LocalServerSyncService {
flip(Reminder.self, #Predicate<Reminder> { $0.syncStatus == synced }) { $0.syncStatus = pending }
flip(ContactRelationship.self, #Predicate<ContactRelationship> { $0.syncStatus == synced }) { $0.syncStatus = pending }
flip(RejectedCalendarEvent.self, #Predicate<RejectedCalendarEvent> { $0.syncStatus == synced }) { $0.syncStatus = pending }
flip(AppNotification.self, #Predicate<AppNotification> { $0.syncStatus == synced }) { $0.syncStatus = pending }
try? context.save()
logger.info("Bootstrap marked \(flippedCount) record(s) pending")
Log.action("sync.bootstrap.markPending", metadata: ["count": "\(flippedCount)"])
Expand Down Expand Up @@ -318,6 +319,8 @@ final class LocalServerSyncService {
total += (try? context.fetchCount(reminderDescriptor)) ?? 0
let relationshipDescriptor = FetchDescriptor<ContactRelationship>(predicate: #Predicate<ContactRelationship> { $0.syncStatus != synced })
total += (try? context.fetchCount(relationshipDescriptor)) ?? 0
let appNotificationDescriptor = FetchDescriptor<AppNotification>(predicate: #Predicate<AppNotification> { $0.syncStatus != synced })
total += (try? context.fetchCount(appNotificationDescriptor)) ?? 0
return total
}

Expand All @@ -334,10 +337,12 @@ final class LocalServerSyncService {
let pendingNotes = try context.fetch(FetchDescriptor<Note>(predicate: #Predicate<Note> { $0.syncStatus != syncedStatus }))
let pendingReminders = try context.fetch(FetchDescriptor<Reminder>(predicate: #Predicate<Reminder> { $0.syncStatus != syncedStatus }))
let pendingRelationships = try context.fetch(FetchDescriptor<ContactRelationship>(predicate: #Predicate<ContactRelationship> { $0.syncStatus != syncedStatus }))
let pendingAppNotifications = try context.fetch(FetchDescriptor<AppNotification>(predicate: #Predicate<AppNotification> { $0.syncStatus != syncedStatus }))

let totalPending = pendingTags.count + pendingGroups.count + pendingLocations.count +
pendingActivities.count + pendingContacts.count + pendingInteractions.count +
pendingNotes.count + pendingReminders.count + pendingRelationships.count
pendingNotes.count + pendingReminders.count + pendingRelationships.count +
pendingAppNotifications.count
logger.info("Pushing \(totalPending) record(s)")

guard totalPending > 0 else { return }
Expand All @@ -358,6 +363,7 @@ final class LocalServerSyncService {
if !pendingNotes.isEmpty { body["notes"] = pendingNotes.map { ModelSyncApply.noteToDict($0) } }
if !pendingReminders.isEmpty { body["reminders"] = pendingReminders.map { ModelSyncApply.reminderToDict($0) } }
if !pendingRelationships.isEmpty { body["contactRelationships"] = pendingRelationships.map { ModelSyncApply.contactRelationshipToDict($0) } }
if !pendingAppNotifications.isEmpty { body["appNotifications"] = pendingAppNotifications.map { ModelSyncApply.appNotificationToDict($0) } }

guard let jsonData = try? JSONSerialization.data(withJSONObject: body) else { return }

Expand Down Expand Up @@ -387,6 +393,7 @@ final class LocalServerSyncService {
for note in pendingNotes { note.syncStatus = SyncStatus.synced.rawValue; note.lastSyncedAt = now }
for reminder in pendingReminders { reminder.syncStatus = SyncStatus.synced.rawValue; reminder.lastSyncedAt = now }
for rel in pendingRelationships { rel.syncStatus = SyncStatus.synced.rawValue; rel.lastSyncedAt = now }
for n in pendingAppNotifications { n.syncStatus = SyncStatus.synced.rawValue; n.lastSyncedAt = now }
try context.save()
}

Expand Down Expand Up @@ -464,8 +471,21 @@ final class LocalServerSyncService {
logger.info("Pulled \(rels.count) relationship(s)")
for dict in rels { try ModelSyncApply.applyRemoteContactRelationship(dict, to: bgContext) }
}
if let appNotifications = top["appNotifications"] as? [[String: Any]] {
logger.info("Pulled \(appNotifications.count) notification(s)")
for dict in appNotifications { try ModelSyncApply.applyRemoteAppNotification(dict, to: bgContext) }
}

try bgContext.save()

// Re-derive lastInteractionDate from the interaction records this device now holds and
// recompute relationship scores, so health reflects freshly-synced interactions even
// when the Dashboard isn't the visible tab (on macOS its @State recalc wouldn't run).
// Runs on the background context → the main context's @Queries see one settled commit.
RelationshipScoreEngine().recalculateAll(context: bgContext)

// Generate "reconnect" suggestions for newly-fading contacts (deduped). Same settled commit.
NotificationService.generateFadingNotifications(context: bgContext)
}

private func fetchPendingContacts(context: ModelContext) throws -> [Contact] {
Expand Down
Loading