Implement annotation syncing to and from server
This commit is contained in:
118
lib/core/services/annotation_sync_service.dart
Normal file
118
lib/core/services/annotation_sync_service.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'api_client.dart';
|
||||
import 'storage_service.dart';
|
||||
|
||||
/// Service for synchronizing annotations between local storage and server.
|
||||
///
|
||||
/// Handles downloading annotations on sheet open and uploading on save,
|
||||
/// comparing timestamps to determine which version is newer.
|
||||
class AnnotationSyncService {
|
||||
final _log = Logger('AnnotationSyncService');
|
||||
final ApiClient _apiClient;
|
||||
final StorageService _storageService;
|
||||
|
||||
AnnotationSyncService({
|
||||
required ApiClient apiClient,
|
||||
required StorageService storageService,
|
||||
}) : _apiClient = apiClient,
|
||||
_storageService = storageService;
|
||||
|
||||
/// Downloads annotations from server and merges with local storage.
|
||||
///
|
||||
/// For each page, compares server's lastModified with local lastModified.
|
||||
/// If server is newer, overwrites local. Local annotations that are newer
|
||||
/// are preserved.
|
||||
Future<void> syncFromServer(String sheetUuid) async {
|
||||
try {
|
||||
_log.info('Syncing annotations from server for sheet $sheetUuid');
|
||||
|
||||
// Fetch all annotations from server
|
||||
final serverAnnotations = await _apiClient.fetchAnnotations(sheetUuid);
|
||||
|
||||
// Get all local annotations with metadata
|
||||
final localAnnotations = await _storageService
|
||||
.readAllAnnotationsWithMetadata(sheetUuid);
|
||||
|
||||
int updatedCount = 0;
|
||||
|
||||
// Process each server annotation
|
||||
for (final serverAnnotation in serverAnnotations) {
|
||||
final page = serverAnnotation.page;
|
||||
final localAnnotation = localAnnotations[page];
|
||||
|
||||
bool shouldUpdate = false;
|
||||
|
||||
if (localAnnotation == null) {
|
||||
// No local annotation - use server version
|
||||
shouldUpdate = true;
|
||||
_log.fine('Page $page: No local annotation, using server version');
|
||||
} else if (serverAnnotation.lastModified.isAfter(
|
||||
localAnnotation.lastModified,
|
||||
)) {
|
||||
// Server is newer - overwrite local
|
||||
shouldUpdate = true;
|
||||
_log.fine(
|
||||
'Page $page: Server is newer '
|
||||
'(server: ${serverAnnotation.lastModified}, '
|
||||
'local: ${localAnnotation.lastModified})',
|
||||
);
|
||||
} else {
|
||||
_log.fine(
|
||||
'Page $page: Local is newer or same, keeping local version',
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldUpdate) {
|
||||
await _storageService.writeAnnotationsWithMetadata(
|
||||
sheetUuid,
|
||||
page,
|
||||
serverAnnotation.annotationsJson,
|
||||
serverAnnotation.lastModified,
|
||||
);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
_log.info(
|
||||
'Sync complete: $updatedCount pages updated from server '
|
||||
'(${serverAnnotations.length} total on server)',
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
_log.warning('Failed to sync annotations from server: $e');
|
||||
} catch (e) {
|
||||
_log.warning('Unexpected error syncing annotations: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads a single page's annotation to the server.
|
||||
///
|
||||
/// Called when annotations are saved (e.g., exiting paint mode).
|
||||
/// Silently fails if upload fails (allows offline usage).
|
||||
Future<bool> uploadAnnotation({
|
||||
required String sheetUuid,
|
||||
required int page,
|
||||
required String annotationsJson,
|
||||
required DateTime lastModified,
|
||||
}) async {
|
||||
try {
|
||||
_log.info('Uploading annotation for sheet $sheetUuid page $page');
|
||||
|
||||
await _apiClient.uploadAnnotation(
|
||||
sheetUuid: sheetUuid,
|
||||
page: page,
|
||||
lastModified: lastModified,
|
||||
annotationsJson: annotationsJson,
|
||||
);
|
||||
|
||||
_log.info('Upload successful');
|
||||
return true;
|
||||
} on ApiException catch (e) {
|
||||
_log.warning('Failed to upload annotation: $e');
|
||||
return false;
|
||||
} catch (e) {
|
||||
_log.warning('Unexpected error uploading annotation: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,61 @@ class ApiClient {
|
||||
_log.info('PDF cached at: ${cachedFile.path}');
|
||||
return cachedFile;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Annotation Operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fetches all annotations for a sheet from the server.
|
||||
///
|
||||
/// Returns a list of [ServerAnnotation] objects containing page number,
|
||||
/// lastModified timestamp, and the annotations JSON string.
|
||||
Future<List<ServerAnnotation>> fetchAnnotations(String sheetUuid) async {
|
||||
final response = await get('/api/sheets/$sheetUuid/annotations');
|
||||
final data = jsonDecode(response.body) as List<dynamic>;
|
||||
|
||||
return data
|
||||
.map((item) => ServerAnnotation.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Uploads annotations for a specific page of a sheet.
|
||||
///
|
||||
/// The [lastModified] should be the current time when the annotation was saved.
|
||||
Future<void> uploadAnnotation({
|
||||
required String sheetUuid,
|
||||
required int page,
|
||||
required DateTime lastModified,
|
||||
required String annotationsJson,
|
||||
}) async {
|
||||
await post('/api/sheets/$sheetUuid/annotations', {
|
||||
'page': page,
|
||||
'lastModified': lastModified.toIso8601String(),
|
||||
'annotations': annotationsJson,
|
||||
});
|
||||
_log.info('Annotation uploaded for sheet $sheetUuid page $page');
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an annotation from the server.
|
||||
class ServerAnnotation {
|
||||
final int page;
|
||||
final DateTime lastModified;
|
||||
final String annotationsJson;
|
||||
|
||||
ServerAnnotation({
|
||||
required this.page,
|
||||
required this.lastModified,
|
||||
required this.annotationsJson,
|
||||
});
|
||||
|
||||
factory ServerAnnotation.fromJson(Map<String, dynamic> json) {
|
||||
return ServerAnnotation(
|
||||
page: json['page'] as int,
|
||||
lastModified: DateTime.parse(json['lastModified'] as String),
|
||||
annotationsJson: json['annotations'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Exception thrown when an API request fails.
|
||||
|
||||
@@ -6,6 +6,29 @@ import 'package:sheetless/core/models/config.dart';
|
||||
/// Keys for secure storage (credentials and tokens).
|
||||
enum SecureStorageKey { url, jwt, email }
|
||||
|
||||
/// Data class for storing annotations with metadata.
|
||||
class StoredAnnotation {
|
||||
final String annotationsJson;
|
||||
final DateTime lastModified;
|
||||
|
||||
StoredAnnotation({
|
||||
required this.annotationsJson,
|
||||
required this.lastModified,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'annotationsJson': annotationsJson,
|
||||
'lastModified': lastModified.toIso8601String(),
|
||||
};
|
||||
|
||||
factory StoredAnnotation.fromMap(Map<dynamic, dynamic> map) {
|
||||
return StoredAnnotation(
|
||||
annotationsJson: map['annotationsJson'] as String,
|
||||
lastModified: DateTime.parse(map['lastModified'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Service for managing local storage operations.
|
||||
///
|
||||
/// Uses [FlutterSecureStorage] for sensitive data (credentials, tokens)
|
||||
@@ -134,16 +157,70 @@ class StorageService {
|
||||
/// Returns the JSON string of annotations, or null if none exist.
|
||||
Future<String?> readAnnotations(String sheetUuid, int pageNumber) async {
|
||||
final box = await Hive.openBox(_annotationsBox);
|
||||
return box.get(_annotationKey(sheetUuid, pageNumber));
|
||||
final value = box.get(_annotationKey(sheetUuid, pageNumber));
|
||||
|
||||
// Handle legacy format (plain string) and new format (map with metadata)
|
||||
if (value == null) return null;
|
||||
if (value is String) return value;
|
||||
if (value is Map) {
|
||||
final stored = StoredAnnotation.fromMap(value);
|
||||
return stored.annotationsJson;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Reads annotations with metadata for a specific sheet page.
|
||||
///
|
||||
/// Returns [StoredAnnotation] with annotations and lastModified, or null if none exist.
|
||||
Future<StoredAnnotation?> readAnnotationsWithMetadata(
|
||||
String sheetUuid,
|
||||
int pageNumber,
|
||||
) async {
|
||||
final box = await Hive.openBox(_annotationsBox);
|
||||
final value = box.get(_annotationKey(sheetUuid, pageNumber));
|
||||
|
||||
if (value == null) return null;
|
||||
|
||||
// Handle legacy format (plain string) - treat as very old
|
||||
if (value is String) {
|
||||
return StoredAnnotation(
|
||||
annotationsJson: value,
|
||||
lastModified: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
}
|
||||
|
||||
if (value is Map) {
|
||||
return StoredAnnotation.fromMap(value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Writes annotations for a specific sheet page.
|
||||
///
|
||||
/// Pass null or empty string to delete annotations for that page.
|
||||
/// Automatically sets lastModified to current time.
|
||||
Future<void> writeAnnotations(
|
||||
String sheetUuid,
|
||||
int pageNumber,
|
||||
String? annotationsJson,
|
||||
) async {
|
||||
await writeAnnotationsWithMetadata(
|
||||
sheetUuid,
|
||||
pageNumber,
|
||||
annotationsJson,
|
||||
DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Writes annotations with a specific lastModified timestamp.
|
||||
///
|
||||
/// Used when syncing from server to preserve server's timestamp.
|
||||
Future<void> writeAnnotationsWithMetadata(
|
||||
String sheetUuid,
|
||||
int pageNumber,
|
||||
String? annotationsJson,
|
||||
DateTime lastModified,
|
||||
) async {
|
||||
final box = await Hive.openBox(_annotationsBox);
|
||||
final key = _annotationKey(sheetUuid, pageNumber);
|
||||
@@ -151,7 +228,11 @@ class StorageService {
|
||||
if (annotationsJson == null || annotationsJson.isEmpty) {
|
||||
await box.delete(key);
|
||||
} else {
|
||||
await box.put(key, annotationsJson);
|
||||
final stored = StoredAnnotation(
|
||||
annotationsJson: annotationsJson,
|
||||
lastModified: lastModified,
|
||||
);
|
||||
await box.put(key, stored.toMap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,8 +250,54 @@ class StorageService {
|
||||
final pageNumber = int.tryParse(pageStr);
|
||||
if (pageNumber != null) {
|
||||
final value = box.get(key);
|
||||
if (value != null && value is String && value.isNotEmpty) {
|
||||
result[pageNumber] = value;
|
||||
if (value != null) {
|
||||
// Handle legacy format (plain string) and new format (map)
|
||||
if (value is String && value.isNotEmpty) {
|
||||
result[pageNumber] = value;
|
||||
} else if (value is Map) {
|
||||
final stored = StoredAnnotation.fromMap(value);
|
||||
if (stored.annotationsJson.isNotEmpty) {
|
||||
result[pageNumber] = stored.annotationsJson;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Reads all annotations with metadata for a sheet (all pages).
|
||||
///
|
||||
/// Returns a map of page number to [StoredAnnotation].
|
||||
Future<Map<int, StoredAnnotation>> readAllAnnotationsWithMetadata(
|
||||
String sheetUuid,
|
||||
) async {
|
||||
final box = await Hive.openBox(_annotationsBox);
|
||||
final prefix = '${sheetUuid}_page_';
|
||||
final result = <int, StoredAnnotation>{};
|
||||
|
||||
for (final key in box.keys) {
|
||||
if (key is String && key.startsWith(prefix)) {
|
||||
final pageStr = key.substring(prefix.length);
|
||||
final pageNumber = int.tryParse(pageStr);
|
||||
if (pageNumber != null) {
|
||||
final value = box.get(key);
|
||||
if (value != null) {
|
||||
StoredAnnotation? stored;
|
||||
// Handle legacy format (plain string) and new format (map)
|
||||
if (value is String && value.isNotEmpty) {
|
||||
stored = StoredAnnotation(
|
||||
annotationsJson: value,
|
||||
lastModified: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
} else if (value is Map) {
|
||||
stored = StoredAnnotation.fromMap(value);
|
||||
}
|
||||
if (stored != null && stored.annotationsJson.isNotEmpty) {
|
||||
result[pageNumber] = stored;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user