Compare commits

...

12 Commits

Author SHA1 Message Date
ba96a8a2ee Customize Kavita extension for local reader
Some checks failed
CI / Prepare job (push) Has been cancelled
CI / Build extensions (${{ matrix.chunk.number }}) (push) Has been cancelled
CI / Publish extension repo (push) Has been cancelled
Create Release on Merge / release (push) Has been cancelled
2026-05-23 14:03:53 +09:00
Mio.
230cf3764a Merge pull request #51 from MajorTanya/fix-hard-crash-loop
Fix hard crash loop
2025-12-22 05:57:03 +01:00
MajorTanya
600d8ca4ff Bump extVersionCode
This results in version 1.4.23.
2025-12-21 10:11:22 +01:00
MajorTanya
13657ba2dc Fix hard crash loop for unreachable servers
It should be noted that this is a band-aid fix for deeper underlying issues.

The code as-was would attempt a login on every init, including when apps like Mihon would recover from the first crash by going to their global crash handler activity.

This usually happened whenever a user would be unable to connect to their Kavita instance (server not up, different/no networks, etc) and left the user unable to fix the issue by removing the broken API URL or uninstalling the extension through the app's menus.

It doesn't matter if the Kavita instance that is unreachable is disabled in the extension settings, the crash will still occur because the login is performed in the init block, which is called regardless of an extension's specific enable/disable state (to be able to display settings, etc).
2025-12-21 10:04:54 +01:00
Mio.
6780871c0f Merge pull request #46 from dear-clouds/master
Fix(release): Final check
2025-12-10 23:53:06 +01:00
Mio.
05bf6b5904 Fix: Switch back to repo branch before creating release 2025-12-10 23:47:28 +01:00
Mio.
dd0f7dd467 refactor(release): use <details> to avoid weird formatting 2025-12-10 23:44:03 +01:00
Mio.
0168daab08 Fix(release): Handle commits without PR numbers gracefully 2025-12-10 23:36:01 +01:00
Mio.
db005805d5 Merge remote-tracking branch 'origin/master' 2025-12-10 23:29:36 +01:00
Mio.
f207b2a3c2 checkout origin/master to be safe 2025-12-10 23:29:27 +01:00
Mio.
a867c9caf5 Merge branch 'Kareadita:master' into master 2025-12-10 23:25:42 +01:00
Mio.
f04e4c92e7 Merge pull request #45 from dear-clouds/master
Fixing new action
2025-12-10 23:07:24 +01:00
6 changed files with 204 additions and 127 deletions

View File

@@ -25,8 +25,8 @@ jobs:
id: pr_info
run: |
# Fetch and checkout master to analyze history
git fetch origin master:master
git checkout master
git fetch origin master
git checkout -B master origin/master
# Initialize variables
FOUND_PR=false
@@ -36,9 +36,11 @@ jobs:
for COMMIT in $MERGE_COMMITS; do
# Extract PR number
PR_NUMBER=$(git show --format=%B -s $COMMIT | grep -oP 'Merge pull request #\K\d+')
PR_NUMBER=$(git show --format=%B -s $COMMIT | grep -oP 'Merge pull request #\K\d+' || true)
# Fallback: Try to match squash merge pattern "Title (#123)"
if [ -z "$PR_NUMBER" ]; then
PR_NUMBER=$(git show --format=%B -s $COMMIT | grep -oP '\(#\K\d+(?=\))')
PR_NUMBER=$(git show --format=%B -s $COMMIT | grep -oP '\(#\K\d+(?=\))' || true)
fi
if [ -n "$PR_NUMBER" ]; then
@@ -153,6 +155,12 @@ jobs:
echo "EOF"
} >> $GITHUB_OUTPUT
# Switch back to 'repo' branch to upload the APK
- name: Final Switch to repo branch
if: steps.check_release.outputs.exists == 'false'
run: |
git checkout repo
- name: Create Release
if: steps.check_release.outputs.exists == 'false'
uses: softprops/action-gh-release@v2
@@ -163,15 +171,15 @@ jobs:
body: |
## 🚀 Release ${{ steps.apk_info.outputs.version }}
### 📝 Pull Request
${{ steps.pr_info.outputs.pr_number && format('- Pull Request #%s by @%s', steps.pr_info.outputs.pr_number, steps.pr_info.outputs.pr_author) || 'Manual release' }}
${{ steps.pr_info.outputs.pr_title && format('- Title: %s', steps.pr_info.outputs.pr_title) || '' }}
${{ steps.pr_info.outputs.is_first_contributor == 'true' && format('### 🎉 Welcome @%s on their first contribution!', steps.pr_info.outputs.pr_author) || '' }}
${{ steps.pr_info.outputs.pr_body }}
${{ steps.pr_info.outputs.pr_body && format('### 📋 Description:\n%s', steps.pr_info.outputs.pr_body) || '' }}
---
<details>
<summary>📦 View Full Commit Log</summary>
### 📦 Changes since last release:
${{ steps.changelog.outputs.changelog }}
</details>
---
🤖 *This release was automatically created*

4
.gitignore vendored
View File

@@ -11,3 +11,7 @@ apk/
gen
generated-src/
.kotlin
signingkey.jks
output.json
tmp/
Inspector.jar

View File

@@ -25,3 +25,5 @@ android.useAndroidX=true
android.enableBuildConfigAsBytecode=true
android.defaults.buildfeatures.resvalues=false
android.defaults.buildfeatures.shaders=false
android.overridePathCheck=true

View File

@@ -2,7 +2,7 @@ ext {
kmkVersionCode = 1
extName = 'Kavita'
extClass = '.KavitaFactory'
extVersionCode = 21
extVersionCode = 22
isNsfw = false
}

View File

@@ -125,10 +125,10 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
override val name = "${KavitaInt.KAVITA_NAME} (${preferences.getString(KavitaConstants.customSourceNamePref, suffix)})"
override val lang = "all"
override val supportsLatest = true
private val apiUrl: String by lazy { getPrefApiUrl() }
private val apiKey: String by lazy { getPrefApiKey() }
override val baseUrl by lazy { getPrefBaseUrl() }
private val address by lazy { getPrefAddress() } // Address for the Kavita OPDS url. Should be http(s)://host:(port)/api/opds/api-key
private val apiUrl: String get() = getPrefApiUrl()
private val apiKey: String get() = getPrefApiKey()
override val baseUrl: String get() = getPrefBaseUrl()
private val address: String get() = getPrefAddress() // Address for the Kavita OPDS url. Should be http(s)://host:(port)/api/opds/api-key
private var jwtToken = "" // * JWT Token for authentication with the server. Stored in memory.
private val LOG_TAG = """Kavita_${"[$suffix]_" + preferences.getString(KavitaConstants.customSourceNamePref, "[$suffix]")!!.replace(' ', '_')}"""
private var isLogged = false // Used to know if login was correct and not send login requests anymore
@@ -265,14 +265,17 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
* Custom implementation for fetch popular, latest and search
* Handles and logs errors to provide a more detailed exception to the users.
*/
private suspend fun fetch(request: Request): MangasPage = withContext(Dispatchers.IO) {
private suspend fun fetch(
request: Request,
parser: (Response) -> MangasPage = ::popularMangaParse,
): MangasPage = withContext(Dispatchers.IO) {
try {
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
val code = response.code
throw IOException("Http Error: $code\n ${intl["http_errors_$code"]}\n${intl["check_version"]}")
}
popularMangaParse(response)
parser(response)
} catch (e: Exception) {
Log.e(LOG_TAG, "Error fetching manga", e)
throw e
@@ -288,7 +291,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
popularMangaParse(response)
} catch (e: Exception) {
Log.e(LOG_TAG, "Error fetching popular manga", e)
MangasPage(emptyList(), false)
throw e
}
}
@@ -303,7 +306,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
readingListParse(response)
}
else -> { // Regular searches (including Want to Read)
fetch(searchMangaRequest(page, query, filters))
fetch(searchMangaRequest(page, query, filters), ::searchMangaParse)
}
}
}
@@ -1693,7 +1696,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
scanlatorFormat = preferences.scanlatorFormat,
volumePageCount = volume.pages,
)
sChapter.url = "/Chapter/${chapter.id}"
sChapter.url = chapterUrlWithPageCount(chapter, volume.pages)
// For singleFileVolume, ensure the scanlator field reflects webtoon terminology
sChapter.scanlator = if (isWebtoon) "Season" else "Volume"
@@ -1712,7 +1715,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
scanlatorFormat = preferences.scanlatorFormat,
)
sChapter.url = "/Chapter/${chapter.id}"
sChapter.url = chapterUrlWithPageCount(chapter)
allChapters.add(sChapter)
}
@@ -1757,6 +1760,48 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
return GET("$apiUrl/$chapterId", headersBuilder().build())
}
private fun chapterUrlWithPageCount(chapter: ChapterDto, pageCount: Int = chapter.pages): String {
val params = mutableListOf<String>()
if (pageCount > 0) {
params += "pages=$pageCount"
}
params += "extractPdf=${shouldExtractPdf(chapter)}"
if (chapter.fileCount > 1) {
params += "split=${chapter.fileCount}"
}
return buildString {
append("/Chapter/${chapter.id}")
if (params.isNotEmpty()) {
append("?")
append(params.joinToString("&"))
}
}
}
private fun pageCountFromChapterUrl(chapter: SChapter): Int? =
chapter.url.substringAfter("pages=", "")
.substringBefore("&")
.toIntOrNull()
?.takeIf { it > 0 }
private fun shouldExtractPdf(chapter: ChapterDto): Boolean =
chapter.files.orEmpty().any { file ->
file.format == MangaFormat.Pdf.format || file.extension.equals("pdf", ignoreCase = true)
}
private fun extractPdfFromChapterUrl(chapter: SChapter): Boolean =
chapter.url.substringAfter("extractPdf=", "")
.substringBefore("&")
.equals("true", ignoreCase = true)
private fun readerImageUrl(chapterId: Any, page: Int, extractPdf: Boolean): String =
"$apiUrl/Reader/image?chapterId=$chapterId&page=$page&extractPdf=$extractPdf&apiKey=$apiKey"
private fun readerPage(index: Int, chapterId: Any, page: Int = index, extractPdf: Boolean = false): Page {
val imageUrl = readerImageUrl(chapterId, page, extractPdf)
return Page(index = index, url = imageUrl, imageUrl = imageUrl)
}
override suspend fun getPageList(chapter: SChapter): List<Page> = withContext(Dispatchers.IO) {
// Check if this is a reading list item (has readingListId in URL)
if (chapter.url.contains("readingListId=")) {
@@ -1804,12 +1849,8 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
}
// Generate pages with consistent URL format
return@withContext (0 until chapterDetails.pages).map { i ->
Page(
index = i,
imageUrl = "$apiUrl/Reader/image?chapterId=${chapterDetails.id}&page=$i&extractPdf=true&apiKey=$apiKey",
)
}
val extractPdf = shouldExtractPdf(chapterDetails)
return@withContext (0 until chapterDetails.pages).map { i -> readerPage(i, chapterDetails.id, extractPdf = extractPdf) }
} catch (e: Exception) {
Log.e(LOG_TAG, "Error processing reading list item", e)
throw e
@@ -1835,31 +1876,22 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
// Get all pages in this volume
val volumeRequest = GET("$apiUrl/Volume/$volumeId", headersBuilder().build())
val volume = client.newCall(volumeRequest).execute().parseAs<VolumeDto>()
val matchingChapter = volume.chapters.firstOrNull() // or match a chapterId if available
?: throw IOException(intl["error_no_chapters_found"])
val initialPages = (0 until matchingChapter.pages).map { i ->
Page(
index = i,
imageUrl = "$apiUrl/Reader/image?chapterId=${matchingChapter.id}&page=$i&extractPdf=true&apiKey=$apiKey",
)
if (volume.chapters.isEmpty()) {
throw IOException(intl["error_no_chapters_found"])
}
val remainingPages = volume.chapters
val volumePages = volume.chapters
.sortedBy { it.number.toFloatOrNull() ?: 0f }
.runningFold(initialPages.size) { acc, chapter -> acc + chapter.pages }
.drop(1)
.runningFold(0) { acc, chapter -> acc + chapter.pages }
.zip(volume.chapters.sortedBy { it.number.toFloatOrNull() ?: 0f })
.flatMap { (offset, chapter) ->
.flatMap { (startIndex, chapter) ->
val extractPdf = shouldExtractPdf(chapter)
(0 until chapter.pages).map { i ->
Page(
index = offset - chapter.pages + i,
imageUrl = "$apiUrl/Reader/image?chapterId=${chapter.id}&page=$i&extractPdf=true&apiKey=$apiKey",
)
readerPage(startIndex + i, chapter.id, i, extractPdf)
}
}
return@withContext (initialPages + remainingPages).toList()
return@withContext volumePages
} else {
// Original chapter handling
val chapterId = when {
@@ -1868,6 +1900,11 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
else -> throw IOException("Invalid chapter URL format")
}
val extractPdf = extractPdfFromChapterUrl(chapter)
pageCountFromChapterUrl(chapter)?.let { pageCount ->
return@withContext (0 until pageCount).map { i -> readerPage(i, chapterId, extractPdf = extractPdf) }
}
val chapterRequest = GET("$apiUrl/Chapter?chapterId=$chapterId", headersBuilder().build())
val response = client.newCall(chapterRequest).execute()
@@ -1886,23 +1923,19 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
throw IOException(errorMessage)
}
return@withContext (0 until chapterDetails.pages).map { i ->
Page(
index = i,
imageUrl = "$apiUrl/Reader/image?chapterId=$chapterId&page=$i&extractPdf=true&apiKey=$apiKey",
)
}
val fetchedExtractPdf = shouldExtractPdf(chapterDetails)
return@withContext (0 until chapterDetails.pages).map { i -> readerPage(i, chapterId, extractPdf = fetchedExtractPdf) }
}
} catch (e: Exception) {
// Fallback to using the scanlator field if we can't get chapter details
Log.e(LOG_TAG, "Error fetching chapter details, using fallback", e)
val fallbackPageCount = chapter.scanlator?.replace(" pages", "")?.toIntOrNull() ?: 1
return@withContext (0 until fallbackPageCount).map { i ->
Page(
index = i,
imageUrl = "$apiUrl/Reader/image?chapterId=${chapter.url.substringBefore("_")}&page=$i&extractPdf=true&apiKey=$apiKey",
)
}
val fallbackChapterId = chapter.url
.substringAfter("/Chapter/", chapter.url)
.substringAfter("chapter_", chapter.url)
.substringBefore("_")
.substringBefore("?")
return@withContext (0 until fallbackPageCount).map { i -> readerPage(i, fallbackChapterId, extractPdf = extractPdfFromChapterUrl(chapter)) }
}
}
@@ -1930,8 +1963,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
MangasPage(mangaList, false)
} catch (e: Exception) {
Log.e(LOG_TAG, "Error parsing search results", e)
// Return empty result instead of throwing exception
MangasPage(emptyList(), false)
throw IOException(intl["check_version"], e)
}
}
@@ -2233,7 +2265,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
return EditTextPreference(context).apply {
key = preKey
this.title = title
val input = preferences.getString(title, null)
val input = preferences.getString(preKey, null)
this.summary = if (input == null || input.isEmpty()) summary else input
this.setDefaultValue(default)
dialogTitle = title
@@ -2249,7 +2281,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
val opdsUrlInPref = opdsUrlInPreferences(newValue.toString()) // We don't allow hot have multiple sources with same ip or domain
if (opdsUrlInPref.isNotEmpty()) {
// TODO("Add option to allow multiple sources with same url at the cost of tracking")
preferences.edit().putString(title, "").apply()
preferences.edit().putString(preKey, "").apply()
Toast.makeText(
context,
@@ -2259,7 +2291,9 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
throw OpdsurlExistsInPref(intl["pref_opds_duplicated_source_url"] + opdsUrlInPref)
}
val res = preferences.edit().putString(title, newValue as String).commit()
val res = preferences.edit().putString(preKey, newValue as String).commit()
jwtToken = ""
isLogged = false
Toast.makeText(
context,
intl["restartapp_settings"],
@@ -2537,14 +2571,15 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
private fun doLogin() {
Log.d(LOG_TAG, "Attempting login with address: ${address.takeIf { it.isNotEmpty() } ?: "EMPTY"}")
if (address.isEmpty()) {
val hasStoredLogin = apiUrl.isNotBlank() && getPrefKey().isNotBlank()
if (address.isEmpty() && !hasStoredLogin) {
Log.e(LOG_TAG, "OPDS URL is empty or null")
throw IOException(intl["pref_opds_must_setup_address"])
}
if (address.split("/opds/").size != 2) {
if (address.isNotEmpty() && address.split("/opds/").size != 2) {
throw IOException(intl["pref_opds_badformed_url"])
}
if (jwtToken.isEmpty()) setupLogin()
if (jwtToken.isEmpty() && address.isNotEmpty()) setupLogin()
Log.v(LOG_TAG, "[Login] Starting login")
val request = POST(
"$apiUrl/Plugin/authenticate?apiKey=${getPrefKey()}&pluginName=Tachiyomi-Kavita",
@@ -2587,10 +2622,8 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
try {
Log.d(LOG_TAG, "Starting Kavita extension initialization")
// First attempt login with timeout
val loginJob = async { doLogin() }
try {
loginJob.await()
doLogin()
} catch (e: Exception) {
Log.e(LOG_TAG, "Login failed during initialization", e)
initializationError = e

View File

@@ -106,16 +106,7 @@ class KavitaHelper {
ChapterType.Regular -> {
val chapterNum = formatChapterNumber(chapter)
val volNum = formatVolumeNumber(volume)
val cleanedTitle = cleanChapterTitle(
titleName,
ChapterTitleContext(
mangaTitle = mangaTitle,
chapterNumber = chapterNum,
volumeNumber = volNum,
volumeName = volume.name.ifBlank { title },
isWebtoon = isWebtoon,
),
)
val cleanedTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon)
val finalCleanTitle = cleanedTitle.ifBlank {
defaultCleanTitle(type, chapterNum, volNum, isWebtoon)
}
@@ -160,7 +151,8 @@ class KavitaHelper {
pages = volume.pages,
fileSize = volume.chapters.flatMap { it.files ?: emptyList() }.sumOf { it.bytes }.toDouble(),
volumeNumber = volumeNumber,
cleanTitle = when {
cleanTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon).ifBlank {
when {
volume.name.isNotBlank() && !volume.name.matches(Regex("^\\d+$")) -> volume.name
volume.name.isNotBlank() && volume.name.matches(Regex("^\\d+$")) -> {
// Handle case where volume name is only a number (e.g., "2")
@@ -175,6 +167,7 @@ class KavitaHelper {
else -> {
if (isWebtoon) "Season $volumeNumber" else "Volume $volumeNumber"
}
}
},
seriesName = seriesName,
libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "",
@@ -202,7 +195,8 @@ class KavitaHelper {
pages = chapter.pages,
fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0,
volumeNumber = formatVolumeNumber(volume),
cleanTitle = when {
cleanTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon).ifBlank {
when {
titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) -> titleName
titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> {
// Handle case where titleName is only a number (e.g., "2")
@@ -222,6 +216,7 @@ class KavitaHelper {
"Special $num"
}
else -> "Special"
}
},
seriesName = seriesName,
libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "",
@@ -237,15 +232,7 @@ class KavitaHelper {
ChapterType.Chapter -> {
val chapterNum = formatChapterNumber(chapter)
val cleanedTitle = cleanChapterTitle(
titleName,
ChapterTitleContext(
mangaTitle = mangaTitle,
chapterNumber = chapterNum,
volumeName = volume.name,
isWebtoon = isWebtoon,
),
)
val cleanedTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon)
val finalCleanTitle = cleanedTitle.ifBlank {
defaultCleanTitle(type, chapterNum, formatVolumeNumber(volume), isWebtoon)
}
@@ -280,7 +267,8 @@ class KavitaHelper {
pages = chapter.pages,
fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0,
volumeNumber = formatVolumeNumber(volume),
cleanTitle = when {
cleanTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon).ifBlank {
when {
titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) && titleName.any { it.isLetter() } -> titleName
titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> {
// Handle case where titleName is only a number (e.g., "2")
@@ -290,6 +278,7 @@ class KavitaHelper {
else -> "Issue #$issueNum"
}.ifBlank {
defaultCleanTitle(type, issueNum, formatVolumeNumber(volume), isWebtoon)
}
},
seriesName = seriesName,
libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "",
@@ -470,6 +459,47 @@ class KavitaHelper {
}
}
internal fun kavitaDisplayTitle(
type: ChapterType,
chapter: ChapterDto,
volume: VolumeDto,
libraryType: LibraryTypeEnum? = null,
isWebtoon: Boolean = false,
): String {
val titleName = chapter.titleName?.trim().orEmpty()
val title = chapter.title.trim()
val range = chapter.range.trim()
val volumeName = volume.name.trim()
val usableVolumeName = volumeName.takeUnless { it.isBlank() || it == KavitaConstants.UNNUMBERED_VOLUME_STR }
fun fallbackChapterLabel(): String {
val number = range.takeUnless { it.isBlank() || it == KavitaConstants.UNNUMBERED_VOLUME_STR }
?: formatChapterNumber(chapter)
return when (libraryType) {
LibraryTypeEnum.Comic, LibraryTypeEnum.ComicVine -> "Issue #$number"
LibraryTypeEnum.Book, LibraryTypeEnum.LightNovel -> "Book $number"
else -> if (isWebtoon) "Episode $number" else "Chapter $number"
}
}
fun joinTitle(left: String?, right: String?): String =
listOfNotNull(
left?.takeIf { it.isNotBlank() },
right?.takeIf { it.isNotBlank() && it != left },
).joinToString(" - ")
return when (type) {
ChapterType.SingleFileVolume -> joinTitle(usableVolumeName, titleName)
.ifBlank { titleName }
.ifBlank { usableVolumeName.orEmpty() }
.ifBlank { title }
ChapterType.Special -> title.ifBlank { titleName }.ifBlank { range }
ChapterType.Regular -> joinTitle(usableVolumeName, titleName.ifBlank { fallbackChapterLabel() })
ChapterType.Chapter -> titleName.ifBlank { fallbackChapterLabel() }
ChapterType.Issue -> titleName.ifBlank { fallbackChapterLabel() }
}
}
internal fun cleanChapterTitle(
originalTitle: String,
context: ChapterTitleContext? = null,