Dictionary coverage, pronunciation, and chapter order #1
@ -69,6 +69,18 @@ object Dictionary {
|
||||
.firstNotNullOfOrNull { direct(database, normalise(it)).ifEmpty { null } }
|
||||
.orEmpty()
|
||||
}
|
||||
// A selection is usually a phrase rather than a word; fall back to its words.
|
||||
.ifEmpty {
|
||||
raw.split(' ', '\n', '\r')
|
||||
.map { normalise(it) }
|
||||
.filter { it.length > 1 && it != word }
|
||||
.firstNotNullOfOrNull { part ->
|
||||
direct(database, part).ifEmpty {
|
||||
lemmas(database, part).flatMap { direct(database, it) }.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun direct(database: SQLiteDatabase, word: String): List<Definition> =
|
||||
@ -94,14 +106,29 @@ object Dictionary {
|
||||
|
||||
private const val ZWNJ = ''
|
||||
|
||||
private val SUFFIXES = listOf("ها", "اش", "ش", "م", "ت", "را", "ی", "ان")
|
||||
// "ال" is the Arabic definite article: poems quote Arabic, so السّاقی has to reach ساقی.
|
||||
private val PREFIXES = listOf("ال", "می", "بر", "ب")
|
||||
/**
|
||||
* Longest first, so تربتش strips شـ rather than matching nothing. These are the endings that
|
||||
* actually turn up in classical verse: plurals, the object marker, and the enclitic pronouns
|
||||
* that Persian glues onto a verb or noun — آیدت is آید + ت, باشدش is باشد + ش.
|
||||
*/
|
||||
private val SUFFIXES = listOf(
|
||||
"شان", "تان", "مان", "ها", "اش", "ست", "یم", "ید", "ند", "را", "ش", "م", "ت", "ی", "ان", "ه",
|
||||
)
|
||||
|
||||
/** Candidate stems after stripping one common affix. Order matters: longest affix first. */
|
||||
internal fun affixes(word: String): List<String> = buildList {
|
||||
SUFFIXES.forEach { if (word.endsWith(it) && word.length > it.length + 1) add(word.dropLast(it.length)) }
|
||||
PREFIXES.forEach { if (word.startsWith(it) && word.length > it.length + 1) add(word.drop(it.length)) }
|
||||
/** "ال" is the Arabic definite article, "ن"/"نمی" negation, "بی" privative. */
|
||||
private val PREFIXES = listOf("نمی", "ال", "می", "بی", "بر", "ن", "ب")
|
||||
|
||||
/**
|
||||
* Candidate stems, one affix deep and then two — برنیاید is بر + ن + یاید, and a single pass
|
||||
* would never reach the verb. Ordered so the least mangled candidate is tried first.
|
||||
*/
|
||||
internal fun affixes(word: String): List<String> {
|
||||
fun oneStep(w: String) = buildList {
|
||||
SUFFIXES.forEach { if (w.endsWith(it) && w.length > it.length + 1) add(w.dropLast(it.length)) }
|
||||
PREFIXES.forEach { if (w.startsWith(it) && w.length > it.length + 1) add(w.drop(it.length)) }
|
||||
}
|
||||
val first = oneStep(word)
|
||||
return (first + first.flatMap(::oneStep)).distinct()
|
||||
}
|
||||
|
||||
private val HARAKAT = (0x064B..0x0652) + listOf(0x0670, 0x0640) + (0x0610..0x0615)
|
||||
|
||||
@ -166,6 +166,37 @@ fun catPath(fullUrl: String) = "poets${fullUrl.trimEnd('/')}/_cat.json"
|
||||
|
||||
fun poemPath(fullUrl: String) = "poets${fullUrl.trimEnd('/')}.json"
|
||||
|
||||
/** A row in a category listing: either a chapter to open, or a poem to read. */
|
||||
sealed interface CatEntry {
|
||||
data class Chapter(val category: Category) : CatEntry
|
||||
data class Poem(val poem: PoemRef) : CatEntry
|
||||
}
|
||||
|
||||
private val PREFACE_TITLES = listOf("دیباچه", "مقدمه", "سرآغاز", "پیشگفتار", "آغاز")
|
||||
|
||||
/**
|
||||
* Orders a category the way ganjoor.net does: a book's own preface first, then its chapters,
|
||||
* then whatever other poems sit directly under it. Golestan's دیباچه belongs above the eight
|
||||
* باب, not below them; Hafez's مقدّمه above his five collections, with مثنوی and ساقینامه after.
|
||||
*
|
||||
* Ganjoor decides this with each poem's MixedModeOrder — 1 sorts a poem above the chapters, 0
|
||||
* below — but that field isn't in the exported `_cat.json`, only on the live API's per-poem
|
||||
* record, which would be one request per poem.
|
||||
*
|
||||
* ponytail: so prefaces are recognised by title instead. Adding MixedModeOrder to the Poems
|
||||
* entries in ganjoor-data would make this exact; until then a book whose preface is named
|
||||
* something unusual still lands after its chapters.
|
||||
*/
|
||||
fun orderedEntries(category: Category): List<CatEntry> {
|
||||
val (prefaces, rest) = category.poems.partition { poem ->
|
||||
val title = normalise(poem.title).trimStart()
|
||||
PREFACE_TITLES.any { title.startsWith(it) }
|
||||
}
|
||||
return prefaces.map(CatEntry::Poem) +
|
||||
category.childCats.map(CatEntry::Chapter) +
|
||||
rest.map(CatEntry::Poem)
|
||||
}
|
||||
|
||||
/** One step of a poem's path. [url] is null for the poem itself, which is already open. */
|
||||
data class Crumb(val label: String, val url: String?)
|
||||
|
||||
|
||||
@ -38,9 +38,11 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ganjoor.android.R
|
||||
import com.ganjoor.android.data.CatEntry
|
||||
import com.ganjoor.android.data.Downloads
|
||||
import com.ganjoor.android.data.Ganjoor
|
||||
import com.ganjoor.android.data.Offline
|
||||
import com.ganjoor.android.data.orderedEntries
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@ -63,6 +65,8 @@ fun CategoryScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val entries = remember(cat) { orderedEntries(cat) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@ -94,12 +98,25 @@ fun CategoryScreen(
|
||||
cat.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||
item { Description(description) }
|
||||
}
|
||||
items(cat.childCats, key = { "c${it.id}" }) { child ->
|
||||
NavRow(child.title, isCategory = true) { onCategory(child.fullUrl) }
|
||||
}
|
||||
items(cat.poems, key = { "p${it.id}" }) { poem ->
|
||||
NavRow(poem.title, isCategory = false, excerpt = excerpts[poem.id]) {
|
||||
onPoem(poem.fullUrl)
|
||||
items(
|
||||
items = entries,
|
||||
key = { entry ->
|
||||
when (entry) {
|
||||
is CatEntry.Chapter -> "c${entry.category.id}"
|
||||
is CatEntry.Poem -> "p${entry.poem.id}"
|
||||
}
|
||||
},
|
||||
) { entry ->
|
||||
when (entry) {
|
||||
is CatEntry.Chapter -> NavRow(entry.category.title, isCategory = true) {
|
||||
onCategory(entry.category.fullUrl)
|
||||
}
|
||||
|
||||
is CatEntry.Poem -> NavRow(
|
||||
title = entry.poem.title,
|
||||
isCategory = false,
|
||||
excerpt = excerpts[entry.poem.id],
|
||||
) { onPoem(entry.poem.fullUrl) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -105,8 +105,14 @@ fun PoemScreen(
|
||||
)
|
||||
}
|
||||
) { insets ->
|
||||
// Free-form selection for copying any span; the per-couplet actions below are for
|
||||
// saving a passage with the reference attached, which a raw copy would lose.
|
||||
// Free-form selection for copying any span; tapping a word looks it up, and the
|
||||
// per-couplet actions save a passage with the reference attached, which a raw copy
|
||||
// would lose.
|
||||
//
|
||||
// ponytail: no dictionary entry in the selection toolbar. Compose 1.10 stopped
|
||||
// routing SelectionContainer through LocalTextToolbar — a custom TextToolbar is
|
||||
// simply never asked to show — and the replacement, foundation's contextmenu
|
||||
// package, is internal. Revisit when that becomes public API.
|
||||
SelectionContainer {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
||||
@ -62,7 +62,7 @@
|
||||
<string name="about">درباره و پروانهها</string>
|
||||
<string name="about_intro">گنجور برای اندروید نرمافزار آزاد است. شعرها، قلمها و همهٔ کتابخانههایی که این برنامه بر آنها ساخته شده در زیر آمدهاند؛ برای خواندن متن کامل پروانه روی هر مورد بزنید.</string>
|
||||
<string name="no_definition">برای این واژه مدخلی یافت نشد.</string>
|
||||
<string name="source_wiktionary">ویکیواژه (CC BY-SA 3.0)</string>
|
||||
<string name="source_daneshjoo">فرهنگ دانشجو</string>
|
||||
<string name="source_wiktionary">ویکیواژه — فارسی به انگلیسی (CC BY-SA 3.0)</string>
|
||||
<string name="source_daneshjoo">فرهنگ دانشجو — فارسی به انگلیسی</string>
|
||||
<string name="dictionary">لغتنامه</string>
|
||||
</resources>
|
||||
|
||||
@ -62,7 +62,7 @@
|
||||
<string name="about">تعارف اور لائسنس</string>
|
||||
<string name="about_intro">گنجور فار اینڈرائیڈ آزاد سافٹ ویئر ہے۔ کلام، فونٹس اور تمام لائبریریاں ذیل میں درج ہیں؛ مکمل لائسنس پڑھنے کے لیے کسی اندراج پر ٹیپ کریں۔</string>
|
||||
<string name="no_definition">اس لفظ کا کوئی اندراج نہیں ملا۔</string>
|
||||
<string name="source_wiktionary">ویکی لغت (CC BY-SA 3.0)</string>
|
||||
<string name="source_daneshjoo">دانشجو لغت</string>
|
||||
<string name="source_wiktionary">ویکی لغت — فارسی سے انگریزی (CC BY-SA 3.0)</string>
|
||||
<string name="source_daneshjoo">دانشجو لغت — فارسی سے انگریزی</string>
|
||||
<string name="dictionary">لغت نامہ</string>
|
||||
</resources>
|
||||
|
||||
@ -67,7 +67,7 @@
|
||||
<string name="about">About & licences</string>
|
||||
<string name="about_intro">Ganjoor for Android is free software. The poems, the fonts and every library it is built on are credited below; tap an entry to read its full licence.</string>
|
||||
<string name="no_definition">No entry for this word.</string>
|
||||
<string name="source_wiktionary">Wiktionary (CC BY-SA 3.0)</string>
|
||||
<string name="source_daneshjoo">Daneshjoo Dictionary</string>
|
||||
<string name="source_wiktionary">Wiktionary — Persian to English (CC BY-SA 3.0)</string>
|
||||
<string name="source_daneshjoo">Daneshjoo — Persian to English</string>
|
||||
<string name="dictionary">Dictionary</string>
|
||||
</resources>
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
package com.ganjoor.android
|
||||
|
||||
import com.ganjoor.android.data.CatEntry
|
||||
import com.ganjoor.android.data.Category
|
||||
import com.ganjoor.android.data.Crumb
|
||||
import com.ganjoor.android.data.PoemRef
|
||||
import com.ganjoor.android.data.orderedEntries
|
||||
import com.ganjoor.android.data.Verse
|
||||
import com.ganjoor.android.data.breadcrumbs
|
||||
import com.ganjoor.android.data.parentUrl
|
||||
@ -125,3 +129,54 @@ class ParentUrlTest {
|
||||
assertEquals("/hafez", parentUrl("/hafez/ghazal/"))
|
||||
}
|
||||
}
|
||||
|
||||
class CategoryOrderTest {
|
||||
private fun cat(chapters: List<String>, poems: List<String>) = Category(
|
||||
id = 1,
|
||||
title = "book",
|
||||
childCats = chapters.mapIndexed { i, t -> Category(id = 100 + i, title = t) },
|
||||
poems = poems.mapIndexed { i, t -> PoemRef(id = 200 + i, title = t) },
|
||||
)
|
||||
|
||||
private fun titles(category: Category) = orderedEntries(category).map {
|
||||
when (it) {
|
||||
is CatEntry.Chapter -> it.category.title
|
||||
is CatEntry.Poem -> it.poem.title
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a preface comes before the chapters, as on ganjoor net`() {
|
||||
// Golestan: دیباچه then the eight باب
|
||||
val golestan = cat(listOf("باب اول", "باب دوم"), listOf("دیباچه"))
|
||||
|
||||
assertEquals(listOf("دیباچه", "باب اول", "باب دوم"), titles(golestan))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `other poems stay after the chapters`() {
|
||||
// Hafez: مقدّمه, then the collections, then مثنوی and ساقینامه
|
||||
val hafez = cat(
|
||||
chapters = listOf("غزلیات", "قطعات"),
|
||||
poems = listOf("مثنوی (الا ای آهوی وحشی)", "ساقینامه", "مقدّمهٔ جمعآورندهٔ دیوان حافظ"),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("مقدّمهٔ جمعآورندهٔ دیوان حافظ", "غزلیات", "قطعات", "مثنوی (الا ای آهوی وحشی)", "ساقینامه"),
|
||||
titles(hafez),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `diacritics in a preface title don't hide it`() {
|
||||
// مقدّمه carries a shadda the plain spelling doesn't
|
||||
assertEquals(listOf("مقدّمه", "باب اول"), titles(cat(listOf("باب اول"), listOf("مقدّمه"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a category with no poems is left exactly as it is`() {
|
||||
val masnavi = cat(listOf("دفتر اول", "دفتر دوم", "دفتر سوم"), emptyList())
|
||||
|
||||
assertEquals(listOf("دفتر اول", "دفتر دوم", "دفتر سوم"), titles(masnavi))
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,3 +79,30 @@ class ArabicArticleTest {
|
||||
assertTrue(affixes("الناس").contains("ناس"))
|
||||
}
|
||||
}
|
||||
|
||||
class PersianMorphologyTest {
|
||||
@Test
|
||||
fun `enclitic pronouns glued onto a verb are stripped`() {
|
||||
assertTrue(affixes("آیدت").contains("آید"))
|
||||
assertTrue(affixes("باشدش").contains("باشد"))
|
||||
assertTrue(affixes("تربتش").contains("تربت"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a prefix and a negation together still reach the verb`() {
|
||||
// برنیاید = بر + ن + یاید; one pass would stop at نیاید
|
||||
assertTrue(affixes("برنیاید").contains("یاید"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plural and object markers still work`() {
|
||||
assertTrue(affixes("دلها").contains("دل"))
|
||||
assertTrue(affixes("مارا").contains("ما"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stripping never produces a single letter`() {
|
||||
assertTrue(affixes("شان").none { it.length < 2 })
|
||||
assertTrue(affixes("بها").none { it.length < 2 })
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user