From 8a67f23d889bd8b89a3473b3ee496613f4b8ffce Mon Sep 17 00:00:00 2001 From: zefutoff Date: Wed, 7 Oct 2026 21:18:22 +0200 Subject: [PATCH] feat: add persistent dictionary selection --- CHANGELOG.md | 1 + ROADMAP.md | 2 +- .../GrammalecteEngineInstrumentedTest.kt | 79 +++++++ .../unofficial/MainActivity.kt | 6 +- .../unofficial/RuleSettingsActivity.kt | 203 +++++++++++++++++- .../src/main/assets/android_bridge.js | 27 +++ .../engine/GrammalecteDictionary.kt | 29 +++ .../GrammalecteDictionaryPreferences.kt | 45 ++++ .../engine/GrammalecteQuickJsEngine.kt | 87 ++++++++ .../engine/RealEngineSmokeTest.kt | 91 ++++++++ tools/test-js-bridge.mjs | 48 +++++ 11 files changed, 606 insertions(+), 12 deletions(-) create mode 100644 engine-grammalecte/src/main/kotlin/fr/grammalecteandroid/engine/GrammalecteDictionary.kt create mode 100644 engine-grammalecte/src/main/kotlin/fr/grammalecteandroid/engine/GrammalecteDictionaryPreferences.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f8fe7e..d8328f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Reproducible Android application compatibility matrix with a helper for collecting device, Android and application-version metadata. - Grammalecte rule-option engine API exposing upstream rule groups, French labels, current/default states, option updates and reset support. - Persistent in-app Grammalecte rule settings shared by the native spell checker, PROCESS_TEXT workflow and correction IME. +- Persistent dictionary selection between all variants, classic orthography and the 1990 reform, shared by all correction modes. ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index d8b95f2..af6cd36 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -80,7 +80,7 @@ Exit criterion: correction remains usable in common editable applications even w ## Phase 3 — User controls - [x] Grammalecte rule-category settings -- [ ] Dictionary choice: all variants / classic / 1990 reform +- [x] Dictionary choice: all variants / classic / 1990 reform - [ ] Personal dictionary - [ ] Reset-to-default controls - [ ] Import/export of non-sensitive preferences diff --git a/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/GrammalecteEngineInstrumentedTest.kt b/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/GrammalecteEngineInstrumentedTest.kt index 1d838c3..fa32515 100644 --- a/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/GrammalecteEngineInstrumentedTest.kt +++ b/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/GrammalecteEngineInstrumentedTest.kt @@ -4,6 +4,8 @@ import android.content.Context import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import fr.grammalecteandroid.core.IssueKind +import fr.grammalecteandroid.engine.GrammalecteDictionary +import fr.grammalecteandroid.engine.GrammalecteDictionaryPreferences import fr.grammalecteandroid.engine.GrammalecteQuickJsEngine import fr.grammalecteandroid.engine.GrammalecteRulePreferences import org.junit.Assert.assertEquals @@ -147,6 +149,83 @@ class GrammalecteEngineInstrumentedTest { } } + @Test + fun existingEngineRefreshesStoredDictionarySelection() { + val context = + ApplicationProvider.getApplicationContext() + + val preferences = + GrammalecteDictionaryPreferences(context) + + preferences.reset() + + val engine = + GrammalecteQuickJsEngine(context) + + fun isValid(word: String): Boolean = + engine + .checkWord( + word = word, + localeTag = "fr-FR", + suggestionLimit = 5, + ).valid + + try { + assertTrue( + "Expected classic spelling to be valid by default", + isValid("coût"), + ) + + assertTrue( + "Expected reformed spelling to be valid by default", + isValid("cout"), + ) + + preferences.select( + GrammalecteDictionary.CLASSIC, + ) + + assertTrue( + "Expected classic spelling after selecting classic dictionary", + isValid("coût"), + ) + + assertFalse( + "Expected reformed spelling to be rejected by classic dictionary", + isValid("cout"), + ) + + preferences.select( + GrammalecteDictionary.REFORM_1990, + ) + + assertFalse( + "Expected classic spelling to be rejected by reformed dictionary", + isValid("coût"), + ) + + assertTrue( + "Expected reformed spelling after selecting reform dictionary", + isValid("cout"), + ) + + preferences.reset() + + assertTrue( + "Expected classic spelling after restoring all variants", + isValid("coût"), + ) + + assertTrue( + "Expected reformed spelling after restoring all variants", + isValid("cout"), + ) + } finally { + preferences.reset() + engine.close() + } + } + private fun withEngine(block: (GrammalecteQuickJsEngine) -> Unit) { val context = ApplicationProvider.getApplicationContext() diff --git a/app/src/main/kotlin/fr/grammalecteandroid/unofficial/MainActivity.kt b/app/src/main/kotlin/fr/grammalecteandroid/unofficial/MainActivity.kt index b244fdd..9e2a4f6 100644 --- a/app/src/main/kotlin/fr/grammalecteandroid/unofficial/MainActivity.kt +++ b/app/src/main/kotlin/fr/grammalecteandroid/unofficial/MainActivity.kt @@ -168,14 +168,14 @@ class MainActivity : content.addView( createCard( - title = "Règles de correction", + title = "Réglages de correction", description = - "Activez ou désactivez les catégories et contrôles " + + "Choisissez le dictionnaire et les contrôles " + "Grammalecte utilisés dans les analyses.", statusView = null, actions = listOf( - "Configurer les règles" to { + "Configurer" to { startActivity( Intent( this@MainActivity, diff --git a/app/src/main/kotlin/fr/grammalecteandroid/unofficial/RuleSettingsActivity.kt b/app/src/main/kotlin/fr/grammalecteandroid/unofficial/RuleSettingsActivity.kt index fe2e723..0ebf89e 100644 --- a/app/src/main/kotlin/fr/grammalecteandroid/unofficial/RuleSettingsActivity.kt +++ b/app/src/main/kotlin/fr/grammalecteandroid/unofficial/RuleSettingsActivity.kt @@ -8,13 +8,17 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.view.Gravity +import android.view.View import android.view.ViewGroup import android.view.WindowInsets import android.widget.Button import android.widget.LinearLayout +import android.widget.RadioButton +import android.widget.RadioGroup import android.widget.ScrollView import android.widget.Switch import android.widget.TextView +import fr.grammalecteandroid.engine.GrammalecteDictionary import fr.grammalecteandroid.engine.GrammalecteQuickJsEngine import fr.grammalecteandroid.engine.GrammalecteRuleOption import java.util.concurrent.Executors @@ -32,10 +36,13 @@ class RuleSettingsActivity : Activity() { } private lateinit var statusView: TextView + private lateinit var dictionaryGroup: RadioGroup private lateinit var optionsContainer: LinearLayout private lateinit var resetButton: Button private var engine: GrammalecteQuickJsEngine? = null + private var selectedDictionary = + GrammalecteDictionary.ALL_VARIANTS @Volatile private var destroyed = false @@ -79,7 +86,7 @@ class RuleSettingsActivity : Activity() { header.addView( TextView(this).apply { - text = "Règles de correction" + text = "Réglages de correction" textSize = 24f setTypeface( @@ -121,9 +128,9 @@ class RuleSettingsActivity : Activity() { content.addView( TextView(this).apply { text = - "Choisissez les contrôles Grammalecte à appliquer. " + - "Les réglages sont utilisés par le correcteur Android, " + - "la correction de sélection et le clavier Grammalecte." + "Choisissez le dictionnaire et les contrôles Grammalecte " + + "à appliquer. Les réglages sont partagés par le correcteur " + + "Android, la correction de sélection et le clavier Grammalecte." textSize = 14f @@ -141,6 +148,64 @@ class RuleSettingsActivity : Activity() { matchWrapParams(), ) + content.addView( + sectionTitle( + "Dictionnaire", + ), + matchWrapParams(), + ) + + content.addView( + TextView(this).apply { + text = + "Choisissez les graphies françaises reconnues " + + "par le correcteur orthographique." + + textSize = 14f + + setTextColor( + getColor(R.color.surface_text_secondary), + ) + + setPadding( + 0, + 0, + 0, + smallSpacing, + ) + }, + matchWrapParams(), + ) + + dictionaryGroup = + RadioGroup(this).apply { + orientation = RadioGroup.VERTICAL + + setPadding( + spacing, + smallSpacing, + spacing, + smallSpacing, + ) + + background = + roundedBackground( + radiusDp = 14f, + ) + } + + content.addView( + dictionaryGroup, + matchWrapParams(), + ) + + content.addView( + sectionTitle( + "Règles de correction", + ), + matchWrapParams(), + ) + statusView = TextView(this).apply { text = "Chargement des règles..." @@ -245,16 +310,20 @@ class RuleSettingsActivity : Activity() { private fun loadOptions() { statusView.text = - "Chargement des règles..." + "Chargement des réglages..." resetButton.isEnabled = false + dictionaryGroup.removeAllViews() optionsContainer.removeAllViews() executor.execute { val result = runCatching { - currentEngine() - .ruleOptions() + val currentEngine = + currentEngine() + + currentEngine.selectedDictionary() to + currentEngine.ruleOptions() } mainHandler.post { @@ -263,7 +332,8 @@ class RuleSettingsActivity : Activity() { } result - .onSuccess { options -> + .onSuccess { (dictionary, options) -> + renderDictionary(dictionary) renderOptions(options) }.onFailure { error -> showError(error) @@ -272,6 +342,123 @@ class RuleSettingsActivity : Activity() { } } + private fun renderDictionary(dictionary: GrammalecteDictionary) { + selectedDictionary = + dictionary + + dictionaryGroup.removeAllViews() + + addDictionaryOption( + dictionary = GrammalecteDictionary.ALL_VARIANTS, + label = "Toutes variantes", + description = + "Accepte les graphies classiques et celles issues " + + "des rectifications de 1990.", + ) + + addDictionaryOption( + dictionary = GrammalecteDictionary.CLASSIC, + label = "Orthographe classique", + description = + "Utilise les graphies traditionnelles.", + ) + + addDictionaryOption( + dictionary = GrammalecteDictionary.REFORM_1990, + label = "Réforme de 1990", + description = + "Utilise les graphies issues des rectifications " + + "orthographiques de 1990.", + ) + } + + private fun addDictionaryOption( + dictionary: GrammalecteDictionary, + label: String, + description: String, + ) { + val spacing = + (6 * resources.displayMetrics.density).toInt() + + dictionaryGroup.addView( + RadioButton(this).apply { + id = View.generateViewId() + + text = + buildString { + append(label) + append("\n") + append(description) + } + + textSize = 15f + isChecked = + dictionary == selectedDictionary + + setTextColor( + getColor(R.color.surface_text), + ) + + setPadding( + 0, + spacing, + 0, + spacing, + ) + + setOnClickListener { + if (dictionary != selectedDictionary) { + saveDictionary(dictionary) + } + } + }, + matchWrapParams(), + ) + } + + private fun saveDictionary(dictionary: GrammalecteDictionary) { + setDictionaryControlsEnabled(false) + + statusView.text = + "Changement de dictionnaire..." + + executor.execute { + val result = + runCatching { + currentEngine() + .setDictionary(dictionary) + } + + mainHandler.post { + if (destroyed) { + return@post + } + + result + .onSuccess { + selectedDictionary = + dictionary + + setDictionaryControlsEnabled(true) + + statusView.text = + "Dictionnaire enregistré." + }.onFailure { error -> + showError(error) + loadOptions() + } + } + } + } + + private fun setDictionaryControlsEnabled(enabled: Boolean) { + for (index in 0 until dictionaryGroup.childCount) { + dictionaryGroup + .getChildAt(index) + .isEnabled = enabled + } + } + private fun renderOptions(options: List) { optionsContainer.removeAllViews() diff --git a/engine-grammalecte/src/main/assets/android_bridge.js b/engine-grammalecte/src/main/assets/android_bridge.js index d9e06ba..37b7487 100644 --- a/engine-grammalecte/src/main/assets/android_bridge.js +++ b/engine-grammalecte/src/main/assets/android_bridge.js @@ -132,6 +132,33 @@ return "ok"; }, + setDictionary: function (dictionaryFile) { + const fileName = String(dictionaryFile || ""); + + const allowed = new Set([ + "fr-allvars.json", + "fr-classic.json", + "fr-reform.json" + ]); + + if (!allowed.has(fileName)) { + return "false"; + } + + const spellChecker = gc_engine.getSpellChecker(); + + const loaded = spellChecker.setMainDictionary( + fileName, + "grammalecte/graphspell/_dictionaries" + ); + + if (loaded) { + spellChecker.clearStorage(); + } + + return loaded ? "true" : "false"; + }, + checkWord: function (word, localeTag, limit) { void localeTag; const spellChecker = gc_engine.getSpellChecker(); diff --git a/engine-grammalecte/src/main/kotlin/fr/grammalecteandroid/engine/GrammalecteDictionary.kt b/engine-grammalecte/src/main/kotlin/fr/grammalecteandroid/engine/GrammalecteDictionary.kt new file mode 100644 index 0000000..6d4a07e --- /dev/null +++ b/engine-grammalecte/src/main/kotlin/fr/grammalecteandroid/engine/GrammalecteDictionary.kt @@ -0,0 +1,29 @@ +package fr.grammalecteandroid.engine + +enum class GrammalecteDictionary( + val preferenceValue: String, + internal val fileName: String, +) { + ALL_VARIANTS( + preferenceValue = "all_variants", + fileName = "fr-allvars.json", + ), + + CLASSIC( + preferenceValue = "classic", + fileName = "fr-classic.json", + ), + + REFORM_1990( + preferenceValue = "reform_1990", + fileName = "fr-reform.json", + ), + ; + + companion object { + fun fromPreferenceValue(value: String?): GrammalecteDictionary = + entries.firstOrNull { dictionary -> + dictionary.preferenceValue == value + } ?: ALL_VARIANTS + } +} diff --git a/engine-grammalecte/src/main/kotlin/fr/grammalecteandroid/engine/GrammalecteDictionaryPreferences.kt b/engine-grammalecte/src/main/kotlin/fr/grammalecteandroid/engine/GrammalecteDictionaryPreferences.kt new file mode 100644 index 0000000..c9af8d6 --- /dev/null +++ b/engine-grammalecte/src/main/kotlin/fr/grammalecteandroid/engine/GrammalecteDictionaryPreferences.kt @@ -0,0 +1,45 @@ +package fr.grammalecteandroid.engine + +import android.content.Context + +class GrammalecteDictionaryPreferences( + context: Context, +) { + private val preferences = + context.applicationContext.getSharedPreferences( + PREFERENCES_NAME, + Context.MODE_PRIVATE, + ) + + fun selected(): GrammalecteDictionary = + GrammalecteDictionary.fromPreferenceValue( + preferences.getString( + KEY_DICTIONARY, + null, + ), + ) + + fun select(dictionary: GrammalecteDictionary) { + preferences + .edit() + .putString( + KEY_DICTIONARY, + dictionary.preferenceValue, + ).apply() + } + + fun reset() { + preferences + .edit() + .remove(KEY_DICTIONARY) + .apply() + } + + private companion object { + const val PREFERENCES_NAME = + "grammalecte_dictionary" + + const val KEY_DICTIONARY = + "selected_dictionary" + } +} diff --git a/engine-grammalecte/src/main/kotlin/fr/grammalecteandroid/engine/GrammalecteQuickJsEngine.kt b/engine-grammalecte/src/main/kotlin/fr/grammalecteandroid/engine/GrammalecteQuickJsEngine.kt index 658e3f5..d1318d1 100644 --- a/engine-grammalecte/src/main/kotlin/fr/grammalecteandroid/engine/GrammalecteQuickJsEngine.kt +++ b/engine-grammalecte/src/main/kotlin/fr/grammalecteandroid/engine/GrammalecteQuickJsEngine.kt @@ -16,16 +16,19 @@ import org.json.JSONObject class GrammalecteQuickJsEngine internal constructor( private val assetLoader: AssetTextLoader, private val rulePreferences: GrammalecteRulePreferences? = null, + private val dictionaryPreferences: GrammalecteDictionaryPreferences? = null, ) : GrammarEngine { constructor(context: Context) : this( AndroidAssetTextLoader(context), GrammalecteRulePreferences(context), + GrammalecteDictionaryPreferences(context), ) private val lock = Any() private var runtime: QuickJs? = null private var appliedRuleOverrides: Map? = null + private var appliedDictionary: GrammalecteDictionary? = null override fun check( text: String, @@ -33,6 +36,7 @@ class GrammalecteQuickJsEngine internal constructor( ): List { if (text.isBlank()) return emptyList() + synchronizeStoredDictionary() synchronizeStoredRuleOptions() val raw = callBridge("__grammalecteAndroid.check", text, localeTag) @@ -49,6 +53,9 @@ class GrammalecteQuickJsEngine internal constructor( suggestionLimit: Int, ): WordCheck { if (word.isBlank()) return WordCheck(valid = true) + + synchronizeStoredDictionary() + val safeLimit = suggestionLimit.coerceAtLeast(0) val raw = @@ -63,6 +70,41 @@ class GrammalecteQuickJsEngine internal constructor( ) } + fun selectedDictionary(): GrammalecteDictionary { + synchronizeStoredDictionary() + + return synchronized(lock) { + appliedDictionary + ?: GrammalecteDictionary.ALL_VARIANTS + } + } + + fun setDictionary(dictionary: GrammalecteDictionary) { + synchronized(lock) { + applyDictionary(dictionary) + + dictionaryPreferences?.select( + dictionary, + ) + + appliedDictionary = + dictionary + } + } + + fun resetDictionary() { + synchronized(lock) { + applyDictionary( + GrammalecteDictionary.ALL_VARIANTS, + ) + + dictionaryPreferences?.reset() + + appliedDictionary = + GrammalecteDictionary.ALL_VARIANTS + } + } + fun ruleOptions(): List { synchronizeStoredRuleOptions() @@ -139,6 +181,47 @@ class GrammalecteQuickJsEngine internal constructor( } } + private fun synchronizeStoredDictionary() { + val preferences = + dictionaryPreferences ?: return + + val selected = + preferences.selected() + + synchronized(lock) { + if (appliedDictionary == selected) { + return + } + + if ( + runtime == null && + selected == GrammalecteDictionary.ALL_VARIANTS + ) { + appliedDictionary = + GrammalecteDictionary.ALL_VARIANTS + + return + } + + applyDictionary(selected) + + appliedDictionary = + selected + } + } + + private fun applyDictionary(dictionary: GrammalecteDictionary) { + val updated = + callBridge( + "__grammalecteAndroid.setDictionary", + dictionary.fileName, + ).toBooleanStrictOrNull() ?: false + + check(updated) { + "Unable to load Grammalecte dictionary: ${dictionary.fileName}" + } + } + private fun synchronizeStoredRuleOptions() { val preferences = rulePreferences ?: return @@ -177,6 +260,7 @@ class GrammalecteQuickJsEngine internal constructor( runtime?.close() runtime = null appliedRuleOverrides = null + appliedDictionary = null } } @@ -242,6 +326,9 @@ class GrammalecteQuickJsEngine internal constructor( "Grammalecte initialization reported errors: $initializationErrors" } + appliedDictionary = + GrammalecteDictionary.ALL_VARIANTS + js.evaluationTimeoutMillis = ANALYSIS_TIMEOUT_MILLIS } } catch (error: Throwable) { diff --git a/engine-grammalecte/src/test/kotlin/fr/grammalecteandroid/engine/RealEngineSmokeTest.kt b/engine-grammalecte/src/test/kotlin/fr/grammalecteandroid/engine/RealEngineSmokeTest.kt index 91c18a2..aa34db8 100644 --- a/engine-grammalecte/src/test/kotlin/fr/grammalecteandroid/engine/RealEngineSmokeTest.kt +++ b/engine-grammalecte/src/test/kotlin/fr/grammalecteandroid/engine/RealEngineSmokeTest.kt @@ -202,6 +202,97 @@ class RealEngineSmokeTest { } } + @Test + fun bundledDictionaryVariantsCanBeSelected() { + val engine = + GrammalecteQuickJsEngine( + FileAssetTextLoader(findAssetRoot()), + ) + + try { + GrammalecteDictionary.entries + .forEach { dictionary -> + engine.setDictionary(dictionary) + + assertEquals( + dictionary, + engine.selectedDictionary(), + ) + + assertFalse( + "Expected magazin to remain invalid with $dictionary", + engine + .checkWord( + word = "magazin", + localeTag = "fr-FR", + suggestionLimit = 8, + ).valid, + ) + } + } finally { + engine.close() + } + } + + @Test + fun dictionaryVariantsApplyExpectedOrthographies() { + val engine = + GrammalecteQuickJsEngine( + FileAssetTextLoader(findAssetRoot()), + ) + + fun isValid(word: String): Boolean = + engine + .checkWord( + word = word, + localeTag = "fr-FR", + suggestionLimit = 5, + ).valid + + try { + engine.setDictionary( + GrammalecteDictionary.ALL_VARIANTS, + ) + + assertTrue(isValid("coût")) + assertTrue(isValid("cout")) + assertTrue(isValid("week-end")) + assertTrue(isValid("weekend")) + + engine.setDictionary( + GrammalecteDictionary.CLASSIC, + ) + + assertTrue(isValid("coût")) + assertFalse(isValid("cout")) + assertTrue(isValid("week-end")) + assertFalse(isValid("weekend")) + + engine.setDictionary( + GrammalecteDictionary.REFORM_1990, + ) + + assertFalse(isValid("coût")) + assertTrue(isValid("cout")) + assertFalse(isValid("week-end")) + assertTrue(isValid("weekend")) + + engine.resetDictionary() + + assertEquals( + GrammalecteDictionary.ALL_VARIANTS, + engine.selectedDictionary(), + ) + + assertTrue(isValid("coût")) + assertTrue(isValid("cout")) + assertTrue(isValid("week-end")) + assertTrue(isValid("weekend")) + } finally { + engine.close() + } + } + @Test fun concurrentCallsShareRuntimeSafely() { val engine = diff --git a/tools/test-js-bridge.mjs b/tools/test-js-bridge.mjs index 887df08..cc81cff 100644 --- a/tools/test-js-bridge.mjs +++ b/tools/test-js-bridge.mjs @@ -40,7 +40,23 @@ globalThis.mfsp = { } }; +let loadedMainDictionary = null; +let dictionaryStorageClearCount = 0; + const fakeSpellChecker = { + setMainDictionary(dictionary, path) { + loadedMainDictionary = { + dictionary, + path + }; + + return true; + }, + + clearStorage() { + dictionaryStorageClearCount += 1; + }, + isValid(word) { return word === "correct"; }, @@ -282,4 +298,36 @@ assert.equal( true ); +assert.equal( + globalThis.__grammalecteAndroid.setDictionary( + "fr-classic.json" + ), + "true" +); + +assert.deepEqual( + loadedMainDictionary, + { + dictionary: "fr-classic.json", + path: "grammalecte/graphspell/_dictionaries" + } +); + +assert.equal( + dictionaryStorageClearCount, + 1 +); + +assert.equal( + globalThis.__grammalecteAndroid.setDictionary( + "not-a-dictionary.json" + ), + "false" +); + +assert.equal( + dictionaryStorageClearCount, + 1 +); + console.log("JavaScript bridge contract OK");