Browse Source

feat : 同步服务器问题到本地

dev
徐振升 9 hours ago
parent
commit
65601485fa
  1. 5
      lib/app/bindings/initial_binding.dart
  2. 15
      lib/data/models/image_metadata_model.dart
  3. 15
      lib/data/models/image_status.dart
  4. 4
      lib/data/models/problem_model.dart
  5. 88
      lib/data/models/server_problem.dart
  6. 23
      lib/data/providers/http_provider.dart
  7. 103
      lib/data/repositories/file_repository.dart
  8. 8
      lib/data/repositories/image_repository.dart
  9. 202
      lib/data/repositories/image_repository_impl.dart
  10. 9
      lib/data/repositories/problem_repository.dart
  11. 162
      lib/modules/problem/controllers/problem_controller.dart
  12. 2
      lib/modules/problem/controllers/problem_form_controller.dart

5
lib/app/bindings/initial_binding.dart

@ -5,6 +5,8 @@ import 'package:problem_check_system/data/providers/http_provider.dart';
import 'package:problem_check_system/data/providers/sqlite_provider.dart';
import 'package:problem_check_system/data/repositories/auth_repository.dart';
import 'package:problem_check_system/data/repositories/file_repository.dart';
import 'package:problem_check_system/data/repositories/image_repository.dart';
import 'package:problem_check_system/data/repositories/image_repository_impl.dart';
import 'package:problem_check_system/data/repositories/problem_repository.dart';
class InitialBinding implements Bindings {
@ -24,6 +26,9 @@ class InitialBinding implements Bindings {
void _registerRepositories() {
Get.lazyPut<FileRepository>(() => FileRepository());
Get.lazyPut<ImageRepository>(
() => ImageRepositoryImpl(httpProvider: Get.find<HttpProvider>()),
);
///
Get.lazyPut<AuthRepository>(

15
lib/data/models/image_metadata_model.dart

@ -29,4 +29,19 @@ class ImageMetadata {
status: ImageStatus.values[map['status'] as int],
);
}
/// Creates a new [ImageMetadata] instance with optional new values.
///
/// The original object remains unchanged.
ImageMetadata copyWith({
String? localPath,
String? remoteUrl,
ImageStatus? status,
}) {
return ImageMetadata(
localPath: localPath ?? this.localPath,
remoteUrl: remoteUrl ?? this.remoteUrl,
status: status ?? this.status,
);
}
}

15
lib/data/models/image_status.dart

@ -1,11 +1,14 @@
///
enum ImageStatus {
///
local,
///
///
synced,
///
deleted,
//
pendingUpload,
///
pendingDeleted,
///
pendingDownload,
}

4
lib/data/models/problem_model.dart

@ -7,7 +7,7 @@ import 'package:problem_check_system/data/models/problem_sync_status.dart';
///
class Problem {
///
final String? id;
final String id;
///
final String description;
@ -37,7 +37,7 @@ class Problem {
final bool isChecked;
Problem({
this.id,
required this.id,
required this.description,
required this.location,
required this.imageUrls,

88
lib/data/models/server_problem.dart

@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
@immutable
class ServerProblem {
final String id;
final String title;
final String location;
final String? censorTaskId;
final String? rowId;
final String? bindData;
final List<String>? imageUrls;
final DateTime creationTime;
final String creatorId;
final DateTime lastModificationTime;
final String lastModifierId;
const ServerProblem({
required this.id,
required this.title,
required this.location,
this.censorTaskId,
this.rowId,
this.bindData,
this.imageUrls,
required this.creationTime,
required this.creatorId,
required this.lastModificationTime,
required this.lastModifierId,
});
factory ServerProblem.fromJson(Map<String, dynamic> json) => ServerProblem(
id: json['id'] as String,
title: json['title'] as String,
location: json['location'] as String,
censorTaskId: json['censorTaskId'] as String?,
rowId: json['rowId'] as String?,
bindData: json['bindData'] as String?,
imageUrls: json['imageUrls'] as List<String>?,
creationTime: DateTime.parse(json['creationTime'] as String),
creatorId: json['creatorId'] as String,
lastModificationTime: DateTime.parse(
json['lastModificationTime'] as String,
),
lastModifierId: json['lastModifierId'] as String,
);
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'location': location,
'censorTaskId': censorTaskId,
'rowId': rowId,
'bindData': bindData,
'imageUrls': imageUrls,
'creationTime': creationTime.toUtc().toIso8601String(),
'creatorId': creatorId,
'lastModificationTime': lastModificationTime.toUtc().toIso8601String(),
'lastModifierId': lastModifierId,
};
ServerProblem copyWith({
String? id,
String? title,
String? location,
String? censorTaskId,
String? rowId,
String? bindData,
List<String>? imageUrls,
DateTime? creationTime,
String? creatorId,
DateTime? lastModificationTime,
String? lastModifierId,
}) {
return ServerProblem(
id: id ?? this.id,
title: title ?? this.title,
location: location ?? this.location,
censorTaskId: censorTaskId ?? this.censorTaskId,
rowId: rowId ?? this.rowId,
bindData: bindData ?? this.bindData,
imageUrls: imageUrls ?? this.imageUrls,
creationTime: creationTime ?? this.creationTime,
creatorId: creatorId ?? this.creatorId,
lastModificationTime: lastModificationTime ?? this.lastModificationTime,
lastModifierId: lastModifierId ?? this.lastModifierId,
);
}
}

23
lib/data/providers/http_provider.dart

@ -232,4 +232,27 @@ class HttpProvider extends GetxService {
cancelToken: cancelToken,
);
}
///
Future<Response> download(
String urlPath,
String savePath, {
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onReceiveProgress,
bool deleteOnError = true,
int? lengthHeader,
Object? data,
Options? requestOptions,
}) async {
return await _dio.download(
urlPath,
savePath,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
onReceiveProgress: onReceiveProgress,
);
}
}

103
lib/data/repositories/file_repository.dart

@ -1,9 +1,14 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; // kDebugMode debugPrint
import 'package:get/get.dart' hide FormData, MultipartFile;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:problem_check_system/core/extensions/http_response_extension.dart';
import 'package:problem_check_system/core/utils/constants/api_endpoints.dart';
import 'package:problem_check_system/data/models/image_metadata_model.dart';
import 'package:problem_check_system/data/models/image_status.dart';
import 'package:problem_check_system/data/providers/http_provider.dart';
class FileRepository {
@ -61,4 +66,102 @@ class FileRepository {
throw Exception('图片上传发生未知错误: $e');
}
}
//
Future<ImageMetadata> downloadImage(
String imageUrl, {
CancelToken? cancelToken,
void Function(int received, int total)? onReceiveProgress,
}) async {
final directory = await getApplicationDocumentsDirectory();
final imagesDir = Directory('${directory.path}/problem_images');
//
if (!await imagesDir.exists()) {
await imagesDir.create(recursive: true);
}
//
final String fileName =
'downloaded_${DateTime.now().millisecondsSinceEpoch}_${imageUrl.hashCode}${_getFileExtension(imageUrl)}';
final String imagePath = '${imagesDir.path}/$fileName';
try {
//
await _httpProvider.download(
imageUrl,
imagePath,
cancelToken: cancelToken,
onReceiveProgress: onReceiveProgress,
);
//
return ImageMetadata(
localPath: imagePath,
remoteUrl: imageUrl,
status: ImageStatus.synced,
);
} catch (e) {
//
final file = File(imagePath);
if (await file.exists()) {
await file.delete();
}
rethrow;
}
}
//
Future<List<ImageMetadata>> downloadImages(
List<String> imageUrls, {
CancelToken? cancelToken,
void Function(int current, int total)? onProgress,
}) async {
final List<ImageMetadata> results = [];
int downloadedCount = 0;
for (final imageUrl in imageUrls) {
if (cancelToken?.isCancelled == true) {
break;
}
try {
final metadata = await downloadImage(
imageUrl,
cancelToken: cancelToken,
onReceiveProgress: (received, total) {
//
},
);
results.add(metadata);
//
downloadedCount++;
onProgress?.call(downloadedCount, imageUrls.length);
} catch (e) {
Get.log('Failed to download image $imageUrl: $e');
//
}
}
return results;
}
//
String _getFileExtension(String url) {
try {
final uri = Uri.parse(url);
final pathSegments = uri.pathSegments;
if (pathSegments.isNotEmpty) {
final fileName = pathSegments.last;
final dotIndex = fileName.lastIndexOf('.');
if (dotIndex != -1 && dotIndex < fileName.length - 1) {
return fileName.substring(dotIndex);
}
}
return '.jpg';
} catch (e) {
return '.jpg';
}
}
}

8
lib/data/repositories/image_repository.dart

@ -0,0 +1,8 @@
// image_repository.dart
abstract class ImageRepository {
Future<String> downloadImage(String imageUrl, String problemId);
Future<bool> isImageDownloaded(String imageUrl, String problemId);
Future<String?> getLocalImagePath(String imageUrl, String problemId);
Future<void> deleteProblemImages(String problemId);
Future<void> cleanupCache({Duration maxAge = const Duration(days: 30)});
}

202
lib/data/repositories/image_repository_impl.dart

@ -0,0 +1,202 @@
// image_repository_impl.dart
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:get/get.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import 'package:problem_check_system/data/providers/http_provider.dart';
import 'package:problem_check_system/data/repositories/image_repository.dart';
class ImageRepositoryImpl implements ImageRepository {
final HttpProvider httpProvider;
ImageRepositoryImpl({required this.httpProvider});
@override
Future<String> downloadImage(String imageUrl, String problemId) async {
try {
// 1.
final Directory appDocDir = await getApplicationDocumentsDirectory();
final String problemDirPath = path.join(
appDocDir.path,
'problems',
problemId,
'images',
);
final Directory problemDir = Directory(problemDirPath);
// 2.
if (!await problemDir.exists()) {
await problemDir.create(recursive: true);
}
// 3.
final String fileExtension = _getFileExtensionFromUrl(imageUrl);
final String fileName =
'${_generateFileNameHash(imageUrl)}.$fileExtension';
final String filePath = path.join(problemDir.path, fileName);
// 4. 使 Dio
final response = await httpProvider.download(
imageUrl,
filePath,
options: Options(
responseType: ResponseType.bytes,
followRedirects: true,
receiveTimeout: const Duration(seconds: 30),
),
onReceiveProgress: (received, total) {
if (total != -1) {
Get.log('下载进度: ${(received / total * 100).toStringAsFixed(1)}%');
}
},
);
if (response.statusCode == 200) {
//
final File file = File(filePath);
if (await file.exists()) {
return filePath;
} else {
throw Exception('文件写入失败');
}
} else {
throw Exception('下载失败: HTTP ${response.statusCode}');
}
} on DioException catch (e) {
throw Exception('图片下载失败: ${e.message}');
} catch (e) {
throw Exception('图片下载失败: $e');
}
}
@override
Future<bool> isImageDownloaded(String imageUrl, String problemId) async {
try {
final String fileExtension = _getFileExtensionFromUrl(imageUrl);
final String fileName =
'${_generateFileNameHash(imageUrl)}.$fileExtension';
final Directory appDocDir = await getApplicationDocumentsDirectory();
final String filePath = path.join(
appDocDir.path,
'problems',
problemId,
'images',
fileName,
);
return await File(filePath).exists();
} catch (e) {
return false;
}
}
@override
Future<String?> getLocalImagePath(String imageUrl, String problemId) async {
try {
final String fileExtension = _getFileExtensionFromUrl(imageUrl);
final String fileName =
'${_generateFileNameHash(imageUrl)}.$fileExtension';
final Directory appDocDir = await getApplicationDocumentsDirectory();
final String filePath = path.join(
appDocDir.path,
'problems',
problemId,
'images',
fileName,
);
if (await File(filePath).exists()) {
return filePath;
}
return null;
} catch (e) {
return null;
}
}
@override
Future<void> deleteProblemImages(String problemId) async {
try {
final Directory appDocDir = await getApplicationDocumentsDirectory();
final String problemDirPath = path.join(
appDocDir.path,
'problems',
problemId,
);
final Directory problemDir = Directory(problemDirPath);
if (await problemDir.exists()) {
await problemDir.delete(recursive: true);
}
} catch (e) {
Get.log('删除图片失败: $e');
}
}
@override
Future<void> cleanupCache({
Duration maxAge = const Duration(days: 30),
}) async {
try {
final Directory appDocDir = await getApplicationDocumentsDirectory();
final String problemsDirPath = path.join(appDocDir.path, 'problems');
final Directory problemsDir = Directory(problemsDirPath);
if (await problemsDir.exists()) {
final DateTime cutoffTime = DateTime.now().subtract(maxAge);
final List<FileSystemEntity> entities = await problemsDir
.list()
.toList();
for (final entity in entities) {
if (entity is Directory) {
try {
final FileStat stat = await entity.stat();
if (stat.modified.isBefore(cutoffTime)) {
await entity.delete(recursive: true);
Get.log('已清理过期目录: ${entity.path}');
}
} catch (e) {
Get.log('无法获取目录状态: ${entity.path}, 错误: $e');
//
try {
await entity.delete(recursive: true);
Get.log('强制清理目录: ${entity.path}');
} catch (deleteError) {
Get.log('删除目录失败: ${entity.path}, 错误: $deleteError');
}
}
}
}
}
} catch (e) {
Get.log('清理缓存失败: $e');
}
}
//
String _getFileExtensionFromUrl(String url) {
try {
final Uri uri = Uri.parse(url);
final String path = uri.path;
if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'jpg';
if (path.endsWith('.png')) return 'png';
if (path.endsWith('.gif')) return 'gif';
if (path.endsWith('.webp')) return 'webp';
if (path.endsWith('.bmp')) return 'bmp';
return 'jpg';
} catch (e) {
return 'jpg';
}
}
String _generateFileNameHash(String url) {
return url.hashCode.abs().toString();
}
}

9
lib/data/repositories/problem_repository.dart

@ -3,6 +3,7 @@ import 'package:get/get.dart' hide MultipartFile, FormData, Response;
import 'package:problem_check_system/core/extensions/http_response_extension.dart';
import 'package:problem_check_system/core/utils/constants/api_endpoints.dart';
import 'package:problem_check_system/data/models/problem_model.dart';
import 'package:problem_check_system/data/models/server_problem.dart';
import 'package:problem_check_system/data/providers/connectivity_provider.dart';
import 'package:problem_check_system/data/providers/http_provider.dart';
import 'package:problem_check_system/data/providers/sqlite_provider.dart';
@ -54,7 +55,7 @@ class ProblemRepository extends GetxService {
}
// ProblemRepository中添加
Future<List<Problem>> fetchProblemsFromServer({
Future<List<ServerProblem>> fetchProblemsFromServer({
DateTime? startTime,
DateTime? endTime,
int? pageNumber,
@ -77,7 +78,7 @@ class ProblemRepository extends GetxService {
if (response.isSuccess) {
// Problem对象的列表
final List<dynamic> data = response.data;
return data.map((json) => Problem.fromJson(json)).toList();
return data.map((json) => ServerProblem.fromJson(json)).toList();
} else {
throw Exception('拉取问题失败: ${response.statusCode}');
}
@ -88,7 +89,7 @@ class ProblemRepository extends GetxService {
/// post
Future<Response> post(
Map<String, Object?> apiPayload,
Map<String, Object> apiPayload,
CancelToken cancelToken,
) async {
// 3.
@ -103,7 +104,7 @@ class ProblemRepository extends GetxService {
/// put
Future<Response> put(
String id,
Map<String, Object?> apiPayload,
Map<String, Object> apiPayload,
CancelToken cancelToken,
) async {
// 3.

162
lib/modules/problem/controllers/problem_controller.dart

@ -11,7 +11,9 @@ import 'package:problem_check_system/core/extensions/http_response_extension.dar
import 'package:problem_check_system/data/models/image_metadata_model.dart';
import 'package:problem_check_system/data/models/image_status.dart';
import 'package:problem_check_system/data/models/problem_sync_status.dart';
import 'package:problem_check_system/data/models/server_problem.dart';
import 'package:problem_check_system/data/repositories/file_repository.dart';
import 'package:problem_check_system/data/repositories/image_repository.dart';
import 'package:problem_check_system/data/repositories/problem_repository.dart';
import 'package:problem_check_system/data/models/problem_model.dart';
import 'package:problem_check_system/modules/problem/views/widgets/models/date_range_enum.dart';
@ -272,7 +274,7 @@ class ProblemController extends GetxController
);
if (updatedProblem.syncStatus == ProblemSyncStatus.untracked) {
problemRepository.deleteProblem(updatedProblem.id!);
problemRepository.deleteProblem(updatedProblem.id);
} else {
problemRepository.updateProblem(updatedProblem);
}
@ -301,7 +303,7 @@ class ProblemController extends GetxController
final List<String> remoteUrls = [];
if (problem.syncStatus != ProblemSyncStatus.pendingDelete) {
final newImages = problem.imageUrls
.where((img) => img.status == ImageStatus.local)
.where((img) => img.status == ImageStatus.pendingUpload)
.toList();
final totalFilesToUpload = newImages.length;
@ -354,13 +356,13 @@ class ProblemController extends GetxController
break;
case ProblemSyncStatus.pendingUpdate:
response = await problemRepository.put(
problem.id!,
problem.id,
apiPayload!,
cancelToken,
);
break;
case ProblemSyncStatus.pendingDelete:
response = await problemRepository.delete(problem.id!, cancelToken);
response = await problemRepository.delete(problem.id, cancelToken);
break;
}
@ -400,7 +402,7 @@ class ProblemController extends GetxController
for (var image in images) {
if (image.status == ImageStatus.synced) {
finalRemoteUrls.add(image.remoteUrl!);
} else if (image.status == ImageStatus.local) {
} else if (image.status == ImageStatus.pendingUpload) {
finalRemoteUrls.add(newRemoteUrls[newImageIndex]);
newImageIndex++;
}
@ -418,7 +420,7 @@ class ProblemController extends GetxController
int uploadedUrlIndex = 0;
for (var image in images) {
if (image.status == ImageStatus.local) {
if (image.status == ImageStatus.pendingUpload) {
updatedImageMetadata.add(
ImageMetadata(
localPath: image.localPath,
@ -438,21 +440,27 @@ class ProblemController extends GetxController
// #endregion
// #region
// TODO
Future<void> pullDataFromServer() async {
isLoading.value = true;
try {
// 1.
final List<Problem> serverProblems = await problemRepository
final List<ServerProblem> serverProblems = await problemRepository
.fetchProblemsFromServer();
// 2.
final List<Problem> localProblems = await problemRepository.getProblems();
// 3.
await _syncProblems(serverProblems, localProblems);
// 3.
final List<Problem> downloadedProblems = await _syncProblems(
serverProblems,
localProblems,
);
// 4.
_downloadImagesForProblems(downloadedProblems);
// 4.
// 5.
await loadProblems();
Get.snackbar('成功', '数据同步完成', snackPosition: SnackPosition.TOP);
@ -463,60 +471,152 @@ class ProblemController extends GetxController
}
}
///
Future<void> _syncProblems(
List<Problem> serverProblems,
///
void _downloadImagesForProblems(List<Problem> problems) {
if (problems.isEmpty) return;
//
Future(() async {
final imageRepository = Get.find<ImageRepository>(); // 使GetX获取实例
for (final problem in problems) {
try {
final List<ImageMetadata> downloadedImages = [];
for (final imageMeta in problem.imageUrls) {
if (imageMeta.remoteUrl != null &&
imageMeta.remoteUrl!.isNotEmpty) {
//
final bool isDownloaded = await imageRepository.isImageDownloaded(
imageMeta.remoteUrl!,
problem.id,
);
String localPath;
if (isDownloaded) {
//
localPath = (await imageRepository.getLocalImagePath(
imageMeta.remoteUrl!,
problem.id,
))!;
} else {
//
localPath = await imageRepository.downloadImage(
imageMeta.remoteUrl!,
problem.id,
);
}
//
final downloadedImage = imageMeta.copyWith(
localPath: localPath,
status: ImageStatus.synced,
);
downloadedImages.add(downloadedImage);
}
}
//
if (downloadedImages.isNotEmpty) {
final updatedProblem = problem.copyWith(
imageUrls: downloadedImages,
);
await problemRepository.updateProblem(updatedProblem);
}
} catch (e) {
Get.log('下载问题 ${problem.id} 的图片失败: $e');
}
}
});
}
///
Future<List<Problem>> _syncProblems(
List<ServerProblem> serverProblems,
List<Problem> localProblems,
) async {
final List<Problem> needDownloadImages = [];
// 便
final Map<String, Problem> serverProblemsMap = {
for (var problem in serverProblems.where((p) => p.id != null))
problem.id!: problem,
final Map<String, ServerProblem> serverProblemsMap = {
for (var problem in serverProblems) problem.id: problem,
};
final Map<String, Problem> localProblemsMap = {
for (var problem in localProblems.where((p) => p.id != null))
problem.id!: problem,
for (var problem in localProblems) problem.id: problem,
};
//
for (final serverProblem in serverProblems) {
if (serverProblem.id != null &&
!localProblemsMap.containsKey(serverProblem.id)) {
if (!localProblemsMap.containsKey(serverProblem.id)) {
//
await problemRepository.insertProblem(serverProblem);
final newProblem = _convertServerProblemToLocal(serverProblem);
await problemRepository.insertProblem(newProblem);
needDownloadImages.add(newProblem);
}
}
//
for (final localProblem in localProblems) {
if (localProblem.id != null &&
!serverProblemsMap.containsKey(localProblem.id)) {
//
await problemRepository.deleteProblem(localProblem.id!);
if (!serverProblemsMap.containsKey(localProblem.id)) {
//
if (localProblem.syncStatus == ProblemSyncStatus.synced) {
await problemRepository.deleteProblem(localProblem.id);
}
// pendingCreate/pendingUpdate
}
}
//
for (final serverProblem in serverProblems) {
if (serverProblem.id != null &&
localProblemsMap.containsKey(serverProblem.id)) {
if (localProblemsMap.containsKey(serverProblem.id)) {
final localProblem = localProblemsMap[serverProblem.id]!;
//
if (localProblem.syncStatus == ProblemSyncStatus.synced) {
// 使
final serverUpdated = serverProblem.lastModifiedTime;
final serverUpdated = serverProblem.lastModificationTime;
final localUpdated = localProblem.lastModifiedTime;
if (serverUpdated.isAfter(localUpdated)) {
//
await problemRepository.updateProblem(serverProblem);
final updatedProblem = _convertServerProblemToLocal(serverProblem);
await problemRepository.updateProblem(updatedProblem);
needDownloadImages.add(updatedProblem);
}
}
//
}
}
return needDownloadImages;
}
///
Problem _convertServerProblemToLocal(ServerProblem serverProblem) {
// URL为ImageMetadata列表
final List<ImageMetadata> imageMetadatas = (serverProblem.imageUrls ?? [])
.map(
(url) => ImageMetadata(
remoteUrl: url,
localPath: '', //
status: ImageStatus.pendingDownload, //
),
)
.toList();
return Problem(
id: serverProblem.id,
description: serverProblem.title,
location: serverProblem.location,
imageUrls: imageMetadatas,
creationTime: serverProblem.creationTime,
lastModifiedTime: serverProblem.lastModificationTime,
syncStatus: ProblemSyncStatus.synced, //
censorTaskId: serverProblem.censorTaskId,
bindData: serverProblem.bindData,
isChecked: false, //
);
}
// #endregion
@ -710,7 +810,7 @@ class ProblemController extends GetxController
final deleteProblem = ProblemStateManager.markForDeletion(problem);
if (deleteProblem.syncStatus == ProblemSyncStatus.untracked) {
//
await problemRepository.deleteProblem(problem.id!);
await problemRepository.deleteProblem(problem.id);
await _deleteProblemImages(problem);
} else {
//

2
lib/modules/problem/controllers/problem_form_controller.dart

@ -189,7 +189,7 @@ class ProblemFormController extends GetxController {
final String imagePath = '${imagesDir.path}/$fileName';
final ImageMetadata imageData = ImageMetadata(
localPath: imagePath,
status: ImageStatus.local,
status: ImageStatus.pendingUpload,
);
final File imageFile = File(imagePath);

Loading…
Cancel
Save