1
import 'dart:convert';
2
3
import 'package:archive/archive.dart';
4
+import 'package:shared_preferences/shared_preferences.dart';
5
6
import '../models/enums.dart';
7
import '../storage/entities.dart';
8
import '../../features/chat/data/models/message.dart';
9
import '../../features/conversations/data/models/conversation.dart';
10
import '../../features/personas/data/models/persona.dart';
11
+import '../../features/servers/data/models/server.dart';
12
import '../../objectbox.g.dart';
13
14
class DataBackupService {
13
- Map<String, dynamic> exportAll(Store store) {
14
- final convBox = store.box<ConversationEntity>();
15
- final messageBox = store.box<MessageEntity>();
16
- final personaBox = store.box<PersonaEntity>();
15
+ Map<String, dynamic> _messageToMap(Message message) => {
16
+ 'id': message.id,
17
+ 'conversationId': message.conversationId,
18
+ 'role': message.role.name,
19
+ 'content': message.content,
20
+ 'createdAt': message.createdAt.toIso8601String(),
21
+ 'status': message.status.name,
22
+ 'modelId': message.modelId,
23
+ 'tokenCount': message.tokenCount,
24
+ 'errorMessage': message.errorMessage,
25
+ 'attachmentPaths': message.attachmentPaths,
26
+ 'generationTimeMs': message.generationTimeMs,
27
+ 'reasoningContent': message.reasoningContent,
28
+ 'toolCallId': message.toolCallId,
29
+ 'isProcessing': message.isProcessing,
30
+ 'toolSessionId': message.toolSessionId,
31
+ 'variantGroupId': message.variantGroupId,
32
+ 'variantIndex': message.variantIndex,
33
+ 'threadOrder': message.threadOrder,
34
+ 'isActiveVariant': message.isActiveVariant,
35
+ 'parentMessageId': message.parentMessageId,
36
+ };
37
18
- final conversations = convBox.getAll().map((entity) {
19
- final domain = entity.toDomain();
20
- return {
38
+ Map<String, dynamic> _conversationToMap(Conversation domain) => {
39
'id': domain.id,
40
'title': domain.title,
41
'createdAt': domain.createdAt.toIso8601String(),
56
'smartRepliesLastMessageId': domain.smartRepliesLastMessageId,
57
'folderId': domain.folderId,
58
};
59
+
60
+ List<Map<String, dynamic>> _exportSavedMessages(Store store) {
61
+ final box = store.box<SavedMessageEntity>();
62
+ return box.getAll().map((e) {
63
+ return {
64
+ 'id': e.id,
65
+ 'sourceMessageId': e.sourceMessageId,
66
+ 'conversationId': e.conversationId,
67
+ 'conversationTitle': e.conversationTitle,
68
+ 'roleIndex': e.roleIndex,
69
+ 'content': e.content,
70
+ 'modelId': e.modelId,
71
+ 'folderId': e.folderId,
72
+ 'savedAt': e.savedAt.toIso8601String(),
73
+ };
74
}).toList();
75
+ }
76
43
- final messages = messageBox.getAll().map((entity) {
44
- final message = entity.toDomain();
77
+ List<Map<String, dynamic>> _exportSavedMessageFolders(Store store) {
78
+ final box = store.box<SavedMessageFolderEntity>();
79
+ return box.getAll().map((e) {
80
return {
46
- 'id': message.id,
47
- 'conversationId': message.conversationId,
48
- 'role': message.role.name,
49
- 'content': message.content,
50
- 'createdAt': message.createdAt.toIso8601String(),
51
- 'status': message.status.name,
52
- 'modelId': message.modelId,
53
- 'tokenCount': message.tokenCount,
54
- 'errorMessage': message.errorMessage,
55
- 'attachmentPaths': message.attachmentPaths,
56
- 'generationTimeMs': message.generationTimeMs,
57
- 'reasoningContent': message.reasoningContent,
58
- 'toolCallId': message.toolCallId,
59
- 'isProcessing': message.isProcessing,
60
- 'toolSessionId': message.toolSessionId,
61
- 'variantGroupId': message.variantGroupId,
62
- 'variantIndex': message.variantIndex,
63
- 'threadOrder': message.threadOrder,
64
- 'isActiveVariant': message.isActiveVariant,
81
+ 'id': e.id,
82
+ 'name': e.name,
83
+ 'sortOrder': e.sortOrder,
84
+ 'createdAt': e.createdAt.toIso8601String(),
85
+ };
86
+ }).toList();
87
+ }
88
+
89
+ List<Map<String, dynamic>> _exportServers(Store store) {
90
+ return store.box<ServerEntity>().getAll().map((entity) {
91
+ final server = entity.toDomain();
92
+ return {
93
+ 'id': server.id,
94
+ 'name': server.name,
95
+ 'type': server.type.name,
96
+ 'host': server.host,
97
+ 'port': server.port,
98
+ 'apiKey': server.apiKey,
99
+ 'isDefault': server.isDefault,
100
+ 'createdAt': server.createdAt.toIso8601String(),
101
+ 'lastConnectedAt': server.lastConnectedAt.toIso8601String(),
102
+ 'status': server.status.name,
103
+ 'iconName': server.iconName,
104
+ 'pathPrefix': server.pathPrefix,
105
};
106
}).toList();
107
+ }
108
+
109
+ Map<String, dynamic>? _readModelMetadata(SharedPreferences? prefs) {
110
+ if (prefs == null) return null;
111
+ final raw = prefs.getString('modelMetadata');
112
+ if (raw == null || raw.isEmpty) return null;
113
+ try {
114
+ return Map<String, dynamic>.from(jsonDecode(raw) as Map);
115
+ } catch (_) {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ Map<String, dynamic> exportAll(Store store, {SharedPreferences? prefs}) {
121
+ final convBox = store.box<ConversationEntity>();
122
+ final messageBox = store.box<MessageEntity>();
123
+ final personaBox = store.box<PersonaEntity>();
124
+
125
+ final conversations =
126
+ convBox.getAll().map((e) => _conversationToMap(e.toDomain())).toList();
127
+ final messages =
128
+ messageBox.getAll().map((e) => _messageToMap(e.toDomain())).toList();
129
130
final personas = personaBox
131
.getAll()
148
.toList();
149
150
return {
89
- 'version': 2,
151
+ 'version': 3,
152
'exportedAt': DateTime.now().toIso8601String(),
153
'conversations': conversations,
154
'messages': messages,
155
'personas': personas,
156
+ 'savedMessages': _exportSavedMessages(store),
157
+ 'savedMessageFolders': _exportSavedMessageFolders(store),
158
+ 'servers': _exportServers(store),
159
+ if (_readModelMetadata(prefs) != null)
160
+ 'modelMetadata': _readModelMetadata(prefs),
161
};
162
}
163
164
Map<String, dynamic> exportConversations(Store store) {
165
final all = exportAll(store);
166
return {
100
- 'version': 2,
167
+ 'version': 3,
168
'type': 'conversations',
169
'exportedAt': all['exportedAt'],
170
'conversations': all['conversations'],
171
'messages': all['messages'],
172
+ 'savedMessages': all['savedMessages'],
173
+ 'savedMessageFolders': all['savedMessageFolders'],
174
+ };
175
+ }
176
+
177
+ Map<String, dynamic> exportConversation(Store store, String conversationId) {
178
+ final all = exportAll(store);
179
+ final conversations = (all['conversations'] as List)
180
+ .where((c) => (c as Map)['id'] == conversationId)
181
+ .toList();
182
+ final messages = (all['messages'] as List)
183
+ .where((m) => (m as Map)['conversationId'] == conversationId)
184
+ .toList();
185
+ final savedMessages = (all['savedMessages'] as List)
186
+ .where((m) => (m as Map)['conversationId'] == conversationId)
187
+ .toList();
188
+
189
+ return {
190
+ 'version': 3,
191
+ 'type': 'conversation',
192
+ 'exportedAt': all['exportedAt'],
193
+ 'conversations': conversations,
194
+ 'messages': messages,
195
+ 'savedMessages': savedMessages,
196
};
197
}
198
199
Map<String, dynamic> exportPersonas(Store store) {
200
final all = exportAll(store);
201
return {
111
- 'version': 2,
202
+ 'version': 3,
203
'type': 'personas',
204
'exportedAt': all['exportedAt'],
205
'personas': all['personas'],
206
};
207
}
208
118
- Map<String, dynamic> exportSettings(String settingsJson) {
119
- return {
120
- 'version': 2,
209
+ Map<String, dynamic> exportSettings(
210
+ String settingsJson, {
211
+ Store? store,
212
+ SharedPreferences? prefs,
213
+ }) {
214
+ final map = <String, dynamic>{
215
+ 'version': 3,
216
'type': 'settings',
217
'exportedAt': DateTime.now().toIso8601String(),
218
'settings': jsonDecode(settingsJson),
219
};
220
+ if (store != null) {
221
+ map['servers'] = _exportServers(store);
222
+ }
223
+ final metadata = _readModelMetadata(prefs);
224
+ if (metadata != null) {
225
+ map['modelMetadata'] = metadata;
226
+ }
227
+ return map;
228
}
229
230
String exportConversationsAsJson(Store store) =>
231
const JsonEncoder.withIndent(' ').convert(exportConversations(store));
232
233
+ String exportConversationAsJson(Store store, String conversationId) =>
234
+ const JsonEncoder.withIndent(' ')
235
+ .convert(exportConversation(store, conversationId));
236
+
237
String exportPersonasAsJson(Store store) =>
238
const JsonEncoder.withIndent(' ').convert(exportPersonas(store));
239
133
- String exportSettingsAsJson(String settingsJson) =>
134
- const JsonEncoder.withIndent(' ').convert(exportSettings(settingsJson));
240
+ String exportSettingsAsJson(
241
+ String settingsJson, {
242
+ Store? store,
243
+ SharedPreferences? prefs,
244
+ }) =>
245
+ const JsonEncoder.withIndent(' ').convert(
246
+ exportSettings(settingsJson, store: store, prefs: prefs),
247
+ );
248
+
249
+ ArchiveFile _jsonArchiveFile(String name, String json) {
250
+ final bytes = utf8.encode(json);
251
+ return ArchiveFile(name, bytes.length, bytes);
252
+ }
253
136
- List<int> exportAllZip(Store store, String settingsJson) {
254
+ List<int> exportAllZip(
255
+ Store store,
256
+ String settingsJson, {
257
+ SharedPreferences? prefs,
258
+ }) {
259
final archive = Archive()
260
..addFile(
139
- ArchiveFile(
261
+ _jsonArchiveFile(
262
'conversations.json',
141
- 0,
142
- utf8.encode(exportConversationsAsJson(store)),
263
+ exportConversationsAsJson(store),
264
),
265
)
266
..addFile(
146
- ArchiveFile(
147
- 'personas.json',
148
- 0,
149
- utf8.encode(exportPersonasAsJson(store)),
150
- ),
267
+ _jsonArchiveFile('personas.json', exportPersonasAsJson(store)),
268
)
269
..addFile(
153
- ArchiveFile(
270
+ _jsonArchiveFile(
271
'settings.json',
155
- 0,
156
- utf8.encode(exportSettingsAsJson(settingsJson)),
272
+ exportSettingsAsJson(settingsJson, store: store, prefs: prefs),
273
),
274
);
275
final encoded = ZipEncoder().encode(archive);
279
return encoded;
280
}
281
166
- String exportAllAsJson(Store store) {
167
- return const JsonEncoder.withIndent(' ').convert(exportAll(store));
282
+ String exportAllAsJson(Store store, {SharedPreferences? prefs}) {
283
+ return const JsonEncoder.withIndent(' ')
284
+ .convert(exportAll(store, prefs: prefs));
285
}
286
287
Future<void> importFromJson(Store store, String jsonString) async {
294
final convBox = store.box<ConversationEntity>();
295
final messageBox = store.box<MessageEntity>();
296
final personaBox = store.box<PersonaEntity>();
297
+ final serverBox = store.box<ServerEntity>();
298
+ final savedBox = store.box<SavedMessageEntity>();
299
+ final savedFolderBox = store.box<SavedMessageFolderEntity>();
300
301
final personas = data['personas'];
302
if (personas is List) {
326
}
327
}
328
329
+ final servers = data['servers'];
330
+ if (servers is List) {
331
+ for (final item in servers) {
332
+ if (item is! Map) continue;
333
+ final server = Server(
334
+ id: item['id'] as String,
335
+ name: item['name'] as String,
336
+ type: ServerType.values.byName(item['type'] as String? ?? 'lmStudio'),
337
+ host: item['host'] as String? ?? 'localhost',
338
+ port: item['port'] as int? ?? 1234,
339
+ apiKey: item['apiKey'] as String?,
340
+ isDefault: item['isDefault'] as bool? ?? false,
341
+ createdAt: DateTime.parse(item['createdAt'] as String),
342
+ lastConnectedAt: DateTime.parse(item['lastConnectedAt'] as String),
343
+ status: ConnectionStatus.values.byName(
344
+ item['status'] as String? ?? 'disconnected',
345
+ ),
346
+ iconName: item['iconName'] as String?,
347
+ pathPrefix: item['pathPrefix'] as String?,
348
+ );
349
+ final query = serverBox.query(ServerEntity_.id.equals(server.id)).build();
350
+ final existing = query.findFirst();
351
+ query.close();
352
+ final entity = ServerEntity.fromDomain(server);
353
+ if (existing != null) entity.internalId = existing.internalId;
354
+ serverBox.put(entity);
355
+ }
356
+ }
357
+
358
+ final savedFolders = data['savedMessageFolders'];
359
+ if (savedFolders is List) {
360
+ for (final item in savedFolders) {
361
+ if (item is! Map) continue;
362
+ final entity = SavedMessageFolderEntity(
363
+ id: item['id'] as String,
364
+ name: item['name'] as String,
365
+ sortOrder: item['sortOrder'] as int? ?? 0,
366
+ createdAt: DateTime.parse(item['createdAt'] as String),
367
+ );
368
+ final query = savedFolderBox
369
+ .query(SavedMessageFolderEntity_.id.equals(entity.id))
370
+ .build();
371
+ final existing = query.findFirst();
372
+ query.close();
373
+ if (existing != null) entity.internalId = existing.internalId;
374
+ savedFolderBox.put(entity);
375
+ }
376
+ }
377
+
378
+ final savedMessages = data['savedMessages'];
379
+ if (savedMessages is List) {
380
+ for (final item in savedMessages) {
381
+ if (item is! Map) continue;
382
+ final entity = SavedMessageEntity(
383
+ id: item['id'] as String,
384
+ sourceMessageId: item['sourceMessageId'] as String,
385
+ conversationId: item['conversationId'] as String,
386
+ conversationTitle: item['conversationTitle'] as String? ?? 'Chat',
387
+ roleIndex: item['roleIndex'] as int? ?? 0,
388
+ content: item['content'] as String? ?? '',
389
+ modelId: item['modelId'] as String?,
390
+ folderId: item['folderId'] as String?,
391
+ savedAt: DateTime.parse(item['savedAt'] as String),
392
+ );
393
+ final query =
394
+ savedBox.query(SavedMessageEntity_.id.equals(entity.id)).build();
395
+ final existing = query.findFirst();
396
+ query.close();
397
+ if (existing != null) entity.internalId = existing.internalId;
398
+ savedBox.put(entity);
399
+ }
400
+ }
401
+
402
final conversations = data['conversations'];
403
if (conversations is List) {
404
for (final item in conversations) {
472
variantIndex: item['variantIndex'] as int? ?? 0,
473
threadOrder: item['threadOrder'] as int? ?? 0,
474
isActiveVariant: item['isActiveVariant'] as bool? ?? true,
475
+ parentMessageId: item['parentMessageId'] as String?,
476
);
477
478
final entity = MessageEntity.fromDomain(message)
491
final archive = ZipDecoder().decodeBytes(bytes);
492
for (final file in archive.files) {
493
if (file.isFile) {
300
- await importFromJson(store, utf8.decode(file.content));
494
+ await importFromJson(store, utf8.decode(file.content as List<int>));
495
}
496
}
497
}