part of 'relationship_repository.dart'; /// Share capture intake, person resolution, and inbox triage. extension LocalRepositoryShareOperations on LocalRepository { Future ingestSharedMessage( SharedMessageIngestInput input, ) async { final DateTime now = DateTime.now(); final SharedPayload payload = SharedPayload( sourceApp: input.sourceApp.trim().toLowerCase(), payloadType: SharedPayloadType.text, rawText: input.messageText.trim(), createdAt: input.sharedAt ?? now, receivedAt: now, sharedMessageDateTime: input.sharedAt, platform: 'legacy', sourceDisplayName: _trimToNull(input.sourceDisplayName), sourceUserId: _trimToNull(input.sourceUserId), sourceThreadId: _trimToNull(input.sourceThreadId), dedupeKey: _dedupeKeyForShare( sourceApp: input.sourceApp, rawText: input.messageText, url: null, sourceUserId: input.sourceUserId, sourceThreadId: input.sourceThreadId, ), ); if (payload.rawText.isEmpty) { throw ArgumentError('Shared message text cannot be empty'); } final LocalDataState current = _requireState(); final String sourceApp = payload.sourceApp; final String? sourceDisplayName = payload.sourceDisplayName; final String normalizedDisplayName = _normalizeDisplayName( sourceDisplayName, ); final String? sourceUserId = payload.sourceUserId; final String? sourceThreadId = payload.sourceThreadId; final String? sourceFingerprint = _buildStableSourceFingerprint( sourceApp: sourceApp, sourceUserId: sourceUserId, sourceThreadId: sourceThreadId, ); final SourceProfileLink? matchedLink = _findExistingSourceLink( links: current.sourceLinks, sourceApp: sourceApp, sourceFingerprint: sourceFingerprint, sourceUserId: sourceUserId, sourceThreadId: sourceThreadId, normalizedDisplayName: normalizedDisplayName, ); PersonProfile? resolvedPerson; if (matchedLink != null) { resolvedPerson = _findPersonById(current.people, matchedLink.profileId); } final List matchingPeople = normalizedDisplayName.isEmpty ? const [] : _findPeopleByNormalizedName(current.people, normalizedDisplayName); if (resolvedPerson == null && matchingPeople.length == 1) { resolvedPerson = matchingPeople.first; } if (resolvedPerson == null && matchingPeople.length > 1) { return _queueSharedInbox( current: current, payload: payload, sourceFingerprint: sourceFingerprint, normalizedDisplayName: normalizedDisplayName, reason: SharedInboxReason.ambiguousProfileMatch, candidateProfileIds: matchingPeople .map((PersonProfile person) => person.id) .toList(growable: false), ); } final List nearMatchPeople = resolvedPerson == null ? _findNearMatchPeople( current.people, normalizedDisplayName: normalizedDisplayName, ) : const []; if (resolvedPerson == null && nearMatchPeople.isNotEmpty) { return _queueSharedInbox( current: current, payload: payload, sourceFingerprint: sourceFingerprint, normalizedDisplayName: normalizedDisplayName, reason: SharedInboxReason.nearProfileConflict, candidateProfileIds: nearMatchPeople .map((PersonProfile person) => person.id) .toList(growable: false), ); } if (resolvedPerson == null) { return _queueSharedInbox( current: current, payload: payload, sourceFingerprint: sourceFingerprint, normalizedDisplayName: normalizedDisplayName, reason: SharedInboxReason.missingIdentity, candidateProfileIds: const [], ); } return _ingestIntoResolvedProfile( current: current, importedAt: now, payload: payload, sourceFingerprint: sourceFingerprint, normalizedDisplayName: normalizedDisplayName, resolvedPerson: resolvedPerson, matchedLink: matchedLink, createdProfile: false, people: current.people, resolvedAutomatically: true, draft: _defaultDraftForPayload(payload), ); } Future resolveSharedInboxToExistingProfile({ required String inboxEntryId, required String profileId, CapturedFactDraft? draft, }) async { final LocalDataState current = _requireState(); final SharedInboxEntry entry = _requireSharedInboxEntry( current, inboxEntryId, ); final PersonProfile? resolvedPerson = _findPersonById( current.people, profileId, ); if (resolvedPerson == null) { throw ArgumentError('Profile not found for inbox entry'); } return captureSharedPayloadToExistingProfile( payload: entry.payload, profileId: profileId, draft: draft ?? entry.draft, consumedInboxEntryId: entry.id, normalizedDisplayName: entry.normalizedDisplayName, sourceFingerprint: entry.sourceFingerprint, resolvedAutomatically: false, ); } Future resolveSharedInboxByCreatingProfile({ required String inboxEntryId, String? name, String relationship = 'WhatsApp Contact', String? location, List aliases = const [], CapturedFactDraft? draft, }) async { final LocalDataState current = _requireState(); final SharedInboxEntry entry = _requireSharedInboxEntry( current, inboxEntryId, ); return captureSharedPayloadByCreatingProfile( payload: entry.payload, name: name, relationship: relationship, location: location, aliases: aliases, draft: draft ?? entry.draft, consumedInboxEntryId: entry.id, normalizedDisplayName: entry.normalizedDisplayName, sourceFingerprint: entry.sourceFingerprint, resolvedAutomatically: false, ); } Future saveSharedPayloadToInbox({ required SharedPayload payload, SharedInboxReason reason = SharedInboxReason.missingIdentity, List candidateProfileIds = const [], String? normalizedDisplayName, String? targetPersonId, String? sourceFingerprint, CapturedFactDraft? draft, }) async { final LocalDataState current = _requireState(); return _queueSharedInbox( current: current, payload: payload, sourceFingerprint: sourceFingerprint ?? _buildStableSourceFingerprint( sourceApp: payload.sourceApp, sourceUserId: payload.sourceUserId, sourceThreadId: payload.sourceThreadId, ), normalizedDisplayName: normalizedDisplayName ?? _normalizeDisplayName(payload.sourceDisplayName), reason: reason, candidateProfileIds: candidateProfileIds, targetPersonId: targetPersonId, draft: draft, ); } Future captureSharedPayloadToExistingProfile({ required SharedPayload payload, required String profileId, CapturedFactDraft? draft, bool resolvedAutomatically = false, String? consumedInboxEntryId, String? normalizedDisplayName, String? sourceFingerprint, }) async { final LocalDataState current = _requireState(); final PersonProfile? resolvedPerson = _findPersonById( current.people, profileId, ); if (resolvedPerson == null) { throw ArgumentError('Profile not found'); } final String effectiveNormalizedDisplayName = normalizedDisplayName ?? _normalizeDisplayName(payload.sourceDisplayName); final String? effectiveSourceFingerprint = sourceFingerprint ?? _buildStableSourceFingerprint( sourceApp: payload.sourceApp, sourceUserId: payload.sourceUserId, sourceThreadId: payload.sourceThreadId, ); final SourceProfileLink? matchedLink = _findExistingSourceLink( links: current.sourceLinks, sourceApp: payload.sourceApp, sourceFingerprint: effectiveSourceFingerprint, sourceUserId: payload.sourceUserId, sourceThreadId: payload.sourceThreadId, normalizedDisplayName: effectiveNormalizedDisplayName, ); return _ingestIntoResolvedProfile( current: current, importedAt: DateTime.now(), payload: payload, sourceFingerprint: effectiveSourceFingerprint, normalizedDisplayName: effectiveNormalizedDisplayName, resolvedPerson: resolvedPerson, matchedLink: matchedLink, createdProfile: false, people: current.people, resolvedAutomatically: resolvedAutomatically, consumedInboxEntryId: consumedInboxEntryId, draft: draft ?? _defaultDraftForPayload(payload), ); } Future captureSharedPayloadByCreatingProfile({ required SharedPayload payload, String? name, String relationship = 'Contact', String? location, List aliases = const [], CapturedFactDraft? draft, bool resolvedAutomatically = false, String? consumedInboxEntryId, String? normalizedDisplayName, String? sourceFingerprint, }) async { final LocalDataState current = _requireState(); final DateTime now = DateTime.now(); final String? profileName = _trimToNull(name); if (profileName == null) { throw ArgumentError('A person name is required to create from a share'); } final String normalizedRelationship = relationship.trim().isEmpty ? 'Contact' : relationship.trim(); final PersonProfile createdPerson = PersonProfile( id: 'p-${_uuid.v4()}', name: profileName, relationship: normalizedRelationship, affinityScore: 70, nextMoment: now.add(const Duration(days: 2)), tags: [ if (payload.sourceApp.trim().isNotEmpty) payload.sourceApp.trim(), ], notes: 'Created from shared capture.', aliases: _normalizeAliases([ ...aliases, if (payload.sourceDisplayName != null) payload.sourceDisplayName!, ]), location: _trimToNull(location), lastUpdatedAt: now, lastInteractedAt: now, ); return _ingestIntoResolvedProfile( current: current, importedAt: now, payload: payload, sourceFingerprint: sourceFingerprint ?? _buildStableSourceFingerprint( sourceApp: payload.sourceApp, sourceUserId: payload.sourceUserId, sourceThreadId: payload.sourceThreadId, ), normalizedDisplayName: normalizedDisplayName ?? _normalizeDisplayName(payload.sourceDisplayName), resolvedPerson: createdPerson, matchedLink: null, createdProfile: true, people: [createdPerson, ...current.people], resolvedAutomatically: resolvedAutomatically, consumedInboxEntryId: consumedInboxEntryId, draft: draft ?? _defaultDraftForPayload(payload), ); } Future dismissSharedInboxEntry(String inboxEntryId) async { final LocalDataState current = _requireState(); final List nextInbox = current.sharedInbox .where((SharedInboxEntry entry) => entry.id != inboxEntryId) .toList(growable: false); if (nextInbox.length == current.sharedInbox.length) { return; } await _setState(current.copyWith(sharedInbox: nextInbox)); } Future _queueSharedInbox({ required LocalDataState current, required SharedPayload payload, required SharedInboxReason reason, required List candidateProfileIds, required String normalizedDisplayName, String? sourceFingerprint, String? targetPersonId, CapturedFactDraft? draft, }) async { final SharedPayload storedPayload = payload.copyWith( rawText: _truncate(payload.rawText, 1800), dedupeKey: payload.dedupeKey ?? _dedupeKeyForShare( sourceApp: payload.sourceApp, rawText: payload.rawText, url: payload.url, sourceUserId: payload.sourceUserId, sourceThreadId: payload.sourceThreadId, ), ); final SharedInboxEntry entry = SharedInboxEntry( id: 'si-${_uuid.v4()}', payload: storedPayload, reason: reason, candidateProfileIds: candidateProfileIds, normalizedDisplayName: normalizedDisplayName, assignmentStatus: ShareAssignmentStatus.savedToInbox, targetPersonId: targetPersonId, sourceFingerprint: sourceFingerprint, draft: draft ?? _defaultDraftForPayload(storedPayload), ); await _setState( current.copyWith( sharedInbox: [entry, ...current.sharedInbox], ), ); return SharedMessageIngestResult.queuedForResolution( inboxEntryId: entry.id, ); } Future _ingestIntoResolvedProfile({ required LocalDataState current, required SharedPayload payload, required DateTime importedAt, required String normalizedDisplayName, required String? sourceFingerprint, required PersonProfile resolvedPerson, required bool createdProfile, required List people, required bool resolvedAutomatically, required CapturedFactDraft draft, SourceProfileLink? matchedLink, String? consumedInboxEntryId, }) async { final String sourceApp = payload.sourceApp; final DateTime sharedAt = payload.sharedMessageDateTime ?? payload.createdAt; final String? sourceUserId = payload.sourceUserId; final String? sourceThreadId = payload.sourceThreadId; final bool createdSourceLink = matchedLink == null; final List nextLinks = current.sourceLinks.toList( growable: true, ); if (matchedLink == null) { nextLinks.insert( 0, SourceProfileLink( id: 'sl-${_uuid.v4()}', sourceApp: sourceApp, sourceUserId: sourceUserId, sourceThreadId: sourceThreadId, sourceFingerprint: sourceFingerprint, normalizedDisplayName: normalizedDisplayName, profileId: resolvedPerson.id, firstSeenAt: sharedAt, lastSeenAt: sharedAt, ), ); } else { final int index = nextLinks.indexWhere( (SourceProfileLink link) => link.id == matchedLink.id, ); if (index >= 0) { nextLinks[index] = matchedLink.copyWith( sourceUserId: sourceUserId ?? matchedLink.sourceUserId, sourceThreadId: sourceThreadId ?? matchedLink.sourceThreadId, sourceFingerprint: sourceFingerprint ?? matchedLink.sourceFingerprint, normalizedDisplayName: normalizedDisplayName.isEmpty ? matchedLink.normalizedDisplayName : normalizedDisplayName, profileId: resolvedPerson.id, lastSeenAt: sharedAt, ); } } final String summary = _truncate(payload.rawText, 1800); final RelationshipMoment moment = RelationshipMoment( id: 'm-${_uuid.v4()}', personId: resolvedPerson.id, title: _titleFromSummary(summary), summary: summary, at: sharedAt, type: sourceApp == 'whatsapp' ? 'whatsapp' : 'shared', ); final SharedMessageEntry sharedEntry = SharedMessageEntry( id: 'sm-${_uuid.v4()}', profileId: resolvedPerson.id, payload: payload.copyWith( rawText: summary, dedupeKey: payload.dedupeKey ?? _dedupeKeyForShare( sourceApp: payload.sourceApp, rawText: payload.rawText, url: payload.url, sourceUserId: payload.sourceUserId, sourceThreadId: payload.sourceThreadId, ), ), importedAt: importedAt, resolvedAutomatically: resolvedAutomatically, assignmentStatus: ShareAssignmentStatus.assignedDirectly, draft: draft, ); final List nextInbox = consumedInboxEntryId == null ? current.sharedInbox : current.sharedInbox .where( (SharedInboxEntry entry) => entry.id != consumedInboxEntryId, ) .toList(growable: false); await _setState( current.copyWith( people: _touchPeople( people, personId: resolvedPerson.id, updatedAt: importedAt, interactedAt: sharedAt, ), moments: [moment, ...current.moments], sourceLinks: nextLinks, sharedMessages: [ sharedEntry, ...current.sharedMessages, ], sharedInbox: nextInbox, personFacts: _applyFactDraftToFacts( current.personFacts, personId: resolvedPerson.id, sharedEntry: sharedEntry, draft: draft, consumedInboxEntryId: consumedInboxEntryId, observedAt: sharedAt, ), importantDates: _applyFactDraftToDates( current.importantDates, personId: resolvedPerson.id, draft: draft, sourceApp: payload.sourceApp, consumedInboxEntryId: consumedInboxEntryId, observedAt: sharedAt, ), ), ); final List outbound = [ if (createdProfile) _buildEnvelope( entityType: 'person', entityId: resolvedPerson.id, op: ChangeOperation.upsert, payload: _personPayload(resolvedPerson), ), _buildEnvelope( entityType: 'capture', entityId: moment.id, op: ChangeOperation.upsert, payload: _momentPayload(moment), ), ]; await _enqueueChanges(outbound); final List extractedSignals = _chatPreferenceExtractor.extract(summary); for (final ExtractedPreferenceSignalCandidate candidate in extractedSignals) { await upsertInferredPreferenceSignalObservation( personId: resolvedPerson.id, key: candidate.key, label: candidate.label, category: candidate.category, polarity: candidate.polarity, confidence: candidate.confidence, sourceApp: sourceApp, evidenceMessageId: sharedEntry.id, evidenceSnippet: candidate.evidenceSnippet, observedAt: sharedAt, ); } return SharedMessageIngestResult.imported( profileId: resolvedPerson.id, profileName: resolvedPerson.name, createdProfile: createdProfile, createdSourceLink: createdSourceLink, momentId: moment.id, ); } } CapturedFactDraft _defaultDraftForPayload(SharedPayload payload) { return ShareCaptureDraftSuggester.suggestForPayload( payload, ).copyWith(needsReview: true); } String _dedupeKeyForShare({ required String sourceApp, required String rawText, required String? url, required String? sourceUserId, required String? sourceThreadId, }) { final String text = rawText.trim().toLowerCase().replaceAll( RegExp(r'\s+'), ' ', ); return [ sourceApp.trim().toLowerCase(), sourceUserId?.trim().toLowerCase() ?? '', sourceThreadId?.trim().toLowerCase() ?? '', url?.trim().toLowerCase() ?? '', text, ].join('|'); } List _applyFactDraftToFacts( List currentFacts, { required String personId, required SharedMessageEntry sharedEntry, required CapturedFactDraft draft, required String? consumedInboxEntryId, required DateTime observedAt, }) { if (draft.type == CapturedFactType.importantDate || draft.text.trim().isEmpty) { return currentFacts; } final PersonFact fact = PersonFact( id: 'pf-${_uuid.v4()}', personId: personId, type: draft.type, text: draft.text.trim(), label: _trimToNull(draft.label), sourceKind: consumedInboxEntryId == null ? CaptureSourceKind.shareSheet : CaptureSourceKind.shareInbox, sourceApp: sharedEntry.sourceApp, sourceUrl: sharedEntry.url, sharedMessageId: sharedEntry.id, inboxEntryId: consumedInboxEntryId, confidence: draft.confidence, needsReview: draft.needsReview, isSensitive: draft.isSensitive, createdAt: observedAt, updatedAt: observedAt, ); return [fact, ...currentFacts]; } List _applyFactDraftToDates( List currentDates, { required String personId, required CapturedFactDraft draft, required String sourceApp, required String? consumedInboxEntryId, required DateTime observedAt, }) { if (draft.type != CapturedFactType.importantDate || draft.dateValue == null) { return currentDates; } final PersonImportantDate value = PersonImportantDate( id: 'pd-${_uuid.v4()}', personId: personId, label: _trimToNull(draft.label) ?? draft.text.trim(), date: draft.dateValue!, classification: 'important', sourceKind: consumedInboxEntryId == null ? CaptureSourceKind.shareSheet : CaptureSourceKind.shareInbox, sourceApp: sourceApp, inboxEntryId: consumedInboxEntryId, isSensitive: draft.isSensitive, createdAt: observedAt, updatedAt: observedAt, ); return [value, ...currentDates]; } SourceProfileLink? _findExistingSourceLink({ required List links, required String sourceApp, required String? sourceFingerprint, required String? sourceUserId, required String? sourceThreadId, required String normalizedDisplayName, }) { if (sourceFingerprint != null && sourceFingerprint.isNotEmpty) { for (final SourceProfileLink link in links) { final String? linkFingerprint = link.sourceFingerprint ?? _buildStableSourceFingerprint( sourceApp: link.sourceApp, sourceUserId: link.sourceUserId, sourceThreadId: link.sourceThreadId, ); if (link.sourceApp == sourceApp && linkFingerprint == sourceFingerprint) { return link; } } } if (sourceUserId != null && sourceUserId.isNotEmpty) { for (final SourceProfileLink link in links) { if (link.sourceApp == sourceApp && link.sourceUserId == sourceUserId) { return link; } } } if (sourceThreadId != null && sourceThreadId.isNotEmpty) { for (final SourceProfileLink link in links) { if (link.sourceApp == sourceApp && link.sourceThreadId == sourceThreadId) { return link; } } } if (normalizedDisplayName.isNotEmpty) { for (final SourceProfileLink link in links) { if (link.sourceApp == sourceApp && link.normalizedDisplayName == normalizedDisplayName) { return link; } } } return null; } List _findPeopleByNormalizedName( List people, String normalizedDisplayName, ) { return people .where((PersonProfile person) { if (_normalizeDisplayName(person.name) == normalizedDisplayName) { return true; } for (final String alias in person.aliases) { if (_normalizeDisplayName(alias) == normalizedDisplayName) { return true; } } return false; }) .toList(growable: false); } List _findNearMatchPeople( List people, { required String normalizedDisplayName, }) { if (normalizedDisplayName.isEmpty) { return const []; } final Map distanceById = {}; final List candidates = []; for (final PersonProfile person in people) { final List probes = [person.name, ...person.aliases]; for (final String probe in probes) { final String personName = _normalizeDisplayName(probe); if (personName.isEmpty || personName == normalizedDisplayName) { continue; } final int distance = _levenshteinDistance( normalizedDisplayName, personName, ); if (_isNearDisplayName( normalizedDisplayName, personName, distance: distance, )) { distanceById[person.id] = distance; if (!candidates.any((PersonProfile item) => item.id == person.id)) { candidates.add(person); } } } } candidates.sort((PersonProfile a, PersonProfile b) { final int left = distanceById[a.id] ?? 999; final int right = distanceById[b.id] ?? 999; if (left != right) { return left.compareTo(right); } return a.name.length.compareTo(b.name.length); }); if (candidates.length <= 5) { return candidates; } return candidates.take(5).toList(growable: false); } bool _isNearDisplayName(String left, String right, {required int distance}) { final int maxLength = left.length > right.length ? left.length : right.length; final int minLength = left.length < right.length ? left.length : right.length; if (minLength >= 4 && (left.contains(right) || right.contains(left))) { return true; } final int threshold = maxLength >= 14 ? 3 : 2; if (distance <= threshold) { return true; } return maxLength >= 8 && (distance / maxLength) <= 0.22; } int _levenshteinDistance(String left, String right) { if (left == right) { return 0; } if (left.isEmpty) { return right.length; } if (right.isEmpty) { return left.length; } List previous = List.generate( right.length + 1, (int index) => index, growable: false, ); for (int i = 1; i <= left.length; i += 1) { final List current = List.filled(right.length + 1, 0); current[0] = i; for (int j = 1; j <= right.length; j += 1) { final int substitutionCost = left[i - 1] == right[j - 1] ? 0 : 1; final int deletion = previous[j] + 1; final int insertion = current[j - 1] + 1; final int substitution = previous[j - 1] + substitutionCost; final int value = deletion < insertion ? deletion : insertion; current[j] = value < substitution ? value : substitution; } previous = current; } return previous[right.length]; } SharedInboxEntry _requireSharedInboxEntry( LocalDataState current, String inboxEntryId, ) { for (final SharedInboxEntry entry in current.sharedInbox) { if (entry.id == inboxEntryId) { return entry; } } throw ArgumentError('Shared inbox entry not found'); } String? _buildStableSourceFingerprint({ required String sourceApp, required String? sourceUserId, required String? sourceThreadId, }) { final String? userId = _trimToNull(sourceUserId); final String? threadId = _trimToNull(sourceThreadId); if (userId == null && threadId == null) { return null; } final List parts = [ sourceApp.trim().toLowerCase(), if (userId != null) 'u:${userId.toLowerCase()}', if (threadId != null) 't:${threadId.toLowerCase()}', ]; return parts.join('|'); } String _normalizeDisplayName(String? raw) { if (raw == null) { return ''; } final String compact = raw .toLowerCase() .replaceAll(RegExp(r'[^a-z0-9]+'), ' ') .trim(); return compact.replaceAll(RegExp(r'\s+'), ' '); }