Files
rely/lib/app/data/relationship_repository_share.dart
T
2026-05-19 19:17:35 +02:00

846 lines
27 KiB
Dart

part of 'relationship_repository.dart';
/// Share capture intake, person resolution, and inbox triage.
extension LocalRepositoryShareOperations on LocalRepository {
Future<SharedMessageIngestResult> 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<PersonProfile> matchingPeople = normalizedDisplayName.isEmpty
? const <PersonProfile>[]
: _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<PersonProfile> nearMatchPeople = resolvedPerson == null
? _findNearMatchPeople(
current.people,
normalizedDisplayName: normalizedDisplayName,
)
: const <PersonProfile>[];
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 <String>[],
);
}
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<SharedMessageIngestResult> 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<SharedMessageIngestResult> resolveSharedInboxByCreatingProfile({
required String inboxEntryId,
String? name,
String relationship = 'WhatsApp Contact',
String? location,
List<String> aliases = const <String>[],
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<SharedMessageIngestResult> saveSharedPayloadToInbox({
required SharedPayload payload,
SharedInboxReason reason = SharedInboxReason.missingIdentity,
List<String> candidateProfileIds = const <String>[],
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<SharedMessageIngestResult> 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<SharedMessageIngestResult> captureSharedPayloadByCreatingProfile({
required SharedPayload payload,
String? name,
String relationship = 'Contact',
String? location,
List<String> aliases = const <String>[],
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: <String>[
if (payload.sourceApp.trim().isNotEmpty) payload.sourceApp.trim(),
],
notes: 'Created from shared capture.',
aliases: _normalizeAliases(<String>[
...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: <PersonProfile>[createdPerson, ...current.people],
resolvedAutomatically: resolvedAutomatically,
consumedInboxEntryId: consumedInboxEntryId,
draft: draft ?? _defaultDraftForPayload(payload),
);
}
Future<void> dismissSharedInboxEntry(String inboxEntryId) async {
final LocalDataState current = _requireState();
final List<SharedInboxEntry> 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<SharedMessageIngestResult> _queueSharedInbox({
required LocalDataState current,
required SharedPayload payload,
required SharedInboxReason reason,
required List<String> 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: <SharedInboxEntry>[entry, ...current.sharedInbox],
),
);
return SharedMessageIngestResult.queuedForResolution(
inboxEntryId: entry.id,
);
}
Future<SharedMessageIngestResult> _ingestIntoResolvedProfile({
required LocalDataState current,
required SharedPayload payload,
required DateTime importedAt,
required String normalizedDisplayName,
required String? sourceFingerprint,
required PersonProfile resolvedPerson,
required bool createdProfile,
required List<PersonProfile> 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<SourceProfileLink> 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<SharedInboxEntry> 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: <RelationshipMoment>[moment, ...current.moments],
sourceLinks: nextLinks,
sharedMessages: <SharedMessageEntry>[
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<ChangeEnvelope> outbound = <ChangeEnvelope>[
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<ExtractedPreferenceSignalCandidate> 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 <String>[
sourceApp.trim().toLowerCase(),
sourceUserId?.trim().toLowerCase() ?? '',
sourceThreadId?.trim().toLowerCase() ?? '',
url?.trim().toLowerCase() ?? '',
text,
].join('|');
}
List<PersonFact> _applyFactDraftToFacts(
List<PersonFact> 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 <PersonFact>[fact, ...currentFacts];
}
List<PersonImportantDate> _applyFactDraftToDates(
List<PersonImportantDate> 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 <PersonImportantDate>[value, ...currentDates];
}
SourceProfileLink? _findExistingSourceLink({
required List<SourceProfileLink> 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<PersonProfile> _findPeopleByNormalizedName(
List<PersonProfile> 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<PersonProfile> _findNearMatchPeople(
List<PersonProfile> people, {
required String normalizedDisplayName,
}) {
if (normalizedDisplayName.isEmpty) {
return const <PersonProfile>[];
}
final Map<String, int> distanceById = <String, int>{};
final List<PersonProfile> candidates = <PersonProfile>[];
for (final PersonProfile person in people) {
final List<String> probes = <String>[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<int> previous = List<int>.generate(
right.length + 1,
(int index) => index,
growable: false,
);
for (int i = 1; i <= left.length; i += 1) {
final List<int> current = List<int>.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<String> parts = <String>[
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+'), ' ');
}