Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App 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
801 lines
26 KiB
Dart
801 lines
26 KiB
Dart
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
import 'package:injectable/injectable.dart';
|
|
import 'package:drift_postgres/drift_postgres.dart';
|
|
import 'package:mnemo_cards_backend/database/database.dart';
|
|
import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
|
|
import 'package:mnemo_cards_backend/packs/voice_storage.dart';
|
|
import 'package:mnemo_cards_backend/storage/minio_config.dart';
|
|
import 'package:mnemo_cards_backend/storage/minio_service.dart';
|
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
|
import 'package:mnemo_cards_backend/tests/generators/models/creation_test_data.dart';
|
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
|
import 'package:drift/drift.dart' as drift;
|
|
|
|
import 'generators/pack_test_generator.dart';
|
|
|
|
@lazySingleton
|
|
class TestManager {
|
|
final AppDatabase _db;
|
|
final MinioService _minioService;
|
|
|
|
TestManager(this._db, this._minioService);
|
|
|
|
static final _uuidRegex = RegExp(
|
|
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
|
caseSensitive: false,
|
|
);
|
|
|
|
bool _isUuid(String value) => _uuidRegex.hasMatch(value.trim());
|
|
|
|
bool _isBase64OrDataUrlImage(String value) =>
|
|
CardImageStorage.tryParseBase64Image(value) != null;
|
|
|
|
String? _extractCardIdFromApiImageUrl(String value) {
|
|
final v = value.trim();
|
|
if (!CardImageStorage.isApiImageUrl(v)) return null;
|
|
final match = RegExp(
|
|
r'/cards/([^/]+)/image(?:Back)?$',
|
|
).firstMatch(v)?.group(1);
|
|
if (match == null) return null;
|
|
return _isUuid(match) ? match : null;
|
|
}
|
|
|
|
Future<void> _tryLinkCardToPack({
|
|
required String packId,
|
|
required String cardId,
|
|
}) async {
|
|
try {
|
|
final card = await _db.packDao.getCardById(cardId);
|
|
if (card == null) return;
|
|
await _db.packDao.addCardToPack(packId: packId, cardId: cardId);
|
|
} catch (_) {
|
|
// Best-effort only.
|
|
}
|
|
}
|
|
|
|
Future<String?> _convertBase64ToCard(
|
|
String base64Image,
|
|
String? packId,
|
|
) async {
|
|
try {
|
|
final companion = GameCardsCompanion.insert(
|
|
original: 'test_image',
|
|
translation: 'test_image',
|
|
image: '',
|
|
mnemo: const drift.Value('test_image'),
|
|
);
|
|
|
|
final cardId = await _db.packDao.createCard(companion);
|
|
if (packId != null) {
|
|
await _tryLinkCardToPack(packId: packId, cardId: cardId);
|
|
}
|
|
|
|
final stored = await CardImageStorage.persistFromBase64(
|
|
cardId: cardId,
|
|
imageValue: base64Image,
|
|
preferredFileName: null,
|
|
isBack: false,
|
|
);
|
|
if (stored == null) return null;
|
|
|
|
final created = await _db.packDao.getCardById(cardId);
|
|
if (created == null) return null;
|
|
|
|
await _db.packDao.updateCard(
|
|
created.copyWith(
|
|
image: stored.fileName,
|
|
updatedAt: PgDateTime(DateTime.now()),
|
|
),
|
|
);
|
|
|
|
return cardId;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<String?> _normalizeImageValueForDb(
|
|
String? value, {
|
|
required String? packId,
|
|
}) async {
|
|
if (value == null) return null;
|
|
final v = value.trim();
|
|
if (v.isEmpty) return null;
|
|
|
|
// Remote URL: keep as is
|
|
if (CardImageStorage.isRemoteUrl(v)) return v;
|
|
|
|
// If it's already a valid UUID (object ID in MinIO), keep it
|
|
if (_isUuid(v)) {
|
|
return v;
|
|
}
|
|
|
|
// API URL: extract cardId if it's a valid UUID
|
|
final fromApi = _extractCardIdFromApiImageUrl(v);
|
|
if (fromApi != null && _isUuid(fromApi)) {
|
|
return fromApi; // Return as object ID
|
|
}
|
|
|
|
// Base64/data-url: upload to MinIO and return object ID
|
|
if (_isBase64OrDataUrlImage(v)) {
|
|
final parsed = CardImageStorage.tryParseBase64Image(v);
|
|
if (parsed != null) {
|
|
try {
|
|
final objectId = await _minioService.uploadFile(
|
|
bucket: MinioConfig.cardImagesBucket,
|
|
bytes: parsed.bytes,
|
|
contentType: parsed.contentType,
|
|
);
|
|
return objectId;
|
|
} catch (e) {
|
|
print('Error uploading test image to MinIO: $e');
|
|
// Fallback: try old method for backward compatibility
|
|
return _convertBase64ToCard(v, packId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Legacy: old filename format or other - keep as is for backward compatibility
|
|
return v;
|
|
}
|
|
|
|
/// Converts image value to presigned URL for display
|
|
Future<String?> _imageValueToApiUrl(
|
|
String? value, {
|
|
required String? packId,
|
|
}) async {
|
|
if (value == null) return null;
|
|
final v = value.trim();
|
|
if (v.isEmpty) return null;
|
|
|
|
// Already a URL: return as is
|
|
if (CardImageStorage.isRemoteUrl(v) || CardImageStorage.isApiImageUrl(v)) {
|
|
return v;
|
|
}
|
|
|
|
// If it's a valid UUID (object ID in MinIO), generate presigned URL
|
|
if (_isUuid(v)) {
|
|
final presignedUrl = await _minioService.getPresignedUrl(
|
|
bucket: MinioConfig.cardImagesBucket,
|
|
objectId: v,
|
|
);
|
|
return presignedUrl;
|
|
}
|
|
|
|
// Legacy: try to extract cardId from API URL
|
|
final cardId = _extractCardIdFromApiImageUrl(v);
|
|
if (cardId != null && packId != null) {
|
|
// If cardId is UUID, generate presigned URL
|
|
if (_isUuid(cardId)) {
|
|
final presignedUrl = await _minioService.getPresignedUrl(
|
|
bucket: MinioConfig.cardImagesBucket,
|
|
objectId: cardId,
|
|
);
|
|
return presignedUrl;
|
|
}
|
|
// Old format: use API endpoint
|
|
return '/api/v2/packs/$packId/cards/$cardId/image';
|
|
}
|
|
|
|
// Don't leak base64 through user API.
|
|
if (_isBase64OrDataUrlImage(v)) return null;
|
|
return v;
|
|
}
|
|
|
|
/// Converts an audio value to a presigned URL if it's a UUID (objectId in MinIO),
|
|
/// or returns it as-is if it's already a remote URL.
|
|
///
|
|
/// Similar to _imageValueToApiUrl but for audio files stored in voice-audio bucket.
|
|
Future<String?> _audioValueToApiUrl(String? value) async {
|
|
if (value == null) return null;
|
|
final v = value.trim();
|
|
if (v.isEmpty) return null;
|
|
|
|
// Already a remote URL: return as is
|
|
if (VoiceStorage.isRemoteUrl(v)) {
|
|
return v;
|
|
}
|
|
|
|
// If it's a valid UUID (object ID in MinIO), generate presigned URL
|
|
if (_isUuid(v)) {
|
|
final presignedUrl = await _minioService.getPresignedUrl(
|
|
bucket: MinioConfig.voiceAudioBucket,
|
|
objectId: v,
|
|
);
|
|
return presignedUrl;
|
|
}
|
|
|
|
// Legacy formats (base64, filenames) - don't leak through API
|
|
// Return null to indicate invalid/unsupported format
|
|
if (VoiceStorage.tryParseBase64Audio(v) != null) return null;
|
|
|
|
// For other formats (like legacy filenames), return null
|
|
// They should be migrated to UUID format
|
|
return null;
|
|
}
|
|
|
|
Future<TestStatisticsDto?> _testStatisticsDto(
|
|
String userId,
|
|
String testId,
|
|
) async {
|
|
final statistics = await _db.testDao.getTestStatistics(userId, testId);
|
|
if (statistics == null) return null;
|
|
|
|
// Convert TestStatistic to TestStatisticsDto
|
|
// metadata is stored as JSON string with 'attempts' key
|
|
final metadataMap = statistics.metadata.isNotEmpty
|
|
? (json.decode(statistics.metadata) as Map<String, dynamic>? ?? {})
|
|
: <String, dynamic>{};
|
|
final attempts = (metadataMap['attempts'] as List<dynamic>?) ?? [];
|
|
|
|
// Get words from the last attempt, or empty list if no attempts
|
|
final lastAttempt = attempts.isNotEmpty
|
|
? attempts.last as Map<String, dynamic>?
|
|
: null;
|
|
final wordsJson = (lastAttempt?['words'] as List<dynamic>?) ?? [];
|
|
|
|
// Convert words JSON to WordStatisticsDto
|
|
final words = wordsJson
|
|
.map((w) => WordStatisticsDto.fromJson(w as Map<String, dynamic>))
|
|
.toList();
|
|
|
|
return TestStatisticsDto(
|
|
testId: statistics.testId,
|
|
words: AllWordsStatisticsDto(words: words),
|
|
attempts: attempts.length,
|
|
sessionToken: lastAttempt?['sessionToken'] as String?,
|
|
);
|
|
}
|
|
|
|
Future<TestDto?> fetchTest(String id, UserModel user) async {
|
|
final testId = id;
|
|
|
|
final test = await _db.testDao.getTestById(testId);
|
|
if (test == null) return null;
|
|
|
|
// Get packId for the test to convert image IDs to URLs
|
|
final packId = await _db.testDao.getPackIdForTest(testId);
|
|
|
|
final questions = await _db.testDao.getTestQuestions(testId);
|
|
final statistics = await _testStatisticsDto(user.id!, testId);
|
|
|
|
// Convert Test to TestDto
|
|
final questionsList = <AbstractTestQuestion>[];
|
|
for (final q in questions) {
|
|
// Build question JSON from separate fields
|
|
final questionJson = <String, dynamic>{
|
|
'questionType': q.questionType,
|
|
'id': q.id,
|
|
'word': q.word,
|
|
'answer': q.answer,
|
|
};
|
|
|
|
// Parse options (JSON array of buttons / matrix cards)
|
|
List<dynamic> buttons = [];
|
|
try {
|
|
buttons = json.decode(q.options) as List<dynamic>;
|
|
} catch (_) {
|
|
buttons = [];
|
|
}
|
|
questionJson['buttons'] = buttons;
|
|
|
|
// Parse uiData (image, text, audio, template, matrixSize, ...)
|
|
Map<String, dynamic> uiData = {};
|
|
try {
|
|
uiData = json.decode(q.uiData) as Map<String, dynamic>;
|
|
} catch (_) {
|
|
uiData = {};
|
|
}
|
|
|
|
var mutated = false;
|
|
|
|
if (uiData['image'] != null) {
|
|
final raw = uiData['image']?.toString();
|
|
final normalized = await _normalizeImageValueForDb(raw, packId: packId);
|
|
if ((raw ?? '').trim() != (normalized ?? '').trim()) {
|
|
mutated = true;
|
|
}
|
|
if (normalized == null) {
|
|
uiData.remove('image');
|
|
} else {
|
|
// Keep image as objectId, add imageUrl as presigned URL
|
|
uiData['image'] = normalized;
|
|
final imageUrl = await _imageValueToApiUrl(
|
|
normalized,
|
|
packId: packId,
|
|
);
|
|
if (imageUrl != null) {
|
|
uiData['imageUrl'] = imageUrl;
|
|
}
|
|
}
|
|
} else {
|
|
// If image was removed, also remove imageUrl
|
|
uiData.remove('imageUrl');
|
|
}
|
|
|
|
// Normalize button images too (so response is always URLs, never base64).
|
|
// Keep image as objectId, add imageUrl as presigned URL
|
|
final normalizedButtons = <dynamic>[];
|
|
for (final b in buttons) {
|
|
if (b is Map) {
|
|
final buttonMap = Map<String, dynamic>.from(
|
|
b.map((k, v) => MapEntry(k.toString(), v)),
|
|
);
|
|
if (buttonMap['image'] != null) {
|
|
final raw = buttonMap['image']?.toString();
|
|
final normalized = await _normalizeImageValueForDb(
|
|
raw,
|
|
packId: packId,
|
|
);
|
|
if ((raw ?? '').trim() != (normalized ?? '').trim()) {
|
|
mutated = true;
|
|
}
|
|
if (normalized == null) {
|
|
buttonMap.remove('image');
|
|
buttonMap.remove('imageUrl');
|
|
} else {
|
|
// Keep image as objectId, add imageUrl as presigned URL
|
|
buttonMap['image'] = normalized;
|
|
final imageUrl = await _imageValueToApiUrl(
|
|
normalized,
|
|
packId: packId,
|
|
);
|
|
if (imageUrl != null) {
|
|
buttonMap['imageUrl'] = imageUrl;
|
|
}
|
|
}
|
|
}
|
|
normalizedButtons.add(buttonMap);
|
|
} else {
|
|
normalizedButtons.add(b);
|
|
}
|
|
}
|
|
questionJson['buttons'] = normalizedButtons;
|
|
|
|
if (mutated) {
|
|
await _db.testDao.updateTestQuestion(
|
|
q.copyWith(
|
|
options: jsonEncode(normalizedButtons),
|
|
uiData: jsonEncode(uiData),
|
|
updatedAt: PgDateTime(DateTime.now()),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Convert audio to presigned URL if it's a UUID (objectId in MinIO)
|
|
// Note: image and imageUrl were already set above during normalization
|
|
final uiDataForResponse = Map<String, dynamic>.from(uiData);
|
|
if (uiDataForResponse['audio'] != null) {
|
|
final audioUrl = await _audioValueToApiUrl(
|
|
uiDataForResponse['audio']?.toString(),
|
|
);
|
|
if (audioUrl != null) {
|
|
uiDataForResponse['audio'] = audioUrl;
|
|
} else {
|
|
// If conversion failed (e.g., base64 or invalid format), remove audio
|
|
uiDataForResponse.remove('audio');
|
|
}
|
|
}
|
|
|
|
// Buttons already have image (objectId) and imageUrl (presigned URL)
|
|
// from normalization above, no need to convert again
|
|
questionJson['buttons'] = normalizedButtons;
|
|
questionJson.addAll(uiDataForResponse);
|
|
|
|
// Matrix question: allow storing only config (matrixSize) and generate
|
|
// actual matrix cards from pack pool on-the-fly if buttons are missing.
|
|
if (q.questionType == TestQuestionType.matrix.name) {
|
|
final matrixSizeRaw = questionJson['matrixSize'];
|
|
final matrixSize = switch (matrixSizeRaw) {
|
|
int v => v,
|
|
num v => v.toInt(),
|
|
String v => int.tryParse(v) ?? 3,
|
|
_ => 3,
|
|
};
|
|
questionJson['matrixSize'] = matrixSize;
|
|
|
|
final currentButtons =
|
|
(questionJson['buttons'] as List<dynamic>?) ?? [];
|
|
if (currentButtons.isEmpty && packId != null) {
|
|
final pool = await _db.packDao.getPackCards(packId);
|
|
final maxSize = sqrt(pool.length).floor().clamp(1, 4);
|
|
final resolvedSize = matrixSize.clamp(1, maxSize);
|
|
final total = resolvedSize * resolvedSize;
|
|
|
|
final rng = Random(
|
|
(testId.hashCode ^ q.id.hashCode ^ resolvedSize) & 0x7fffffff,
|
|
);
|
|
|
|
final shuffled = pool.toList()..shuffle(rng);
|
|
final selected = shuffled.take(total).toList();
|
|
|
|
final generatedButtons = selected
|
|
.map(
|
|
(c) => <String, dynamic>{
|
|
'id': c.id,
|
|
// store image as objectId (MinIO) or filename
|
|
'image': c.image,
|
|
'original': c.original,
|
|
'translation': c.translation,
|
|
},
|
|
)
|
|
.toList();
|
|
|
|
questionJson['buttons'] = generatedButtons;
|
|
|
|
if (selected.isNotEmpty) {
|
|
final target = selected[rng.nextInt(selected.length)];
|
|
questionJson['word'] = target.original;
|
|
questionJson['answer'] = target.id;
|
|
}
|
|
|
|
questionJson['matrixSize'] = resolvedSize;
|
|
} else if ((questionJson['answer'] as String?)?.isEmpty != false) {
|
|
// If matrix cards exist but answer is missing, derive initial target
|
|
// from the first card.
|
|
final first = currentButtons.firstOrNull;
|
|
if (first is Map<String, dynamic>) {
|
|
final id = first['id']?.toString();
|
|
final original = first['original']?.toString();
|
|
if (id != null && id.isNotEmpty) {
|
|
questionJson['answer'] = id;
|
|
}
|
|
if (original != null && original.isNotEmpty) {
|
|
questionJson['word'] = original;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert button images to URLs (works for both TestButtonDto and matrix cards)
|
|
// Add imageUrl while keeping image (objectId) for admin
|
|
// All images now use the same cardImagesBucket
|
|
final updatedButtons = await Future.wait(
|
|
(questionJson['buttons'] as List<dynamic>? ?? []).map((button) async {
|
|
if (button is Map<String, dynamic> && button['image'] != null) {
|
|
final buttonMap = Map<String, dynamic>.from(button);
|
|
final imageValue = buttonMap['image']?.toString();
|
|
|
|
// Convert image to presigned URL
|
|
final imageUrl = await _imageValueToApiUrl(
|
|
imageValue,
|
|
packId: packId,
|
|
);
|
|
|
|
if (imageUrl != null) {
|
|
buttonMap['imageUrl'] = imageUrl;
|
|
}
|
|
return buttonMap;
|
|
}
|
|
return button;
|
|
}),
|
|
);
|
|
questionJson['buttons'] = updatedButtons;
|
|
|
|
questionsList.add(AbstractTestQuestion.fromJson(questionJson));
|
|
}
|
|
|
|
final normalizedCover = await _normalizeImageValueForDb(
|
|
test.cover,
|
|
packId: packId,
|
|
);
|
|
final coverUrl = await _imageValueToApiUrl(normalizedCover, packId: packId);
|
|
|
|
return TestDto(
|
|
id: testId.toString(),
|
|
name: test.name,
|
|
color: test.color,
|
|
cover: normalizedCover, // Object ID (for admin)
|
|
coverUrl: coverUrl, // Presigned URL (for display)
|
|
version: test.version ?? '1.0',
|
|
time: test.time,
|
|
timeSubtitle: test.timeSubtitle,
|
|
questions: questionsList,
|
|
statistics: statistics,
|
|
);
|
|
}
|
|
|
|
Future<List<TestDto>> availableTests(UserModel userModel) async {
|
|
final tests = await _db.testDao.getAllTests();
|
|
|
|
final testDtos = <TestDto>[];
|
|
for (final test in tests) {
|
|
final questions = await _db.testDao.getTestQuestions(test.id);
|
|
final statistics = await _testStatisticsDto(userModel.id!, test.id);
|
|
|
|
final questionsList = questions.map((q) {
|
|
// Build question JSON from separate fields
|
|
final questionJson = <String, dynamic>{
|
|
'questionType': q.questionType,
|
|
'id': q.id,
|
|
'word': q.word,
|
|
};
|
|
|
|
// Parse options (JSON array of buttons)
|
|
try {
|
|
final options = json.decode(q.options) as List<dynamic>;
|
|
questionJson['buttons'] = options;
|
|
} catch (e) {
|
|
questionJson['buttons'] = [];
|
|
}
|
|
|
|
// Add answer
|
|
questionJson['answer'] = q.answer;
|
|
|
|
// Parse uiData (image, text, audio, template)
|
|
try {
|
|
final uiData = json.decode(q.uiData) as Map<String, dynamic>;
|
|
questionJson.addAll(uiData);
|
|
} catch (e) {
|
|
// If uiData is empty or invalid, ignore
|
|
}
|
|
|
|
return AbstractTestQuestion.fromJson(questionJson);
|
|
}).toList();
|
|
|
|
testDtos.add(
|
|
TestDto(
|
|
id: test.id.toString(),
|
|
name: test.name,
|
|
color: test.color,
|
|
cover: test.cover,
|
|
version: test.version ?? '1.0',
|
|
time: test.time,
|
|
timeSubtitle: test.timeSubtitle,
|
|
questions: questionsList,
|
|
statistics: statistics,
|
|
),
|
|
);
|
|
}
|
|
|
|
return testDtos;
|
|
}
|
|
|
|
Future<List<TestDto>> fetchPackTests(
|
|
UserModel user,
|
|
CardPackModel model,
|
|
) async {
|
|
final packId = model.id;
|
|
if (packId == null) return [];
|
|
|
|
final tests = await _db.testDao.getTestsByPackId(packId);
|
|
|
|
final testDtos = <TestDto>[];
|
|
for (final test in tests) {
|
|
final questions = await _db.testDao.getTestQuestions(test.id);
|
|
final statistics = await _testStatisticsDto(user.id!, test.id);
|
|
|
|
final questionsList = await Future.wait(
|
|
questions.map((q) async {
|
|
// Build question JSON from separate fields
|
|
final questionJson = <String, dynamic>{
|
|
'questionType': q.questionType,
|
|
'id': q.id,
|
|
'word': q.word,
|
|
};
|
|
|
|
// Parse options (JSON array of buttons)
|
|
List<dynamic> buttons = [];
|
|
try {
|
|
buttons = json.decode(q.options) as List<dynamic>;
|
|
} catch (e) {
|
|
buttons = [];
|
|
}
|
|
|
|
// Convert button images to URLs (works for both TestButtonDto and matrix cards)
|
|
// Add imageUrl while keeping image (objectId) for admin
|
|
final updatedButtons = await Future.wait(
|
|
buttons.map((button) async {
|
|
if (button is Map<String, dynamic> && button['image'] != null) {
|
|
final buttonMap = Map<String, dynamic>.from(button);
|
|
final imageValue = buttonMap['image']?.toString();
|
|
|
|
// Convert image to presigned URL
|
|
final imageUrl = await _imageValueToApiUrl(
|
|
imageValue,
|
|
packId: packId,
|
|
);
|
|
|
|
if (imageUrl != null) {
|
|
buttonMap['imageUrl'] = imageUrl;
|
|
}
|
|
return buttonMap;
|
|
}
|
|
return button;
|
|
}),
|
|
);
|
|
|
|
questionJson['buttons'] = updatedButtons;
|
|
|
|
// Add answer
|
|
questionJson['answer'] = q.answer;
|
|
|
|
// Parse uiData (image, text, audio, template)
|
|
try {
|
|
final uiData = json.decode(q.uiData) as Map<String, dynamic>;
|
|
// Convert question image to URL
|
|
if (uiData['image'] != null) {
|
|
final imageValue = uiData['image']?.toString();
|
|
final imageUrl = await _imageValueToApiUrl(
|
|
imageValue,
|
|
packId: packId,
|
|
);
|
|
if (imageUrl != null) {
|
|
uiData['imageUrl'] = imageUrl;
|
|
}
|
|
}
|
|
questionJson.addAll(uiData);
|
|
} catch (e) {
|
|
// If uiData is empty or invalid, ignore
|
|
}
|
|
|
|
return AbstractTestQuestion.fromJson(questionJson);
|
|
}),
|
|
);
|
|
|
|
final normalizedCover = await _normalizeImageValueForDb(
|
|
test.cover,
|
|
packId: packId,
|
|
);
|
|
final coverUrl = await _imageValueToApiUrl(
|
|
normalizedCover,
|
|
packId: packId,
|
|
);
|
|
|
|
testDtos.add(
|
|
TestDto(
|
|
id: test.id.toString(),
|
|
name: test.name,
|
|
color: test.color,
|
|
cover: normalizedCover, // Object ID (for admin)
|
|
coverUrl: coverUrl, // Presigned URL (for display)
|
|
version: test.version ?? '1.0',
|
|
time: test.time,
|
|
timeSubtitle: test.timeSubtitle,
|
|
questions: questionsList,
|
|
statistics: statistics,
|
|
),
|
|
);
|
|
}
|
|
|
|
return testDtos;
|
|
}
|
|
|
|
Future<void> refreshCustomCreationTestsData() async {
|
|
// Load custom creation test data from database
|
|
// For now, keep it simple
|
|
}
|
|
|
|
Future<void> updateGeneratedTests(CardPackModel model) async {
|
|
final packId = model.id;
|
|
if (packId == null) return;
|
|
|
|
// Get pack data from database
|
|
final pack = await _db.packDao.getPackById(packId);
|
|
if (pack == null) return;
|
|
|
|
// Get all cards for the pack
|
|
final cards = await _db.packDao.getPackCards(packId);
|
|
if (cards.isEmpty) return;
|
|
|
|
// Delete old generated tests for this pack
|
|
final existingTests = await _db.testDao.getTestsByPackId(packId);
|
|
final oldGeneratedTests = existingTests
|
|
.where((test) => test.version == 'generated')
|
|
.toList();
|
|
for (final oldTest in oldGeneratedTests) {
|
|
await _db.testDao.softDeleteTest(oldTest.id);
|
|
}
|
|
|
|
// Create creation test data
|
|
// Get voices for all cards to use UUID from MinIO instead of text
|
|
final testDataItems = await Future.wait(
|
|
cards.map((card) async {
|
|
// Get voices for this card
|
|
final voices = await _db.packDao.getCardVoices(card.id.toString());
|
|
|
|
// Use first voice's voiceUrl if it's a valid UUID, otherwise null
|
|
String? audioUuid;
|
|
if (voices.isNotEmpty) {
|
|
final voiceUrl = voices.first.voiceUrl.trim();
|
|
// Check if voiceUrl is a valid UUID (object ID in MinIO)
|
|
if (_isUuid(voiceUrl)) {
|
|
audioUuid = voiceUrl;
|
|
}
|
|
}
|
|
|
|
return TestDataItem(
|
|
id: card.id.toString(),
|
|
original: card.original,
|
|
translation: card.translation,
|
|
image: card.image,
|
|
audio: audioUuid, // Use UUID from MinIO if available
|
|
);
|
|
}),
|
|
);
|
|
|
|
final creationTestData = CreationTestData(
|
|
items: testDataItems,
|
|
title: pack.title,
|
|
color: pack.color,
|
|
packId: packId.toString(),
|
|
);
|
|
|
|
// Generate test using PackTestGenerator
|
|
final generator = PackTestGenerator(creationTestData);
|
|
final testDto = await generator.generate(
|
|
name: '${pack.title} Test',
|
|
ratios: {
|
|
TestQuestionType.simple: 0.6,
|
|
TestQuestionType.input_buttons: 0.2,
|
|
TestQuestionType.matrix: 0.2,
|
|
},
|
|
multiply: 1.0,
|
|
);
|
|
|
|
// Save test to database and link it to the pack in one transaction.
|
|
await addTest(testDto, packId: packId);
|
|
}
|
|
|
|
Future<String> addTest(TestDto testDto, {String? packId}) async {
|
|
String? createdTestId;
|
|
await _db.transaction(() async {
|
|
// Create test
|
|
final testCompanion = TestsCompanion.insert(
|
|
name: testDto.name,
|
|
color: drift.Value(testDto.color),
|
|
cover: drift.Value(testDto.cover),
|
|
version: drift.Value(testDto.version),
|
|
time: drift.Value(testDto.time),
|
|
timeSubtitle: drift.Value(testDto.timeSubtitle),
|
|
);
|
|
|
|
createdTestId = await _db.testDao.createTest(testCompanion);
|
|
final testId = createdTestId!;
|
|
|
|
if (packId != null) {
|
|
await _db.testDao.linkTestToPack(testId, packId);
|
|
}
|
|
|
|
// Create questions
|
|
int orderIndex = 0;
|
|
for (final question in testDto.questions) {
|
|
final questionJson = question.toJson();
|
|
|
|
// Extract key fields
|
|
final word = questionJson['word'] as String? ?? '';
|
|
final answer = questionJson['answer'] as String? ?? '';
|
|
final buttons = questionJson['buttons'] as List<dynamic>? ?? [];
|
|
|
|
// UI data (image, text, audio, template)
|
|
final uiData = <String, dynamic>{};
|
|
if (questionJson['image'] != null)
|
|
uiData['image'] = questionJson['image'];
|
|
if (questionJson['text'] != null) uiData['text'] = questionJson['text'];
|
|
if (questionJson['audio'] != null)
|
|
uiData['audio'] = questionJson['audio'];
|
|
if (questionJson['template'] != null)
|
|
uiData['template'] = questionJson['template'];
|
|
if (questionJson['matrixSize'] != null)
|
|
uiData['matrixSize'] = questionJson['matrixSize'];
|
|
if (questionJson['stages'] != null)
|
|
uiData['stages'] = questionJson['stages'];
|
|
|
|
final questionCompanion = TestQuestionsCompanion.insert(
|
|
testId: testId,
|
|
orderIndex: drift.Value(orderIndex++),
|
|
questionType: question.questionType.name,
|
|
word: word,
|
|
answer: answer,
|
|
options: drift.Value(json.encode(buttons)),
|
|
uiData: drift.Value(json.encode(uiData)),
|
|
);
|
|
|
|
await _db.testDao.createTestQuestion(questionCompanion);
|
|
}
|
|
});
|
|
return createdTestId!;
|
|
}
|
|
}
|