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 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: {'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 _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: { 'word_id': spellingWordId, 'displayed': displayedWord, 'is_correct': isCorrectlySpelled, 'answer': correctAnswer, }, ); } /// Active catalog rows with per-user familiarity (0 when unseen). Future> 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: {'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> 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 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: {'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: {'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 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 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: {'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 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: {'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: {'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: {'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'); } }