diff --git a/core/data/BUILD.bazel b/core/data/BUILD.bazel index 0693739..6217a99 100644 --- a/core/data/BUILD.bazel +++ b/core/data/BUILD.bazel @@ -1,7 +1,5 @@ load("@rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library", "kt_jvm_test") -package(default_visibility = ["//visibility:public"]) - kt_jvm_library( name = "data", srcs = glob(["src/main/kotlin/**/*.kt"]), @@ -15,67 +13,94 @@ kt_jvm_library( kt_jvm_test( name = "seed_database_test", - size = "small", srcs = ["src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedDatabaseTest.kt"], test_class = "com.anysoftkeyboard.calque.data.seed.SeedDatabaseTest", deps = [ ":data", - "//core/linguistics", "@maven//:junit_junit", - "@maven//:org_xerial_sqlite_jdbc", ], ) kt_jvm_test( name = "seed_dictionary_repository_test", - size = "small", srcs = ["src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedDictionaryRepositoryTest.kt"], test_class = "com.anysoftkeyboard.calque.data.seed.SeedDictionaryRepositoryTest", deps = [ ":data", "//core/linguistics", "@maven//:junit_junit", - "@maven//:org_xerial_sqlite_jdbc", ], ) kt_jvm_test( name = "seed_false_cognates_repository_test", - size = "small", srcs = ["src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedFalseCognatesRepositoryTest.kt"], test_class = "com.anysoftkeyboard.calque.data.seed.SeedFalseCognatesRepositoryTest", deps = [ ":data", "//core/linguistics", "@maven//:junit_junit", - "@maven//:org_xerial_sqlite_jdbc", ], ) kt_jvm_test( name = "seed_curated_lessons_repository_test", - size = "small", srcs = ["src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedCuratedLessonsRepositoryTest.kt"], test_class = "com.anysoftkeyboard.calque.data.seed.SeedCuratedLessonsRepositoryTest", deps = [ ":data", - "//core/linguistics", "@maven//:junit_junit", - "@maven//:org_jetbrains_kotlinx_kotlinx_serialization_json", - "@maven//:org_xerial_sqlite_jdbc", ], ) kt_jvm_test( name = "seed_cognate_rules_repository_test", - size = "small", srcs = ["src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedCognateRulesRepositoryTest.kt"], test_class = "com.anysoftkeyboard.calque.data.seed.SeedCognateRulesRepositoryTest", + deps = [ + ":data", + "@maven//:junit_junit", + ], +) + +kt_jvm_test( + name = "user_database_test", + srcs = ["src/test/kotlin/com/anysoftkeyboard/calque/data/user/UserDatabaseTest.kt"], + test_class = "com.anysoftkeyboard.calque.data.user.UserDatabaseTest", + deps = [ + ":data", + "@maven//:junit_junit", + ], +) + +kt_jvm_test( + name = "user_word_state_repository_test", + srcs = ["src/test/kotlin/com/anysoftkeyboard/calque/data/user/UserWordStateRepositoryTest.kt"], + test_class = "com.anysoftkeyboard.calque.data.user.UserWordStateRepositoryTest", + deps = [ + ":data", + "//core/linguistics", + "@maven//:junit_junit", + ], +) + +kt_jvm_test( + name = "user_custom_text_repository_test", + srcs = ["src/test/kotlin/com/anysoftkeyboard/calque/data/user/UserCustomTextRepositoryTest.kt"], + test_class = "com.anysoftkeyboard.calque.data.user.UserCustomTextRepositoryTest", + deps = [ + ":data", + "@maven//:junit_junit", + ], +) + +kt_jvm_test( + name = "calque_word_lookup_provider_test", + srcs = ["src/test/kotlin/com/anysoftkeyboard/calque/data/CalqueWordLookupProviderTest.kt"], + test_class = "com.anysoftkeyboard.calque.data.CalqueWordLookupProviderTest", deps = [ ":data", "//core/linguistics", "@maven//:junit_junit", - "@maven//:org_jetbrains_kotlinx_kotlinx_serialization_json", - "@maven//:org_xerial_sqlite_jdbc", ], ) diff --git a/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/CalqueWordLookupProvider.kt b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/CalqueWordLookupProvider.kt new file mode 100644 index 0000000..b851ca8 --- /dev/null +++ b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/CalqueWordLookupProvider.kt @@ -0,0 +1,38 @@ +package com.anysoftkeyboard.calque.data + +import com.anysoftkeyboard.calque.data.seed.DictionaryRepository +import com.anysoftkeyboard.calque.data.seed.FalseCognatesRepository +import com.anysoftkeyboard.calque.data.user.UserWordStateRepository +import com.anysoftkeyboard.calque.data.user.batchGetWordStatuses +import com.anysoftkeyboard.calque.linguistics.DictionaryEntry +import com.anysoftkeyboard.calque.linguistics.FalseCognateEntry +import com.anysoftkeyboard.calque.linguistics.UserWordStatus +import com.anysoftkeyboard.calque.linguistics.WordLookupProvider + +/** + * Composite [WordLookupProvider] implementation bridging [DictionaryRepository], + * [FalseCognatesRepository], and [UserWordStateRepository] for reader enrichment and linguistic + * lookups. + */ +class CalqueWordLookupProvider( + private val dictionaryRepository: DictionaryRepository, + private val falseCognatesRepository: FalseCognatesRepository, + private val userWordStateRepository: UserWordStateRepository, +) : WordLookupProvider { + + override fun batchLookupDictionary(words: Set): Map { + return dictionaryRepository.getBySurfaceForms(words) + } + + override fun batchLookupFalseCognates(words: Set): Map { + return falseCognatesRepository.getBySurfaceForms(words) + } + + override fun batchLookupUserState(lemmas: Set): Map { + return userWordStateRepository.batchGetWordStatuses(lemmas) + } + + override fun lookupSingleWord(word: String): DictionaryEntry? { + return dictionaryRepository.getBySurfaceForm(word) + } +} diff --git a/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/models/UserCustomText.kt b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/models/UserCustomText.kt new file mode 100644 index 0000000..824ae45 --- /dev/null +++ b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/models/UserCustomText.kt @@ -0,0 +1,11 @@ +package com.anysoftkeyboard.calque.data.models + +/** Domain model representing user-imported reading passages and optional AI-simplified versions. */ +data class UserCustomText( + val textId: Long = 0L, + val title: String, + val originalSpanish: String, + val simplifiedSpanish: String? = null, + val readabilityLevel: Int = 0, + val createdTimestamp: Long = 0L, +) diff --git a/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/models/UserWordState.kt b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/models/UserWordState.kt new file mode 100644 index 0000000..f8bea78 --- /dev/null +++ b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/models/UserWordState.kt @@ -0,0 +1,15 @@ +package com.anysoftkeyboard.calque.data.models + +import com.anysoftkeyboard.calque.linguistics.UserWordStatus + +/** + * Domain model representing a learner's personal state and vault status for a vocabulary [lemma]. + */ +data class UserWordState( + val lemma: String, + val status: UserWordStatus = UserWordStatus.NEW, + val starred: Boolean = false, + val savedContextSentence: String? = null, + val encounterCount: Int = 0, + val lastSeenTimestamp: Long = 0L, +) diff --git a/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/user/UserCustomTextRepository.kt b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/user/UserCustomTextRepository.kt new file mode 100644 index 0000000..6176d9b --- /dev/null +++ b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/user/UserCustomTextRepository.kt @@ -0,0 +1,164 @@ +package com.anysoftkeyboard.calque.data.user + +import com.anysoftkeyboard.calque.data.models.UserCustomText +import java.sql.Connection +import java.sql.ResultSet +import java.sql.Statement +import java.sql.Types + +/** Repository interface for managing user-imported custom texts and simplified versions. */ +interface UserCustomTextRepository { + /** Inserts a new custom text and returns its auto-generated textId. */ + fun insert(text: UserCustomText): Long + + /** + * Updates an existing custom text by its [UserCustomText.textId]. Returns true if found and + * updated. + */ + fun update(text: UserCustomText): Boolean + + /** Retrieves a custom text entry by [textId]. */ + fun getById(textId: Long): UserCustomText? + + /** Retrieves all saved custom texts ordered from newest to oldest. */ + fun getAll(): List + + /** Deletes a custom text entry by [textId]. Returns true if deleted. */ + fun delete(textId: Long): Boolean +} + +/** SQLite-backed implementation of [UserCustomTextRepository]. */ +class SqliteUserCustomTextRepository( + private val connection: Connection, +) : UserCustomTextRepository { + + override fun insert(text: UserCustomText): Long { + val sql = + """ + INSERT INTO user_custom_texts ( + title, original_spanish, simplified_spanish, readability_level, created_timestamp + ) VALUES (?, ?, ?, ?, ?) + """ + .trimIndent() + + connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS).use { stmt -> + stmt.setString(PARAM_TITLE, text.title) + stmt.setString(PARAM_ORIGINAL, text.originalSpanish) + if (text.simplifiedSpanish != null) { + stmt.setString(PARAM_SIMPLIFIED, text.simplifiedSpanish) + } else { + stmt.setNull(PARAM_SIMPLIFIED, Types.VARCHAR) + } + stmt.setInt(PARAM_READABILITY, text.readabilityLevel) + stmt.setLong(PARAM_TIMESTAMP, text.createdTimestamp) + stmt.executeUpdate() + + stmt.generatedKeys.use { rs -> + if (rs.next()) { + return rs.getLong(1) + } + } + } + return 0L + } + + override fun update(text: UserCustomText): Boolean { + if (text.textId <= 0L) return false + + val sql = + """ + UPDATE user_custom_texts + SET title = ?, + original_spanish = ?, + simplified_spanish = ?, + readability_level = ?, + created_timestamp = ? + WHERE text_id = ? + """ + .trimIndent() + + connection.prepareStatement(sql).use { stmt -> + stmt.setString(PARAM_TITLE, text.title) + stmt.setString(PARAM_ORIGINAL, text.originalSpanish) + if (text.simplifiedSpanish != null) { + stmt.setString(PARAM_SIMPLIFIED, text.simplifiedSpanish) + } else { + stmt.setNull(PARAM_SIMPLIFIED, Types.VARCHAR) + } + stmt.setInt(PARAM_READABILITY, text.readabilityLevel) + stmt.setLong(PARAM_TIMESTAMP, text.createdTimestamp) + stmt.setLong(PARAM_UPDATE_ID, text.textId) + return stmt.executeUpdate() > 0 + } + } + + override fun getById(textId: Long): UserCustomText? { + if (textId <= 0L) return null + + val sql = + """ + SELECT text_id, title, original_spanish, simplified_spanish, readability_level, created_timestamp + FROM user_custom_texts + WHERE text_id = ? + LIMIT 1 + """ + .trimIndent() + + connection.prepareStatement(sql).use { stmt -> + stmt.setLong(1, textId) + stmt.executeQuery().use { rs -> + return if (rs.next()) mapRow(rs) else null + } + } + } + + override fun getAll(): List { + val sql = + """ + SELECT text_id, title, original_spanish, simplified_spanish, readability_level, created_timestamp + FROM user_custom_texts + ORDER BY created_timestamp DESC + """ + .trimIndent() + + val results = mutableListOf() + connection.createStatement().use { stmt -> + stmt.executeQuery(sql).use { rs -> + while (rs.next()) { + results.add(mapRow(rs)) + } + } + } + return results + } + + override fun delete(textId: Long): Boolean { + if (textId <= 0L) return false + + val sql = "DELETE FROM user_custom_texts WHERE text_id = ?" + connection.prepareStatement(sql).use { stmt -> + stmt.setLong(1, textId) + return stmt.executeUpdate() > 0 + } + } + + private fun mapRow(rs: ResultSet): UserCustomText { + return UserCustomText( + textId = rs.getLong("text_id"), + title = rs.getString("title"), + originalSpanish = rs.getString("original_spanish"), + simplifiedSpanish = rs.getString("simplified_spanish"), + readabilityLevel = rs.getInt("readability_level"), + createdTimestamp = rs.getLong("created_timestamp"), + ) + } + + private companion object { + const val PARAM_TITLE = 1 + const val PARAM_ORIGINAL = 2 + const val PARAM_SIMPLIFIED = 3 + const val PARAM_READABILITY = 4 + const val PARAM_TIMESTAMP = 5 + const val PARAM_UPDATE_ID = 6 + } +} diff --git a/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/user/UserDatabase.kt b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/user/UserDatabase.kt new file mode 100644 index 0000000..696a9c2 --- /dev/null +++ b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/user/UserDatabase.kt @@ -0,0 +1,64 @@ +package com.anysoftkeyboard.calque.data.user + +import java.sql.Connection +import java.sql.DriverManager + +/** Encapsulates the mutable user SQLite database containing [wordStates] and [customTexts]. */ +class UserDatabase( + val connection: Connection, +) : AutoCloseable { + + val wordStates: UserWordStateRepository = SqliteUserWordStateRepository(connection) + val customTexts: UserCustomTextRepository = SqliteUserCustomTextRepository(connection) + + override fun close() { + connection.close() + } + + companion object { + private val USER_TABLE_STATEMENTS = + listOf( + """ + CREATE TABLE IF NOT EXISTS user_word_state ( + lemma TEXT PRIMARY KEY, + status TEXT NOT NULL, + starred INTEGER NOT NULL, + saved_context_sentence TEXT, + encounter_count INTEGER NOT NULL, + last_seen_timestamp INTEGER NOT NULL + ) + """ + .trimIndent(), + "CREATE INDEX IF NOT EXISTS idx_user_word_state_status ON user_word_state(status)", + """ + CREATE TABLE IF NOT EXISTS user_custom_texts ( + text_id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + original_spanish TEXT NOT NULL, + simplified_spanish TEXT, + readability_level INTEGER NOT NULL, + created_timestamp INTEGER NOT NULL + ) + """ + .trimIndent(), + "CREATE INDEX IF NOT EXISTS idx_user_custom_texts_created ON user_custom_texts(created_timestamp)", + ) + + /** Opens a user SQLite database connection at the given [jdbcUrl]. */ + fun open(jdbcUrl: String = "jdbc:sqlite::memory:"): UserDatabase { + val connection = DriverManager.getConnection(jdbcUrl) + return UserDatabase(connection) + } + + /** + * Creates all user tables and indexes defined in docs/DATA.md on the provided [connection]. + */ + fun createTables(connection: Connection) { + connection.createStatement().use { stmt -> + for (sql in USER_TABLE_STATEMENTS) { + stmt.execute(sql) + } + } + } + } +} diff --git a/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/user/UserWordStateRepository.kt b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/user/UserWordStateRepository.kt new file mode 100644 index 0000000..fe3b631 --- /dev/null +++ b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/user/UserWordStateRepository.kt @@ -0,0 +1,327 @@ +package com.anysoftkeyboard.calque.data.user + +import com.anysoftkeyboard.calque.data.models.UserWordState +import com.anysoftkeyboard.calque.linguistics.UserWordStatus +import java.sql.Connection +import java.sql.ResultSet +import java.sql.Types +import java.util.Locale + +/** Repository interface for managing user vocabulary learning progress and vault items. */ +interface UserWordStateRepository { + /** Looks up the user state for a specific base [lemma]. Returns null if never seen. */ + fun getWordState(lemma: String): UserWordState? + + /** Batch queries user states for a collection of lemmas. */ + fun batchGetWordStates(lemmas: Collection): Map + + /** Inserts or completely replaces the state of a vocabulary word. */ + fun upsertWordState(state: UserWordState) + + /** + * Increments the encounter count for [lemma] and updates its last seen timestamp. Transitions + * unrecorded words to [UserWordStatus.LEARNING]. + */ + fun recordEncounter(lemma: String, timestamp: Long = System.currentTimeMillis()) + + /** Updates the status of a word (e.g. marking as [UserWordStatus.MASTERED]). */ + fun setWordStatus( + lemma: String, + status: UserWordStatus, + timestamp: Long = System.currentTimeMillis() + ) + + /** Stars or unstars a word in the user's Vault, optionally attaching the context sentence. */ + fun setStarred( + lemma: String, + starred: Boolean, + contextSentence: String? = null, + timestamp: Long = System.currentTimeMillis(), + ) + + /** Retrieves all words explicitly starred by the user. */ + fun getStarredWords(): List + + /** Retrieves all words with a given [UserWordStatus]. */ + fun getWordsByStatus(status: UserWordStatus): List + + /** Deletes user state record for [lemma]. Returns true if a record was removed. */ + fun deleteWordState(lemma: String): Boolean +} + +/** Returns the [UserWordStatus] for a [lemma], defaulting to [UserWordStatus.NEW] if unrecorded. */ +fun UserWordStateRepository.getWordStatus(lemma: String): UserWordStatus { + return getWordState(lemma)?.status ?: UserWordStatus.NEW +} + +/** + * Batch queries status for a collection of lemmas, guaranteeing an entry for every lemma + * (defaulting to [UserWordStatus.NEW] when not explicitly saved). + */ +fun UserWordStateRepository.batchGetWordStatuses( + lemmas: Collection +): Map { + val states = batchGetWordStates(lemmas) + return lemmas.associate { rawLemma -> + val normalized = rawLemma.lowercase(Locale.ROOT).trim() + rawLemma to (states[normalized]?.status ?: UserWordStatus.NEW) + } +} + +/** SQLite-backed implementation of [UserWordStateRepository]. */ +class SqliteUserWordStateRepository( + private val connection: Connection, +) : UserWordStateRepository { + + override fun getWordState(lemma: String): UserWordState? { + val normalized = lemma.lowercase(Locale.ROOT).trim() + if (normalized.isEmpty()) return null + + val sql = + """ + SELECT lemma, status, starred, saved_context_sentence, encounter_count, last_seen_timestamp + FROM user_word_state + WHERE lemma = ? + LIMIT 1 + """ + .trimIndent() + + connection.prepareStatement(sql).use { stmt -> + stmt.setString(PARAM_1, normalized) + stmt.executeQuery().use { rs -> + return if (rs.next()) mapRow(rs) else null + } + } + } + + override fun batchGetWordStates(lemmas: Collection): Map { + val uniqueLemmas = + lemmas.map { it.lowercase(Locale.ROOT).trim() }.filter { it.isNotEmpty() }.distinct() + if (uniqueLemmas.isEmpty()) return emptyMap() + + val results = mutableMapOf() + for (chunk in uniqueLemmas.chunked(MAX_BATCH_CHUNK_SIZE)) { + queryChunk(connection, chunk, results) + } + return results + } + + override fun upsertWordState(state: UserWordState) { + val normalized = state.lemma.lowercase(Locale.ROOT).trim() + if (normalized.isEmpty()) return + + val sql = + """ + INSERT INTO user_word_state ( + lemma, status, starred, saved_context_sentence, encounter_count, last_seen_timestamp + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(lemma) DO UPDATE SET + status = excluded.status, + starred = excluded.starred, + saved_context_sentence = excluded.saved_context_sentence, + encounter_count = excluded.encounter_count, + last_seen_timestamp = excluded.last_seen_timestamp + """ + .trimIndent() + + connection.prepareStatement(sql).use { stmt -> + stmt.setString(PARAM_1, normalized) + stmt.setString(PARAM_2, state.status.name) + stmt.setInt(PARAM_3, if (state.starred) FLAG_TRUE else FLAG_FALSE) + if (state.savedContextSentence != null) { + stmt.setString(PARAM_4, state.savedContextSentence) + } else { + stmt.setNull(PARAM_4, Types.VARCHAR) + } + stmt.setInt(PARAM_5, state.encounterCount) + stmt.setLong(PARAM_6, state.lastSeenTimestamp) + stmt.executeUpdate() + } + } + + override fun recordEncounter(lemma: String, timestamp: Long) { + val normalized = lemma.lowercase(Locale.ROOT).trim() + if (normalized.isEmpty()) return + + val sql = + """ + INSERT INTO user_word_state ( + lemma, status, starred, saved_context_sentence, encounter_count, last_seen_timestamp + ) VALUES (?, ?, 0, NULL, 1, ?) + ON CONFLICT(lemma) DO UPDATE SET + encounter_count = user_word_state.encounter_count + 1, + last_seen_timestamp = excluded.last_seen_timestamp + """ + .trimIndent() + + connection.prepareStatement(sql).use { stmt -> + stmt.setString(PARAM_1, normalized) + stmt.setString(PARAM_2, UserWordStatus.LEARNING.name) + stmt.setLong(PARAM_3, timestamp) + stmt.executeUpdate() + } + } + + override fun setWordStatus(lemma: String, status: UserWordStatus, timestamp: Long) { + val normalized = lemma.lowercase(Locale.ROOT).trim() + if (normalized.isEmpty()) return + + val sql = + """ + INSERT INTO user_word_state ( + lemma, status, starred, saved_context_sentence, encounter_count, last_seen_timestamp + ) VALUES (?, ?, 0, NULL, 1, ?) + ON CONFLICT(lemma) DO UPDATE SET + status = excluded.status, + last_seen_timestamp = excluded.last_seen_timestamp + """ + .trimIndent() + + connection.prepareStatement(sql).use { stmt -> + stmt.setString(PARAM_1, normalized) + stmt.setString(PARAM_2, status.name) + stmt.setLong(PARAM_3, timestamp) + stmt.executeUpdate() + } + } + + override fun setStarred( + lemma: String, + starred: Boolean, + contextSentence: String?, + timestamp: Long + ) { + val normalized = lemma.lowercase(Locale.ROOT).trim() + if (normalized.isEmpty()) return + + val sql = + """ + INSERT INTO user_word_state ( + lemma, status, starred, saved_context_sentence, encounter_count, last_seen_timestamp + ) VALUES (?, ?, ?, ?, 1, ?) + ON CONFLICT(lemma) DO UPDATE SET + starred = excluded.starred, + saved_context_sentence = COALESCE(excluded.saved_context_sentence, user_word_state.saved_context_sentence), + last_seen_timestamp = excluded.last_seen_timestamp + """ + .trimIndent() + + connection.prepareStatement(sql).use { stmt -> + stmt.setString(PARAM_1, normalized) + stmt.setString(PARAM_2, UserWordStatus.LEARNING.name) + stmt.setInt(PARAM_3, if (starred) FLAG_TRUE else FLAG_FALSE) + if (contextSentence != null) { + stmt.setString(PARAM_4, contextSentence) + } else { + stmt.setNull(PARAM_4, Types.VARCHAR) + } + stmt.setLong(PARAM_5, timestamp) + stmt.executeUpdate() + } + } + + override fun getStarredWords(): List { + val sql = + """ + SELECT lemma, status, starred, saved_context_sentence, encounter_count, last_seen_timestamp + FROM user_word_state + WHERE starred = 1 + ORDER BY last_seen_timestamp DESC + """ + .trimIndent() + + val results = mutableListOf() + connection.createStatement().use { stmt -> + stmt.executeQuery(sql).use { rs -> + while (rs.next()) { + results.add(mapRow(rs)) + } + } + } + return results + } + + override fun getWordsByStatus(status: UserWordStatus): List { + val sql = + """ + SELECT lemma, status, starred, saved_context_sentence, encounter_count, last_seen_timestamp + FROM user_word_state + WHERE status = ? + ORDER BY last_seen_timestamp DESC + """ + .trimIndent() + + val results = mutableListOf() + connection.prepareStatement(sql).use { stmt -> + stmt.setString(PARAM_1, status.name) + stmt.executeQuery().use { rs -> + while (rs.next()) { + results.add(mapRow(rs)) + } + } + } + return results + } + + override fun deleteWordState(lemma: String): Boolean { + val normalized = lemma.lowercase(Locale.ROOT).trim() + if (normalized.isEmpty()) return false + + val sql = "DELETE FROM user_word_state WHERE lemma = ?" + connection.prepareStatement(sql).use { stmt -> + stmt.setString(PARAM_1, normalized) + return stmt.executeUpdate() > 0 + } + } + + private companion object { + const val MAX_BATCH_CHUNK_SIZE = 400 + const val FLAG_TRUE = 1 + const val FLAG_FALSE = 0 + + const val PARAM_1 = 1 + const val PARAM_2 = 2 + const val PARAM_3 = 3 + const val PARAM_4 = 4 + const val PARAM_5 = 5 + const val PARAM_6 = 6 + } +} + +private fun queryChunk( + connection: Connection, + chunk: List, + results: MutableMap +) { + val placeholders = chunk.joinToString(",") { "?" } + val sql = + """ + SELECT lemma, status, starred, saved_context_sentence, encounter_count, last_seen_timestamp + FROM user_word_state + WHERE lemma IN ($placeholders) + """ + .trimIndent() + + connection.prepareStatement(sql).use { stmt -> + chunk.forEachIndexed { index, lemma -> stmt.setString(index + 1, lemma) } + stmt.executeQuery().use { rs -> + while (rs.next()) { + val state = mapRow(rs) + results[state.lemma] = state + } + } + } +} + +private fun mapRow(rs: ResultSet): UserWordState { + val rawStatus = rs.getString("status") + val status = runCatching { UserWordStatus.valueOf(rawStatus) }.getOrDefault(UserWordStatus.NEW) + return UserWordState( + lemma = rs.getString("lemma"), + status = status, + starred = rs.getInt("starred") == 1, + savedContextSentence = rs.getString("saved_context_sentence"), + encounterCount = rs.getInt("encounter_count"), + lastSeenTimestamp = rs.getLong("last_seen_timestamp"), + ) +} diff --git a/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/CalqueWordLookupProviderTest.kt b/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/CalqueWordLookupProviderTest.kt new file mode 100644 index 0000000..94d8150 --- /dev/null +++ b/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/CalqueWordLookupProviderTest.kt @@ -0,0 +1,121 @@ +package com.anysoftkeyboard.calque.data + +import com.anysoftkeyboard.calque.data.seed.SeedDatabase +import com.anysoftkeyboard.calque.data.user.UserDatabase +import com.anysoftkeyboard.calque.linguistics.ReaderEnricher +import com.anysoftkeyboard.calque.linguistics.UserWordStatus +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class CalqueWordLookupProviderTest { + + private lateinit var seedDb: SeedDatabase + private lateinit var userDb: UserDatabase + private lateinit var provider: CalqueWordLookupProvider + + @Before + fun setUp() { + seedDb = SeedDatabase.open() + SeedDatabase.createTables(seedDb.connection) + + userDb = UserDatabase.open() + UserDatabase.createTables(userDb.connection) + + provider = + CalqueWordLookupProvider( + dictionaryRepository = seedDb.dictionary, + falseCognatesRepository = seedDb.falseCognates, + userWordStateRepository = userDb.wordStates, + ) + + populateSeedData() + } + + @After + fun tearDown() { + seedDb.close() + userDb.close() + } + + private fun populateSeedData() { + // Insert dictionary entry for 'hotel' + val dictSql = + """ + INSERT INTO dictionary (surface_form, lemma, pos, grammar_info, english_gloss, is_cognate, frequency_rank) + VALUES (?, ?, ?, ?, ?, ?, ?) + """ + .trimIndent() + seedDb.connection.prepareStatement(dictSql).use { stmt -> + stmt.setString(1, "hotel") + stmt.setString(2, "hotel") + stmt.setString(3, "noun") + stmt.setString(4, "masc. sing.") + stmt.setString(5, "hotel") + stmt.setInt(6, 1) + stmt.setInt(7, 500) + stmt.executeUpdate() + } + + // Insert false cognate entry for 'éxito' + val fcSql = + """ + INSERT INTO false_cognates ( + surface_form, lemma, apparent_english, actual_english, real_spanish_for_apparent, + severity, disambiguation_tip, example_es, example_en + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + .trimIndent() + seedDb.connection.prepareStatement(fcSql).use { stmt -> + stmt.setString(1, "éxito") + stmt.setString(2, "éxito") + stmt.setString(3, "exit") + stmt.setString(4, "success") + stmt.setString(5, "salida") + stmt.setString(6, "HIGH_RISK") + stmt.setString(7, "éxito means success") + stmt.setString(8, "Gran éxito") + stmt.setString(9, "Great success") + stmt.executeUpdate() + } + + // Set user state for 'hotel' + userDb.wordStates.recordEncounter("hotel") + userDb.wordStates.setWordStatus("hotel", UserWordStatus.LEARNING) + } + + @Test + fun provider_resolvesDictionaryAndUserStateTogether() { + val dictEntry = provider.lookupSingleWord("hotel") + assertNotNull(dictEntry) + assertEquals("hotel", dictEntry?.lemma) + assertTrue(dictEntry!!.isCognate) + + val fcEntry = provider.batchLookupFalseCognates(setOf("éxito")) + assertTrue(fcEntry.containsKey("éxito")) + assertEquals("success", fcEntry["éxito"]?.actualEnglish) + + val statuses = provider.batchLookupUserState(setOf("hotel", "éxito", "nuevo")) + assertEquals(UserWordStatus.LEARNING, statuses["hotel"]) + assertEquals(UserWordStatus.NEW, statuses["éxito"]) + assertEquals(UserWordStatus.NEW, statuses["nuevo"]) + } + + @Test + fun readerEnricher_integratesWithCalqueWordLookupProvider() { + val spans = ReaderEnricher.enrichText("El hotel tuvo gran éxito.", provider) + val hotelSpan = spans.firstOrNull { it.normalizedWord == "hotel" } + assertNotNull(hotelSpan) + assertEquals(UserWordStatus.LEARNING, hotelSpan?.userStatus) + assertTrue(hotelSpan?.dictionaryEntry?.isCognate == true) + + val exitoSpan = spans.firstOrNull { it.normalizedWord == "éxito" } + assertNotNull(exitoSpan) + assertNotNull(exitoSpan?.falseCognateEntry) + assertEquals("success", exitoSpan?.falseCognateEntry?.actualEnglish) + assertEquals("exit", exitoSpan?.falseCognateEntry?.apparentEnglish) + } +} diff --git a/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/user/UserCustomTextRepositoryTest.kt b/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/user/UserCustomTextRepositoryTest.kt new file mode 100644 index 0000000..32e019a --- /dev/null +++ b/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/user/UserCustomTextRepositoryTest.kt @@ -0,0 +1,132 @@ +package com.anysoftkeyboard.calque.data.user + +import com.anysoftkeyboard.calque.data.models.UserCustomText +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class UserCustomTextRepositoryTest { + + private lateinit var database: UserDatabase + + @Before + fun setUp() { + database = UserDatabase.open() + UserDatabase.createTables(database.connection) + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun insertAndGetById_autoGeneratesIdAndRetrieves() { + val entry = + UserCustomText( + title = "Noticia breve", + originalSpanish = "El presidente anunció nuevas medidas económicas hoy.", + simplifiedSpanish = "El líder presentó nuevas reglas de dinero hoy.", + readabilityLevel = 1, + createdTimestamp = 1000L, + ) + + val id = database.customTexts.insert(entry) + assertTrue(id > 0L) + + val retrieved = database.customTexts.getById(id) + assertNotNull(retrieved) + assertEquals(id, retrieved?.textId) + assertEquals("Noticia breve", retrieved?.title) + assertEquals( + "El presidente anunció nuevas medidas económicas hoy.", + retrieved?.originalSpanish + ) + assertEquals("El líder presentó nuevas reglas de dinero hoy.", retrieved?.simplifiedSpanish) + assertEquals(1, retrieved?.readabilityLevel) + assertEquals(1000L, retrieved?.createdTimestamp) + } + + @Test + fun update_modifiesFields() { + val id = + database.customTexts.insert( + UserCustomText( + title = "Borrador", + originalSpanish = "Texto sin simplificar.", + readabilityLevel = 2, + createdTimestamp = 500L, + ) + ) + + val updated = + UserCustomText( + textId = id, + title = "Borrador Final", + originalSpanish = "Texto sin simplificar.", + simplifiedSpanish = "Texto fácil.", + readabilityLevel = 0, + createdTimestamp = 500L, + ) + + assertTrue(database.customTexts.update(updated)) + + val retrieved = database.customTexts.getById(id) + assertEquals("Borrador Final", retrieved?.title) + assertEquals("Texto fácil.", retrieved?.simplifiedSpanish) + assertEquals(0, retrieved?.readabilityLevel) + } + + @Test + fun getAll_returnsOrderedNewestFirst() { + database.customTexts.insert( + UserCustomText( + title = "Primero", + originalSpanish = "Texto 1", + createdTimestamp = 100L, + ) + ) + database.customTexts.insert( + UserCustomText( + title = "Segundo", + originalSpanish = "Texto 2", + createdTimestamp = 300L, + ) + ) + database.customTexts.insert( + UserCustomText( + title = "Tercero", + originalSpanish = "Texto 3", + createdTimestamp = 200L, + ) + ) + + val all = database.customTexts.getAll() + assertEquals(3, all.size) + // Order by created_timestamp DESC + assertEquals("Segundo", all[0].title) + assertEquals("Tercero", all[1].title) + assertEquals("Primero", all[2].title) + } + + @Test + fun delete_removesEntry() { + val id = + database.customTexts.insert( + UserCustomText( + title = "Para eliminar", + originalSpanish = "Texto", + createdTimestamp = 100L, + ) + ) + + assertTrue(database.customTexts.delete(id)) + assertNull(database.customTexts.getById(id)) + assertFalse(database.customTexts.delete(id)) + } +} diff --git a/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/user/UserDatabaseTest.kt b/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/user/UserDatabaseTest.kt new file mode 100644 index 0000000..c6b948f --- /dev/null +++ b/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/user/UserDatabaseTest.kt @@ -0,0 +1,36 @@ +package com.anysoftkeyboard.calque.data.user + +import java.sql.Connection +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class UserDatabaseTest { + + @Test + fun openInMemoryDatabase_createsAllUserTablesAndIndices() { + UserDatabase.open().use { db -> + UserDatabase.createTables(db.connection) + + assertNotNull(db.wordStates) + assertNotNull(db.customTexts) + + val tables = queryExistingTableNames(db.connection) + assertTrue(tables.contains("user_word_state")) + assertTrue(tables.contains("user_custom_texts")) + } + } + + private fun queryExistingTableNames(connection: Connection): Set { + val tables = mutableSetOf() + val sql = "SELECT name FROM sqlite_master WHERE type = 'table'" + connection.createStatement().use { stmt -> + stmt.executeQuery(sql).use { rs -> + while (rs.next()) { + tables.add(rs.getString("name")) + } + } + } + return tables + } +} diff --git a/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/user/UserWordStateRepositoryTest.kt b/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/user/UserWordStateRepositoryTest.kt new file mode 100644 index 0000000..4a10c4b --- /dev/null +++ b/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/user/UserWordStateRepositoryTest.kt @@ -0,0 +1,160 @@ +package com.anysoftkeyboard.calque.data.user + +import com.anysoftkeyboard.calque.data.models.UserWordState +import com.anysoftkeyboard.calque.linguistics.UserWordStatus +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class UserWordStateRepositoryTest { + + private lateinit var database: UserDatabase + + @Before + fun setUp() { + database = UserDatabase.open() + UserDatabase.createTables(database.connection) + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun getWordState_unrecordedWordReturnsNullAndDefaultStatusNew() { + assertNull(database.wordStates.getWordState("hablar")) + assertEquals(UserWordStatus.NEW, database.wordStates.getWordStatus("hablar")) + } + + @Test + fun recordEncounter_createsEntryWithStatusLearningAndIncrements() { + val timestamp1 = 1000L + val timestamp2 = 2000L + + database.wordStates.recordEncounter("hablar", timestamp = timestamp1) + val state1 = database.wordStates.getWordState("hablar") + assertNotNull(state1) + assertEquals("hablar", state1?.lemma) + assertEquals(UserWordStatus.LEARNING, state1?.status) + assertFalse(state1!!.starred) + assertEquals(1, state1.encounterCount) + assertEquals(timestamp1, state1.lastSeenTimestamp) + + // Second encounter increments count and updates timestamp + database.wordStates.recordEncounter("hablar", timestamp = timestamp2) + val state2 = database.wordStates.getWordState("hablar") + assertNotNull(state2) + assertEquals(2, state2?.encounterCount) + assertEquals(timestamp2, state2?.lastSeenTimestamp) + } + + @Test + fun setWordStatus_updatesStatusWithoutResettingEncounters() { + database.wordStates.recordEncounter("comer", timestamp = 500L) + database.wordStates.setWordStatus("comer", UserWordStatus.MASTERED, timestamp = 1500L) + + val state = database.wordStates.getWordState("comer") + assertNotNull(state) + assertEquals(UserWordStatus.MASTERED, state?.status) + assertEquals(1, state?.encounterCount) + assertEquals(1500L, state?.lastSeenTimestamp) + } + + @Test + fun setStarred_addsToVaultWithContextSentence() { + database.wordStates.setStarred( + lemma = "éxito", + starred = true, + contextSentence = "El concierto fue un gran éxito.", + timestamp = 3000L, + ) + + val state = database.wordStates.getWordState("éxito") + assertNotNull(state) + assertTrue(state!!.starred) + assertEquals("El concierto fue un gran éxito.", state.savedContextSentence) + + val starredList = database.wordStates.getStarredWords() + assertEquals(1, starredList.size) + assertEquals("éxito", starredList[0].lemma) + + // Unstarring retains the context sentence + database.wordStates.setStarred( + lemma = "éxito", + starred = false, + timestamp = 4000L, + ) + val unstarred = database.wordStates.getWordState("éxito") + assertFalse(unstarred!!.starred) + assertEquals("El concierto fue un gran éxito.", unstarred.savedContextSentence) + assertTrue(database.wordStates.getStarredWords().isEmpty()) + } + + @Test + fun batchQueries_returnExpectedStatesAndStatuses() { + database.wordStates.upsertWordState( + UserWordState( + lemma = "vivir", + status = UserWordStatus.LEARNING, + starred = false, + encounterCount = 3, + lastSeenTimestamp = 100L, + ) + ) + database.wordStates.upsertWordState( + UserWordState( + lemma = "saber", + status = UserWordStatus.MASTERED, + starred = true, + encounterCount = 10, + lastSeenTimestamp = 200L, + ) + ) + + val states = database.wordStates.batchGetWordStates(listOf("vivir", "saber", "desconocido")) + assertEquals(2, states.size) + assertTrue(states.containsKey("vivir")) + assertTrue(states.containsKey("saber")) + + val statuses = + database.wordStates.batchGetWordStatuses(listOf("vivir", "saber", "desconocido")) + assertEquals(3, statuses.size) + assertEquals(UserWordStatus.LEARNING, statuses["vivir"]) + assertEquals(UserWordStatus.MASTERED, statuses["saber"]) + assertEquals(UserWordStatus.NEW, statuses["desconocido"]) + } + + @Test + fun getWordsByStatus_filtersCorrectly() { + database.wordStates.upsertWordState( + UserWordState(lemma = "uno", status = UserWordStatus.LEARNING) + ) + database.wordStates.upsertWordState( + UserWordState(lemma = "dos", status = UserWordStatus.LEARNING) + ) + database.wordStates.upsertWordState( + UserWordState(lemma = "tres", status = UserWordStatus.MASTERED) + ) + + val learning = database.wordStates.getWordsByStatus(UserWordStatus.LEARNING) + assertEquals(2, learning.size) + + val mastered = database.wordStates.getWordsByStatus(UserWordStatus.MASTERED) + assertEquals(1, mastered.size) + assertEquals("tres", mastered[0].lemma) + } + + @Test + fun deleteWordState_removesRecord() { + database.wordStates.recordEncounter("borrar") + assertTrue(database.wordStates.deleteWordState("borrar")) + assertNull(database.wordStates.getWordState("borrar")) + assertFalse(database.wordStates.deleteWordState("borrar")) + } +}