Add LLM digest setup and share timestamp context

This commit is contained in:
Rijad Zuzo
2026-05-19 19:17:35 +02:00
parent 9d2da0c600
commit 5d80af375e
42 changed files with 2878 additions and 39 deletions
@@ -38,6 +38,7 @@ class SharedPayload {
required this.createdAt,
required this.receivedAt,
required this.platform,
this.sharedMessageDateTime,
this.url,
this.sourceDisplayName,
this.sourceUserId,
@@ -53,6 +54,7 @@ class SharedPayload {
final String? url;
final DateTime createdAt;
final DateTime receivedAt;
final DateTime? sharedMessageDateTime;
final String platform;
final String? sourceDisplayName;
final String? sourceUserId;
@@ -68,6 +70,7 @@ class SharedPayload {
String? url,
DateTime? createdAt,
DateTime? receivedAt,
DateTime? sharedMessageDateTime,
String? platform,
String? sourceDisplayName,
String? sourceUserId,
@@ -83,6 +86,8 @@ class SharedPayload {
url: url ?? this.url,
createdAt: createdAt ?? this.createdAt,
receivedAt: receivedAt ?? this.receivedAt,
sharedMessageDateTime:
sharedMessageDateTime ?? this.sharedMessageDateTime,
platform: platform ?? this.platform,
sourceDisplayName: sourceDisplayName ?? this.sourceDisplayName,
sourceUserId: sourceUserId ?? this.sourceUserId,
@@ -101,6 +106,7 @@ class SharedPayload {
'url': url,
'createdAt': createdAt.toUtc().toIso8601String(),
'receivedAt': receivedAt.toUtc().toIso8601String(),
'sharedMessageDateTime': sharedMessageDateTime?.toUtc().toIso8601String(),
'platform': platform,
'sourceDisplayName': sourceDisplayName,
'sourceUserId': sourceUserId,
@@ -130,6 +136,9 @@ class SharedPayload {
json['receivedAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
sharedMessageDateTime: json['sharedMessageDateTime'] == null
? null
: DateTime.parse(json['sharedMessageDateTime'] as String).toLocal(),
platform: json['platform'] as String? ?? 'unknown',
sourceDisplayName: json['sourceDisplayName'] as String?,
sourceUserId: json['sourceUserId'] as String?,
@@ -244,7 +253,8 @@ class SharedMessageEntry {
String get sourceApp => payload.sourceApp;
String get messageText => payload.rawText;
DateTime get sharedAt => payload.createdAt;
DateTime get sharedAt => payload.sharedMessageDateTime ?? payload.createdAt;
DateTime? get sharedMessageDateTime => payload.sharedMessageDateTime;
SharedPayloadType get payloadType => payload.payloadType;
String? get url => payload.url;
String? get sourceDisplayName => payload.sourceDisplayName;
@@ -1,4 +1,5 @@
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/share_intake/domain/shared_message_datetime_extractor.dart';
import 'package:relationship_saver/features/share_intake/domain/signal_share_parser.dart';
import 'package:relationship_saver/features/share_intake/whatsapp_share_parser.dart';
@@ -20,6 +21,8 @@ class SharePayloadParser {
final DateTime now = DateTime.now();
final String normalizedSourceApp = _normalizeSourceApp(sourceApp);
final String trimmed = rawText.trim();
final DateTime? sharedMessageDateTime =
SharedMessageDateTimeExtractor.extract(trimmed, now: receivedAt ?? now);
final _ParsedSourceMetadata? sourceMetadata = _parseSourceMetadata(
sourceApp: normalizedSourceApp,
rawText: trimmed,
@@ -48,6 +51,7 @@ class SharePayloadParser {
url: url,
createdAt: createdAt ?? now,
receivedAt: receivedAt ?? now,
sharedMessageDateTime: sharedMessageDateTime,
platform: platform,
sourceDisplayName: sourceMetadata?.sourceDisplayName,
sourceUserId: sourceMetadata?.sourceUserId,
@@ -56,11 +60,14 @@ class SharePayloadParser {
if (normalizedSourceApp != 'share_sheet')
'source_app_$normalizedSourceApp',
if (url != null) 'contains_url',
if (sharedMessageDateTime != null) 'shared_message_datetime_detected',
if (sourceMetadata?.sourceDisplayName case final String _)
'sender_detected',
],
metadata: <String, String>{
if (url case final String value) 'primaryUrl': value,
if (sharedMessageDateTime case final DateTime value)
'shared_message_datetime': value.toUtc().toIso8601String(),
},
);
}
@@ -0,0 +1,377 @@
/// Best-effort extraction of the original message timestamp from shared text.
///
/// This intentionally prefers source-message metadata shapes (chat export
/// prefixes, bracketed timestamps, and explicit sent/received labels). It avoids
/// treating arbitrary content dates like "birthday is May 20" as the time the
/// message was received.
class SharedMessageDateTimeExtractor {
const SharedMessageDateTimeExtractor._();
static final RegExp _isoDateTimePattern = RegExp(
r'(\d{4}-\d{1,2}-\d{1,2})(?:[tT\s]+(\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?)(?:\s*(Z|[+-]\d{2}:?\d{2}))?)?',
);
static final RegExp _numericDatePattern = RegExp(
r'(\d{1,2})[./-](\d{1,2})[./-](\d{2,4})(?:[,\s]+(?:at\s+)?(\d{1,2})(?::(\d{2}))?(?::(\d{2}))?\s*(AM|PM|am|pm)?)?',
);
static final RegExp _monthNameDatePattern = RegExp(
r'(?:(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-zÀ-ž.]+)\s*,?\s*(\d{2,4})|([A-Za-zÀ-ž.]+)\s+(\d{1,2})(?:st|nd|rd|th)?[,]?\s*(\d{2,4}))(?:[,\s]+(?:at\s+)?(\d{1,2})(?::(\d{2}))?(?::(\d{2}))?\s*(AM|PM|am|pm)?)?',
);
static final RegExp _sourcePrefixPattern = RegExp(
r'^\s*(?:\[([^\]]{3,80})\]|([^-–—\n]{3,80}))\s*(?:[-–—]\s*)?([^:\n]{2,80}):\s+.+$',
dotAll: true,
);
static final RegExp _labelledLinePattern = RegExp(
r'^\s*(?:sent|sent at|received|received at|date|datetime|timestamp|message date|message time)\s*[:=-]\s*(.+)$',
caseSensitive: false,
);
static final RegExp _relativeLabelPattern = RegExp(
r'\b(today|yesterday)\b(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))(?::(\d{2}))?\s*(AM|PM|am|pm)?',
caseSensitive: false,
);
static final Map<String, int> _months = <String, int>{
'jan': 1,
'january': 1,
'januar': 1,
'januari': 1,
'janvier': 1,
'feb': 2,
'february': 2,
'februar': 2,
'februari': 2,
'fevrier': 2,
'février': 2,
'mar': 3,
'march': 3,
'märz': 3,
'marz': 3,
'maerz': 3,
'mars': 3,
'apr': 4,
'april': 4,
'avr': 4,
'avril': 4,
'may': 5,
'mai': 5,
'maj': 5,
'jun': 6,
'june': 6,
'juni': 6,
'juin': 6,
'jul': 7,
'july': 7,
'juli': 7,
'juillet': 7,
'aug': 8,
'august': 8,
'aout': 8,
'août': 8,
'sep': 9,
'sept': 9,
'september': 9,
'septembre': 9,
'okt': 10,
'oct': 10,
'october': 10,
'oktober': 10,
'octobre': 10,
'nov': 11,
'november': 11,
'novembre': 11,
'dec': 12,
'dez': 12,
'december': 12,
'dezember': 12,
'decembre': 12,
'décembre': 12,
};
static DateTime? extract(String rawText, {DateTime? now}) {
try {
return _extract(rawText, now: now ?? DateTime.now());
} catch (_) {
return null;
}
}
static DateTime? _extract(String rawText, {required DateTime now}) {
final String input = rawText.trim();
if (input.isEmpty) {
return null;
}
for (final String candidate in _candidateFragments(input)) {
final DateTime? parsed = _parseDateTime(candidate, now: now);
if (parsed != null) {
return parsed;
}
}
final RegExpMatch? sourcePrefix = _sourcePrefixPattern.firstMatch(input);
if (sourcePrefix != null) {
for (final String? group in <String?>[
sourcePrefix.group(1),
sourcePrefix.group(2),
]) {
if (group == null) {
continue;
}
final DateTime? parsed = _parseDateTime(group, now: now);
if (parsed != null) {
return parsed;
}
}
}
return null;
}
static List<String> _candidateFragments(String input) {
final List<String> candidates = <String>[];
final List<String> lines = input
.split(RegExp(r'\r?\n'))
.map((String line) => line.trim())
.where((String line) => line.isNotEmpty)
.take(6)
.toList(growable: false);
for (final String line in lines) {
final RegExpMatch? labelled = _labelledLinePattern.firstMatch(line);
if (labelled != null) {
candidates.add(labelled.group(1)!.trim());
}
if (line.startsWith('[') && line.contains(']')) {
final int end = line.indexOf(']');
candidates.add(line.substring(1, end).trim());
}
final int dashIndex = _firstSeparatorIndex(line);
final int colonIndex = line.indexOf(':');
if (dashIndex > 0 && dashIndex <= 80) {
candidates.add(line.substring(0, dashIndex).trim());
} else if (colonIndex > 0 && colonIndex <= 80) {
candidates.add(line.substring(0, colonIndex).trim());
}
candidates.add(line);
}
return candidates;
}
static int _firstSeparatorIndex(String value) {
final List<int> indexes = <int>[
value.indexOf(' - '),
value.indexOf(' '),
value.indexOf(''),
].where((int index) => index >= 0).toList(growable: false);
if (indexes.isEmpty) {
return -1;
}
indexes.sort();
return indexes.first;
}
static DateTime? _parseDateTime(String raw, {required DateTime now}) {
String value = raw
.trim()
.replaceFirst(RegExp(r'^[A-Za-z]{3,9},\s+'), '')
.replaceAll(RegExp(r'\s+'), ' ');
value = value.replaceAll(' um ', ' at ');
value = value.replaceAll(' à ', ' at ');
final RegExpMatch? relative = _relativeLabelPattern.firstMatch(value);
if (relative != null) {
final DateTime date = relative.group(1)!.toLowerCase() == 'yesterday'
? now.subtract(const Duration(days: 1))
: now;
return _dateTimeOrNull(
date.year,
date.month,
date.day,
_hour(relative.group(2), relative.group(5)),
_intOrNull(relative.group(3)) ?? 0,
_intOrNull(relative.group(4)) ?? 0,
);
}
final RegExpMatch? iso = _isoDateTimePattern.firstMatch(value);
if (iso != null && _looksLikeMetadataFragment(value, iso.group(0)!)) {
final String date = iso.group(1)!;
final String? time = iso.group(2);
final String? zone = iso.group(3);
final String isoValue = time == null
? date
: '${date}T$time${zone == null ? '' : _normalizeZone(zone)}';
final DateTime? parsed = DateTime.tryParse(isoValue);
if (parsed != null) {
return parsed.isUtc ? parsed.toLocal() : parsed;
}
}
final RegExpMatch? numeric = _numericDatePattern.firstMatch(value);
if (numeric != null &&
_looksLikeMetadataFragment(value, numeric.group(0)!)) {
final int first = int.parse(numeric.group(1)!);
final int second = int.parse(numeric.group(2)!);
final int year = _expandYear(int.parse(numeric.group(3)!));
final bool preferMonthFirst = _preferMonthFirst(value);
final int day;
final int month;
if (first > 12) {
day = first;
month = second;
} else if (second > 12) {
day = second;
month = first;
} else if (preferMonthFirst) {
day = second;
month = first;
} else {
day = first;
month = second;
}
return _dateTimeOrNull(
year,
month,
day,
_hour(numeric.group(4), numeric.group(7)),
_intOrNull(numeric.group(5)) ?? 0,
_intOrNull(numeric.group(6)) ?? 0,
);
}
final RegExpMatch? named = _monthNameDatePattern.firstMatch(value);
if (named != null && _looksLikeMetadataFragment(value, named.group(0)!)) {
final bool dayFirst = named.group(1) != null;
final int day = int.parse(dayFirst ? named.group(1)! : named.group(5)!);
final int? month = _monthNumber(
dayFirst ? named.group(2)! : named.group(4)!,
);
if (month == null) {
return null;
}
final int year = _expandYear(
int.parse(dayFirst ? named.group(3)! : named.group(6)!),
);
return _dateTimeOrNull(
year,
month,
day,
_hour(named.group(7), named.group(10)),
_intOrNull(named.group(8)) ?? 0,
_intOrNull(named.group(9)) ?? 0,
);
}
return null;
}
static bool _looksLikeMetadataFragment(String fragment, String match) {
final String value = fragment.trim();
if (value == match.trim()) {
return true;
}
if (value.startsWith(match)) {
final String tail = value.substring(match.length).trimLeft();
return tail.isEmpty ||
tail.startsWith('-') ||
tail.startsWith('') ||
tail.startsWith('') ||
tail.startsWith(']') ||
tail.startsWith(',') ||
tail.startsWith(':') ||
tail.toLowerCase().startsWith('at ');
}
return false;
}
static String _normalizeZone(String zone) {
if (zone == 'Z') {
return zone;
}
if (RegExp(r'^[+-]\d{4}$').hasMatch(zone)) {
return '${zone.substring(0, 3)}:${zone.substring(3)}';
}
return zone;
}
static bool _preferMonthFirst(String value) {
final String lower = value.toLowerCase();
return lower.contains('am') || lower.contains('pm');
}
static int _expandYear(int year) {
if (year >= 100) {
return year;
}
return year >= 70 ? 1900 + year : 2000 + year;
}
static int? _monthNumber(String raw) {
final String key = raw
.toLowerCase()
.replaceAll('.', '')
.replaceAll('ä', 'a')
.replaceAll('é', 'e')
.replaceAll('è', 'e')
.replaceAll('û', 'u')
.trim();
return _months[key] ?? _months[raw.toLowerCase().replaceAll('.', '')];
}
static int _hour(String? rawHour, String? meridiem) {
if (rawHour == null) {
return 0;
}
int hour = int.parse(rawHour);
final String? marker = meridiem?.toLowerCase();
if (marker == 'pm' && hour < 12) {
hour += 12;
}
if (marker == 'am' && hour == 12) {
hour = 0;
}
return hour;
}
static int? _intOrNull(String? value) {
if (value == null || value.isEmpty) {
return null;
}
return int.tryParse(value);
}
static DateTime? _dateTimeOrNull(
int year,
int month,
int day,
int hour,
int minute,
int second,
) {
if (month < 1 ||
month > 12 ||
day < 1 ||
day > 31 ||
hour < 0 ||
hour > 23 ||
minute < 0 ||
minute > 59 ||
second < 0 ||
second > 59) {
return null;
}
final DateTime value = DateTime(year, month, day, hour, minute, second);
if (value.year != year || value.month != month || value.day != day) {
return null;
}
return value;
}
}