part of 'relationship_repository.dart'; /// Person-profile persistence and merge operations. extension LocalRepositoryPeopleOperations on LocalRepository { Future addPerson({ required String name, required String relationship, required String notes, required List tags, DateTime? nextMoment, String? location, List aliases = const [], }) async { final String normalizedName = name.trim(); final String normalizedRelationship = relationship.trim(); if (normalizedName.isEmpty || normalizedRelationship.isEmpty) { throw ArgumentError('Name and relationship are required'); } final DateTime now = DateTime.now(); final LocalDataState current = _requireState(); final PersonProfile person = PersonProfile( id: 'p-${_uuid.v4()}', name: normalizedName, relationship: normalizedRelationship, affinityScore: 75, nextMoment: nextMoment ?? now.add(const Duration(days: 3)), tags: _mergeTags(tags, const []), notes: notes.trim(), aliases: _normalizeAliases(aliases), location: _trimToNull(location), lastUpdatedAt: now, ); await _setState( current.copyWith(people: [person, ...current.people]), ); await _enqueueChange( _buildEnvelope( entityType: 'person', entityId: person.id, op: ChangeOperation.upsert, payload: _personPayload(person), ), ); } Future updatePerson(PersonProfile person) async { final String normalizedName = person.name.trim(); final String normalizedRelationship = person.relationship.trim(); if (normalizedName.isEmpty || normalizedRelationship.isEmpty) { throw ArgumentError('Name and relationship are required'); } final DateTime now = DateTime.now(); final PersonProfile updatedPerson = person.copyWith( name: normalizedName, relationship: normalizedRelationship, notes: person.notes.trim(), tags: _mergeTags(person.tags, const []), aliases: _normalizeAliases(person.aliases), location: _trimToNull(person.location), lastUpdatedAt: now, ); final LocalDataState current = _requireState(); final List updated = current.people .map( (PersonProfile item) => item.id == updatedPerson.id ? updatedPerson : item, ) .toList(growable: false); await _setState(current.copyWith(people: updated)); await _enqueueChange( _buildEnvelope( entityType: 'person', entityId: updatedPerson.id, op: ChangeOperation.upsert, payload: _personPayload(updatedPerson), ), ); } Future deletePerson(String personId) async { final LocalDataState current = _requireState(); final List removedMoments = current.moments .where((RelationshipMoment moment) => moment.personId == personId) .toList(growable: false); final List removedIdeas = current.ideas .where((RelationshipIdea idea) => idea.personId == personId) .toList(growable: false); final List removedReminders = current.reminders .where((ReminderRule reminder) => reminder.personId == personId) .toList(growable: false); final List removedFacts = current.personFacts .where((PersonFact fact) => fact.personId == personId) .toList(growable: false); final List removedDates = current.importantDates .where((PersonImportantDate value) => value.personId == personId) .toList(growable: false); await _setState( current.copyWith( people: current.people .where((PersonProfile person) => person.id != personId) .toList(growable: false), moments: current.moments .where((RelationshipMoment moment) => moment.personId != personId) .toList(growable: false), ideas: current.ideas .where((RelationshipIdea idea) => idea.personId != personId) .toList(growable: false), reminders: current.reminders .where((ReminderRule reminder) => reminder.personId != personId) .toList(growable: false), preferenceSignals: current.preferenceSignals .where( (PersonPreferenceSignal signal) => signal.personId != personId, ) .toList(growable: false), personFacts: current.personFacts .where((PersonFact fact) => fact.personId != personId) .toList(growable: false), aiSuggestionDrafts: current.aiSuggestionDrafts .where((AiSuggestionDraft draft) => draft.personId != personId) .toList(growable: false), importantDates: current.importantDates .where((PersonImportantDate value) => value.personId != personId) .toList(growable: false), sourceLinks: current.sourceLinks .where((SourceProfileLink link) => link.profileId != personId) .toList(growable: false), sharedMessages: current.sharedMessages .where((SharedMessageEntry entry) => entry.profileId != personId) .toList(growable: false), sharedInbox: current.sharedInbox .map((SharedInboxEntry entry) { final List nextCandidates = entry.candidateProfileIds .where((String candidateId) => candidateId != personId) .toList(growable: false); return entry.copyWith( candidateProfileIds: nextCandidates, targetPersonId: entry.targetPersonId == personId ? null : entry.targetPersonId, ); }) .toList(growable: false), ), ); await _enqueueChanges([ _buildEnvelope( entityType: 'person', entityId: personId, op: ChangeOperation.delete, ), ...removedMoments.map( (RelationshipMoment moment) => _buildEnvelope( entityType: 'capture', entityId: moment.id, op: ChangeOperation.delete, ), ), ...removedIdeas.map( (RelationshipIdea idea) => _buildEnvelope( entityType: _ideaEntityType(idea.type), entityId: idea.id, op: ChangeOperation.delete, ), ), ...removedReminders.map( (ReminderRule reminder) => _buildEnvelope( entityType: 'reminderRule', entityId: reminder.id, op: ChangeOperation.delete, ), ), ...removedFacts.map( (PersonFact fact) => _buildEnvelope( entityType: 'personFact', entityId: fact.id, op: ChangeOperation.delete, ), ), ...removedDates.map( (PersonImportantDate value) => _buildEnvelope( entityType: 'personDate', entityId: value.id, op: ChangeOperation.delete, ), ), ]); } Future mergePersonProfiles({ required String sourcePersonId, required String targetPersonId, }) async { if (sourcePersonId == targetPersonId) { throw ArgumentError('Source and target profile must be different'); } final LocalDataState current = _requireState(); final PersonProfile? source = _findPersonById( current.people, sourcePersonId, ); final PersonProfile? target = _findPersonById( current.people, targetPersonId, ); if (source == null || target == null) { throw ArgumentError('Both source and target profiles must exist'); } final DateTime now = DateTime.now(); final PersonProfile mergedTarget = target.copyWith( affinityScore: target.affinityScore > source.affinityScore ? target.affinityScore : source.affinityScore, nextMoment: source.nextMoment.isBefore(target.nextMoment) ? source.nextMoment : target.nextMoment, tags: _mergeTags(target.tags, source.tags), notes: _mergeNotes( targetNotes: target.notes, sourceName: source.name, sourceNotes: source.notes, ), aliases: _normalizeAliases([ ...target.aliases, ...source.aliases, ]), location: _effectiveLocation(target.location, source.location), lastUpdatedAt: now, lastInteractedAt: _latestDate( target.lastInteractedAt, source.lastInteractedAt, ), ); final List nextPeople = current.people .where((PersonProfile person) => person.id != sourcePersonId) .map( (PersonProfile person) => person.id == targetPersonId ? mergedTarget : person, ) .toList(growable: false); final List updatedMoments = []; final List nextMoments = current.moments .map((RelationshipMoment moment) { if (moment.personId != sourcePersonId) { return moment; } final RelationshipMoment merged = moment.copyWith( personId: targetPersonId, ); updatedMoments.add(merged); return merged; }) .toList(growable: false); final List updatedIdeas = []; final List nextIdeas = current.ideas .map((RelationshipIdea idea) { if (idea.personId != sourcePersonId) { return idea; } final RelationshipIdea merged = idea.copyWith( personId: targetPersonId, ); updatedIdeas.add(merged); return merged; }) .toList(growable: false); final List updatedReminders = []; final List nextReminders = current.reminders .map((ReminderRule reminder) { if (reminder.personId != sourcePersonId) { return reminder; } final ReminderRule merged = reminder.copyWith( personId: targetPersonId, ); updatedReminders.add(merged); return merged; }) .toList(growable: false); final List nextLinks = current.sourceLinks .map((SourceProfileLink link) { if (link.profileId != sourcePersonId) { return link; } return link.copyWith(profileId: targetPersonId); }) .toList(growable: false); final List nextMessages = current.sharedMessages .map((SharedMessageEntry entry) { if (entry.profileId != sourcePersonId) { return entry; } return entry.copyWith(profileId: targetPersonId); }) .toList(growable: false); final List nextInbox = current.sharedInbox .map((SharedInboxEntry entry) { if (!entry.candidateProfileIds.contains(sourcePersonId) && entry.targetPersonId != sourcePersonId) { return entry; } final List candidates = entry.candidateProfileIds .map( (String profileId) => profileId == sourcePersonId ? targetPersonId : profileId, ) .toSet() .toList(growable: false); return entry.copyWith( candidateProfileIds: candidates, targetPersonId: entry.targetPersonId == sourcePersonId ? targetPersonId : entry.targetPersonId, ); }) .toList(growable: false); final List nextPreferenceSignals = current .preferenceSignals .map((PersonPreferenceSignal signal) { if (signal.personId != sourcePersonId) { return signal; } return signal.copyWith(personId: targetPersonId); }) .toList(growable: false); final List nextFacts = current.personFacts .map((PersonFact fact) { if (fact.personId != sourcePersonId) { return fact; } return fact.copyWith(personId: targetPersonId, updatedAt: now); }) .toList(growable: false); final List nextImportantDates = current.importantDates .map((PersonImportantDate value) { if (value.personId != sourcePersonId) { return value; } return value.copyWith(personId: targetPersonId, updatedAt: now); }) .toList(growable: false); final List nextAiSuggestionDrafts = current .aiSuggestionDrafts .map((AiSuggestionDraft draft) { if (draft.personId != sourcePersonId) { return draft; } return draft.copyWith(personId: targetPersonId); }) .toList(growable: false); await _setState( current.copyWith( people: nextPeople, moments: nextMoments, ideas: nextIdeas, reminders: nextReminders, sourceLinks: nextLinks, sharedMessages: nextMessages, sharedInbox: nextInbox, personFacts: nextFacts, importantDates: nextImportantDates, preferenceSignals: nextPreferenceSignals, aiSuggestionDrafts: nextAiSuggestionDrafts, ), ); await _enqueueChanges([ _buildEnvelope( entityType: 'person', entityId: mergedTarget.id, op: ChangeOperation.upsert, payload: _personPayload(mergedTarget), ), _buildEnvelope( entityType: 'person', entityId: sourcePersonId, op: ChangeOperation.delete, ), ...updatedMoments.map( (RelationshipMoment moment) => _buildEnvelope( entityType: 'capture', entityId: moment.id, op: ChangeOperation.upsert, payload: _momentPayload(moment), ), ), ...updatedIdeas.map( (RelationshipIdea idea) => _buildEnvelope( entityType: _ideaEntityType(idea.type), entityId: idea.id, op: ChangeOperation.upsert, payload: _ideaPayload(idea), ), ), ...updatedReminders.map( (ReminderRule reminder) => _buildEnvelope( entityType: 'reminderRule', entityId: reminder.id, op: ChangeOperation.upsert, payload: _reminderPayload(reminder), ), ), ]); } } List _mergeTags(List primary, List secondary) { final List merged = []; final Set seen = {}; for (final String raw in [...primary, ...secondary]) { final String tag = raw.trim(); if (tag.isEmpty) { continue; } final String normalized = tag.toLowerCase(); if (seen.add(normalized)) { merged.add(tag); } } return merged; } String _mergeNotes({ required String targetNotes, required String sourceName, required String sourceNotes, }) { final String target = targetNotes.trim(); final String source = sourceNotes.trim(); if (target.isEmpty) { return source; } if (source.isEmpty) { return target; } if (target == source) { return target; } return '$target\n\nMerged from $sourceName:\n$source'; } String? _effectiveLocation(String? target, String? source) { final String? targetValue = _trimToNull(target); if (targetValue != null) { return targetValue; } return _trimToNull(source); } List _normalizeAliases(List aliases) { final List values = []; final Set seen = {}; for (final String raw in aliases) { final String value = raw.trim(); if (value.isEmpty) { continue; } final String key = value.toLowerCase(); if (seen.add(key)) { values.add(value); } } return values; } DateTime? _latestDate(DateTime? left, DateTime? right) { if (left == null) { return right; } if (right == null) { return left; } return left.isAfter(right) ? left : right; } List _touchPeople( List people, { required String personId, DateTime? updatedAt, DateTime? interactedAt, }) { return people .map((PersonProfile person) { if (person.id != personId) { return person; } return person.copyWith( lastUpdatedAt: updatedAt ?? person.lastUpdatedAt ?? DateTime.now(), lastInteractedAt: interactedAt ?? person.lastInteractedAt ?? DateTime.now(), ); }) .toList(growable: false); } PersonProfile? _findPersonById(List people, String id) { for (final PersonProfile person in people) { if (person.id == id) { return person; } } return null; }