Add LLM digest setup and share timestamp context
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user