feat(dialects): a catalogue of the languages most spoken in Africa
Fifty-six entries -- fifty-three African languages and three European ones -- for populating a group's dialects without typing each into a three-field form. Nothing reads it yet; the plan it belongs to is docs/african-dialect-catalogue.md. The unit is a language *in a country* rather than a language, because that is what a dialect is here: `MantraDialect` is (name, country, code), so Kiswahili is three entries and that is the point rather than a duplication. (code, country) is the identity and the name is deliberately not part of it. Nothing anywhere makes a dialect unique -- the id is a content hash including createdAt, there is no index, and the rows are quorum-signed so they cannot be rewritten -- so duplication has to be prevented at proposal time, and of the three fields the name is the only one that is a matter of spelling. Codes are ISO 639-1 where the language has one and 639-3 otherwise, rather than 639-3 throughout: a uniform `sot` would disagree with the `st` that every room which used the form has already signed. Two decisions a reviewer should look at. `lua` names Tshiluba (Luba-Kasai); 639-1's `lu` is Luba-Katanga, a different language of the same country, and taking the two-letter code because it was there would have named the wrong one. And the three European languages are a list of their own rather than lines buried in the African one, so dropping them is one edit. It lives outside ui/ on purpose. m3-audit scans ui/ and strings.xml for string literals and title case, and "Northern Sotho" is title case for the same reason m3-title-case.py keeps a SAMPLE set of proper nouns -- reference data out of reach is cheaper and more honest than an m3-string-exempt marker on a file that is not UI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
package press.mantra.compose.language
|
||||
|
||||
/**
|
||||
* A language the catalogue offers, in one of the countries it is spoken in.
|
||||
*
|
||||
* The unit is the pair rather than the language. A dialect in this app is
|
||||
* `(name, country, code)` -- see `MantraDialect` -- so Kiswahili is three
|
||||
* entries, one for Kenya, one for Tanzania and one for the DR Congo, and that is
|
||||
* the point rather than a duplication: a group translating for Kenyan readers is
|
||||
* not translating for Tanzanian ones. The whole reason the kind is called a
|
||||
* dialect is that it is finer than a language.
|
||||
*
|
||||
* [code] and [country] together are the identity. [name] is deliberately not
|
||||
* part of it: a member typing "Sesotho" into the form and a catalogue saying
|
||||
* "Sesotho" agree today and disagree the moment one of them carries a diacritic,
|
||||
* and of the three fields the name is the only one that is a matter of spelling
|
||||
* rather than of fact. See `DialectScaffold`.
|
||||
*/
|
||||
data class AfricanLanguage(
|
||||
/**
|
||||
* What the dialect row will be called.
|
||||
*
|
||||
* The name a speaker would recognise, which means the endonym wherever that
|
||||
* is also the usual name in English -- Sesotho, isiZulu, Kiswahili -- and the
|
||||
* English name where it is not. These are judgment calls and a reviewer is
|
||||
* welcome to overrule any of them; nothing matches on this field.
|
||||
*/
|
||||
val name: String,
|
||||
/**
|
||||
* ISO 639-1 where the language has one, ISO 639-3 otherwise.
|
||||
*
|
||||
* Not 639-3 throughout, which would be more uniform and worse here: it would
|
||||
* make this catalogue's `sot` disagree with the `st` that every room which
|
||||
* used the hand-typed form has already signed, and those rows cannot be
|
||||
* rewritten -- a quorum signed them. The form's placeholder has always said
|
||||
* two letters (`eg. st`).
|
||||
*/
|
||||
val code: String,
|
||||
val country: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* The languages most spoken in Africa, for populating a group's dialects without
|
||||
* typing each one into a three-field form.
|
||||
*
|
||||
* Reference data, deliberately outside `ui/`. `docs/scripts/m3-audit.sh` scans
|
||||
* `ui/` and `strings.xml` for string literals and title case, and "Northern
|
||||
* Sotho" is title case for exactly the reason `m3-title-case.py` keeps a
|
||||
* `SAMPLE` set of proper nouns at all. Keeping the catalogue out of `ui/` is
|
||||
* cheaper and more honest than an `m3-string-exempt` marker on a file that is
|
||||
* not UI.
|
||||
*
|
||||
* Also not a composeResource: there is no `files/` directory under
|
||||
* `composeResources`, a bundled JSON would need a parser and a failure mode, and
|
||||
* a Kotlin list is compile-checked, greppable and testable.
|
||||
*
|
||||
* **[ALL] has to stay under `FrostSigningManager.MAX_BATCH_SIZE`** or a wave
|
||||
* stops being one signing session. `DialectCatalogueSizeTest` is the tripwire,
|
||||
* because somebody adding a language has no reason to be thinking about FROST.
|
||||
*
|
||||
* Not exhaustive and not trying to be. It is the cross-border lingua francas and
|
||||
* the high-population national languages; a group wanting a language this does
|
||||
* not list still has the form.
|
||||
*/
|
||||
object AfricanLanguages {
|
||||
|
||||
/**
|
||||
* Ordered by country, then by how widely the language is spoken there, so a
|
||||
* reader scanning for theirs scans once. `AfricanLanguagesTest` holds it to
|
||||
* that, which is why the European languages are a list of their own: they
|
||||
* are appended, and appending them would break the grouping.
|
||||
*/
|
||||
val african: List<AfricanLanguage> = listOf(
|
||||
AfricanLanguage("Kabyle", "kab", "Algeria"),
|
||||
|
||||
AfricanLanguage("Umbundu", "umb", "Angola"),
|
||||
AfricanLanguage("Kimbundu", "kmb", "Angola"),
|
||||
|
||||
AfricanLanguage("Setswana", "tn", "Botswana"),
|
||||
|
||||
AfricanLanguage("Mooré", "mos", "Burkina Faso"),
|
||||
|
||||
AfricanLanguage("Kirundi", "rn", "Burundi"),
|
||||
|
||||
AfricanLanguage("Sango", "sg", "Central African Republic"),
|
||||
|
||||
// `lua` is Luba-Kasai, which is what Tshiluba means. ISO 639-1's `lu` is
|
||||
// Luba-Katanga, a different language of the same country, and taking the
|
||||
// two-letter code because it was there would have named the wrong one.
|
||||
AfricanLanguage("Lingala", "ln", "Democratic Republic of the Congo"),
|
||||
AfricanLanguage("Kiswahili", "sw", "Democratic Republic of the Congo"),
|
||||
AfricanLanguage("Tshiluba", "lua", "Democratic Republic of the Congo"),
|
||||
AfricanLanguage("Kikongo", "kg", "Democratic Republic of the Congo"),
|
||||
|
||||
AfricanLanguage("Arabic", "ar", "Egypt"),
|
||||
|
||||
AfricanLanguage("Tigrinya", "ti", "Eritrea"),
|
||||
|
||||
AfricanLanguage("siSwati", "ss", "Eswatini"),
|
||||
|
||||
AfricanLanguage("Amharic", "am", "Ethiopia"),
|
||||
AfricanLanguage("Oromo", "om", "Ethiopia"),
|
||||
|
||||
AfricanLanguage("Akan", "ak", "Ghana"),
|
||||
AfricanLanguage("Ewe", "ee", "Ghana"),
|
||||
AfricanLanguage("Ga", "gaa", "Ghana"),
|
||||
|
||||
AfricanLanguage("Kiswahili", "sw", "Kenya"),
|
||||
AfricanLanguage("Gikuyu", "ki", "Kenya"),
|
||||
AfricanLanguage("Dholuo", "luo", "Kenya"),
|
||||
|
||||
AfricanLanguage("Sesotho", "st", "Lesotho"),
|
||||
|
||||
AfricanLanguage("Malagasy", "mg", "Madagascar"),
|
||||
|
||||
AfricanLanguage("Chichewa", "ny", "Malawi"),
|
||||
|
||||
AfricanLanguage("Bambara", "bm", "Mali"),
|
||||
|
||||
AfricanLanguage("Arabic", "ar", "Morocco"),
|
||||
AfricanLanguage("Tamazight", "zgh", "Morocco"),
|
||||
|
||||
AfricanLanguage("Emakhuwa", "vmw", "Mozambique"),
|
||||
|
||||
AfricanLanguage("Hausa", "ha", "Niger"),
|
||||
|
||||
AfricanLanguage("Hausa", "ha", "Nigeria"),
|
||||
AfricanLanguage("Yoruba", "yo", "Nigeria"),
|
||||
AfricanLanguage("Igbo", "ig", "Nigeria"),
|
||||
|
||||
AfricanLanguage("Kinyarwanda", "rw", "Rwanda"),
|
||||
|
||||
AfricanLanguage("Wolof", "wo", "Senegal"),
|
||||
AfricanLanguage("Fulfulde", "ff", "Senegal"),
|
||||
|
||||
AfricanLanguage("Krio", "kri", "Sierra Leone"),
|
||||
|
||||
AfricanLanguage("Somali", "so", "Somalia"),
|
||||
|
||||
// `nr` is South Ndebele and `nd` is North Ndebele, and both are called
|
||||
// isiNdebele by their speakers. The country tells them apart, which is
|
||||
// what a dialect being a language in a place is for.
|
||||
AfricanLanguage("isiZulu", "zu", "South Africa"),
|
||||
AfricanLanguage("isiXhosa", "xh", "South Africa"),
|
||||
AfricanLanguage("Afrikaans", "af", "South Africa"),
|
||||
AfricanLanguage("Sepedi", "nso", "South Africa"),
|
||||
AfricanLanguage("Setswana", "tn", "South Africa"),
|
||||
AfricanLanguage("Xitsonga", "ts", "South Africa"),
|
||||
AfricanLanguage("siSwati", "ss", "South Africa"),
|
||||
AfricanLanguage("Tshivenda", "ve", "South Africa"),
|
||||
AfricanLanguage("isiNdebele", "nr", "South Africa"),
|
||||
|
||||
AfricanLanguage("Arabic", "ar", "Sudan"),
|
||||
|
||||
AfricanLanguage("Kiswahili", "sw", "Tanzania"),
|
||||
|
||||
AfricanLanguage("Luganda", "lg", "Uganda"),
|
||||
|
||||
AfricanLanguage("Bemba", "bem", "Zambia"),
|
||||
|
||||
AfricanLanguage("Shona", "sn", "Zimbabwe"),
|
||||
AfricanLanguage("isiNdebele", "nd", "Zimbabwe"),
|
||||
)
|
||||
|
||||
/**
|
||||
* Not African languages, and among the most spoken on the continent.
|
||||
*
|
||||
* Included because a catalogue that cannot offer a group the language it
|
||||
* actually publishes in is a catalogue that group will not use, and one
|
||||
* country each because these are the countries the most speakers are in
|
||||
* rather than everywhere the language is official.
|
||||
*
|
||||
* Leaving them out would make this a catalogue of African languages instead,
|
||||
* which is a defensible thing to want -- so they are a list of their own
|
||||
* rather than three lines buried in [african], and dropping them is one edit
|
||||
* to [ALL].
|
||||
*/
|
||||
val european: List<AfricanLanguage> = listOf(
|
||||
AfricanLanguage("French", "fr", "Democratic Republic of the Congo"),
|
||||
AfricanLanguage("Portuguese", "pt", "Angola"),
|
||||
AfricanLanguage("English", "en", "Nigeria"),
|
||||
)
|
||||
|
||||
/** Every entry the picker offers. */
|
||||
val ALL: List<AfricanLanguage> = african + european
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package press.mantra.compose.language
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The catalogue, held to the shape the rest of the feature reads it with.
|
||||
*
|
||||
* Nothing here is about which languages are in it -- that is a judgment call and
|
||||
* a reviewer's to make. It is about the invariants `DialectScaffold` matches on,
|
||||
* every one of which fails silently rather than loudly if it is broken: a
|
||||
* duplicated pair proposes the same dialect twice in one wave and the two do not
|
||||
* even collide, because their ids differ by `createdAt`.
|
||||
*/
|
||||
class AfricanLanguagesTest {
|
||||
|
||||
@Test
|
||||
fun everyCodeIsAnIsoCodeShape() {
|
||||
AfricanLanguages.ALL.forEach { language ->
|
||||
assertEquals(
|
||||
language.code.lowercase(),
|
||||
language.code,
|
||||
"${language.name}'s code is not lowercase, and matching lowercases both sides"
|
||||
)
|
||||
assertTrue(
|
||||
language.code.length == 2 || language.code.length == 3,
|
||||
"${language.name}'s code ${language.code} is neither ISO 639-1 nor 639-3"
|
||||
)
|
||||
assertTrue(
|
||||
language.code.all { it in 'a'..'z' },
|
||||
"${language.name}'s code ${language.code} is not letters"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyEntryIsNamedAndPlaced() {
|
||||
AfricanLanguages.ALL.forEach { language ->
|
||||
assertTrue(language.name.isNotBlank(), "an entry for ${language.code} has no name")
|
||||
assertTrue(
|
||||
language.country.isNotBlank(),
|
||||
"${language.name} is not in a country, and a dialect is a language in a place"
|
||||
)
|
||||
assertEquals(
|
||||
language.name.trim(),
|
||||
language.name,
|
||||
"${language.name} has whitespace around it, which will end up in a signed event"
|
||||
)
|
||||
assertEquals(
|
||||
language.country.trim(),
|
||||
language.country,
|
||||
"${language.name}'s country has whitespace around it"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity rule. Two entries sharing a code and a country are one
|
||||
* dialect proposed twice, and `DialectScaffold.missingFrom` cannot tell them
|
||||
* apart because there is nothing to tell apart.
|
||||
*/
|
||||
@Test
|
||||
fun codeAndCountryTogetherAreUnique() {
|
||||
val duplicates = AfricanLanguages.ALL
|
||||
.groupBy { it.code to it.country }
|
||||
.filterValues { it.size > 1 }
|
||||
|
||||
assertEquals(
|
||||
emptyMap(),
|
||||
duplicates,
|
||||
"these (code, country) pairs appear more than once"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A language in two countries is two entries, which is the whole model. Here
|
||||
* as an assertion rather than a comment because the uniqueness test above
|
||||
* reads as though it forbids this, and it does not.
|
||||
*/
|
||||
@Test
|
||||
fun aLanguageMayAppearInSeveralCountries() {
|
||||
val swahili = AfricanLanguages.ALL.filter { it.code == "sw" }
|
||||
|
||||
assertTrue(swahili.size > 1, "Kiswahili is spoken in more than one country")
|
||||
assertEquals(
|
||||
swahili.size,
|
||||
swahili.map { it.country }.distinct().size,
|
||||
"Kiswahili's entries do not name distinct countries"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouped by country, as the list says it is.
|
||||
*
|
||||
* Contiguity rather than alphabetical order: what a reader needs is for
|
||||
* their country's languages to be together, and `african` is the list that
|
||||
* promises it -- the European entries are appended and would break it.
|
||||
*/
|
||||
@Test
|
||||
fun theAfricanEntriesAreGroupedByCountry() {
|
||||
val countries = AfricanLanguages.african.map { it.country }
|
||||
val firstSeen = countries.distinct()
|
||||
|
||||
assertEquals(
|
||||
firstSeen,
|
||||
countries.zipWithNext()
|
||||
.filter { (previous, next) -> previous != next }
|
||||
.map { (_, next) -> next }
|
||||
.let { listOf(countries.first()) + it },
|
||||
"a country's languages are split apart in the list"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allIsTheTwoListsAndNothingElse() {
|
||||
assertEquals(
|
||||
AfricanLanguages.african + AfricanLanguages.european,
|
||||
AfricanLanguages.ALL
|
||||
)
|
||||
assertTrue(
|
||||
AfricanLanguages.african.isNotEmpty(),
|
||||
"a catalogue of African languages with no African languages in it"
|
||||
)
|
||||
}
|
||||
}
|
||||
482
docs/african-dialect-catalogue.md
Normal file
482
docs/african-dialect-catalogue.md
Normal file
@@ -0,0 +1,482 @@
|
||||
# A catalogue of African languages, and dialects proposed in waves
|
||||
|
||||
How a group fills its dialect list from a bundled catalogue of the languages most
|
||||
spoken in Africa instead of typing each one into a three-field form, and how
|
||||
admins top that list up later — proposing only what is still missing, in waves,
|
||||
without two of them proposing the same wave twice.
|
||||
|
||||
**Not built.** This is the plan. Seven phases, one commit each.
|
||||
|
||||
Two facts shape every phase and are stated before them, because both are easy to
|
||||
design around and expensive to discover afterwards.
|
||||
|
||||
## A dialect is a language *in a place*
|
||||
|
||||
`MantraDialect` is `(name, country, language)` — "Sesotho", "Lesotho", `st`. The
|
||||
`language` column holds an ISO 639 code; the form's placeholder has always said
|
||||
so (`eg. st`). So the catalogue's unit is not a language but a language paired
|
||||
with a country, and Swahili-in-Kenya and Swahili-in-Tanzania are two entries.
|
||||
That is the feature rather than a duplication: a group translating for Kenyan
|
||||
readers is not translating for Tanzanian ones, and the whole reason this kind is
|
||||
called a dialect is that it is finer than a language.
|
||||
|
||||
## Nothing anywhere makes a dialect unique
|
||||
|
||||
A dialect's id is `EventHasher.hashId(pubKey, tags, content, createdAt, kind)`
|
||||
over the room's key. Two proposals of Sesotho-in-Lesotho a minute apart hash
|
||||
differently, are two `MantraDialect` rows, and neither is wrong by any rule the
|
||||
schema knows. There is no unique index, and adding one would reject a legitimate
|
||||
re-signing as readily as a duplicate.
|
||||
|
||||
So **duplication is prevented at proposal time or not at all**, and the handle
|
||||
has to be `(code, country)`. Not the name: a member typing "Sesotho" and a
|
||||
catalogue saying "Sesotho" agree today and disagree the moment one of them
|
||||
carries a diacritic, and the name is the one field of the three that is a matter
|
||||
of spelling rather than of fact.
|
||||
|
||||
## A session signs at most 64 events, all or nothing
|
||||
|
||||
`FrostSigningManager.MAX_BATCH_SIZE` is 64 and `MAX_PROPOSAL_BYTES` is 64 KiB,
|
||||
and a batch that cannot aggregate one item applies none of them —
|
||||
[frost-batch-signing.md](frost-batch-signing.md) says why, and says a retry must
|
||||
be a fresh batch rather than the same one again.
|
||||
|
||||
The catalogue below is about fifty entries, so a first wave is one session. Both
|
||||
properties still have to be defended by a test, because a catalogue that grows
|
||||
to sixty-five silently becomes two sessions and a reviewer adding an entry has no
|
||||
reason to be thinking about FROST.
|
||||
|
||||
The all-or-nothing risk is answered by the same rule that prevents duplication:
|
||||
because the picker proposes only what is missing, re-proposing a failed wave is
|
||||
safe and lands exactly the entries the failed one did not. "It is safe to ask
|
||||
again" is what the signing screen already tells a member of a failed session, and
|
||||
here it is true without qualification.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — the catalogue, as data
|
||||
|
||||
**Half a day. No behaviour change; nothing reads it yet.**
|
||||
|
||||
New file,
|
||||
`composeApp/src/commonMain/kotlin/press/mantra/compose/language/AfricanLanguage.kt`:
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* A language the catalogue offers, in one of the countries it is spoken in.
|
||||
*
|
||||
* The unit is the pair, not the language: a dialect is `(name, country, code)`,
|
||||
* so Swahili appears once for Kenya and once for Tanzania. See the note on
|
||||
* identity in docs/african-dialect-catalogue.md.
|
||||
*/
|
||||
data class AfricanLanguage(
|
||||
/** What the dialect row will be called. The endonym where there is a settled one. */
|
||||
val name: String,
|
||||
/** ISO 639-1 where the language has one, ISO 639-3 otherwise. */
|
||||
val code: String,
|
||||
val country: String,
|
||||
)
|
||||
|
||||
object AfricanLanguages {
|
||||
/** Ordered by country, so a reader scanning for theirs scans once. */
|
||||
val ALL: List<AfricanLanguage> = listOf(/* … */)
|
||||
}
|
||||
```
|
||||
|
||||
**Where it lives matters.** `press/mantra/compose/language/` rather than
|
||||
anywhere under `ui/`, because `docs/scripts/m3-audit.sh` scans `ui/` and
|
||||
`strings.xml` for string literals and title case, and "Northern Sotho" is title
|
||||
case for exactly the reason `m3-title-case.py` keeps a `SAMPLE` set of proper
|
||||
nouns at all — it is one. Putting reference data outside `ui/` is cheaper and more honest than an
|
||||
`m3-string-exempt` marker on a file that is not UI at all.
|
||||
|
||||
It is also not a composeResource. There is no `files/` directory under
|
||||
`composeResources` today, a bundled JSON would need a parser and a failure mode,
|
||||
and a Kotlin list is compile-checked, greppable and testable. The catalogue is
|
||||
reference data in the same sense `SMALL` in `m3-title-case.py` is.
|
||||
|
||||
**Codes: 639-1 where it exists, 639-3 otherwise.** `st`, `sw`, `zu`, `ha` — and
|
||||
`nso`, `bem`, `luo`, `mos`, `kab` for the ones ISO never gave two letters. The
|
||||
alternative, 639-3 throughout, is more uniform and worse here: it would make the
|
||||
catalogue's `sot` disagree with the `st` every room that used the form has
|
||||
already signed, and those rows cannot be rewritten — they are signed by a
|
||||
quorum.
|
||||
|
||||
Roughly fifty entries, covering the cross-border lingua francas and the
|
||||
high-population national languages: Swahili (Tanzania, Kenya), Hausa (Nigeria,
|
||||
Niger), Yoruba, Igbo, Fulfulde, Amharic, Oromo, Tigrinya, Somali, Arabic (Egypt,
|
||||
Sudan, Morocco), Zulu, Xhosa, Afrikaans, Northern Sotho, Southern Sotho, Tswana,
|
||||
Tsonga, Venda, siSwati, Shona, Northern Ndebele, Chichewa, Bemba, Lingala,
|
||||
Kikongo, Tshiluba, Kinyarwanda, Kirundi, Luganda, Kikuyu, Dholuo, Akan, Ewe, Ga,
|
||||
Wolof, Bambara, Mooré, Malagasy, Sango, Umbundu, Kimbundu, Makhuwa, Tamazight,
|
||||
Kabyle, Krio.
|
||||
|
||||
**One judgment call to settle in review.** French, Portuguese and English are
|
||||
among the most spoken languages on the continent and are not African languages.
|
||||
Including them makes the catalogue useful to groups that actually translate into
|
||||
them; leaving them out keeps it a catalogue of African languages. The plan
|
||||
includes three entries — `fr` · DR Congo, `pt` · Angola, `en` · Nigeria — as one
|
||||
contiguous, clearly commented block, so deleting them is a four-line change
|
||||
rather than an argument.
|
||||
|
||||
**Test:** `composeApp/src/commonTest/.../language/AfricanLanguagesTest.kt` —
|
||||
every code lowercase and two or three characters, every name and country
|
||||
non-blank, `(code, country)` unique across the list, and the list ordered as it
|
||||
claims to be. The uniqueness assertion is the one that earns its place: a
|
||||
duplicate pair in the catalogue would propose the same dialect twice in one wave,
|
||||
and the two would not even collide, because their ids differ.
|
||||
|
||||
**Acceptance:** `:composeApp:compileDebugKotlinAndroid` and the new test pass;
|
||||
`:composeApp:m3Audit` unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — `DialectScaffold`: what is missing, and the events for it
|
||||
|
||||
**Half a day. Pure functions. Still nothing calls them.**
|
||||
|
||||
New file,
|
||||
`composeApp/src/commonMain/kotlin/press/mantra/compose/managers/DialectScaffold.kt`,
|
||||
built as a deliberate parallel to
|
||||
[`TranslationScaffold`](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/TranslationScaffold.kt) —
|
||||
same package, same shape, same reasons:
|
||||
|
||||
```kotlin
|
||||
object DialectScaffold {
|
||||
/** The catalogue entries this room has no dialect for, and none in flight. */
|
||||
fun missingFrom(
|
||||
catalogue: List<AfricanLanguage> = AfricanLanguages.ALL,
|
||||
existing: List<MantraDialect>,
|
||||
pending: List<AfricanLanguage> = emptyList(),
|
||||
): List<AfricanLanguage>
|
||||
|
||||
/** One dialect event per entry, stamped with one timestamp for the wave. */
|
||||
fun dialectsOf(
|
||||
languages: List<AfricanLanguage>,
|
||||
createdAt: Long,
|
||||
): List<EventTemplate<DialectEvent>>
|
||||
}
|
||||
```
|
||||
|
||||
`missingFrom` matches on `(code, country)` normalised with `trim()` and
|
||||
`lowercase()` — the no-argument `lowercase()` deliberately, which in Kotlin
|
||||
common is locale-invariant, so a Turkish device does not decide that `TI` and
|
||||
`ti` are different languages. The name is ignored for matching and carried
|
||||
through for display.
|
||||
|
||||
`dialectsOf` takes one `createdAt` for the whole list rather than reading the
|
||||
clock per template, for the reason `TranslationScaffold.chaptersOf` does: a wave
|
||||
proposed as one act should read as one act, not as fifty dialects that happen to
|
||||
share a minute. It also makes the wave's event ids a function of its inputs,
|
||||
which is what makes the test below possible.
|
||||
|
||||
**Test:** `composeApp/src/commonTest/.../managers/DialectScaffoldTest.kt`,
|
||||
mirroring `TranslationScaffoldTest`:
|
||||
|
||||
- a room with no dialects is missing the whole catalogue;
|
||||
- a room holding `st` · Lesotho is missing everything else and not that;
|
||||
- a row whose code is ` ST ` still counts as held;
|
||||
- a row whose *name* is spelled differently but whose code and country match
|
||||
still counts as held — the assertion that pins the identity rule;
|
||||
- `sw` · Kenya held does not make `sw` · Tanzania held;
|
||||
- a row with a code the catalogue does not list changes nothing;
|
||||
- `dialectsOf` emits `DialectEvent.KIND`, in catalogue order, all with the one
|
||||
`createdAt`, and each round-trips through `MantraDialect.fromDialectEventTemplate`
|
||||
back to the name, country and code it came from.
|
||||
|
||||
**Acceptance:** compile and the new test pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — what is already in flight
|
||||
|
||||
**Half a day. Read-only; the first phase that touches a repository.**
|
||||
|
||||
A catalogue entry is in one of three states, and the picker is wrong without all
|
||||
three:
|
||||
|
||||
| state | how it is known | selectable |
|
||||
|---|---|---|
|
||||
| in the room | a `MantraDialect` row for `(code, country)` | no |
|
||||
| in flight | an open signing session holds a kind-30304 event for `(code, country)` | no |
|
||||
| missing | neither | yes |
|
||||
|
||||
Without "in flight" the feature has a race it will hit on its first real use:
|
||||
two admins open the picker, both see the same forty missing, both propose, and
|
||||
the room gets eighty dialects — forty of them duplicates that nothing will ever
|
||||
merge, because nothing makes a dialect unique.
|
||||
|
||||
Everything needed is already on `FrostSigningRepository`:
|
||||
`observeSessionsForChatRoom(chatRoomId)` gives `LocalFrostSigningSession`, each
|
||||
with its `items`, and `unsignedEvents(items)` turns those into events **at
|
||||
proposal time** — before any signature, which is precisely when this has to be
|
||||
readable.
|
||||
|
||||
Add one more pure function to `DialectScaffold`:
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* The catalogue entries an open session is already signing.
|
||||
*
|
||||
* `FrostSigningStage.FAILED` and `COMPLETE` are both excluded, for opposite
|
||||
* reasons: a failed wave left nothing behind and must be proposable again --
|
||||
* the signing screen says as much -- and a complete one is a row by now, so
|
||||
* counting it here would hide it behind the wrong explanation.
|
||||
*/
|
||||
fun pendingIn(
|
||||
sessions: List<LocalFrostSigningSession>,
|
||||
unsignedEvents: (List<FrostSigningItem>) -> List<Event>,
|
||||
catalogue: List<AfricanLanguage> = AfricanLanguages.ALL,
|
||||
): List<AfricanLanguage>
|
||||
```
|
||||
|
||||
The `unsignedEvents` lambda is passed in rather than imported, for the reason
|
||||
`ProposedEvent.summarize` takes `resolve`: it keeps the function pure and
|
||||
testable without a database behind it.
|
||||
|
||||
**Test:** `commonTest`, with hand-built `FrostSigningItem` rows holding
|
||||
`DialectEvent.build(...)` JSON. Cases: an open session's dialects are pending; a
|
||||
`FAILED` session's are not; a `COMPLETE` session's are not; a session signing
|
||||
chapters contributes nothing; an item whose JSON will not parse is skipped rather
|
||||
than throwing — a malformed item must not take the picker down with it.
|
||||
|
||||
**Acceptance:** compile and the new test pass. No wire change, no schema change.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — the picker
|
||||
|
||||
**Two days. The screen, and the way in.**
|
||||
|
||||
A new pushed screen beside the hand-typed form, which stays exactly as it is.
|
||||
Five files, following the house pattern (route · state · view model · screen ·
|
||||
registration) and `SelectSubgroupAdminsScreen` for the multi-select mechanics:
|
||||
|
||||
- `ui/composable/navigation/routes/AddDialectsFromCatalogueRoute.kt` —
|
||||
`activeUserPublicKey`, `chatRoomId`, `relayHint`, as `AddDialectRoute` has.
|
||||
- `ui/view/state/AddDialectsFromCatalogueUIState.kt` — `Loading` / `Error` /
|
||||
`Loaded(localChatRoom, rows: List<CatalogueRow>, selected: Set<String>, canSign)`,
|
||||
where a `CatalogueRow` is an `AfricanLanguage` plus its state from the table in
|
||||
phase 3.
|
||||
- `ui/view/model/AddDialectsFromCatalogueViewModel.kt`
|
||||
- `ui/composable/AddDialectsFromCatalogueScreen.kt`
|
||||
- the `composable<…>` arm in `MantraNavHost.kt`, copying `AddDialectRoute`'s
|
||||
`popUpTo` so back does not return to a form whose proposal has gone out.
|
||||
|
||||
**State goes in a `StateFlow`, not `mutableStateOf`.** The existing
|
||||
`AddDialectViewModel` uses `mutableStateOf` and gets away with it because it
|
||||
writes the state once. This screen does not: the loaded state is written from
|
||||
`Dispatchers.IO`, *and* a collector on `observeSessionsForChatRoom` folds the
|
||||
pending list into it, *and* the UI read-modify-writes the selection. That is the
|
||||
case where a `mutableStateOf` in a view model loses writes — a
|
||||
`MutableStateFlow` with `update { }` is the shape that does not.
|
||||
|
||||
The selection may live beside it as `mutableStateListOf<String>()` of
|
||||
`"$code|$country"` keys, which is what `SelectSubgroupAdminsViewModel` does with
|
||||
`selectedPublicKeys`; the point is that it is not folded into a `mutableStateOf`
|
||||
holding the whole UI state.
|
||||
|
||||
**What the screen shows.** A `TopAppBar` with `NavigateBackButton` (it is pushed;
|
||||
the desktop target has no system back), a search field filtering on name, code
|
||||
and country, and a `LazyColumn` of `ListItem` rows grouped by country. Each row
|
||||
by state:
|
||||
|
||||
- **missing** — a `Checkbox`, enabled, toggling selection;
|
||||
- **in the room** — a `Checkbox(checked = true, enabled = false, onCheckedChange = null)`
|
||||
and a supporting line saying so, exactly as the subgroup screen renders the
|
||||
coordinator who is an admin by construction;
|
||||
- **in flight** — disabled, `Modifier.semantics { disabled() }`, supporting line
|
||||
"waiting for the group to sign", with the pending wave reachable from the
|
||||
room's proposal list.
|
||||
|
||||
A header line carries the count — "12 of 50 selected" — and a "select everything
|
||||
missing" action, which is the whole convenience the feature is named for.
|
||||
|
||||
All four screen states, per the house rules: `ScreenStateTransition` wrapping the
|
||||
`when`, `ErrorState` with a real `onRetry` (re-running `initiate()` is a genuine
|
||||
retry here, so passing null would be the wrong decision), `LoadingDataIndicator`,
|
||||
and an `EmptyState` with its own message for the case worth getting right — a
|
||||
room that already holds the whole catalogue. "No dialects found" would be a lie
|
||||
there; the message is that the catalogue has nothing left to add and the form is
|
||||
the way to add one it does not list, with that form as the `EmptyState`'s
|
||||
`action`.
|
||||
|
||||
`canSign` gates the FAB the way `AddDialectScreen` does, with the same
|
||||
explanation of why, because a group with no shared key cannot sign a dialect into
|
||||
existence at all.
|
||||
|
||||
**Spacing from `MaterialTheme.spacing`, colour from roles, strings from
|
||||
`strings.xml` in sentence case, every target 48dp, every icon's
|
||||
`contentDescription` decided, `Modifier.padding(innerPadding).readableContent()`
|
||||
on the content root.** About a dozen new strings. The language names are *not*
|
||||
among them: they come from the catalogue, which is data.
|
||||
|
||||
**The way in:** a second `TextButton` under the dialect list on
|
||||
`ChatRoomDetailScreen`, beside "Add dialect" — "Add dialects from a list". The
|
||||
existing button is untouched.
|
||||
|
||||
**Test:** a `@ConformancePreviews` preview per state, as every screen here has,
|
||||
with `FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY`. Row-state logic is
|
||||
phase 3's and is tested there rather than through the composable.
|
||||
|
||||
**Acceptance:** `:composeApp:m3Audit` reports no new budget breach and no drop
|
||||
below either floor; `compileDebugKotlinAndroid` and `jvmTest` pass; the previews
|
||||
render in all six schemes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — proposing the wave
|
||||
|
||||
**A day. The first phase that changes what a group sees.**
|
||||
|
||||
One method on the view model, built on
|
||||
`TranslationArtifactVersionDetailViewModel.catchUpMissingChapters` — which is the
|
||||
same problem, down to the chunking:
|
||||
|
||||
```kotlin
|
||||
fun proposeSelectedDialects(
|
||||
localChatRoom: LocalChatRoom,
|
||||
selected: List<AfricanLanguage>,
|
||||
onSuccess: (sessionId: String) -> Unit,
|
||||
onFailure: () -> Unit,
|
||||
)
|
||||
```
|
||||
|
||||
- empty selection fails fast rather than opening an empty session;
|
||||
- `isActionPending` guards the double tap, as every proposing view model here
|
||||
does;
|
||||
- one `TimeUtils.now()` for the wave;
|
||||
- `selected.chunked(FrostSigningManager.MAX_BATCH_SIZE)`, one
|
||||
`proposeSigningBatch(localChatRoom, userPublicKey, events)` per chunk — the
|
||||
flat form, since dialects name nothing and need no lead;
|
||||
- navigate to the **first** session with `popUpTo<AddDialectsFromCatalogueRoute> { inclusive = true }`,
|
||||
the rest being in the room's proposal list beside it;
|
||||
- the `onSuccess` callback invoked from `Dispatchers.Main`. This is not
|
||||
decoration: the callback navigates, and a navigation from `Dispatchers.IO`
|
||||
crashes. The handoff belongs inside the view model, which is where the
|
||||
existing ones put it.
|
||||
|
||||
With fifty entries the chunking never fires. It is one line, it is the
|
||||
established idiom, and it is what stops the sixty-fifth catalogue entry from
|
||||
being a bug instead of a second session.
|
||||
|
||||
Nothing else needs changing to make a wave readable.
|
||||
`ProposedEvent.summarize` already has its `DialectEvent.KIND` arm — "New dialect"
|
||||
over "Sesotho · Lesotho · st" — and `WhatIsBeingSigned` already leads a batch
|
||||
with "N events signed together" and renders every item, refusing to let any of it
|
||||
be signed unless all of it parsed. Fifty two-line blocks in a scrolling `Column`
|
||||
is a long screen, and that is the correct length: a member is being asked to put
|
||||
the group's name to fifty dialects, and the batch is all-or-nothing.
|
||||
|
||||
**Test:** `composeApp/src/jvmTest/.../managers/DialectWaveProposalJvmTest.kt`,
|
||||
modelled on `TranslationBatchProposalJvmTest` — over a real in-memory database
|
||||
and a real key, assert that a wave of N opens one session with N items in
|
||||
catalogue order, that each item's unsigned event is a kind-30304 whose tags round
|
||||
-trip to the catalogue entry, that the events share one `createdAt`, and that a
|
||||
selection of 65 opens two sessions with 64 and 1.
|
||||
|
||||
**Acceptance:** the above, plus a manual pass: pick five, propose, approve on
|
||||
enough devices, see five dialect rows appear on the group detail screen.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — one transcript line for a wave, not fifty
|
||||
|
||||
**Half a day. A behaviour fix the previous phase makes necessary.**
|
||||
|
||||
`FrostSigningManager.applySignedEvent` hands each signed event to
|
||||
`ChatMessage.applyInnerEvent` and files whatever chat line comes back. The
|
||||
dialect arm returns one — "Added Sesotho to dialects" — so a wave of fifty posts
|
||||
fifty lines into the group's conversation, followed by the session's own "The
|
||||
group signed 50 events".
|
||||
|
||||
This is not a problem the existing batches have, and the reason is instructive.
|
||||
Of the kinds `applyInnerEvent` files a line for — chat, artifact, chapter,
|
||||
dialect, translation, project — every one that can appear many-per-batch already
|
||||
returns null for exactly this reason: a chunk is part of a chapter and the
|
||||
chapter's line speaks for it; a translation chapter, a contributor list and both
|
||||
project memberships likewise. `DialectEvent` is the first kind where a batch
|
||||
holds many peers each of which wants a line of its own.
|
||||
|
||||
So the rule, stated once where the batch is visible:
|
||||
|
||||
> A signed event files its chat line unless the batch holds more than one event
|
||||
> of its kind, in which case the session's completion line speaks for them.
|
||||
|
||||
Implemented as a `fileChatLine: Boolean` parameter on the private
|
||||
`applySignedEvent`, computed in its one caller as
|
||||
`signedEvents.count { it.kind == signedEvent.kind } == 1`. `applyInnerEvent` is
|
||||
untouched — it still writes the `MantraDialect` row, which is the part that
|
||||
matters — and only the `chatMessageDao().upsert` is skipped.
|
||||
|
||||
Checked against every batch the app builds today, this changes nothing: a chapter
|
||||
batch is one `ChapterEvent` and N `ChunkEvent`s that file nothing; a translation
|
||||
batch is one `TranslationArtifactVersionEvent` and N translation chapters that
|
||||
file nothing; a project batch is one `ProjectEvent` and N memberships that file
|
||||
nothing. The only batch it affects is the one phase 5 introduces.
|
||||
|
||||
`ChronicleManager.applyPage` needs nothing: it already drops the chat line a
|
||||
payload returns, and says why.
|
||||
|
||||
**Test:** extend the jvmTest that covers `applySignedEvent`'s effects — a batch
|
||||
of one dialect still files its line; a batch of three files none and still writes
|
||||
three `MantraDialect` rows; a batch of a chapter and its chunks still files the
|
||||
chapter's line.
|
||||
|
||||
**Acceptance:** the above, and a manual pass showing one completion line rather
|
||||
than fifty.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — the guards that keep it true
|
||||
|
||||
**Half a day.**
|
||||
|
||||
`composeApp/src/commonTest/.../language/DialectCatalogueSizeTest.kt`, modelled on
|
||||
[`ChapterProposalSizeTest`](../composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterProposalSizeTest.kt):
|
||||
|
||||
- the whole catalogue fits one batch —
|
||||
`AfricanLanguages.ALL.size <= FrostSigningManager.MAX_BATCH_SIZE`;
|
||||
- the whole catalogue as a proposal fits `MAX_PROPOSAL_BYTES`, measured the way
|
||||
the manager measures it rather than estimated;
|
||||
- and, because the first assertion will eventually fail on purpose, a failure
|
||||
message that says what to do about it: the chunking in phase 5 already handles
|
||||
it, and the test is lowered to a note rather than the catalogue trimmed.
|
||||
|
||||
Then the two commands from `CLAUDE.md`, and a line in
|
||||
[docs/README.md](README.md) pointing here.
|
||||
|
||||
```bash
|
||||
./gradlew :composeApp:m3Audit
|
||||
```
|
||||
|
||||
```bash
|
||||
ANDROID_HOME=/home/sigidli/Android/Sdk ./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:jvmTest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What this deliberately does not do
|
||||
|
||||
**No schema change, no migration, no new event kind, no wire change.** The
|
||||
feature is a different way of building events the group already signs. Database
|
||||
version stays at 22.
|
||||
|
||||
**No uniqueness constraint on `MantraDialect`.** It would reject a re-signing as
|
||||
readily as a duplicate, and the duplicate is prevented at proposal time instead —
|
||||
see the note at the top.
|
||||
|
||||
**No merging of dialects that are already duplicated.** A room that typed
|
||||
"Sesotho" by hand and then selects it from the catalogue gets one row, because
|
||||
the codes match. A room holding two rows from before this feature keeps both;
|
||||
nothing here can remove a row a quorum signed.
|
||||
|
||||
**No admin role.** The user's framing was that admins propose in waves, and in
|
||||
this app the gate on proposing is `canSign` — holding a share of the group's key
|
||||
— rather than a role. Every member who can sign can propose a wave, and a quorum
|
||||
decides. Introducing a dialect-proposing role would be a change to what a group
|
||||
is, not a convenience method.
|
||||
|
||||
**No automatic wave.** Nothing proposes the catalogue on a room's behalf when it
|
||||
is created. A dialect is a statement the group signs about what it will
|
||||
translate into, and fifty of them arriving unasked is a statement nobody made.
|
||||
Reference in New Issue
Block a user