feat: add per-email notes stored on IMAP/JMAP server #443

Merged
guettlibot merged 5 commits from issue-436-notes-on-emails into main 2026-06-05 17:31:37 +00:00
3 changed files with 27 additions and 42 deletions
Showing only changes of commit 92eb72c7fc - Show all commits
+17 -30
View File
@@ -40,8 +40,7 @@ class NoteRepositoryImpl implements NoteRepository {
return (_db.select(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(accountId) &
t.messageId.equals(messageId),
t.accountId.equals(accountId) & t.messageId.equals(messageId),
)
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
.watch()
@@ -131,8 +130,7 @@ class NoteRepositoryImpl implements NoteRepository {
.get();
for (final note in local) {
if (!fetchedIds.contains(note.id)) {
await (_db.delete(_db.emailNotes)
..where((t) => t.id.equals(note.id)))
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(note.id)))
.go();
}
}
@@ -178,10 +176,9 @@ class NoteRepositoryImpl implements NoteRepository {
'0',
],
]);
final ids =
List<String>.from(
(_responseArgs(queryResp, 0, 'Email/query')['ids'] as List? ?? []),
);
final ids = List<String>.from(
(_responseArgs(queryResp, 0, 'Email/query')['ids'] as List? ?? []),
);
if (ids.isEmpty) {
await (_db.delete(_db.emailNotes)
@@ -219,11 +216,9 @@ class NoteRepositoryImpl implements NoteRepository {
final fetchedIds = <String>{};
for (final e in list) {
final m = e as Map<String, dynamic>;
final noteFor =
(m['header:$_headerNoteFor:asText'] as String?)?.trim();
final noteFor = (m['header:$_headerNoteFor:asText'] as String?)?.trim();
if (noteFor != messageId) continue;
final noteId =
(m['header:$_headerNoteId:asText'] as String?)?.trim();
final noteId = (m['header:$_headerNoteId:asText'] as String?)?.trim();
if (noteId == null || noteId.isEmpty) continue;
final jmapEmailId = m['id'] as String;
@@ -232,19 +227,16 @@ class NoteRepositoryImpl implements NoteRepository {
var noteText = '';
if (textBodyParts.isNotEmpty) {
final partId =
(textBodyParts.first as Map<String, dynamic>)['partId']
as String?;
(textBodyParts.first as Map<String, dynamic>)['partId'] as String?;
if (partId != null) {
noteText =
(bodyValues[partId] as Map<String, dynamic>?)?['value']
noteText = (bodyValues[partId] as Map<String, dynamic>?)?['value']
as String? ??
'';
}
}
final createdAt =
DateTime.tryParse(m['receivedAt'] as String? ?? '') ??
DateTime.now();
DateTime.tryParse(m['receivedAt'] as String? ?? '') ?? DateTime.now();
fetchedIds.add(noteId);
await _db.into(_db.emailNotes).insertOnConflictUpdate(
EmailNotesCompanion.insert(
@@ -262,14 +254,12 @@ class NoteRepositoryImpl implements NoteRepository {
final local = await (_db.select(_db.emailNotes)
..where(
(t) =>
t.accountId.equals(account.id) &
t.messageId.equals(messageId),
t.accountId.equals(account.id) & t.messageId.equals(messageId),
))
.get();
for (final note in local) {
if (!fetchedIds.contains(note.id)) {
await (_db.delete(_db.emailNotes)
..where((t) => t.id.equals(note.id)))
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(note.id)))
.go();
}
}
@@ -328,10 +318,9 @@ class NoteRepositoryImpl implements NoteRepository {
);
final uidList =
appendResult.responseCodeAppendUid?.targetSequence.toList();
final serverId =
(uidList != null && uidList.isNotEmpty)
? uidList.first.toString()
: '';
final serverId = (uidList != null && uidList.isNotEmpty)
? uidList.first.toString()
: '';
await _db.into(_db.emailNotes).insertOnConflictUpdate(
EmailNotesCompanion.insert(
@@ -514,8 +503,7 @@ class NoteRepositoryImpl implements NoteRepository {
'0',
],
]);
final list =
_responseArgs(resp, 0, 'Mailbox/get')['list'] as List<dynamic>;
final list = _responseArgs(resp, 0, 'Mailbox/get')['list'] as List<dynamic>;
for (final m in list) {
final map = m as Map<String, dynamic>;
if (map['name'] == _notesFolder) return map['id'] as String?;
@@ -574,8 +562,7 @@ class NoteRepositoryImpl implements NoteRepository {
final bytes = List<int>.generate(16, (_) => rng.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
final hex =
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}'
'-${hex.substring(12, 16)}-${hex.substring(16, 20)}'
'-${hex.substring(20)}';
+2 -3
View File
@@ -297,7 +297,6 @@ final noteRepositoryProvider = Provider<NoteRepository>((ref) {
/// Stream of notes for a specific email, identified by (accountId, messageId).
final notesProvider =
StreamProvider.autoDispose.family<List<EmailNote>, (String, String)>(
(ref, params) => ref
.watch(noteRepositoryProvider)
.observeNotes(params.$1, params.$2),
(ref, params) =>
ref.watch(noteRepositoryProvider).observeNotes(params.$1, params.$2),
);
+8 -9
View File
@@ -56,9 +56,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
_notesSynced = true;
unawaited(
ref.read(noteRepositoryProvider).syncNotes(
email!.accountId,
email.messageId!,
),
email!.accountId,
email.messageId!,
),
);
}
},
@@ -268,8 +268,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
body.textBody ?? '',
style: Theme.of(ctx).textTheme.bodyMedium,
),
if (header?.messageId != null)
_buildNotesSection(ctx, header!),
if (header?.messageId != null) _buildNotesSection(ctx, header!),
if (body.attachments.isNotEmpty) ...[
const Divider(),
Padding(
@@ -455,10 +454,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (!context.mounted) return;
await ref.read(noteRepositoryProvider).addNote(
header.accountId,
messageId,
text,
);
header.accountId,
messageId,
text,
);
}
Widget _buildHeader(BuildContext ctx, Email email) {