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

This commit is contained in:
2026-05-23 14:03:53 +09:00
parent 230cf3764a
commit ba96a8a2ee
4 changed files with 184 additions and 113 deletions

4
.gitignore vendored
View File

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

View File

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

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 name = "${KavitaInt.KAVITA_NAME} (${preferences.getString(KavitaConstants.customSourceNamePref, suffix)})"
override val lang = "all" override val lang = "all"
override val supportsLatest = true override val supportsLatest = true
private val apiUrl: String by lazy { getPrefApiUrl() } private val apiUrl: String get() = getPrefApiUrl()
private val apiKey: String by lazy { getPrefApiKey() } private val apiKey: String get() = getPrefApiKey()
override val baseUrl by lazy { getPrefBaseUrl() } override val baseUrl: String get() = 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 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 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 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 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 * Custom implementation for fetch popular, latest and search
* Handles and logs errors to provide a more detailed exception to the users. * 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 { try {
val response = client.newCall(request).execute() val response = client.newCall(request).execute()
if (!response.isSuccessful) { if (!response.isSuccessful) {
val code = response.code val code = response.code
throw IOException("Http Error: $code\n ${intl["http_errors_$code"]}\n${intl["check_version"]}") throw IOException("Http Error: $code\n ${intl["http_errors_$code"]}\n${intl["check_version"]}")
} }
popularMangaParse(response) parser(response)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(LOG_TAG, "Error fetching manga", e) Log.e(LOG_TAG, "Error fetching manga", e)
throw e throw e
@@ -288,7 +291,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
popularMangaParse(response) popularMangaParse(response)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(LOG_TAG, "Error fetching popular manga", e) 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) readingListParse(response)
} }
else -> { // Regular searches (including Want to Read) 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, scanlatorFormat = preferences.scanlatorFormat,
volumePageCount = volume.pages, volumePageCount = volume.pages,
) )
sChapter.url = "/Chapter/${chapter.id}" sChapter.url = chapterUrlWithPageCount(chapter, volume.pages)
// For singleFileVolume, ensure the scanlator field reflects webtoon terminology // For singleFileVolume, ensure the scanlator field reflects webtoon terminology
sChapter.scanlator = if (isWebtoon) "Season" else "Volume" sChapter.scanlator = if (isWebtoon) "Season" else "Volume"
@@ -1712,7 +1715,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
scanlatorFormat = preferences.scanlatorFormat, scanlatorFormat = preferences.scanlatorFormat,
) )
sChapter.url = "/Chapter/${chapter.id}" sChapter.url = chapterUrlWithPageCount(chapter)
allChapters.add(sChapter) allChapters.add(sChapter)
} }
@@ -1757,6 +1760,48 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
return GET("$apiUrl/$chapterId", headersBuilder().build()) 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) { override suspend fun getPageList(chapter: SChapter): List<Page> = withContext(Dispatchers.IO) {
// Check if this is a reading list item (has readingListId in URL) // Check if this is a reading list item (has readingListId in URL)
if (chapter.url.contains("readingListId=")) { if (chapter.url.contains("readingListId=")) {
@@ -1804,12 +1849,8 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
} }
// Generate pages with consistent URL format // Generate pages with consistent URL format
return@withContext (0 until chapterDetails.pages).map { i -> val extractPdf = shouldExtractPdf(chapterDetails)
Page( return@withContext (0 until chapterDetails.pages).map { i -> readerPage(i, chapterDetails.id, extractPdf = extractPdf) }
index = i,
imageUrl = "$apiUrl/Reader/image?chapterId=${chapterDetails.id}&page=$i&extractPdf=true&apiKey=$apiKey",
)
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(LOG_TAG, "Error processing reading list item", e) Log.e(LOG_TAG, "Error processing reading list item", e)
throw e throw e
@@ -1835,31 +1876,22 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
// Get all pages in this volume // Get all pages in this volume
val volumeRequest = GET("$apiUrl/Volume/$volumeId", headersBuilder().build()) val volumeRequest = GET("$apiUrl/Volume/$volumeId", headersBuilder().build())
val volume = client.newCall(volumeRequest).execute().parseAs<VolumeDto>() val volume = client.newCall(volumeRequest).execute().parseAs<VolumeDto>()
val matchingChapter = volume.chapters.firstOrNull() // or match a chapterId if available if (volume.chapters.isEmpty()) {
?: throw IOException(intl["error_no_chapters_found"]) 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",
)
} }
val remainingPages = volume.chapters val volumePages = volume.chapters
.sortedBy { it.number.toFloatOrNull() ?: 0f } .sortedBy { it.number.toFloatOrNull() ?: 0f }
.runningFold(initialPages.size) { acc, chapter -> acc + chapter.pages } .runningFold(0) { acc, chapter -> acc + chapter.pages }
.drop(1)
.zip(volume.chapters.sortedBy { it.number.toFloatOrNull() ?: 0f }) .zip(volume.chapters.sortedBy { it.number.toFloatOrNull() ?: 0f })
.flatMap { (offset, chapter) -> .flatMap { (startIndex, chapter) ->
val extractPdf = shouldExtractPdf(chapter)
(0 until chapter.pages).map { i -> (0 until chapter.pages).map { i ->
Page( readerPage(startIndex + i, chapter.id, i, extractPdf)
index = offset - chapter.pages + i,
imageUrl = "$apiUrl/Reader/image?chapterId=${chapter.id}&page=$i&extractPdf=true&apiKey=$apiKey",
)
} }
} }
return@withContext (initialPages + remainingPages).toList() return@withContext volumePages
} else { } else {
// Original chapter handling // Original chapter handling
val chapterId = when { val chapterId = when {
@@ -1868,6 +1900,11 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
else -> throw IOException("Invalid chapter URL format") 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 chapterRequest = GET("$apiUrl/Chapter?chapterId=$chapterId", headersBuilder().build())
val response = client.newCall(chapterRequest).execute() val response = client.newCall(chapterRequest).execute()
@@ -1886,23 +1923,19 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
throw IOException(errorMessage) throw IOException(errorMessage)
} }
return@withContext (0 until chapterDetails.pages).map { i -> val fetchedExtractPdf = shouldExtractPdf(chapterDetails)
Page( return@withContext (0 until chapterDetails.pages).map { i -> readerPage(i, chapterId, extractPdf = fetchedExtractPdf) }
index = i,
imageUrl = "$apiUrl/Reader/image?chapterId=$chapterId&page=$i&extractPdf=true&apiKey=$apiKey",
)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
// Fallback to using the scanlator field if we can't get chapter details // Fallback to using the scanlator field if we can't get chapter details
Log.e(LOG_TAG, "Error fetching chapter details, using fallback", e) Log.e(LOG_TAG, "Error fetching chapter details, using fallback", e)
val fallbackPageCount = chapter.scanlator?.replace(" pages", "")?.toIntOrNull() ?: 1 val fallbackPageCount = chapter.scanlator?.replace(" pages", "")?.toIntOrNull() ?: 1
return@withContext (0 until fallbackPageCount).map { i -> val fallbackChapterId = chapter.url
Page( .substringAfter("/Chapter/", chapter.url)
index = i, .substringAfter("chapter_", chapter.url)
imageUrl = "$apiUrl/Reader/image?chapterId=${chapter.url.substringBefore("_")}&page=$i&extractPdf=true&apiKey=$apiKey", .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) MangasPage(mangaList, false)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(LOG_TAG, "Error parsing search results", e) Log.e(LOG_TAG, "Error parsing search results", e)
// Return empty result instead of throwing exception throw IOException(intl["check_version"], e)
MangasPage(emptyList(), false)
} }
} }
@@ -2233,7 +2265,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
return EditTextPreference(context).apply { return EditTextPreference(context).apply {
key = preKey key = preKey
this.title = title 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.summary = if (input == null || input.isEmpty()) summary else input
this.setDefaultValue(default) this.setDefaultValue(default)
dialogTitle = title 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 val opdsUrlInPref = opdsUrlInPreferences(newValue.toString()) // We don't allow hot have multiple sources with same ip or domain
if (opdsUrlInPref.isNotEmpty()) { if (opdsUrlInPref.isNotEmpty()) {
// TODO("Add option to allow multiple sources with same url at the cost of tracking") // 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( Toast.makeText(
context, context,
@@ -2259,7 +2291,9 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
throw OpdsurlExistsInPref(intl["pref_opds_duplicated_source_url"] + opdsUrlInPref) 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( Toast.makeText(
context, context,
intl["restartapp_settings"], intl["restartapp_settings"],
@@ -2537,14 +2571,15 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
private fun doLogin() { private fun doLogin() {
Log.d(LOG_TAG, "Attempting login with address: ${address.takeIf { it.isNotEmpty() } ?: "EMPTY"}") 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") Log.e(LOG_TAG, "OPDS URL is empty or null")
throw IOException(intl["pref_opds_must_setup_address"]) 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"]) throw IOException(intl["pref_opds_badformed_url"])
} }
if (jwtToken.isEmpty()) setupLogin() if (jwtToken.isEmpty() && address.isNotEmpty()) setupLogin()
Log.v(LOG_TAG, "[Login] Starting login") Log.v(LOG_TAG, "[Login] Starting login")
val request = POST( val request = POST(
"$apiUrl/Plugin/authenticate?apiKey=${getPrefKey()}&pluginName=Tachiyomi-Kavita", "$apiUrl/Plugin/authenticate?apiKey=${getPrefKey()}&pluginName=Tachiyomi-Kavita",

View File

@@ -106,16 +106,7 @@ class KavitaHelper {
ChapterType.Regular -> { ChapterType.Regular -> {
val chapterNum = formatChapterNumber(chapter) val chapterNum = formatChapterNumber(chapter)
val volNum = formatVolumeNumber(volume) val volNum = formatVolumeNumber(volume)
val cleanedTitle = cleanChapterTitle( val cleanedTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon)
titleName,
ChapterTitleContext(
mangaTitle = mangaTitle,
chapterNumber = chapterNum,
volumeNumber = volNum,
volumeName = volume.name.ifBlank { title },
isWebtoon = isWebtoon,
),
)
val finalCleanTitle = cleanedTitle.ifBlank { val finalCleanTitle = cleanedTitle.ifBlank {
defaultCleanTitle(type, chapterNum, volNum, isWebtoon) defaultCleanTitle(type, chapterNum, volNum, isWebtoon)
} }
@@ -160,20 +151,22 @@ class KavitaHelper {
pages = volume.pages, pages = volume.pages,
fileSize = volume.chapters.flatMap { it.files ?: emptyList() }.sumOf { it.bytes }.toDouble(), fileSize = volume.chapters.flatMap { it.files ?: emptyList() }.sumOf { it.bytes }.toDouble(),
volumeNumber = volumeNumber, volumeNumber = volumeNumber,
cleanTitle = when { cleanTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon).ifBlank {
volume.name.isNotBlank() && !volume.name.matches(Regex("^\\d+$")) -> volume.name when {
volume.name.isNotBlank() && volume.name.matches(Regex("^\\d+$")) -> { volume.name.isNotBlank() && !volume.name.matches(Regex("^\\d+$")) -> volume.name
// Handle case where volume name is only a number (e.g., "2") volume.name.isNotBlank() && volume.name.matches(Regex("^\\d+$")) -> {
if (isWebtoon) { // Handle case where volume name is only a number (e.g., "2")
val volNum = volume.name.toIntOrNull()?.toString() ?: volume.name if (isWebtoon) {
"Season $volNum" val volNum = volume.name.toIntOrNull()?.toString() ?: volume.name
} else { "Season $volNum"
val volNum = volume.name.toIntOrNull()?.toString() ?: volume.name } else {
"Volume $volNum" val volNum = volume.name.toIntOrNull()?.toString() ?: volume.name
"Volume $volNum"
}
}
else -> {
if (isWebtoon) "Season $volumeNumber" else "Volume $volumeNumber"
} }
}
else -> {
if (isWebtoon) "Season $volumeNumber" else "Volume $volumeNumber"
} }
}, },
seriesName = seriesName, seriesName = seriesName,
@@ -202,26 +195,28 @@ class KavitaHelper {
pages = chapter.pages, pages = chapter.pages,
fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0, fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0,
volumeNumber = formatVolumeNumber(volume), volumeNumber = formatVolumeNumber(volume),
cleanTitle = when { cleanTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon).ifBlank {
titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) -> titleName when {
titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> { titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) -> titleName
// Handle case where titleName is only a number (e.g., "2") titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> {
val num = titleName.toIntOrNull()?.toString()?.padStart(2, '0') ?: titleName // Handle case where titleName is only a number (e.g., "2")
"Special $num" val num = titleName.toIntOrNull()?.toString()?.padStart(2, '0') ?: titleName
"Special $num"
}
title.isNotBlank() && !title.matches(Regex("^\\d+$")) -> title
title.isNotBlank() && title.matches(Regex("^\\d+$")) -> {
// Handle case where title is only a number (e.g., "2")
val num = title.toIntOrNull()?.toString()?.padStart(2, '0') ?: title
"Special $num"
}
range.isNotBlank() && !range.matches(Regex("^\\d+$")) -> range
range.isNotBlank() && range.matches(Regex("^\\d+$")) -> {
// Handle case where range is only a number (e.g., "2")
val num = range.toIntOrNull()?.toString()?.padStart(2, '0') ?: range
"Special $num"
}
else -> "Special"
} }
title.isNotBlank() && !title.matches(Regex("^\\d+$")) -> title
title.isNotBlank() && title.matches(Regex("^\\d+$")) -> {
// Handle case where title is only a number (e.g., "2")
val num = title.toIntOrNull()?.toString()?.padStart(2, '0') ?: title
"Special $num"
}
range.isNotBlank() && !range.matches(Regex("^\\d+$")) -> range
range.isNotBlank() && range.matches(Regex("^\\d+$")) -> {
// Handle case where range is only a number (e.g., "2")
val num = range.toIntOrNull()?.toString()?.padStart(2, '0') ?: range
"Special $num"
}
else -> "Special"
}, },
seriesName = seriesName, seriesName = seriesName,
libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "", libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "",
@@ -237,15 +232,7 @@ class KavitaHelper {
ChapterType.Chapter -> { ChapterType.Chapter -> {
val chapterNum = formatChapterNumber(chapter) val chapterNum = formatChapterNumber(chapter)
val cleanedTitle = cleanChapterTitle( val cleanedTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon)
titleName,
ChapterTitleContext(
mangaTitle = mangaTitle,
chapterNumber = chapterNum,
volumeName = volume.name,
isWebtoon = isWebtoon,
),
)
val finalCleanTitle = cleanedTitle.ifBlank { val finalCleanTitle = cleanedTitle.ifBlank {
defaultCleanTitle(type, chapterNum, formatVolumeNumber(volume), isWebtoon) defaultCleanTitle(type, chapterNum, formatVolumeNumber(volume), isWebtoon)
} }
@@ -280,16 +267,18 @@ class KavitaHelper {
pages = chapter.pages, pages = chapter.pages,
fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0, fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0,
volumeNumber = formatVolumeNumber(volume), volumeNumber = formatVolumeNumber(volume),
cleanTitle = when { cleanTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon).ifBlank {
titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) && titleName.any { it.isLetter() } -> titleName when {
titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> { titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) && titleName.any { it.isLetter() } -> titleName
// Handle case where titleName is only a number (e.g., "2") titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> {
val num = titleName.toIntOrNull()?.toString()?.padStart(3, '0') ?: titleName // Handle case where titleName is only a number (e.g., "2")
"Issue #$num" val num = titleName.toIntOrNull()?.toString()?.padStart(3, '0') ?: titleName
"Issue #$num"
}
else -> "Issue #$issueNum"
}.ifBlank {
defaultCleanTitle(type, issueNum, formatVolumeNumber(volume), isWebtoon)
} }
else -> "Issue #$issueNum"
}.ifBlank {
defaultCleanTitle(type, issueNum, formatVolumeNumber(volume), isWebtoon)
}, },
seriesName = seriesName, seriesName = seriesName,
libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "", 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( internal fun cleanChapterTitle(
originalTitle: String, originalTitle: String,
context: ChapterTitleContext? = null, context: ChapterTitleContext? = null,