diff --git a/CHANGELOG.md b/CHANGELOG.md index 426f3da23..85ba73ab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Emojis for the following are chosen based on [gitmoji](https://gitmoji.dev/). - The user is directed to download data in the keyboard UI if they access it with empty databases ([#581](https://github.com/scribe-org/Scribe-Android/issues/581), [#650](https://github.com/scribe-org/Scribe-Android/issues/650)). - The data download UI was created to download data for any keyboards that have been installed ([#437](https://github.com/scribe-org/Scribe-Android/issues/437), [#439](https://github.com/scribe-org/Scribe-Android/issues/439), [#513](https://github.com/scribe-org/Scribe-Android/issues/513), [#520](https://github.com/scribe-org/Scribe-Android/issues/520), [#554](https://github.com/scribe-org/Scribe-Android/issues/554)). - Network indicators for data request have been added to the application and are shown via toasts ([#651](https://github.com/scribe-org/Scribe-Android/issues/651)). +- Language data contracts were updated to use nested nouns and verbs structures, bundled contract assets were removed, and dynamic contract downloads from Scribe-Server were connected to local storage ([#714](https://github.com/scribe-org/Scribe-Android/issues/714)). ### 🎨 Design diff --git a/app/src/androidTestKeyboards/kotlin/be/scri/helpers/data/PluralFormsManagerTest.kt b/app/src/androidTestKeyboards/kotlin/be/scri/helpers/data/PluralFormsManagerTest.kt index cdb5a5bbb..4e268ac14 100644 --- a/app/src/androidTestKeyboards/kotlin/be/scri/helpers/data/PluralFormsManagerTest.kt +++ b/app/src/androidTestKeyboards/kotlin/be/scri/helpers/data/PluralFormsManagerTest.kt @@ -2,6 +2,8 @@ package be.scri.helpers.data import DataContract +import Nouns +import NumberPair import android.database.Cursor import android.database.sqlite.SQLiteDatabase import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -23,10 +25,12 @@ class PluralFormsManagerTest { val mockDatabaseFileManager = mockk() val mockDatabase = mockk() val mockDataContract = mockk() + val mockNouns = mockk() val mockCursor = mockk(relaxed = true) every { mockDatabaseFileManager.getLanguageDatabase("EN") } returns mockDatabase - every { mockDataContract.numbers } returns mapOf("singular" to "plural") + every { mockDataContract.nouns } returns mockNouns + every { mockNouns.numbers } returns mapOf(1 to NumberPair("singular", "plural")) every { mockDatabase.rawQuery( "SELECT `singular`, `plural` FROM nouns WHERE `singular` = ? COLLATE NOCASE", diff --git a/app/src/keyboards/java/be/scri/helpers/KeyboardDataHandler.kt b/app/src/keyboards/java/be/scri/helpers/KeyboardDataHandler.kt index 1086f223f..a862935a1 100644 --- a/app/src/keyboards/java/be/scri/helpers/KeyboardDataHandler.kt +++ b/app/src/keyboards/java/be/scri/helpers/KeyboardDataHandler.kt @@ -75,9 +75,12 @@ class KeyboardDataHandler { nounKeywords = dbManagers.genderManager.findGenderOfWord(languageAlias, dataContract) suggestionWords = dbManagers.suggestionManager.getSuggestions(languageAlias) val numbersColumns = - dataContract?.numbers?.let { map -> - (map.keys + map.values).distinct() - } ?: emptyList() + dataContract + ?.nouns + ?.numbers + ?.values + ?.flatMap { listOf(it.singular, it.plural) } + ?.distinct() ?: emptyList() autocompletionManager.loadWords(languageAlias, numbersColumns) caseAnnotation = dbManagers.prepositionManager.getCaseAnnotations(languageAlias) diff --git a/app/src/main/assets/data-contracts/de.yaml b/app/src/main/assets/data-contracts/de.yaml deleted file mode 100644 index 65bdad8e5..000000000 --- a/app/src/main/assets/data-contracts/de.yaml +++ /dev/null @@ -1,958 +0,0 @@ -# Database column mappings that are read by Scribe applications to structure how data is displayed to the user. -# Includes interface titles and the order that values should be presented (chronological or alphabetical). - -# MARK: Noun Gender - -# A map of which columns in the nouns table contain gender information. -genders: - canonical: [gender] - feminines: [] - masculines: [] - commons: [] - neuters: [] - -# MARK: Plural - -# Key-value pairs that link the key of a noun singular to its plural for the Plural command. -numbers: - nominativeSingular: nominativePlural - -# MARK: Conjugate - -# Map of verb tenses to their conjugations for the Conjugate command. -# Sections provide meta tense selections if there is more than one tense included. -conjugations: - 1: - sectionTitle: Präsens - tenses: - 1: - tenseTitle: Präsens - tenseForms: - 1: - label: ich - value: indicativePresentFirstPersonSingular - 2: - label: du - value: indicativePresentSecondPersonSingular - 3: - label: er/sie/es - value: indicativePresentThirdPersonSingular - 4: - label: wir - value: indicativePresentFirstPersonPlural - 5: - label: ihr - value: indicativePresentSecondPersonPlural - 6: - label: sie/Sie - value: indicativePresentThirdPersonPlural - - 2: - sectionTitle: Preterite - tenses: - 1: - tenseTitle: Preterite - tenseForms: - 1: - label: ich - value: indicativePreteriteFirstPersonSingular - 2: - label: du - value: indicativePreteriteSecondPersonSingular - 3: - label: er/sie/es - value: indicativePreteriteThirdPersonSingular - 4: - label: wir - value: indicativePreteriteFirstPersonPlural - 5: - label: ihr - value: indicativePreteriteSecondPersonPlural - 6: - label: sie/Sie - value: indicativePreteriteThirdPersonPlural - - 3: - sectionTitle: Perfekt - tenses: - 1: - tenseTitle: Perfekt - tenseForms: - 1: - label: ich - value: "[indicativePresentFirstPersonSingular auxiliaryVerb] pastParticiple" - 2: - label: du - value: "[indicativePresentSecondPersonSingular auxiliaryVerb] pastParticiple" - 3: - label: er/sie/es - value: "[indicativePresentThirdPersonSingular auxiliaryVerb] pastParticiple" - 4: - label: wir - value: "[indicativePresentFirstPersonPlural auxiliaryVerb] pastParticiple" - 5: - label: ihr - value: "[indicativePresentSecondPersonPlural auxiliaryVerb] pastParticiple" - 6: - label: sie/Sie - value: "[indicativePresentThirdPersonPlural auxiliaryVerb] pastParticiple" - -# MARK: Translate - -# Map of interface titles to display for the Translate command. -# Needed database columns are the same across languages (descriptions are labels for values). -# Note: Descriptions for article and pronoun translations are their genders. -translations: - wordType: - sectionTitle: Übersetzungsworttyp auswählen - - adjective: - displayValue: Adjektiv - sectionTitle: Adjektiv Übersetzung auswählen - - adverb: - displayValue: Adverb - sectionTitle: Adjektiv Übersetzung auswählen - - article: - displayValue: Artikel - sectionTitle: Artikel Übersetzung auswählen - - conjunction: - displayValue: Konjunktion - sectionTitle: Konjunktion Übersetzung auswählen - - noun: - displayValue: Nomen - sectionTitle: Nomen Übersetzung auswählen - - postposition: - displayValue: Postposition - sectionTitle: Postposition Übersetzung auswählen - - preposition: - displayValue: Preposition - sectionTitle: Preposition Übersetzung auswählen - - proper_noun: - displayValue: Eigenname - sectionTitle: Eigenname Übersetzung auswählen - - pronoun: - displayValue: Pronomen - sectionTitle: Pronomen Übersetzung auswählen - - verb: - displayValue: Verb - sectionTitle: Verb Übersetzung auswählen - -# MARK: Decline - -# Map of pronoun declensions given cases. -declensions: - 1: - sectionTitle: Akkusativ Definitpronomen - declensionForms: - 1: - label: M - value: den - 2: - label: F - value: die - 3: - label: N - value: das - 4: - label: PL - value: die - - 2: - sectionTitle: Akkusativ Indefinitpronomen - declensionForms: - 1: - label: M - value: einen - 2: - label: F - value: eine - 3: - label: N - value: ein - 4: - label: PL - value: welche - - 3: - sectionTitle: Akkusativ Personalpronomen - declensionForms: - 1: - label: ich - value: mich - - 2: - label: du/Sie - displayValue: dich/Sie - title: Formalität des Subjekts? - declensionForms: - 1: - label: informell - value: dich - 2: - label: formell - value: Sie - - 3: - label: er/sie/es - displayValue: ihn/sie/es - title: Geschlecht des Subjekts? - declensionForms: - 1: - label: er - value: ihn - 2: - label: sie - value: sie - 3: - label: es - value: es - - 4: - label: wir - value: uns - - 5: - label: ihr - value: euch - - 6: - label: sie - value: sie - - 4: - sectionTitle: Akkusativ Possessivpronomen - declensionForms: - 1: - label: ich - displayValue: mein* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: meinen - 2: - label: F - value: meine - 3: - label: N - value: mein - 4: - label: PL - value: meine - - 2: - label: du/Sie - displayValue: dein*/Ihr* - title: Formalität des Subjekts? - declensionForms: - 1: - label: informell - displayValue: dein* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: deinen - 2: - label: F - value: deine - 3: - label: N - value: dein - 4: - label: PL - value: deine - - 2: - label: formell - displayValue: Ihr* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: Ihren - 2: - label: F - value: Ihre - 3: - label: N - value: Ihr - 4: - label: PL - value: Ihre - - 3: - label: er/sie/es - displayValue: sein*/ihr*/sein* - title: Geschlecht des Subjekts? - declensionForms: - 1: - label: er - displayValue: sein* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: seinen - 2: - label: F - value: seine - 3: - label: N - value: sein - 4: - label: PL - value: seine - - 2: - label: sie - displayValue: ihr* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: ihren - 2: - label: F - value: ihre - 3: - label: N - value: ihr - 4: - label: PL - value: ihre - - 3: - label: es - displayValue: sein* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: seinen - 2: - label: F - value: seine - 3: - label: N - value: sein - 4: - label: PL - value: seine - - 4: - label: wir - displayValue: unser* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: unsren - 2: - label: F - value: unsre - 3: - label: N - value: unser - 4: - label: PL - value: unsre - - 5: - label: ihr - displayValue: euer* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: euren - 2: - label: F - value: eure - 3: - label: N - value: euer - 4: - label: PL - value: eure - - 6: - label: sie - displayValue: ihr* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: ihren - 2: - label: F - value: ihre - 3: - label: N - value: ihr - 4: - label: PL - value: ihre - - 5: - sectionTitle: Akkusativ Demonstrativpronomen - declensionForms: - 1: - label: M - value: diesen - 2: - label: F - value: diese - 3: - label: N - value: dieses - 4: - label: PL - value: diese - - 6: - sectionTitle: Dativ Definitpronomen - declensionForms: - 1: - label: M - value: dem - 2: - label: F - value: der - 3: - label: N - value: dem - 4: - label: PL - value: den - - 7: - sectionTitle: Dativ Indefinitpronomen - declensionForms: - 1: - label: M - value: einem - 2: - label: F - value: einer - 3: - label: N - value: einem - 4: - label: PL - value: welchen - - 8: - sectionTitle: Dativ Personalpronomen - declensionForms: - 1: - label: ich - value: mir - - 2: - label: du/Sie - displayValue: dir/Ihnen - title: Formalität des Subjekts? - declensionForms: - 1: - label: informell - value: dir - 2: - label: formell - value: Ihnen - - 3: - label: er/sie/es - displayValue: ihm/ihr/ihm - title: Geschlecht des Subjekts? - declensionForms: - 1: - label: er - value: ihm - 2: - label: sie - value: ihr - 3: - label: es - value: ihm - - 4: - label: wir - value: uns - - 5: - label: ihr - value: euch - - 6: - label: sie - value: ihnen - - 9: - sectionTitle: Dativ Possessivpronomen - declensionForms: - 1: - label: ich - displayValue: mein* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: meinem - 2: - label: F - value: meiner - 3: - label: N - value: meinem - 4: - label: PL - value: meinen - - 2: - label: du/Sie - displayValue: dein*/Ihr* - title: Formalität des Subjekts? - declensionForms: - 1: - label: informell - displayValue: dein* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: deinem - 2: - label: F - value: deiner - 3: - label: N - value: deinem - 4: - label: PL - value: deinen - - 2: - label: formell - displayValue: Ihr* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: Ihrem - 2: - label: F - value: Ihrer - 3: - label: N - value: Ihrem - 4: - label: PL - value: Ihren - - 3: - label: er/sie/es - displayValue: sein*/ihr*/sein* - title: Geschlecht des Subjekts? - declensionForms: - 1: - label: er - displayValue: sein* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: seinem - 2: - label: F - value: seiner - 3: - label: N - value: seinem - 4: - label: PL - value: seinen - - 2: - label: sie - displayValue: ihr* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: ihrem - 2: - label: F - value: ihrer - 3: - label: N - value: ihrem - 4: - label: PL - value: ihren - - 3: - label: es - displayValue: sein* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: seinem - 2: - label: F - value: seiner - 3: - label: N - value: seinem - 4: - label: PL - value: seinen - - 4: - label: wir - displayValue: unser* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: unsrem - 2: - label: F - value: unsrer - 3: - label: N - value: unsrem - 4: - label: PL - value: unsren - - 5: - label: ihr - displayValue: euer* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: eurem - 2: - label: F - value: eurer - 3: - label: N - value: eurem - 4: - label: PL - value: euren - - 6: - label: sie - displayValue: ihr* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: ihrem - 2: - label: F - value: ihrer - 3: - label: N - value: ihrem - 4: - label: PL - value: ihren - - 10: - sectionTitle: Dativ Demonstrativpronomen - declensionForms: - 1: - label: M - value: diesem - 2: - label: F - value: dieser - 3: - label: N - value: diesem - 4: - label: PL - value: diesen - - 11: - sectionTitle: Genitiv Definitpronomen - declensionForms: - 1: - label: M - value: des - 2: - label: F - value: der - 3: - label: N - value: des - 4: - label: PL - value: der - - 12: - sectionTitle: Genitiv Indefinitpronomen - declensionForms: - 1: - label: M - value: eines - 2: - label: F - value: einer - 3: - label: N - value: eines - 4: - label: PL - value: welcher - - 13: - sectionTitle: Genitiv Personalpronomen - declensionForms: - 1: - label: ich - value: meiner - - 2: - label: du/Sie - displayValue: deiner/Ihrer - title: Formalität des Subjekts? - declensionForms: - 1: - label: informell - value: deiner - 2: - label: formell - value: Ihrer - - 3: - label: er/sie/es - displayValue: seiner/ihrer/seiner - title: Geschlecht des Subjekts? - declensionForms: - 1: - label: er - value: seiner - 2: - label: sie - value: ihrer - 3: - label: es - value: seiner - - 4: - label: wir - value: unser - - 5: - label: ihr - value: euer - - 6: - label: sie - value: ihrer - - 14: - sectionTitle: Genitiv Possessivpronomen - declensionForms: - 1: - label: ich - displayValue: mein* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: meines - 2: - label: F - value: meiner - 3: - label: N - value: meines - 4: - label: PL - value: meiner - - 2: - label: du/Sie - displayValue: dein*/Ihr* - title: Formalität des Subjekts? - declensionForms: - 1: - label: informell - displayValue: dein* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: deines - 2: - label: F - value: deiner - 3: - label: N - value: deines - 4: - label: PL - value: deiner - - 2: - label: formell - displayValue: Ihr* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: Ihres - 2: - label: F - value: Ihrer - 3: - label: N - value: Ihres - 4: - label: PL - value: Ihrer - - 3: - label: er/sie/es - displayValue: sein*/ihr*/sein* - title: Geschlecht des Subjekts? - declensionForms: - 1: - label: er - displayValue: sein* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: seines - 2: - label: F - value: seiner - 3: - label: N - value: seines - 4: - label: PL - value: seiner - - 2: - label: sie - displayValue: ihr* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: ihres - 2: - label: F - value: ihr - 3: - label: N - value: ihres - 4: - label: PL - value: ihr - - 3: - label: es - displayValue: sein* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: seines - 2: - label: F - value: seiner - 3: - label: N - value: seines - 4: - label: PL - value: seiner - - 4: - label: wir - displayValue: unser* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: unsres - 2: - label: F - value: unsrer - 3: - label: N - value: unsres - 4: - label: PL - value: unsrer - - 5: - label: ihr - displayValue: euer* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: eures - 2: - label: F - value: eurer - 3: - label: N - value: eures - 4: - label: PL - value: eurer - - 6: - label: sie - displayValue: ihr* - title: Geschlecht des Objekts? - declensionForms: - 1: - label: M - value: ihres - 2: - label: F - value: ihrer - 3: - label: N - value: ihres - 4: - label: PL - value: ihrer - - 15: - sectionTitle: Genitiv Demonstrativpronomen - declensionForms: - 1: - label: M - value: dieses - 2: - label: F - value: dieser - 3: - label: N - value: dieses - 4: - label: PL - value: dieser diff --git a/app/src/main/assets/data-contracts/en.yaml b/app/src/main/assets/data-contracts/en.yaml deleted file mode 100644 index ae217fa3c..000000000 --- a/app/src/main/assets/data-contracts/en.yaml +++ /dev/null @@ -1,214 +0,0 @@ -# Database column mappings that are read by Scribe applications to structure how data is displayed to the user. -# Includes interface titles and the order that values should be presented (chronological or alphabetical). - -# MARK: Noun Gender - -# A map of which columns in the nouns table contain gender information. -genders: - canonical: [] - feminines: [] - masculines: [] - commons: [] - neuters: [] - -# MARK: Plural - -# Key-value pairs that link the key of a noun singular to its plural for the Plural command. -numbers: - singular: plural - -# MARK: Conjugate - -# Map of verb tenses to their conjugations for the Conjugate command. -# Sections provide meta tense selections if there is more than one tense included. -conjugations: - 1: - sectionTitle: Present - tenses: - 1: - tenseTitle: Pr. Simple - tenseForms: - 1: - label: I/you/plural - value: simplePresent - 2: - label: he/she/it - value: simplePresentThirdPersonSingular - - 2: - tenseTitle: Pr. Perfect - tenseForms: - 1: - label: I/you/plural - value: "[have] pastParticiple" - 2: - label: he/she/it - value: "[has] pastParticiple" - - 3: - tenseTitle: Pr. Continuous - tenseForms: - 1: - label: I - value: "[am] presentParticiple" - 2: - label: you/plural - value: "[are] presentParticiple" - 3: - label: he/she/it - value: "[is] presentParticiple" - - 4: - tenseTitle: Pr. Perf. Continuous - tenseForms: - 1: - label: I/you/plural - value: "[have been] presentParticiple" - 2: - label: he/she/it - value: "[has been] presentParticiple" - - 2: - sectionTitle: Past - tenses: - 1: - tenseTitle: Past Simple - tenseForms: - 1: - label: all - value: simplePast - - 2: - tenseTitle: Past Perfect - tenseForms: - 1: - label: all - value: "[had] pastParticiple" - - 3: - tenseTitle: Past Continuous - tenseForms: - 1: - label: I/he/she/it - value: "[was] presentParticiple" - 2: - label: you/plural - value: "[were] presentParticiple" - - 4: - tenseTitle: Past Perf. Continuous - tenseForms: - 1: - label: all - value: "[had been] presentParticiple" - - 3: - sectionTitle: Future - tenses: - 1: - tenseTitle: Fut. Simple - tenseForms: - 1: - label: all - value: "[will] infinitive" - - 2: - tenseTitle: Fut. Perfect - tenseForms: - 1: - label: all - value: "[will have] pastParticiple" - - 3: - tenseTitle: Fut. Continuous - tenseForms: - 1: - label: all - value: "[will be] presentParticiple" - - 4: - tenseTitle: Fut. Perf. Continuous - tenseForms: - 1: - label: all - value: "[will have been] presentParticiple" - - 4: - sectionTitle: Conditional - tenses: - 1: - tenseTitle: Cond. Simple - tenseForms: - 1: - label: all - value: "[would] infinitive" - - 2: - tenseTitle: Cond. Perfect - tenseForms: - 1: - label: all - value: "[would have] pastParticiple" - - 3: - tenseTitle: Cond. Continuous - tenseForms: - 1: - label: all - value: "[would be] presentParticiple" - - 4: - tenseTitle: Cond. Perf. Continuous - tenseForms: - 1: - label: all - value: "[would have been] presentParticiple" - -# MARK: Translate - -# Map of interface titles to display for the Translate command. -# Needed database columns are the same across languages (descriptions are labels for values). -# Note: Descriptions for article and pronoun translations are their genders. -translations: - wordType: - sectionTitle: Select translation word type - - adjective: - displayValue: adjective - sectionTitle: Select adjective translation - - adverb: - displayValue: adverb - sectionTitle: Select adverb translation - - article: - displayValue: article - sectionTitle: Select article translation - - conjunction: - displayValue: conjunction - sectionTitle: Select conjunction translation - - noun: - displayValue: noun - sectionTitle: Select noun translation - - postposition: - displayValue: postposition - sectionTitle: Select postposition translation - - preposition: - displayValue: preposition - sectionTitle: Select preposition translation - - proper_noun: - displayValue: proper noun - sectionTitle: Select proper noun translation - - pronoun: - displayValue: pronoun - sectionTitle: Select pronoun translation - - verb: - displayValue: verb - sectionTitle: Select verb infinitive diff --git a/app/src/main/assets/data-contracts/es.yaml b/app/src/main/assets/data-contracts/es.yaml deleted file mode 100644 index 58d7d1deb..000000000 --- a/app/src/main/assets/data-contracts/es.yaml +++ /dev/null @@ -1,148 +0,0 @@ -# Database column mappings that are read by Scribe applications to structure how data is displayed to the user. -# Includes interface titles and the order that values should be presented (chronological or alphabetical). - -# MARK: Noun Gender - -# A map of which columns in the nouns table contain gender information. -genders: - canonical: [] - feminines: [feminineSingular] - masculines: [masculineSingular] - commons: [] - neuters: [] - -# MARK: Plural - -# Key-value pairs that link the key of a noun singular to its plural for the Plural command. -numbers: - feminineSingular: femininePlural - masculineSingular: masculinePlural - -# MARK: Conjugate - -# Map of verb tenses to their conjugations for the Conjugate command. -# Sections provide meta tense selections if there is more than one tense included. -conjugations: - 1: - sectionTitle: Presente - tenses: - 1: - tenseTitle: Presente - tenseForms: - 1: - label: yo - value: indicativePresentFirstPersonSingular - 2: - label: tú - value: indicativePresentSecondPersonSingular - 3: - label: él/ella/Ud. - value: indicativePresentThirdPersonSingular - 4: - label: nosotros - value: indicativePresentFirstPersonPlural - 5: - label: vosotros - value: indicativePresentSecondPersonPlural - 6: - label: ellos/ellas/Uds. - value: indicativePresentThirdPersonPlural - - 2: - sectionTitle: Pretérito - tenses: - 1: - tenseTitle: Pretérito - tenseForms: - 1: - label: yo - value: preteriteFirstPersonSingular - 2: - label: tú - value: preteriteSecondPersonSingular - 3: - label: él/ella/Ud. - value: preteriteThirdPersonSingular - 4: - label: nosotros - value: preteriteFirstPersonPlural - 5: - label: vosotros - value: preteriteSecondPersonPlural - 6: - label: ellos/ellas/Uds. - value: preteriteThirdPersonPlural - - 3: - sectionTitle: Imperfecto - tenses: - 1: - tenseTitle: Imperfecto - tenseForms: - 1: - label: yo - value: pastImperfectFirstPersonSingular - 2: - label: tú - value: pastImperfectSecondPersonSingular - 3: - label: él/ella/Ud. - value: pastImperfectThirdPersonSingular - 4: - label: nosotros - value: pastImperfectFirstPersonPlural - 5: - label: vosotros - value: pastImperfectSecondPersonPlural - 6: - label: ellos/ellas/Uds. - value: pastImperfectThirdPersonPlural - -# MARK: Translate - -# Map of interface titles to display for the Translate command. -# Needed database columns are the same across languages (descriptions are labels for values). -# Note: Descriptions for article and pronoun translations are their genders. -translations: - wordType: - sectionTitle: Seleccione el tipo de traducción - - adjective: - displayValue: adjetivo - sectionTitle: Seleccione traducción de adjetivo - - adverb: - displayValue: adverbio - sectionTitle: Seleccione traducción de adverbio - - article: - displayValue: artículo - sectionTitle: Seleccione traducción de artículo - - conjunction: - displayValue: conjunción - sectionTitle: Seleccione traducción de conjunción - - noun: - displayValue: sustantivo - sectionTitle: Seleccione traducción de sustantivo - - postposition: - displayValue: posposición - sectionTitle: Seleccione traducción de posposición - - preposition: - displayValue: preposición - sectionTitle: Seleccione traducción de preposición - - proper_noun: - displayValue: nombre propio - sectionTitle: Seleccione traducción de nombre propio - - pronoun: - displayValue: pronombre - sectionTitle: Seleccione traducción de pronombre - - verb: - displayValue: verbo - sectionTitle: Seleccionar verbo infinitivo diff --git a/app/src/main/assets/data-contracts/fr.yaml b/app/src/main/assets/data-contracts/fr.yaml deleted file mode 100644 index aede2e980..000000000 --- a/app/src/main/assets/data-contracts/fr.yaml +++ /dev/null @@ -1,172 +0,0 @@ -# Database column mappings that are read by Scribe applications to structure how data is displayed to the user. -# Includes interface titles and the order that values should be presented (chronological or alphabetical). - -# MARK: Noun Gender - -# A map of which columns in the nouns table contain gender information. -genders: - canonical: [gender] - feminines: [] - masculines: [] - commons: [] - neuters: [] - -# MARK: Plural - -# Key-value pairs that link the key of a noun singular to its plural for the Plural command. -numbers: - singular: plural - -# MARK: Conjugate - -# Map of verb tenses to their conjugations for the Conjugate command. -# Sections provide meta tense selections if there is more than one tense included. -conjugations: - 1: - sectionTitle: Présent - tenses: - 1: - tenseTitle: Présent - tenseForms: - 1: - label: je - value: indicativePresentFirstPersonSingular - 2: - label: tu - value: indicativePresentSecondPersonSingular - 3: - label: il/elle - value: indicativePresentThirdPersonSingular - 4: - label: nous - value: indicativePresentFirstPersonPlural - 5: - label: vous - value: indicativePresentSecondPersonPlural - 6: - label: ils/elles - value: indicativePresentThirdPersonPlural - - 2: - sectionTitle: Passé simple - tenses: - 1: - tenseTitle: Passé simple - tenseForms: - 1: - label: je - value: indicativePreteriteFirstPersonSingular - 2: - label: tu - value: indicativePreteriteSecondPersonSingular - 3: - label: il/elle - value: indicativePreteriteThirdPersonSingular - 4: - label: nous - value: indicativePreteriteFirstPersonPlural - 5: - label: vous - value: indicativePreteriteSecondPersonPlural - 6: - label: ils/elles - value: indicativePreteriteThirdPersonPlural - - 3: - sectionTitle: Imparfait - tenses: - 1: - tenseTitle: Imparfait - tenseForms: - 1: - label: je - value: indicativeImperfectFirstPersonSingular - 2: - label: tu - value: indicativeImperfectSecondPersonSingular - 3: - label: il/elle - value: indicativeImperfectThirdPersonSingular - 4: - label: nous - value: indicativeImperfectFirstPersonPlural - 5: - label: vous - value: indicativeImperfectSecondPersonPlural - 6: - label: ils/elles - value: indicativeImperfectThirdPersonPlural - - 4: - sectionTitle: Futur - tenses: - 1: - tenseTitle: Futur - tenseForms: - 1: - label: je - value: indicativeSimpleFutureFirstPersonSingular - 2: - label: tu - value: indicativeSimpleFutureSecondPersonSingular - 3: - label: il/elle - value: indicativeSimpleFutureThirdPersonSingular - 4: - label: nous - value: indicativeSimpleFutureFirstPersonPlural - 5: - label: vous - value: indicativeSimpleFutureSecondPersonPlural - 6: - label: ils/elles - value: indicativeSimpleFutureThirdPersonPlural - -# MARK: Translate - -# Map of interface titles to display for the Translate command. -# Needed database columns are the same across languages (descriptions are labels for values). -# Note: Descriptions for article and pronoun translations are their genders. -translations: - wordType: - sectionTitle: Sélectionnez le type à traduire - - adjective: - displayValue: adjectif - sectionTitle: Sélectionner la traduction de l'adjectif - - adverb: - displayValue: adverbe - sectionTitle: Sélectionner la traduction de l'adverbe - - article: - displayValue: article - sectionTitle: Sélectionner la traduction de l'article - - conjunction: - displayValue: conjonction - sectionTitle: Sélectionner la traduction de conjonction - - noun: - displayValue: nom - sectionTitle: Sélectionner la traduction du nom - - postposition: - displayValue: postposition - sectionTitle: Sélectionnez la traduction de la postposition - - preposition: - displayValue: préposition - sectionTitle: Sélectionnez la traduction de la préposition - - proper_noun: - displayValue: nom propre - sectionTitle: Sélectionnez la traduction du nom propre - - pronoun: - displayValue: pronom - sectionTitle: Sélectionner la traduction des pronoms - - verb: - displayValue: verbe - sectionTitle: Sélectionnez l'infinitif du verbe diff --git a/app/src/main/assets/data-contracts/it.yaml b/app/src/main/assets/data-contracts/it.yaml deleted file mode 100644 index ec2106349..000000000 --- a/app/src/main/assets/data-contracts/it.yaml +++ /dev/null @@ -1,147 +0,0 @@ -# Database column mappings that are read by Scribe applications to structure how data is displayed to the user. -# Includes interface titles and the order that values should be presented (chronological or alphabetical). - -# MARK: Noun Gender - -# A map of which columns in the nouns table contain gender information. -genders: - canonical: [gender] - feminines: [] - masculines: [] - commons: [] - neuters: [] - -# MARK: Plural - -# Key-value pairs that link the key of a noun singular to its plural for the Plural command. -numbers: - singular: plural - -# MARK: Conjugate - -# Map of verb tenses to their conjugations for the Conjugate command. -# Sections provide meta tense selections if there is more than one tense included. -conjugations: - 1: - sectionTitle: Presente - tenses: - 1: - tenseTitle: Presente - tenseForms: - 1: - label: io - value: presentIndicativeFirstPersonSingular - 2: - label: tu - value: presentIndicativeSecondPersonSingular - 3: - label: lei/lui - value: presentIndicativeThirdPersonSingular - 4: - label: noi - value: presentIndicativeFirstPersonPlural - 5: - label: voi - value: presentIndicativeSecondPersonPlural - 6: - label: loro - value: presentIndicativeThirdPersonPlural - - 2: - sectionTitle: Preterito - tenses: - 1: - tenseTitle: Preterito - tenseForms: - 1: - label: io - value: preteriteFirstPersonSingular - 2: - label: tu - value: preteriteSecondPersonSingular - 3: - label: lei/lui - value: preteriteThirdPersonSingular - 4: - label: noi - value: preteriteFirstPersonPlural - 5: - label: voi - value: preteriteSecondPersonPlural - 6: - label: loro - value: preteriteThirdPersonPlural - - 3: - sectionTitle: Imperfetto - tenses: - 1: - tenseTitle: Imperfetto - tenseForms: - 1: - label: io - value: pastImperfectFirstPersonSingular - 2: - label: tu - value: pastImperfectSecondPersonSingular - 3: - label: lei/lui - value: pastImperfectThirdPersonSingular - 4: - label: noi - value: pastImperfectFirstPersonPlural - 5: - label: voi - value: pastImperfectSecondPersonPlural - 6: - label: loro - value: pastImperfectThirdPersonPlural - -# MARK: Translate - -# Map of interface titles to display for the Translate command. -# Needed database columns are the same across languages (descriptions are labels for values). -# Note: Descriptions for article and pronoun translations are their genders. -translations: - wordType: - sectionTitle: Seleziona il tipo da tradurre - - adjective: - displayValue: aggettivo - sectionTitle: Seleziona la traduzione dell'aggettivo - - adverb: - displayValue: avverbio - sectionTitle: Seleziona la traduzione dell'avverbio - - article: - displayValue: articolo - sectionTitle: Seleziona la traduzione dell'articolo - - conjunction: - displayValue: congiunzione - sectionTitle: Seleziona la traduzione della congiunzione - - noun: - displayValue: nome - sectionTitle: Seleziona la traduzione del nome - - postposition: - displayValue: posposizione - sectionTitle: Seleziona la traduzione della posposizione - - preposition: - displayValue: preposizione - sectionTitle: Seleziona la traduzione della preposizione - - proper_noun: - displayValue: nome proprio - sectionTitle: Seleziona la traduzione del nome proprio - - pronoun: - displayValue: pronome - sectionTitle: Seleziona la traduzione del pronome - - verb: - displayValue: verbo - sectionTitle: Seleziona il verbo infinito diff --git a/app/src/main/assets/data-contracts/pt.yaml b/app/src/main/assets/data-contracts/pt.yaml deleted file mode 100644 index 3665c3cd0..000000000 --- a/app/src/main/assets/data-contracts/pt.yaml +++ /dev/null @@ -1,172 +0,0 @@ -# Database column mappings that are read by Scribe applications to structure how data is displayed to the user. -# Includes interface titles and the order that values should be presented (chronological or alphabetical). - -# MARK: Noun Gender - -# A map of which columns in the nouns table contain gender information. -genders: - canonical: [gender] - feminines: [] - masculines: [] - commons: [] - neuters: [] - -# MARK: Plural - -# Key-value pairs that link the key of a noun singular to its plural for the Plural command. -numbers: - singular: plural - -# MARK: Conjugate - -# Map of verb tenses to their conjugations for the Conjugate command. -# Sections provide meta tense selections if there is more than one tense included. -conjugations: - 1: - sectionTitle: Presente - tenses: - 1: - tenseTitle: Presente - tenseForms: - 1: - label: eu - value: indicativePresentFirstPersonSingular - 2: - label: tu - value: indicativePresentSecondPersonSingular - 3: - label: ele/ela/você - value: indicativePresentThirdPersonSingular - 4: - label: nós - value: indicativePresentFirstPersonPlural - 5: - label: vós - value: indicativePresentSecondPersonPlural - 6: - label: eles/elas/vocês - value: indicativePresentThirdPersonPlural - - 2: - sectionTitle: Pretérito Perfeito - tenses: - 1: - tenseTitle: Pretérito Perfeito - tenseForms: - 1: - label: eu - value: indicativePastPerfectFirstPersonSingular - 2: - label: tu - value: indicativePastPerfectSecondPersonSingular - 3: - label: ele/ela/você - value: indicativePastPerfectThirdPersonSingular - 4: - label: nós - value: indicativePastPerfectFirstPersonPlural - 5: - label: vós - value: indicativePastPerfectSecondPersonPlural - 6: - label: eles/elas/vocês - value: indicativePastPerfectThirdPersonPlural - - 3: - sectionTitle: Pretérito Imperfeito - tenses: - 1: - tenseTitle: Pretérito Imperfeito - tenseForms: - 1: - label: eu - value: indicativePastImperfectFirstPersonSingular - 2: - label: tu - value: indicativePastImperfectSecondPersonSingular - 3: - label: ele/ela/você - value: indicativePastImperfectThirdPersonSingular - 4: - label: nós - value: indicativePastImperfectFirstPersonPlural - 5: - label: vós - value: indicativePastImperfectSecondPersonPlural - 6: - label: eles/elas/vocês - value: indicativePastImperfectThirdPersonPlural - - 4: - sectionTitle: Futuro Simples - tenses: - 1: - tenseTitle: Futuro Simples - tenseForms: - 1: - label: eu - value: indicativePluperfectFirstPersonSingular - 2: - label: tu - value: indicativePluperfectSecondPersonSingular - 3: - label: ele/ela/você - value: indicativePluperfectThirdPersonSingular - 4: - label: nós - value: indicativePluperfectFirstPersonPlural - 5: - label: vós - value: indicativePluperfectSecondPersonPlural - 6: - label: eles/elas/vocês - value: indicativePluperfectThirdPersonPlural - -# MARK: Translate - -# Map of interface titles to display for the Translate command. -# Needed database columns are the same across languages (descriptions are labels for values). -# Note: Descriptions for article and pronoun translations are their genders. -translations: - wordType: - sectionTitle: Selecione o tipo a ser traduzida - - adjective: - displayValue: adjetivo - sectionTitle: Selecione a tradução do adjetivo - - adverb: - displayValue: advérbio - sectionTitle: Selecione a tradução do advérbio - - article: - displayValue: artigo - sectionTitle: Selecione a tradução do artigo - - conjunction: - displayValue: conjunção - sectionTitle: Selecione a tradução da conjunção - - noun: - displayValue: nome - sectionTitle: Selecione a tradução do substantivo - - postposition: - displayValue: posposição - sectionTitle: Selecione a tradução de posposição - - preposition: - displayValue: preposição - sectionTitle: Selecione a tradução da preposiçãon - - proper_noun: - displayValue: nome próprio - sectionTitle: Selecione a tradução do nome próprio - - pronoun: - displayValue: pronome - sectionTitle: Selecione a tradução do pronome - - verb: - displayValue: verbo - sectionTitle: Selecione o verbo no infinitivo diff --git a/app/src/main/assets/data-contracts/ru.yaml b/app/src/main/assets/data-contracts/ru.yaml deleted file mode 100644 index ff16b2a80..000000000 --- a/app/src/main/assets/data-contracts/ru.yaml +++ /dev/null @@ -1,315 +0,0 @@ -# Database column mappings that are read by Scribe applications to structure how data is displayed to the user. -# Includes interface titles and the order that values should be presented (chronological or alphabetical). - -# MARK: Noun Gender - -# A map of which columns in the nouns table contain gender information. -genders: - canonical: [gender] - feminines: [] - masculines: [] - commons: [] - neuters: [] - -# MARK: Plural - -# Key-value pairs that link the key of a noun singular to its plural for the Plural command. -numbers: - nominativeSingular: nominativePlural - -# MARK: Conjugate - -# Map of verb tenses to their conjugations for the Conjugate command. -# Sections provide meta tense selections if there is more than one tense included. -conjugations: - 1: - sectionTitle: Настоящее - tenses: - 1: - tenseTitle: Indicative Present - tenseForms: - 1: - label: я - value: indicativePresentFirstPersonSingular - 2: - label: ты - value: indicativePresentSecondPersonSingular - 3: - label: он/она/оно - value: indicativePresentThirdPersonSingular - 4: - label: мы - value: indicativePresentFirstPersonPlural - 5: - label: вы - value: indicativePresentSecondPersonPlural - 6: - label: они - value: indicativePresentThirdPersonPlural - - 2: - sectionTitle: Прошедшее - tenses: - 1: - tenseTitle: Indicative Past - tenseForms: - 1: - label: я/ты/она - value: feminineIndicativePast - 2: - label: я/ты/он - value: masculineIndicativePast - 3: - label: оно - value: neuterIndicativePast - 4: - label: мы/вы/они - value: indicativePastPlural - -# MARK: Translate - -# Map of interface titles to display for the Translate command. -# Needed database columns are the same across languages (descriptions are labels for values). -# Note: Descriptions for article and pronoun translations are their genders. -translations: - wordType: - sectionTitle: Выберите тип переводимого слова - - adjective: - displayValue: прилагательное - sectionTitle: Выберите перевод прилагательного - - adverb: - displayValue: наречие - sectionTitle: Выберите перевод наречия - - article: - displayValue: статья - sectionTitle: Выберите перевод статьи - - conjunction: - displayValue: соединение - sectionTitle: Выберите перевод союза - - noun: - displayValue: существительное - sectionTitle: Выберите перевод существительного - - postposition: - displayValue: послелог - sectionTitle: Выберите перевод послелога - - preposition: - displayValue: предлог - sectionTitle: Выберите перевод предлога - - proper_noun: - displayValue: имя собственное - sectionTitle: Выберите перевод имени собственного. - - pronoun: - displayValue: местоимение - sectionTitle: Выберите перевод местоимения - - verb: - displayValue: глагол - sectionTitle: Выберите инфинитив глагола - -# MARK: Decline - -# Map of pronoun declensions given cases. -declensions: - 1: - title: Винительные местоимения - declensionForms: - 1: - label: я - value: меня - - 2: - label: ты - value: тебя - - 3: - label: он/она/оно - displayValue: его/её/его - title: Пол объекта? - declensionForms: - 1: - label: он - value: его - 2: - label: она - value: её - 3: - label: оно - value: его - - 4: - label: мы - value: нас - - 5: - label: вы - value: вас - - 6: - label: они - value: их - - 2: - title: Дательные местоимения - declensionForms: - 1: - label: я - value: мне - - 2: - label: ты - value: тебе - - 3: - label: он/она/оно - displayValue: ему/ей/ему - title: Пол объекта? - declensionForms: - 1: - label: он - value: ему - - 2: - label: она - value: ей - - 3: - label: оно - value: ему - - 4: - label: мы - value: нам - - 5: - label: вы - value: вам - 6: - label: они - value: им - - 3: - title: Родительные местоимения - declensionForms: - 1: - label: я - value: меня - - 2: - label: ты - value: тебя - - 3: - label: он/она/оно - displayValue: его/её/его - title: Пол объекта? - declensionForms: - 1: - label: он - value: его - - 2: - label: она - value: её - - 3: - label: оно - value: его - - 4: - label: мы - value: нас - - 5: - label: вы - value: вас - - 6: - label: они - value: их - - 4: - title: Творительные местоимения - declensionForms: - 1: - label: я - value: мной - - 2: - label: ты - value: тобой - - 3: - label: он/она/оно - displayValue: им/ей/им - title: Пол объекта? - declensionForms: - 1: - label: он - value: им - 2: - label: она - value: ей - 3: - label: оно - value: им - - 4: - label: мы - value: нами - - 5: - label: вы - value: вами - - 6: - label: они - value: ими - - 5: - title: Предложные местоимения - declensionForms: - 1: - label: я - value: мне - - 2: - label: ты - value: тебе - - 3: - label: он/она/оно - displayValue: нём/ней/нём - title: Пол объекта? - declensionForms: - 1: - label: он - value: нём - - 2: - label: она - value: ней - - 3: - label: оно - value: нём - - 4: - label: мы - value: нас - - 5: - label: вы - value: вас - - 6: - label: они - value: них diff --git a/app/src/main/assets/data-contracts/sv.yaml b/app/src/main/assets/data-contracts/sv.yaml deleted file mode 100644 index 53c1dbb21..000000000 --- a/app/src/main/assets/data-contracts/sv.yaml +++ /dev/null @@ -1,111 +0,0 @@ -# Database column mappings that are read by Scribe applications to structure how data is displayed to the user. -# Includes interface titles and the order that values should be presented (chronological or alphabetical). - -# MARK: Noun Gender - -# A map of which columns in the nouns table contain gender information. -genders: - canonical: [gender] - feminines: [] - masculines: [] - commons: [] - neuters: [] - -# MARK: Plural - -# Key-value pairs that link the key of a noun singular to its plural for the Plural command. -numbers: - nominativeIndefiniteSingular: nominativeIndefinitePlural - nominativeDefiniteSingular: nominativeDefinitePlural - -# MARK: Conjugate - -# Map of verb tenses to their conjugations for the Conjugate command. -# Sections provide meta tense selections if there is more than one tense included. -conjugations: - 1: - sectionTitle: Aktiv - tenses: - 1: - tenseTitle: Aktiv - tenseForms: - 1: - label: infinitiv - value: activeInfinitive - 2: - label: presens - value: activePresent - 3: - label: preteritum - value: activePreterite - 4: - label: supinum - value: activeSupine - - 2: - sectionTitle: Passiv - tenses: - 1: - tenseTitle: Passiv - tenseForms: - 1: - label: infinitiv - value: passiveInfinitive - 2: - label: presens - value: passivePresent - 3: - label: preteritum - value: passivePreterite - 4: - label: supinum - value: passiveSupine - -# MARK: Translate - -# Map of interface titles to display for the Translate command. -# Needed database columns are the same across languages (descriptions are labels for values). -# Note: Descriptions for article and pronoun translations are their genders. -translations: - wordType: - sectionTitle: Välj översättningsordtyp - - adjective: - displayValue: adjektiv - sectionTitle: Välj adjektivöversättning - - adverb: - displayValue: adverb - sectionTitle: Välj adverböversättning - - article: - displayValue: artikel - sectionTitle: Välj artikelöversättning - - conjunction: - displayValue: konjunktion - sectionTitle: Välj konjunktionsöversättning - - noun: - displayValue: substantiv - sectionTitle: Välj substantivöversättning - - postposition: - displayValue: postposition - sectionTitle: Välj postpositionsöversättning - - preposition: - displayValue: preposition - sectionTitle: Välj prepositionsöversättning - - proper_noun: - displayValue: egennamn - sectionTitle: Välj egennamnöversättning - - pronoun: - displayValue: pronomen - sectionTitle: Välj pronomenöversättning - - verb: - displayValue: verb - sectionTitle: Välj verbinfinitiv diff --git a/app/src/main/assets/i18n b/app/src/main/assets/i18n index 8b15cc913..faf95460a 160000 --- a/app/src/main/assets/i18n +++ b/app/src/main/assets/i18n @@ -1 +1 @@ -Subproject commit 8b15cc9134aaa584c340e789f18f8f11d741a85f +Subproject commit faf95460a83758bcf1f42eaf25e026218e3a38e0 diff --git a/app/src/main/java/be/scri/data/remote/ApiService.kt b/app/src/main/java/be/scri/data/remote/ApiService.kt index 698fc213c..e5c31529c 100644 --- a/app/src/main/java/be/scri/data/remote/ApiService.kt +++ b/app/src/main/java/be/scri/data/remote/ApiService.kt @@ -4,11 +4,13 @@ package be.scri.data.remote import be.scri.data.model.DataResponse import be.scri.data.model.DataVersionResponse +import okhttp3.ResponseBody import retrofit2.http.GET import retrofit2.http.Path +import retrofit2.http.Query /** - * Defines the API service for fetching data and data version information. + * Defines the API service for fetching data, data version information, and data contracts. */ interface ApiService { @GET("data/{lang}") @@ -20,4 +22,9 @@ interface ApiService { suspend fun getDataVersion( @Path("lang") language: String, ): DataVersionResponse + + @GET("contracts") + suspend fun getContract( + @Query("lang") language: String, + ): ResponseBody } diff --git a/app/src/main/java/be/scri/helpers/DatabaseManagers.kt b/app/src/main/java/be/scri/helpers/DatabaseManagers.kt index d9325f0eb..854655ce3 100644 --- a/app/src/main/java/be/scri/helpers/DatabaseManagers.kt +++ b/app/src/main/java/be/scri/helpers/DatabaseManagers.kt @@ -39,11 +39,16 @@ class DatabaseManagers( /** * A facade method to load the data contract for a given language. * It delegates the loading and parsing logic to the [ContractDataLoader]. + * If the contract is missing, it is downloaded in the background for the next load. * * @param language The language code (e.g., "DE", "FR") for which to load the contract. * * @return A [DataContract] object containing the language's structural metadata, or `null` * if not found or on error. */ - fun getLanguageContract(language: String): DataContract? = contractLoader.loadContract(language) + fun getLanguageContract(language: String): DataContract? { + val contract = contractLoader.loadContract(language) + if (contract == null) contractLoader.downloadMissingContractInBackground(language) + return contract + } } diff --git a/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt b/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt index 090874735..114a72680 100644 --- a/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt @@ -6,6 +6,7 @@ import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteException import android.util.Log +import be.scri.data.remote.SqlIdentifier import be.scri.helpers.DatabaseFileManager /** @@ -33,7 +34,7 @@ class ConjugateDataManager( word: String, ): MutableMap>>? { val finalOutput: MutableMap>> = mutableMapOf() - yamlData?.conjugations?.values?.forEach { tenseGroup -> + yamlData?.verbs?.conjugations?.values?.forEach { tenseGroup -> val conjugateForms: MutableMap> = mutableMapOf() tenseGroup.tenses.values.forEach { conjugationCategory -> val forms = @@ -65,7 +66,7 @@ class ConjugateDataManager( word: String, ): Set { val allFormKeys = mutableSetOf() - yamlData?.conjugations?.values?.forEach { tenseGroup -> + yamlData?.verbs?.conjugations?.values?.forEach { tenseGroup -> tenseGroup.tenses.values.forEach { conjugationCategory -> conjugationCategory.tenseForms.values.forEach { form -> allFormKeys.add(form.label) @@ -139,7 +140,7 @@ class ConjugateDataManager( form: String, language: String, ): String = - if (form.contains("[")) { + if (form.contains("[") || form.contains("{")) { parseComplexForm(cursor, form, language) } else { try { @@ -152,8 +153,9 @@ class ConjugateDataManager( /** * Parses a complex conjugation form that includes an auxiliary part in brackets, - * such as "[have] past_participle". It combines the auxiliary word with the value - * from the specified database column. + * such as "[have] past_participle" (literal words) or + * "{indicativePresentFirstPersonSingular auxiliaryVerb} pastParticiple" (column lookup). + * It combines the auxiliary word with the value from the specified database column. * * @param cursor The database cursor positioned at the correct row. * @param form The complex form string to parse. @@ -165,7 +167,7 @@ class ConjugateDataManager( form: String, language: String, ): String { - val bracketRegex = Regex("""\[(.*?)]""") + val bracketRegex = Regex("""[\[{](.*?)[]}]""") val match = bracketRegex.find(form) ?: return "" val auxiliaryWords = match.groupValues[1] @@ -188,6 +190,8 @@ class ConjugateDataManager( val targetForm = words.first() + require(SqlIdentifier.isValid(targetForm)) { "Invalid column name: $targetForm" } + val db = fileManager.getConjugateDatabase(language = language) var auxResult = "" diff --git a/app/src/main/java/be/scri/helpers/data/ContractDataLoader.kt b/app/src/main/java/be/scri/helpers/data/ContractDataLoader.kt index 31c5945ce..07a219ba4 100644 --- a/app/src/main/java/be/scri/helpers/data/ContractDataLoader.kt +++ b/app/src/main/java/be/scri/helpers/data/ContractDataLoader.kt @@ -4,21 +4,44 @@ package be.scri.helpers.data import DataContract import android.content.Context import android.util.Log +import be.scri.data.remote.RetrofitClient +import be.scri.data.remote.SqlIdentifier import com.charleskorn.kaml.Yaml import com.charleskorn.kaml.YamlConfiguration -import com.charleskorn.kaml.YamlException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.SerializationException +import retrofit2.HttpException +import java.io.File import java.io.IOException +import java.util.concurrent.ConcurrentHashMap /** - * Loads and deserializes contract data from assets. + * Loads, deserializes, downloads, and saves contract data from local storage or network. + * Contracts are stored as `.yaml`; JSON responses are stored as-is since JSON is valid YAML. * @param context The application context. */ class ContractDataLoader( private val context: Context, ) { + companion object { + private const val TAG = "ContractDataLoader" + private const val CONTRACTS_DIR = "data-contracts" + private val yaml = + Yaml( + configuration = YamlConfiguration(strictMode = false), + ) + + // Languages with a background download in flight, so repeated keyboard loads don't stack requests. + private val pendingDownloads = ConcurrentHashMap.newKeySet() + } + + private fun contractFile(language: String) = File(File(context.filesDir, CONTRACTS_DIR), "${language.lowercase()}.yaml") + /** - * Loads and deserializes a data contract from a YAML file in the assets folder. - * It gracefully handles file-not-found and YAML parsing errors by returning null. + * Loads and deserializes the locally saved data contract for the specified language. + * Gracefully handles file-not-found and parsing errors by returning null. * * @param language The language code (e.g., "DE", "EN") used to determine the filename (e.g., "de.yaml"). * @@ -26,24 +49,170 @@ class ContractDataLoader( * if the file does not exist or cannot be parsed. */ fun loadContract(language: String): DataContract? { - val contractName = "${language.lowercase()}.yaml" - Log.d("ContractDataLoader", "Attempting to load contract: $contractName") - + val file = contractFile(language) + if (!file.isFile) { + Log.e(TAG, "No local contract file for: ${language.lowercase()}") + return null + } return try { - context.assets.open("data-contracts/$contractName").use { contractFile -> - val content = contractFile.bufferedReader().readText() - val yaml = - Yaml( - configuration = YamlConfiguration(strictMode = false), - ) - yaml.decodeFromString(DataContract.serializer(), content) - } + parseContract(file.readText()) } catch (e: IOException) { - Log.e("ContractDataLoader", "Error loading contract file: $contractName. It may not exist.", e) + Log.e(TAG, "Error reading local contract file: ${file.path}", e) null - } catch (e: YamlException) { - Log.e("ContractDataLoader", "Error parsing YAML for contract: $contractName", e) + } + } + + /** + * Parses a YAML or JSON string into a [DataContract]. + * + * @param content The YAML or JSON content to decode. + * @return The decoded [DataContract] object, or `null` if parsing fails. + */ + fun parseContract(content: String): DataContract? = + try { + yaml.decodeFromString(DataContract.serializer(), content) + } catch (e: SerializationException) { + Log.e(TAG, "Error parsing contract content", e) + null + } catch (e: IllegalArgumentException) { + Log.e(TAG, "Error parsing contract content", e) null } + + /** + * Validates that all database column names specified in [contract] are safe SQL identifiers. + * Prevents SQL injection vulnerabilities when dynamic queries are formed. + * + * @param contract The [DataContract] to inspect. + * @return True if all column names pass [SqlIdentifier.isValid], false otherwise. + */ + fun validateContract(contract: DataContract): Boolean { + for ((_, pair) in contract.nouns.numbers) { + if (!SqlIdentifier.isValid(pair.singular) || !SqlIdentifier.isValid(pair.plural)) { + Log.e(TAG, "Invalid column name in contract numbers: '${pair.singular}', '${pair.plural}'") + return false + } + } + val genderColumns = + contract.nouns.genders.canonical + + contract.nouns.genders.feminines + + contract.nouns.genders.masculines + + contract.nouns.genders.commons + + contract.nouns.genders.neuters + for (col in genderColumns) { + if (!SqlIdentifier.isValid(col)) { + Log.e(TAG, "Invalid column name in contract genders: '$col'") + return false + } + } + for ((_, tenseGroup) in contract.verbs.conjugations) { + for ((_, category) in tenseGroup.tenses) { + for ((_, form) in category.tenseForms) { + // Bracketed words are checked where they are used in ConjugateDataManager.parseComplexForm. + val rawCol = form.value.replace(Regex("""[\[{].*?[]}]"""), "").trim() + if (rawCol.isNotEmpty() && !SqlIdentifier.isValid(rawCol)) { + Log.e(TAG, "Invalid column name in contract verbs: '$rawCol'") + return false + } + } + } + } + return true + } + + /** + * Validates and saves contract content (YAML or JSON) to local app storage. + * Content is parse-checked and SQL-identifier-validated before writing. + * Writing uses a uniquely named temporary file and atomic rename so a failed, invalid + * or concurrent write cannot corrupt or overwrite an existing working contract. + * + * @param language The language code (e.g., "DE", "EN"). + * @param content The contract string content to persist. + * @return True if validated and saved successfully, false otherwise. + */ + fun saveContract( + language: String, + content: String, + ): Boolean { + val parsedContract = parseContract(content) + if (parsedContract == null) { + Log.e(TAG, "Failed to parse contract content for $language before saving") + return false + } + if (!validateContract(parsedContract)) { + Log.e(TAG, "Contract content for $language contains invalid SQL identifiers") + return false + } + + val dir = File(context.filesDir, CONTRACTS_DIR) + if (!dir.mkdirs() && !dir.isDirectory) { + Log.e(TAG, "Failed to create directory: ${dir.path}") + return false + } + val targetFile = contractFile(language) + + return try { + val tempFile = File.createTempFile(targetFile.name, ".tmp", dir) + try { + tempFile.writeText(content) + tempFile.renameTo(targetFile).also { renamed -> + if (!renamed) Log.e(TAG, "Failed to move temp contract file to target file for $language") + } + } finally { + tempFile.delete() + } + } catch (e: IOException) { + Log.e(TAG, "Failed to write contract to temp file for $language", e) + false + } + } + + /** + * Downloads and saves the data contract for the specified language from Scribe-Server. + * + * @param language The language code (e.g., "DE", "EN"). + * @return True if downloaded and saved successfully, false otherwise. + */ + suspend fun downloadAndSaveContract(language: String): Boolean { + val lang = language.lowercase() + return try { + val response = RetrofitClient.apiService.getContract(lang) + val content = response.string() + saveContract(lang, content) + } catch (e: IOException) { + Log.e(TAG, "Failed to download and save contract for $lang", e) + false + } catch (e: HttpException) { + Log.e(TAG, "Server returned error while fetching contract for $lang", e) + false + } + } + + /** + * Loads the local contract for [language], downloading it first if it is missing or unreadable. + * + * @param language The language code (e.g., "DE", "EN"). + * @return The decoded [DataContract], or `null` if it could not be loaded or downloaded. + */ + suspend fun loadOrDownloadContract(language: String): DataContract? = + loadContract(language) ?: if (downloadAndSaveContract(language)) loadContract(language) else null + + /** + * Downloads the contract for [language] in the background if its language database is already + * downloaded, so users who downloaded data before contracts existed get one on their next load. + * + * @param language The language code (e.g., "DE", "EN"). + */ + fun downloadMissingContractInBackground(language: String) { + val lang = language.lowercase() + val hasDatabase = context.getDatabasePath("${lang.uppercase()}LanguageData.sqlite").exists() + if (!hasDatabase || !pendingDownloads.add(lang)) return + CoroutineScope(Dispatchers.IO).launch { + try { + loadOrDownloadContract(lang) + } finally { + pendingDownloads.remove(lang) + } + } } } diff --git a/app/src/main/java/be/scri/helpers/data/GenderDataManager.kt b/app/src/main/java/be/scri/helpers/data/GenderDataManager.kt index 329e4694c..8c835886b 100644 --- a/app/src/main/java/be/scri/helpers/data/GenderDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/GenderDataManager.kt @@ -51,20 +51,29 @@ class GenderDataManager( hasCanonicalGender(contract) -> processGenders( db = db, - nounColumn = contract.numbers.keys.firstOrNull(), - genderColumn = contract.genders.canonical.firstOrNull(), + nounColumn = + contract.nouns.numbers.values + .firstOrNull() + ?.singular, + genderColumn = + contract.nouns.genders.canonical + .firstOrNull(), genderMap = genderMap, ) hasMasculineFeminine(contract) -> { processGenders( db = db, - nounColumn = contract.genders.masculines.firstOrNull(), + nounColumn = + contract.nouns.genders.masculines + .firstOrNull(), genderMap = genderMap, defaultGender = "masculine", ) processGenders( db = db, - nounColumn = contract.genders.feminines.firstOrNull(), + nounColumn = + contract.nouns.genders.feminines + .firstOrNull(), genderMap = genderMap, defaultGender = "feminine", ) @@ -82,7 +91,7 @@ class GenderDataManager( * @return true if a canonical gender column is specified, false otherwise. */ private fun hasCanonicalGender(contract: DataContract): Boolean = - contract.genders.canonical + contract.nouns.genders.canonical .firstOrNull() ?.isNotEmpty() == true @@ -94,8 +103,8 @@ class GenderDataManager( * @return true if both masculine and feminine columns are specified, false otherwise. */ private fun hasMasculineFeminine(contract: DataContract): Boolean { - val masculineList = contract.genders.masculines - val feminineList = contract.genders.feminines + val masculineList = contract.nouns.genders.masculines + val feminineList = contract.nouns.genders.feminines val hasMasculine = masculineList.isNotEmpty() val hasFeminine = feminineList.isNotEmpty() diff --git a/app/src/main/java/be/scri/helpers/data/PluralFormsManager.kt b/app/src/main/java/be/scri/helpers/data/PluralFormsManager.kt index 0d4b84f2c..e094c3e0b 100644 --- a/app/src/main/java/be/scri/helpers/data/PluralFormsManager.kt +++ b/app/src/main/java/be/scri/helpers/data/PluralFormsManager.kt @@ -26,7 +26,7 @@ class PluralFormsManager( language: String, yamlData: DataContract?, ): List? = - yamlData?.numbers?.values?.toList()?.takeIf { it.isNotEmpty() }?.let { pluralForms -> + yamlData?.nouns?.numbers?.values?.map { it.plural }?.takeIf { it.isNotEmpty() }?.let { pluralForms -> fileManager.getLanguageDatabase(language)?.use { db -> queryAllPluralForms(db, pluralForms) } @@ -47,9 +47,10 @@ class PluralFormsManager( yamlData: DataContract?, noun: String, ): Map = - yamlData?.numbers?.let { numbers -> - val singularCol = numbers.keys.firstOrNull() - val pluralCol = numbers.values.firstOrNull() + yamlData?.nouns?.numbers?.let { numbers -> + val firstPair = numbers.values.firstOrNull() + val singularCol = firstPair?.singular + val pluralCol = firstPair?.plural if (singularCol != null && pluralCol != null) { val wasCapitalized = isWordCapitalized(noun) diff --git a/app/src/main/java/be/scri/models/DataContract.kt b/app/src/main/java/be/scri/models/DataContract.kt index 250cecf68..f6ce93c97 100644 --- a/app/src/main/java/be/scri/models/DataContract.kt +++ b/app/src/main/java/be/scri/models/DataContract.kt @@ -12,10 +12,35 @@ import kotlinx.serialization.Serializable */ @Serializable data class DataContract( - val numbers: Map, + val nouns: Nouns, + val verbs: Verbs, + val translations: Translations, +) + +/** + * Represents noun grammar metadata (genders and number representations). + */ +@Serializable +data class Nouns( val genders: Genders, + val numbers: Map, +) + +/** + * Represents a singular-plural column mapping pair for nouns. + */ +@Serializable +data class NumberPair( + val singular: String, + val plural: String, +) + +/** + * Represents verb grammar metadata (conjugations). + */ +@Serializable +data class Verbs( val conjugations: Map, - val translations: Translations, ) /** diff --git a/app/src/main/java/be/scri/ui/screens/ConjugationSelectionScreen.kt b/app/src/main/java/be/scri/ui/screens/ConjugationSelectionScreen.kt index c9481417e..edc46c197 100644 --- a/app/src/main/java/be/scri/ui/screens/ConjugationSelectionScreen.kt +++ b/app/src/main/java/be/scri/ui/screens/ConjugationSelectionScreen.kt @@ -83,14 +83,13 @@ fun ConjugationSelectionScreen( LaunchedEffect(verb, languageAlias) { withContext(Dispatchers.IO) { - val loader = ContractDataLoader(context) - val contract = loader.loadContract(languageAlias) + val contract = ContractDataLoader(context).loadOrDownloadContract(languageAlias) if (contract != null) { val fileManager = DatabaseFileManager(context) val manager = ConjugateDataManager(fileManager) val structuredData = mutableMapOf>>>() - contract.conjugations.values.forEach { tenseGroup -> + contract.verbs.conjugations.values.forEach { tenseGroup -> val categories = mutableMapOf>>() tenseGroup.tenses.values.forEach { conjugationCategory -> val pairs = diff --git a/app/src/main/java/be/scri/ui/screens/download/ConjugateDataDownloadViewModel.kt b/app/src/main/java/be/scri/ui/screens/download/ConjugateDataDownloadViewModel.kt index 31b553fb6..c1e1999b8 100644 --- a/app/src/main/java/be/scri/ui/screens/download/ConjugateDataDownloadViewModel.kt +++ b/app/src/main/java/be/scri/ui/screens/download/ConjugateDataDownloadViewModel.kt @@ -15,6 +15,7 @@ import be.scri.data.remote.RetrofitClient import be.scri.helpers.LanguageMappingConstants import be.scri.helpers.NetworkMonitor import be.scri.helpers.StringUtils +import be.scri.helpers.data.ContractDataLoader import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException @@ -155,34 +156,50 @@ class ConjugateDataDownloadViewModel( val serverLastUpdate = response.contract.updatedAt // Always download when forcing, or when update is available. - if (forceDownload || isUpdateAvailable(localLastUpdate, serverLastUpdate)) { + val updateNeeded = forceDownload || isUpdateAvailable(localLastUpdate, serverLastUpdate) + if (updateNeeded) { val dbHelper = ConjugateDynamicDbHelper(getApplication(), langCode) dbHelper.syncConjugateDatabase(response) + } - // Save timestamp. - if (isValidTimestamp(serverLastUpdate)) { - prefs.edit().putString("last_conjugate_update_$langCode", serverLastUpdate).apply() + // Refresh the contract along with new data; otherwise download it only if missing or unreadable. + val contractLoader = ContractDataLoader(getApplication()) + val contractReady = + if (updateNeeded) { + contractLoader.downloadAndSaveContract(langCode) + } else { + contractLoader.loadOrDownloadContract(langCode) != null } + if (!contractReady) { + val template = + getApplication().getString( + R.string.i18n_app_download_error_server, + ) + val errorMsg = StringUtils.formatStringWithParams(template, "Contract download failed") + updateErrorState(key, errorMsg) + return@launch + } - withContext(Dispatchers.Main) { - downloadStates[key] = DownloadState.Completed - val template = - getApplication().getString( - R.string.i18n_app_download_menu_ui_conjugate_data_download_success, - ) - val msg = StringUtils.formatStringWithParams(template, displayLang) - showToast(msg) - } - } else { - // Already up to date: Skip the DB work. - withContext(Dispatchers.Main) { - downloadStates[key] = DownloadState.Completed - val msg = + // Save timestamp only if both database and contract saved successfully. + if (updateNeeded && isValidTimestamp(serverLastUpdate)) { + prefs.edit().putString("last_conjugate_update_$langCode", serverLastUpdate).apply() + } + + withContext(Dispatchers.Main) { + downloadStates[key] = DownloadState.Completed + val msg = + if (updateNeeded) { + val template = + getApplication().getString( + R.string.i18n_app_download_menu_ui_conjugate_data_download_success, + ) + StringUtils.formatStringWithParams(template, displayLang) + } else { getApplication().getString( R.string.i18n_app_download_menu_ui_download_data_generic_already_up_to_date, ) - showToast(msg) - } + } + showToast(msg) } } catch (e: IOException) { val template = diff --git a/app/src/main/java/be/scri/ui/screens/download/DataDownloadViewModel.kt b/app/src/main/java/be/scri/ui/screens/download/DataDownloadViewModel.kt index 046f64b51..14b70e5e2 100644 --- a/app/src/main/java/be/scri/ui/screens/download/DataDownloadViewModel.kt +++ b/app/src/main/java/be/scri/ui/screens/download/DataDownloadViewModel.kt @@ -15,6 +15,7 @@ import be.scri.data.remote.RetrofitClient import be.scri.helpers.LanguageMappingConstants import be.scri.helpers.NetworkMonitor import be.scri.helpers.StringUtils +import be.scri.helpers.data.ContractDataLoader import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException @@ -155,34 +156,50 @@ class DataDownloadViewModel( val serverLastUpdate = response.contract.updatedAt // Always download when forcing, or when update is available. - if (forceDownload || isUpdateAvailable(localLastUpdate, serverLastUpdate)) { + val updateNeeded = forceDownload || isUpdateAvailable(localLastUpdate, serverLastUpdate) + if (updateNeeded) { val dbHelper = DynamicDbHelper(getApplication(), langCode) dbHelper.syncDatabase(response) + } - // Save timestamp. - if (isValidTimestamp(serverLastUpdate)) { - prefs.edit().putString("last_update_$langCode", serverLastUpdate).apply() + // Refresh the contract along with new data; otherwise download it only if missing or unreadable. + val contractLoader = ContractDataLoader(getApplication()) + val contractReady = + if (updateNeeded) { + contractLoader.downloadAndSaveContract(langCode) + } else { + contractLoader.loadOrDownloadContract(langCode) != null } + if (!contractReady) { + val template = + getApplication().getString( + R.string.i18n_app_download_error_server, + ) + val errorMsg = StringUtils.formatStringWithParams(template, "Contract download failed") + updateErrorState(key, errorMsg) + return@launch + } - withContext(Dispatchers.Main) { - downloadStates[key] = DownloadState.Completed - val template = - getApplication().getString( - R.string.i18n_app_download_menu_ui_download_data_download_success, - ) - val msg = StringUtils.formatStringWithParams(template, displayLang) - showToast(msg) - } - } else { - // Already up to date: Skip the DB work. - withContext(Dispatchers.Main) { - downloadStates[key] = DownloadState.Completed - val msg = + // Save timestamp only if both database and contract saved successfully. + if (updateNeeded && isValidTimestamp(serverLastUpdate)) { + prefs.edit().putString("last_update_$langCode", serverLastUpdate).apply() + } + + withContext(Dispatchers.Main) { + downloadStates[key] = DownloadState.Completed + val msg = + if (updateNeeded) { + val template = + getApplication().getString( + R.string.i18n_app_download_menu_ui_download_data_download_success, + ) + StringUtils.formatStringWithParams(template, displayLang) + } else { getApplication().getString( R.string.i18n_app_download_menu_ui_download_data_generic_already_up_to_date, ) - showToast(msg) - } + } + showToast(msg) } } catch (e: IOException) { val template = diff --git a/app/src/test/kotlin/be/scri/helpers/data/ContractDataLoaderTest.kt b/app/src/test/kotlin/be/scri/helpers/data/ContractDataLoaderTest.kt new file mode 100644 index 000000000..d8796a448 --- /dev/null +++ b/app/src/test/kotlin/be/scri/helpers/data/ContractDataLoaderTest.kt @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers.data + +import android.content.Context +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.File + +class ContractDataLoaderTest { + private lateinit var context: Context + private lateinit var tempFilesDir: File + private lateinit var loader: ContractDataLoader + + private val sampleJson = + """ + { + "nouns": { + "genders": { + "canonical": ["gender"], + "feminines": [], + "masculines": [], + "commons": [], + "neuters": [] + }, + "numbers": { + "1": { + "singular": "nominativeSingular", + "plural": "nominativePlural" + } + } + }, + "verbs": { + "conjugations": { + "1": { + "sectionTitle": "Present", + "tenses": { + "1": { + "tenseTitle": "Pr. Simple", + "tenseForms": { + "1": { + "label": "I/you/plural", + "value": "simplePresent" + } + } + } + } + } + } + }, + "translations": { + "wordType": { + "sectionTitle": "Word Types", + "adjective": { "displayValue": "Adjective", "sectionTitle": "Adjectives" }, + "adverb": { "displayValue": "Adverb", "sectionTitle": "Adverbs" }, + "article": { "displayValue": "Article", "sectionTitle": "Articles" }, + "conjunction": { "displayValue": "Conjunction", "sectionTitle": "Conjunctions" }, + "noun": { "displayValue": "Noun", "sectionTitle": "Nouns" }, + "postposition": { "displayValue": "Postposition", "sectionTitle": "Postpositions" }, + "preposition": { "displayValue": "Preposition", "sectionTitle": "Prepositions" }, + "proper_noun": { "displayValue": "Proper Noun", "sectionTitle": "Proper Nouns" }, + "pronoun": { "displayValue": "Pronoun", "sectionTitle": "Pronouns" }, + "verb": { "displayValue": "Verb", "sectionTitle": "Verbs" } + } + } + } + """.trimIndent() + + private val sampleYaml = + """ + nouns: + genders: + canonical: [gender] + feminines: [] + masculines: [] + commons: [] + neuters: [] + numbers: + 1: + singular: nominativeSingular + plural: nominativePlural + verbs: + conjugations: + 1: + sectionTitle: Present + tenses: + 1: + tenseTitle: Pr. Simple + tenseForms: + 1: + label: I/you/plural + value: simplePresent + translations: + wordType: + sectionTitle: Word Types + adjective: + displayValue: Adjective + sectionTitle: Adjectives + adverb: + displayValue: Adverb + sectionTitle: Adverbs + article: + displayValue: Article + sectionTitle: Articles + conjunction: + displayValue: Conjunction + sectionTitle: Conjunctions + noun: + displayValue: Noun + sectionTitle: Nouns + postposition: + displayValue: Postposition + sectionTitle: Postpositions + preposition: + displayValue: Preposition + sectionTitle: Prepositions + proper_noun: + displayValue: Proper Noun + sectionTitle: Proper Nouns + pronoun: + displayValue: Pronoun + sectionTitle: Pronouns + verb: + displayValue: Verb + sectionTitle: Verbs + """.trimIndent() + + @BeforeEach + fun setup() { + context = mockk(relaxed = true) + tempFilesDir = + java.nio.file.Files + .createTempDirectory("scribe_test_contracts") + .toFile() + every { context.filesDir } returns tempFilesDir + loader = ContractDataLoader(context) + } + + @AfterEach + fun tearDown() { + tempFilesDir.deleteRecursively() + } + + @Test + fun loadContract_fromLocalStorage_parsesSuccessfully() { + val contractsDir = File(tempFilesDir, "data-contracts") + contractsDir.mkdirs() + File(contractsDir, "en.yaml").writeText(sampleYaml) + + val contract = loader.loadContract("EN") + + assertNotNull(contract) + assertEquals(listOf("gender"), contract?.nouns?.genders?.canonical) + assertEquals( + "nominativeSingular", + contract + ?.nouns + ?.numbers + ?.get(1) + ?.singular, + ) + assertEquals( + "nominativePlural", + contract + ?.nouns + ?.numbers + ?.get(1) + ?.plural, + ) + assertEquals( + "Present", + contract + ?.verbs + ?.conjugations + ?.get(1) + ?.sectionTitle, + ) + assertEquals("Word Types", contract?.translations?.wordType?.sectionTitle) + } + + @Test + fun saveContract_writesFileAndCanBeLoaded() { + val saved = loader.saveContract("DE", sampleYaml) + assertTrue(saved) + + val contract = loader.loadContract("DE") + assertNotNull(contract) + assertEquals(listOf("gender"), contract?.nouns?.genders?.canonical) + } + + @Test + fun loadContract_fileNotFound_returnsNull() { + val contract = loader.loadContract("NON_EXISTENT") + + assertNull(contract) + } + + @Test + fun loadContract_malformedYaml_returnsNull() { + val contractsDir = File(tempFilesDir, "data-contracts") + contractsDir.mkdirs() + File(contractsDir, "en.yaml").writeText("nouns: [invalid: yaml: :") + + val contract = loader.loadContract("EN") + + assertNull(contract) + } + + @Test + fun parseContract_validYaml_returnsDataContract() { + val contract = loader.parseContract(sampleYaml) + assertNotNull(contract) + assertEquals(listOf("gender"), contract?.nouns?.genders?.canonical) + } + + @Test + fun parseContract_validJson_returnsDataContract() { + val contract = loader.parseContract(sampleJson) + assertNotNull(contract) + assertEquals(listOf("gender"), contract?.nouns?.genders?.canonical) + } + + @Test + fun parseContract_invalidYaml_returnsNull() { + val contract = loader.parseContract("invalid: yaml: [") + assertNull(contract) + } + + @Test + fun loadContract_jsonContent_parsesSuccessfully() { + val contractsDir = File(tempFilesDir, "data-contracts") + contractsDir.mkdirs() + File(contractsDir, "en.yaml").writeText(sampleJson) + + val contract = loader.loadContract("EN") + assertNotNull(contract) + assertEquals(listOf("gender"), contract?.nouns?.genders?.canonical) + assertEquals( + "nominativeSingular", + contract + ?.nouns + ?.numbers + ?.get(1) + ?.singular, + ) + } + + @Test + fun saveContract_withJsonContent_savesAsSingleContractFile() { + val saved = loader.saveContract("ES", sampleJson) + assertTrue(saved) + assertEquals(listOf("es.yaml"), File(tempFilesDir, "data-contracts").list()?.toList()) + + val contract = loader.loadContract("ES") + assertNotNull(contract) + assertEquals(listOf("gender"), contract?.nouns?.genders?.canonical) + } + + @Test + fun saveContract_invalidContent_returnsFalseAndDoesNotOverwriteExisting() { + // Save initial working contract + val initialSave = loader.saveContract("DE", sampleYaml) + assertTrue(initialSave) + + // Try to overwrite with malformed content + val badSave = loader.saveContract("DE", "malformed: [yaml: {") + assertFalse(badSave) + + // Verify the original contract is untouched and still loads + val contract = loader.loadContract("DE") + assertNotNull(contract) + assertEquals(listOf("gender"), contract?.nouns?.genders?.canonical) + } + + @Test + fun saveContract_sqlInjectionInColumnName_returnsFalseAndDoesNotOverwriteExisting() { + // Save initial working contract + val initialSave = loader.saveContract("DE", sampleJson) + assertTrue(initialSave) + + // Payload with SQL injection in column name + val injectionJson = + sampleJson.replace("nominativeSingular", "singular_col; DROP TABLE nouns;--") + val injectionSave = loader.saveContract("DE", injectionJson) + assertFalse(injectionSave) + + // Verify original contract is preserved + val contract = loader.loadContract("DE") + assertNotNull(contract) + assertEquals( + "nominativeSingular", + contract + ?.nouns + ?.numbers + ?.get(1) + ?.singular, + ) + } + + @Test + fun validateContract_validContract_returnsTrue() { + val contract = loader.parseContract(sampleJson) + assertNotNull(contract) + assertTrue(loader.validateContract(contract!!)) + } + + @Test + fun validateContract_bracketedAuxiliaryForms_returnsTrue() { + val germanStyle = + sampleJson.replace( + "\"value\": \"simplePresent\"", + "\"value\": \"{indicativePresentFirstPersonSingular auxiliaryVerb} pastParticiple\"", + ) + val englishStyle = sampleJson.replace("\"value\": \"simplePresent\"", "\"value\": \"[had been] presentParticiple\"") + + assertTrue(loader.validateContract(loader.parseContract(germanStyle)!!)) + assertTrue(loader.validateContract(loader.parseContract(englishStyle)!!)) + } +} diff --git a/app/src/test/kotlin/be/scri/helpers/data/SqlIdentifierTest.kt b/app/src/test/kotlin/be/scri/helpers/data/SqlIdentifierTest.kt new file mode 100644 index 000000000..4c1d0c8aa --- /dev/null +++ b/app/src/test/kotlin/be/scri/helpers/data/SqlIdentifierTest.kt @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers.data + +import be.scri.data.remote.SqlIdentifier +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SqlIdentifierTest { + @Test + fun isValid_acceptsValidColumnNames() { + assertTrue(SqlIdentifier.isValid("gender")) + assertTrue(SqlIdentifier.isValid("nominativeSingular")) + assertTrue(SqlIdentifier.isValid("nominative_plural")) + assertTrue(SqlIdentifier.isValid("_id")) + assertTrue(SqlIdentifier.isValid("simplePresent1")) + assertTrue(SqlIdentifier.isValid("columnName")) + } + + @Test + fun isValid_rejectsSqlInjectionAndSpecialCharacters() { + assertFalse(SqlIdentifier.isValid("col; DROP TABLE nouns;--")) + assertFalse(SqlIdentifier.isValid("col' OR '1'='1")) + assertFalse(SqlIdentifier.isValid("col\" OR \"1\"=\"1")) + assertFalse(SqlIdentifier.isValid("col`")) + assertFalse(SqlIdentifier.isValid("col-name")) + assertFalse(SqlIdentifier.isValid("col name")) + assertFalse(SqlIdentifier.isValid("col(1)")) + assertFalse(SqlIdentifier.isValid("col[0]")) + assertFalse(SqlIdentifier.isValid("col/name")) + } + + @Test + fun isValid_rejectsEmptyOrBlank() { + assertFalse(SqlIdentifier.isValid("")) + assertFalse(SqlIdentifier.isValid(" ")) + } + + @Test + fun isValid_rejectsStartingWithDigit() { + assertFalse(SqlIdentifier.isValid("1column")) + assertFalse(SqlIdentifier.isValid("0")) + } + + @Test + fun quote_wrapsInDoubleQuotes() { + assertEquals("\"columnName\"", SqlIdentifier.quote("columnName")) + } +}