mnemo_cards/mnemo_cards_backend/lib/packs/pack_manager.dart
Dmitry a3a5a55fdc
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
err
2025-12-11 22:00:11 +03:00

580 lines
18 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:typed_data';
import 'package:image/image.dart';
import 'package:injectable/injectable.dart';
import 'package:isar/isar.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:archive/archive.dart';
import 'package:mnemo_cards_backend/packs/card_dto_extension.dart';
import 'package:mnemo_cards_backend/packs/card_pack_model_extension.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import '../main.dart';
import 'pack_dto_converter.dart';
@lazySingleton
class PackManager {
final PackDtoConverter packDtoConverter;
PackManager(this.packDtoConverter);
Future<List<CardPackPreviewDto>> listPacksPreviews(
UserModel? userModel,
Map<String, String>? params,
) async {
final models = await isar.cardPackModels
.filter()
.optional(
userModel?.admin != true,
(q) => q.enabledEqualTo(true),
)
.findAll();
models.sort((p, n) => p.order.compareTo(n.order));
return (await Future.wait(models.map(
(model) async {
final dto =
await packDtoConverter.toCardPackPreviewDto(model, userModel);
if (!model.enabled) {
return dto.copyWith(subtitle: 'DISABLED ${dto.subtitle}');
}
return dto;
},
)))
..sort((prev, next) {
final bothAvailable = prev.isAvailable && next.isAvailable;
if (bothAvailable) {
return 0;
}
final prevAd = canOpenForAd(int.tryParse(prev.id));
final nextAd = canOpenForAd(int.tryParse(next.id));
final orderAds = prevAd == nextAd
? 0
: prevAd
? -1
: 1;
final orderAvailability = prev.isAvailable == next.isAvailable
? 0
: prev.isAvailable
? -1
: 1;
return orderAvailability == 0 ? orderAds : orderAvailability;
});
}
//no images
Future<List<CardPackAction>> listPacksActions(
Map<String, String?>? packsData,
UserModel user,
) async {
final availablePacks = await isar.txn(() async {
await user.subscriptionModel.load();
if (user.subscriptionModel.value?.features
.contains(SubscriptionFeatureEnum.packs) ==
true) {
return isar.cardPackModels.filter().enabledEqualTo(true).findAll();
} else {
return user.packs.filter().enabledEqualTo(true).findAll();
}
});
List<CardPackAction> response = [];
for (final pack in availablePacks) {
final expectedUserVersion = pack.version;
final userPackVersion = packsData?[pack.id.toString()];
if (userPackVersion == expectedUserVersion) {
//last version
continue;
} else {
// needs update
response.add(
CardPackAction(
id: pack.id.toString(),
action: PackAction.update,
),
);
}
}
//if front has packs we dont have - action delete
for (final id in packsData!.keys.where(
(id) =>
id.isNotEmpty &&
availablePacks.where((p) => p.id.toString() == id.toString()).isEmpty,
)) {
response.add(
CardPackAction(
id: id,
action: PackAction.delete,
),
);
}
return response;
}
// Future<void> addPack(CardPackModel model, String productId) async {
// await isar.writeTxn(
// () async {
// if (model.id != null && model.id! < 0) {
// model = model.copyWith(id: null);
// }
// await isar.cardPackModels.put(model);
// final cards = cardPackDto.cards.map((e) => e.toModel()).toList();
// isar.gameCardModels.putAll(cards);
// await model.cards
// ..addAll(cards)
// ..save();
// },
// );
// }
Future<bool> deletePack(String idString) async {
return isar.writeTxn(() async {
final id = int.tryParse(idString);
if (id == null) return false;
final model = await isar.cardPackModels.get(id);
if (model == null) return false;
return await isar.cardPackModels.delete(id);
});
}
Future<bool> deleteCard(String idString) async {
return isar.writeTxn(() async {
final id = int.tryParse(idString);
if (id == null) return false;
final model = await isar.gameCardModels.get(id);
if (model == null) return false;
await model.packs.load();
if (model.packs.isNotEmpty) return false;
return await isar.gameCardModels.delete(id);
});
}
Future<void> addCard(GameCardDto dto) async {
await isar.writeTxn(() async {
GameCardModel model;
if (dto.id < 0) {
print('Creating new card ${dto.original}');
model = dto.toModel().copyWith(id: null);
} else {
final existingModel = await isar.gameCardModels.get(dto.id);
if (existingModel != null) {
print('Editing card ${dto.id} ${dto.original}');
model = existingModel.copyWith(
image: dto.image ?? existingModel.image,
mnemo: dto.mnemo ?? existingModel.mnemo,
original: dto.original ?? existingModel.original,
translation: dto.translation ?? existingModel.translation,
transcription: dto.transcription ?? existingModel.transcription,
imageBack: dto.imageBack ?? existingModel.imageBack,
transcriptionMnemo:
dto.transcriptionMnemo ?? existingModel.transcriptionMnemo,
);
} else {
print('Creating new card with id ${dto.id} ${dto.original}');
model = dto.toModel();
}
}
final cardId = await isar.gameCardModels.put(model);
if (model.image.length > 100) {
print('Adding base64 image for card ${dto.id} ${dto.original}');
//base64 image
try {
final data = base64Decode(model.image);
final image = decodeImage(data)!;
final fileName = '${cardId}_${dto.original}.png';
final resizedImage = copyResize(image, width: 1024, height: 1024);
final png = encodePng(resizedImage, level: 9);
final imageFile = File('${assetsDirectory.path}/cards/$fileName');
for (final imageSize in _ImageSize.values) {
final resizedFile = _getResizedFile(fileName, 'cards', imageSize);
try {
if (await resizedFile.exists()) {
print(
'Removing old image cache for ${imageSize.name}: ${resizedFile.path}');
await resizedFile.delete();
}
} catch (_) {
print('Error deleting resized image ${resizedFile.path}');
}
}
imageFile.writeAsBytesSync(png, flush: true);
print('Card image saved ${imageFile.path}');
model = model.copyWith(image: fileName);
} catch (_) {
throw Exception('Some exception');
}
}
final id = await isar.gameCardModels.put(model);
final cardPackModel = (await isar.gameCardModels.get(id))!;
await cardPackModel.packs.load();
final packs = cardPackModel.packs.toList();
for (final pack in packs) {
await isar.cardPackModels.put(
pack.copyWith(version: pack.version.incVersion),
);
}
});
}
Future<GetCardPackResponse?> getPackOrBuy(
String packId,
UserModel? userModel,
) async {
final model = await _fetchPackModel(packId);
if (userModel?.packs.contains(packId) != true) {
return packDtoConverter.toCardPackBuyDto(model, userModel);
}
return packDtoConverter.toDto(model);
}
Future<CardPackModel?> getPackModelIfAvailable(
UserModel userModel,
String packId,
) async {
final id = int.parse(packId);
userModel.subscriptionModel.load();
final allPacksAvailable =
userModel.subscriptionModel.value?.features.contains(
SubscriptionFeatureEnum.packs,
) ==
true;
if (allPacksAvailable) {
final model = await isar.txn(() => isar.cardPackModels.get(id));
if (model?.enabled == true) {
return model;
}
return null;
}
final model = await userModel.packs
.filter()
.enabledEqualTo(true)
.idEqualTo(id)
.findFirst();
return model;
}
Future<GetCardPackResponse?> getPack(
String packId,
UserModel? userModel,
) async {
// Preview pack (id=10) доступен без авторизации
if (packId == '10') {
final model = await _fetchPackModel(packId);
if (model.enabled) {
return packDtoConverter.toDto(model);
}
return null;
}
if (userModel == null) {
return null;
}
final model = await getPackModelIfAvailable(userModel, packId);
if (model == null) {
return null;
}
return packDtoConverter.toDto(model);
}
Future<EditCardPackDto?> getEditPack(String packId) async {
final id = int.tryParse(packId);
if (id == null) return null;
return isar.txn(() async {
final model = await isar.cardPackModels.get(id);
if (model == null) return null;
return await packDtoConverter.toEditDto(model);
});
}
Future<bool> editPack(EditCardPackDto dto) async {
return isar.writeTxn(() async {
final oldId = int.tryParse(dto.id ?? '');
final oldModel =
oldId == null ? null : await isar.cardPackModels.get(oldId);
final editModel = packDtoConverter.toModel(dto, oldModel);
final dtoPreviewCardsIds =
dto.previewCards?.map(int.tryParse).whereNotNull().toList() ?? [];
final dtoAddCardsIds =
dto.addCardIds?.map(int.tryParse).whereNotNull().toList() ?? [];
final dtoAddTestIds =
dto.addTestIds?.map(int.tryParse).whereNotNull().toList() ?? [];
final dtoPreviewCards = dtoPreviewCardsIds.isEmpty
? <GameCardModel>[]
: (await isar.gameCardModels.getAll(dtoPreviewCardsIds))
.whereNotNull()
.toList();
final dtoAddCards = dtoAddCardsIds.isEmpty
? <GameCardModel>[]
: (await isar.gameCardModels.getAll(dtoAddCardsIds))
.whereNotNull()
.toList();
final dtoAddTests = dtoAddTestIds.isEmpty
? <TestModel>[]
: (await isar.testModels.getAll(dtoAddTestIds))
.whereNotNull()
.toList();
final modelId = await isar.cardPackModels.put(
editModel.copyWith.version(editModel.version.incVersion),
);
final savedEditModel = (await isar.cardPackModels.get(modelId))!;
if (dtoPreviewCards.isNotEmpty) {
await savedEditModel.previewCards.reset();
savedEditModel.previewCards.addAll(dtoPreviewCards);
await savedEditModel.previewCards.save();
}
await savedEditModel.cards.load();
savedEditModel.cards
..removeWhere(
(card) => dto.removeCardIds?.contains(card.id.toString()) ?? false,
)
..addAll(dtoAddCards);
await savedEditModel.cards.save();
await savedEditModel.tests.load();
savedEditModel.tests
..removeWhere(
(test) => dto.removeTestIds?.contains(test.id.toString()) ?? false,
)
..addAll(dtoAddTests);
await savedEditModel.tests.save();
return true;
});
}
Future<CardPackBuyDto?> getBuyPage(String packId, UserModel? user) async {
final id = int.tryParse(packId);
if (id == null) {
return null;
}
if (await user?.packs.filter().idEqualTo(id).findFirst() == null) {
final model = await _fetchPackModel(packId);
return packDtoConverter.toCardPackBuyDto(model, user);
}
return null;
}
Future<CardPackBuyDto?> getPublicBuyPage(String packId) async {
final id = int.tryParse(packId);
if (id == null) {
return null;
}
final model = await isar.cardPackModels.get(id);
if (model == null || !model.enabled) {
return null;
}
return packDtoConverter.toCardPackBuyDto(model, null);
}
Future<CardPackModel> _fetchPackModel(String packId) async {
return (await isar.cardPackModels.get(int.parse(packId)))!;
}
Future<List<int>> fetchPackImagesArchive(
String packId,
int userId,
String appVersion,
) async {
final pack = await _fetchPackModel(packId);
try {
final archiveFile = File(
'${assetsDirectory.path}/pack_archives/${appVersion}/${packId}_${pack.version}.zip',
);
if (await archiveFile.exists()) {
return await archiveFile.readAsBytes();
}
} catch (e, s) {
log('Error when fetching pack images archive', error: e, stackTrace: s);
return [];
}
final cards = pack.cards;
final rawData = _fetchPackImages(cards.toList());
final archive = Archive();
for (final entry in rawData.entries) {
archive.addFile(ArchiveFile(entry.key, 0, entry.value));
}
final password = TokenGenerator.generateArchivePassword(
appVersion: appVersion,
id: packId,
);
final bytes = ZipEncoder(
password: password,
).encode(
archive,
level: Deflate.BEST_COMPRESSION,
)!;
Future<void> saveFile() async {
try {
await archiveFile.create(recursive: true);
await archiveFile.writeAsBytes(bytes);
} catch (e, s) {
log(
'Error when creating archive ${archiveFile.path}',
error: e,
stackTrace: s,
);
}
}
unawaited(saveFile());
return bytes;
}
Map<String, List<int>> _fetchPackImages(List<GameCardModel> cards) {
return Map.fromEntries(
cards.map((e) {
final empty = MapEntry(e.id.toString(), <int>[]);
try {
final image = e.image.isEmpty
? empty
: MapEntry(
e.id.toString(),
File('${assetsDirectory.path}/cards/${e.image}')
.readAsBytesSync(),
);
return image;
} catch (e, s) {
log('error while reading image', error: e, stackTrace: s);
return empty;
}
}),
);
}
static Directory get assetsDirectory {
String mainPath = Platform.resolvedExecutable;
if ((Platform.isMacOS || Platform.isLinux) &&
!Platform.script.toString().contains('StudioProjects')) {
mainPath = mainPath.substring(0, mainPath.lastIndexOf("/"));
var dir = Directory("$mainPath/../data");
if (dir.existsSync()) {
return dir;
}
dir = Directory("$mainPath/data");
if (dir.existsSync()) {
return dir;
}
throw Exception('No asset dir! $mainPath');
}
if (Platform.isMacOS) {
mainPath = mainPath.substring(0, mainPath.lastIndexOf("/"));
return Directory(
'/Users/dmitry/StudioProjects/mnemo_cards/mnemo_cards_backend/data');
} else if (Platform.isWindows) {
mainPath = mainPath.substring(0, mainPath.lastIndexOf("\\"));
return Directory("$mainPath/data/flutter_assets/data");
} else {
return Directory('');
}
}
static File _getResizedFile(String id, String type, _ImageSize size) =>
File('${PackManager.assetsDirectory.path}/$type/${size.name}/$id');
}
enum _ImageSize {
big,
medium,
small,
extraSmall,
}
extension on _ImageSize {
int get width => switch (this) {
_ImageSize.big => 1024,
_ImageSize.medium => 512,
_ImageSize.small => 320,
_ImageSize.extraSmall => 192,
};
}
extension coverStringExt on String {
Future<String> get base64Image => _base64Image();
Future<String> _base64FromBytes(Uint8List bytes) async =>
base64.normalize(base64Encode(bytes));
// base64 or cardId or 'cards/id' or 'images/id'
Future<String> _base64Image([_ImageSize? size]) async {
try {
if (length > 100) {
if (size != null) {
final bytes = base64Decode(this);
final image = decodeImage(bytes)!;
final reizedImage = copyResize(image, width: size.width);
return _base64FromBytes(encodePng(reizedImage));
}
//is base 64
return this;
}
if (!contains('/')) {
return _base64FromBytes(_getById(this, 'cards', size));
} else if (startsWith('cards/')) {
return _base64FromBytes(_getById(split('/').last, 'cards', size));
} else if (startsWith('images/')) {
return _base64FromBytes(_getById(split('/').last, 'images', size));
}
} catch (e, s) {
log(e.toString(), stackTrace: s);
}
return '';
}
Uint8List _getById(String id, String type, _ImageSize? size) {
if (size == null) {
return File('${PackManager.assetsDirectory.path}/$type/$id')
.readAsBytesSync();
}
final resizedFile = PackManager._getResizedFile(id, type, size);
if (resizedFile.existsSync()) {
try {
return resizedFile.readAsBytesSync();
} catch (_) {
print(
'Error, deleting ${size.name} image with id:$id,type:$type (${resizedFile.path})');
resizedFile.deleteSync();
}
} else {
print(
'No ${size.name} image with id:$id,type:$type (${resizedFile.path})');
}
final file = File('${PackManager.assetsDirectory.path}/$type/$id');
if (file.existsSync()) {
try {
final bytes = file.readAsBytesSync();
final image = decodeImage(bytes)!;
final reizedImage = copyResize(image, width: size.width);
final resizedBytes = encodePng(reizedImage);
resizedFile.createSync(recursive: true);
resizedFile.writeAsBytesSync(resizedBytes);
print(
'Saved ${size.name} image with id:$id,type:$type (${resizedFile.path})');
return resizedBytes;
} catch (e, s) {
log('error while resizing image', error: e, stackTrace: s);
}
}
return Uint8List(0);
}
Future<String> get smallBase64Image => _base64Image(_ImageSize.small);
Future<String> get extraSmallBase64Image =>
_base64Image(_ImageSize.extraSmall);
Future<String> get mediumBase64Image => _base64Image(_ImageSize.medium);
Future<String> get bigSmallBase64Image => _base64Image(null);
}