Some checks failed
Admin portal tests / test (push) Has been cancelled
- Replaced single Postgres Connection with Pool across the server to prevent runTx locking - Added _filterForEnabledSources to client's _fetchUnansweredForPanel to strictly enforce local configuration Co-authored-by: Cursor <cursoragent@cursor.com>
251 lines
7.0 KiB
Dart
251 lines
7.0 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]),
|
|
);
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|