cyberhybridhub/server/lib/spelling/spelling_items_db.dart
2026-06-07 03:10:21 +00:00

305 lines
8.5 KiB
Dart

import 'package:postgres/postgres.dart';
import '../pipeline/spelling_word_list.dart';
import 'spelling_item_record.dart';
/// Shared spelling catalog ([spelling_words], [spelling_items]).
class SpellingItemsDb {
SpellingItemsDb(this._connection);
final Pool _connection;
/// Inserts curriculum from [SpellingWordList] when the catalog is empty.
Future<void> ensureCatalogSeeded() async {
final Result count = await _connection.execute(
'SELECT COUNT(*)::int FROM spelling_words',
);
if ((count.first[0]! as num).toInt() > 0) {
return;
}
for (final ({String correct, String misspelled}) pair
in SpellingWordList.pairs) {
final Result wordRow = await _connection.execute(
Sql.named(
'''
INSERT INTO spelling_words (canonical_word)
VALUES (@canonical)
ON CONFLICT (canonical_word) DO UPDATE SET canonical_word = EXCLUDED.canonical_word
RETURNING id
''',
),
parameters: <String, dynamic>{'canonical': pair.correct},
);
final String wordId = wordRow.first[0]!.toString();
await _insertItem(
spellingWordId: wordId,
displayedWord: pair.correct,
isCorrectlySpelled: true,
correctAnswer: 10,
);
await _insertItem(
spellingWordId: wordId,
displayedWord: pair.misspelled,
isCorrectlySpelled: false,
correctAnswer: -10,
);
}
}
Future<void> _insertItem({
required String spellingWordId,
required String displayedWord,
required bool isCorrectlySpelled,
required num correctAnswer,
}) async {
await _connection.execute(
Sql.named(
'''
INSERT INTO spelling_items (
spelling_word_id, displayed_word, is_correctly_spelled, correct_answer
) VALUES (
@word_id::uuid, @displayed, @is_correct, @answer
)
ON CONFLICT (spelling_word_id, displayed_word) DO NOTHING
''',
),
parameters: <String, dynamic>{
'word_id': spellingWordId,
'displayed': displayedWord,
'is_correct': isCorrectlySpelled,
'answer': correctAnswer,
},
);
}
/// Active catalog rows with per-user familiarity (0 when unseen).
Future<List<SpellingItemRecord>> listActiveWithProgress(
String firebaseUid,
) async {
final Result result = await _connection.execute(
Sql.named(
'''
SELECT
si.id,
si.spelling_word_id,
sw.canonical_word,
si.displayed_word,
si.is_correctly_spelled,
si.correct_answer,
COALESCE(usp.familiarity_score, 0),
COALESCE(usp.attempt_count, 0)
FROM spelling_items si
INNER JOIN spelling_words sw ON sw.id = si.spelling_word_id
LEFT JOIN user_spelling_progress usp
ON usp.spelling_item_id = si.id
AND usp.firebase_uid = @uid
WHERE si.active = true AND sw.active = true
ORDER BY sw.canonical_word, si.displayed_word
''',
),
parameters: <String, dynamic>{'uid': firebaseUid},
);
return result
.map(
(ResultRow row) => SpellingItemRecord(
id: row[0]!.toString(),
spellingWordId: row[1]!.toString(),
canonicalWord: row[2]! as String,
displayedWord: row[3]! as String,
isCorrectlySpelled: row[4]! as bool,
correctAnswer: _readNumeric(row[5]),
familiarityScore: (row[6]! as num).toInt(),
attemptCount: (row[7]! as num).toInt(),
),
)
.toList(growable: false);
}
/// Canonical words in the shared catalog (no user history).
Future<List<String>> listCanonicalWords() async {
final Result result = await _connection.execute(
'''
SELECT canonical_word
FROM spelling_words
WHERE active = true
ORDER BY canonical_word ASC
''',
);
return result
.map((ResultRow row) => row[0]! as String)
.toList(growable: false);
}
/// Adds a word and quiz items when absent; returns false if already present.
Future<bool> addCanonicalWord(String rawWord) async {
final String canonical = _normalizeCanonical(rawWord);
if (canonical.isEmpty) {
return false;
}
final Result existing = await _connection.execute(
Sql.named(
'''
SELECT id FROM spelling_words
WHERE canonical_word = @canonical
''',
),
parameters: <String, dynamic>{'canonical': canonical},
);
if (existing.isNotEmpty) {
return false;
}
final Result wordRow = await _connection.execute(
Sql.named(
'''
INSERT INTO spelling_words (canonical_word)
VALUES (@canonical)
RETURNING id
''',
),
parameters: <String, dynamic>{'canonical': canonical},
);
final String wordId = wordRow.first[0]!.toString();
final String misspelled = SpellingWordList.generatedMisspelling(canonical);
await _insertItem(
spellingWordId: wordId,
displayedWord: canonical,
isCorrectlySpelled: true,
correctAnswer: 10,
);
if (misspelled != canonical) {
await _insertItem(
spellingWordId: wordId,
displayedWord: misspelled,
isCorrectlySpelled: false,
correctAnswer: -10,
);
}
return true;
}
/// One line per word; skips blanks and duplicates.
Future<({int added, int skipped})> importCanonicalWords(
Iterable<String> lines,
) async {
var added = 0;
var skipped = 0;
for (final String line in lines) {
final String canonical = _normalizeCanonical(line);
if (canonical.isEmpty) {
continue;
}
if (await addCanonicalWord(canonical)) {
added++;
} else {
skipped++;
}
}
return (added: added, skipped: skipped);
}
static String _normalizeCanonical(String raw) =>
raw.trim().toLowerCase();
Future<SpellingItemRecord?> findById(String spellingItemId) async {
final Result result = await _connection.execute(
Sql.named(
'''
SELECT
si.id,
si.spelling_word_id,
sw.canonical_word,
si.displayed_word,
si.is_correctly_spelled,
si.correct_answer
FROM spelling_items si
INNER JOIN spelling_words sw ON sw.id = si.spelling_word_id
WHERE si.id = @id::uuid
LIMIT 1
''',
),
parameters: <String, dynamic>{'id': spellingItemId},
);
if (result.isEmpty) {
return null;
}
final ResultRow row = result.first;
return SpellingItemRecord(
id: row[0]!.toString(),
spellingWordId: row[1]!.toString(),
canonicalWord: row[2]! as String,
displayedWord: row[3]! as String,
isCorrectlySpelled: row[4]! as bool,
correctAnswer: _readNumeric(row[5]),
);
}
/// Deletes a canonical word, its items, user progress, links, and associated questions.
Future<bool> deleteCanonicalWord(String rawWord) async {
final String canonical = _normalizeCanonical(rawWord);
if (canonical.isEmpty) {
return false;
}
final Result wordRow = await _connection.execute(
Sql.named(
'''
SELECT id FROM spelling_words
WHERE canonical_word = @canonical
''',
),
parameters: <String, dynamic>{'canonical': canonical},
);
if (wordRow.isEmpty) {
return false;
}
final String wordId = wordRow.first[0]!.toString();
// 1. Delete associated questions in the questions table.
await _connection.execute(
Sql.named(
'''
DELETE FROM questions
WHERE id IN (
SELECT question_id
FROM spelling_question_links
WHERE spelling_item_id IN (
SELECT id FROM spelling_items
WHERE spelling_word_id = @wordId::uuid
)
)
''',
),
parameters: <String, dynamic>{'wordId': wordId},
);
// 2. Delete the spelling word (ON DELETE CASCADE will clean up spelling_items,
// user_spelling_progress, and spelling_question_links).
final Result deleteResult = await _connection.execute(
Sql.named(
'''
DELETE FROM spelling_words
WHERE id = @wordId::uuid
''',
),
parameters: <String, dynamic>{'wordId': wordId},
);
return deleteResult.affectedRows > 0;
}
static num _readNumeric(Object? value) {
if (value is num) {
return value;
}
if (value is String) {
return num.parse(value);
}
throw ArgumentError('Expected numeric value, got $value');
}
}