diff --git a/app/src/main/java/com/ganjoor/android/data/Dictionary.kt b/app/src/main/java/com/ganjoor/android/data/Dictionary.kt index c58f913..002ff0b 100644 --- a/app/src/main/java/com/ganjoor/android/data/Dictionary.kt +++ b/app/src/main/java/com/ganjoor/android/data/Dictionary.kt @@ -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 = @@ -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 = 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 { + 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) diff --git a/app/src/main/java/com/ganjoor/android/data/Ganjoor.kt b/app/src/main/java/com/ganjoor/android/data/Ganjoor.kt index df0dcd0..f2ac4bc 100644 --- a/app/src/main/java/com/ganjoor/android/data/Ganjoor.kt +++ b/app/src/main/java/com/ganjoor/android/data/Ganjoor.kt @@ -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 { + 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?) diff --git a/app/src/main/java/com/ganjoor/android/ui/CategoryScreen.kt b/app/src/main/java/com/ganjoor/android/ui/CategoryScreen.kt index fb61485..3f408b6 100644 --- a/app/src/main/java/com/ganjoor/android/ui/CategoryScreen.kt +++ b/app/src/main/java/com/ganjoor/android/ui/CategoryScreen.kt @@ -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) } } } } diff --git a/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt b/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt index 633ef1a..e285a44 100644 --- a/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt +++ b/app/src/main/java/com/ganjoor/android/ui/PoemScreen.kt @@ -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(), diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 2a1da44..b6ccd2b 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -62,7 +62,7 @@ درباره و پروانه‌ها گنجور برای اندروید نرم‌افزار آزاد است. شعرها، قلم‌ها و همهٔ کتابخانه‌هایی که این برنامه بر آن‌ها ساخته شده در زیر آمده‌اند؛ برای خواندن متن کامل پروانه روی هر مورد بزنید. برای این واژه مدخلی یافت نشد. - ویکی‌واژه (CC BY-SA 3.0) - فرهنگ دانشجو + ویکی‌واژه — فارسی به انگلیسی (CC BY-SA 3.0) + فرهنگ دانشجو — فارسی به انگلیسی لغت‌نامه diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index a9dcf86..42d718c 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -62,7 +62,7 @@ تعارف اور لائسنس گنجور فار اینڈرائیڈ آزاد سافٹ ویئر ہے۔ کلام، فونٹس اور تمام لائبریریاں ذیل میں درج ہیں؛ مکمل لائسنس پڑھنے کے لیے کسی اندراج پر ٹیپ کریں۔ اس لفظ کا کوئی اندراج نہیں ملا۔ - ویکی لغت (CC BY-SA 3.0) - دانشجو لغت + ویکی لغت — فارسی سے انگریزی (CC BY-SA 3.0) + دانشجو لغت — فارسی سے انگریزی لغت نامہ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bb03491..4ba5bf9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -67,7 +67,7 @@ About & licences 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. No entry for this word. - Wiktionary (CC BY-SA 3.0) - Daneshjoo Dictionary + Wiktionary — Persian to English (CC BY-SA 3.0) + Daneshjoo — Persian to English Dictionary diff --git a/app/src/test/java/com/ganjoor/android/CoupletsTest.kt b/app/src/test/java/com/ganjoor/android/CoupletsTest.kt index 6ba0f3d..3f1d977 100644 --- a/app/src/test/java/com/ganjoor/android/CoupletsTest.kt +++ b/app/src/test/java/com/ganjoor/android/CoupletsTest.kt @@ -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, poems: List) = 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)) + } +} diff --git a/app/src/test/java/com/ganjoor/android/DictionaryTest.kt b/app/src/test/java/com/ganjoor/android/DictionaryTest.kt index 0724708..7c7855d 100644 --- a/app/src/test/java/com/ganjoor/android/DictionaryTest.kt +++ b/app/src/test/java/com/ganjoor/android/DictionaryTest.kt @@ -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 }) + } +}