Merge pull request #44 from dear-clouds/master

Version 1.4.22
This commit is contained in:
Joe Milazzo
2025-12-10 14:29:56 -07:00
committed by GitHub
16 changed files with 1817 additions and 628 deletions

BIN
.github/screenshots/Komikku_1.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
.github/screenshots/Komikku_2.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
.github/screenshots/Mihon_1.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1007 KiB

BIN
.github/screenshots/Mihon_2.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -21,6 +21,7 @@ for module in to_delete:
shutil.copytree(src=LOCAL_REPO.joinpath("apk"), dst=REMOTE_REPO.joinpath("apk"), dirs_exist_ok = True)
shutil.copytree(src=LOCAL_REPO.joinpath("icon"), dst=REMOTE_REPO.joinpath("icon"), dirs_exist_ok = True)
shutil.copy2(src=LOCAL_REPO.joinpath("badge-version.json"), dst=REMOTE_REPO.joinpath("badge-version.json"))
with REMOTE_REPO.joinpath("index.min.json").open() as remote_index_file:
remote_index = json.load(remote_index_file)

148
.github/workflows/create_release.yml vendored Normal file
View File

@@ -0,0 +1,148 @@
name: Create Release on Merge
on:
push:
branches:
- master
workflow_dispatch:
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: read
steps:
- name: Checkout repo branch
uses: actions/checkout@v4
with:
ref: repo
fetch-depth: 0
# Get the merged PR info (only if not triggered manually)
- name: Get PR information
id: pr_info
if: github.event_name == 'push'
run: |
# Check out the master branch to get the PR info
git fetch origin master:master
git checkout master
# Find the merge commit that triggered this workflow
MERGE_COMMIT=$(git rev-parse HEAD)
# Check if this is a merge commit
if git show --summary $MERGE_COMMIT | grep -q "Merge pull request"; then
# Extract PR number from merge commit message
PR_NUMBER=$(git log -1 --pretty=%B | grep -oP 'Merge pull request #\K\d+')
if [ -n "$PR_NUMBER" ]; then
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
# Get PR details
PR_JSON=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUMBER)
PR_TITLE=$(echo "$PR_JSON" | jq -r .title)
echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT
PR_AUTHOR=$(echo "$PR_JSON" | jq -r .user.login)
echo "pr_author=$PR_AUTHOR" >> $GITHUB_OUTPUT
# Check if first-time contributor
CONTRIBUTOR_JSON=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
https://api.github.com/repos/${{ github.repository }}/contributors/$PR_AUTHOR)
CONTRIBUTIONS=$(echo "$CONTRIBUTOR_JSON" | jq -r .contributions)
if [ "$CONTRIBUTIONS" = "1" ]; then
echo "is_first_contributor=true" >> $GITHUB_OUTPUT
fi
# Get PR body
PR_BODY=$(echo "$PR_JSON" | jq -r .body)
echo "pr_body=$PR_BODY" >> $GITHUB_OUTPUT
fi
fi
# Find the Kavita APK and extract version
- name: Find APK and extract version
id: apk_info
run: |
# Find the Kavita APK in the apk folder
APK_PATH=$(find ./apk -name "tachiyomi-all.kavita-*.apk" -type f | head -n 1)
if [ -z "$APK_PATH" ]; then
echo "No Kavita APK found in ./apk folder!"
exit 1
fi
echo "apk_path=$APK_PATH" >> $GITHUB_OUTPUT
# Extract version from filename (tachiyomi-all.kavita-v1.4.21.apk -> v1.4.21)
APK_FILENAME=$(basename "$APK_PATH")
VERSION=$(echo "$APK_FILENAME" | grep -oP 'v\d+\.\d+\.\d+')
if [ -z "$VERSION" ]; then
echo "Could not extract version from filename: $APK_FILENAME"
VERSION="v$(date +%Y%m%d-%H%M%S)"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "apk_name=$APK_FILENAME" >> $GITHUB_OUTPUT
echo "Found APK: $APK_PATH"
echo "Extracted version: $VERSION"
# Get commit messages since last tag
- name: Get changelog
id: changelog
run: |
# Switch to master branch to get git history
git checkout master
# Get the last tag
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -n "$LAST_TAG" ]; then
COMMITS=$(git log $LAST_TAG..HEAD --pretty=format:"- %s (%h)")
else
COMMITS=$(git log --pretty=format:"- %s (%h)" -n 20)
fi
echo "changelog<<EOF" >> $GITHUB_OUTPUT
echo "$COMMITS" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
# Create the release (on the repo branch)
- name: Create Release
uses: softprops/action-gh-release@v2
with:
name: Release ${{ steps.apk_info.outputs.version }}
tag_name: ${{ steps.apk_info.outputs.version }}
target_commitish: repo
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 && format('### 📋 Description:\n%s', steps.pr_info.outputs.pr_body) || '' }}
### 📦 Changes since last release:
${{ steps.changelog.outputs.changelog }}
---
🤖 *This release was automatically created*
files: ${{ steps.apk_info.outputs.apk_path }}
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Notify on failure
if: failure()
run: |
echo "Release creation failed. Check the logs for details."

View File

@@ -14,7 +14,7 @@
This repository contains the Kavita extension, compatible with [Komikku](https://github.com/komikku-app/komikku), [Mihon](https://github.com/mihonapp/mihon), Tachiyomi, and other forks.
## Recommended Apps
## Recommended & Tested
### - [Mihon](https://github.com/mihonapp/mihon)
@@ -22,16 +22,22 @@ This repository contains the Kavita extension, compatible with [Komikku](https:/
## How to add the repo
<a href="https://intradeus.github.io/http-protocol-redirector/?r=tachiyomi://add-repo?url=https://raw.githubusercontent.com/Kareadita/tach-extensions/repo/index.min.json">
<a href="https://intradeus.github.io/http-protocol-redirector/?r=tachiyomi://add-repo?url=https://raw.githubusercontent.com/Kareadita/tach-extension/repo/index.min.json">
<img alt="Install Kavita Repo" src="https://img.shields.io/badge/Click%20to%20Add%20Kavita%20Repo-49CC90?style=for-the-badge&labelColor=555555&color=49CC90&logo=android&logoColor=white">
</a>
Or copy & paste this URL into your app:
Or copy & paste this URL into your instance (Suwayomi included):
```
https://raw.githubusercontent.com/Kareadita/tach-extensions/repo/index.min.json
https://raw.githubusercontent.com/Kareadita/tach-extension/repo/index.min.json
```
**Please, follow [our guide](https://wiki.kavitareader.com/guides/3rdparty/tachi-like/) for more detailed instructions with steps and screenshots.**
# Screenshots
<img src="https://files.catbox.moe/u41pb0.png" width="24%" alt="Komikku1"> <img src="https://files.catbox.moe/ixxs8u.png" width="24%" alt="Mihon1"> <img src="https://files.catbox.moe/j4cnlx.png" width="24%" alt="Komikku2"> <img src="https://files.catbox.moe/nbetf9.png" width="24%" alt="Mihon2">
# Requests
To request a new feature or bug fix, [create an issue](https://github.com/Kareadita/tach-extensions/issues/new/choose).

View File

@@ -1,30 +1,30 @@
[versions]
kotlin_version = "1.7.21"
coroutines_version = "1.6.4"
serialization_version = "1.4.0"
coreVersion = "1.16.0"
commonsText = "1.14.0"
kotlin = "1.7.21"
coroutines = "1.6.4"
serialization = "1.4.0"
[libraries]
gradle-agp = { module = "com.android.tools.build:gradle", version = "8.10.1" }
gradle-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin_version" }
gradle-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin_version" }
commons-text = { module = "org.apache.commons:commons-text", version.ref = "commonsText" }
gradle-agp = { module = "com.android.tools.build:gradle", version = "8.13.0" }
gradle-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
gradle-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" }
gradle-kotlinter = { module = "org.jmailen.gradle:kotlinter-gradle", version = "3.13.0" }
tachiyomi-lib = { module = "com.github.komikku-app:extensions-lib", version = "1.7.1" }
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin_version" }
kotlin-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "serialization_version" }
kotlin-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_version" }
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" }
kotlin-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "serialization" }
kotlin-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines_version" }
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines_version" }
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
injekt-core = { module = "com.github.null2264.injekt:injekt-core", version = "4135455a2a" }
rxjava = { module = "io.reactivex:rxjava", version = "1.3.8" }
jsoup = { module = "org.jsoup:jsoup", version = "1.15.1" }
jsoup = { module = "org.jsoup:jsoup", version = "1.15.3" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version = "5.0.0-alpha.11" }
quickjs = { module = "app.cash.quickjs:quickjs-android", version = "0.9.2" }
core = { group = "androidx.core", name = "core", version.ref = "coreVersion" }
[bundles]
common = ["kotlin-stdlib", "coroutines-core", "coroutines-android", "injekt-core", "rxjava", "kotlin-protobuf", "kotlin-json", "jsoup", "okhttp", "tachiyomi-lib", "quickjs"]

View File

@@ -1,41 +0,0 @@
# Komga
Table of Content
- [FAQ](#FAQ)
- [Why do I see no manga?](#why-do-i-see-no-manga)
- [Where can I get more information about Komga?](#where-can-i-get-more-information-about-komga)
- [The Komga extension stopped working?](#the-komga-extension-stopped-working)
- [Can I add more than one Komga server or user?](#can-i-add-more-than-one-komga-server-or-user)
- [Can I test the Komga extension before setting up my own server?](#can-i-test-the-komga-extension-before-setting-up-my-own-server)
- [Guides](#Guides)
- [How do I add my Komga server to the app?](#how-do-i-add-my-komga-server-to-the-app)
## FAQ
### Why do I see no manga?
Komga is a self-hosted comic/manga media server.
### Where can I get more information about Komga?
You can visit the [Komga](https://komga.org/) website for for more information.
### The Komga extension stopped working?
Make sure that your Komga server and extension are on the newest version.
### Can I add more than one Komga server or user?
Yes, currently you can add up to 11 different Komga instances to the app. The number of instances
available can be customized in extension settings.
### Can I test the Komga extension before setting up my own server?
Yes, you can try it out with the DEMO server `https://demo.komga.org`, username `demo@komga.org` and
password `komga-demo`.
### Why are EPUB chapters/books missing?
Mihon/Tachiyomi does not support reading text. EPUB files containing only images will be available
if your Komga server version is [1.9.0](https://github.com/gotson/komga/releases/tag/1.9.0)
or newer.
## Guides
### How do I add my Komga server to the app?
Go into the settings of the Komga extension (under Browse -> Extensions) and fill in your server
address and login details.

View File

@@ -1,12 +1,10 @@
# ================ #
# Authentication #
# ================ #
login_errors_failed_login=Authentication failed.\nPlease check your credentials
login_errors_header_token_empty=Authorization token missing\nPlease open the extension first to generate a token
login_errors_invalid_url=Invalid server URL:
login_errors_parse_tokendto=Failed to process authentication token
http_errors_401=Authentication expired\nPlease log in again
http_errors_500=Server error occurred
login_errors_failed_login=Authentication failed.\nPlease check your credentials and server URL
login_errors_header_token_empty=Authentication required\nPlease open this extension once to generate a login token
login_errors_invalid_url=Invalid server URL format:
login_errors_parse_tokendto=Server response invalid\nPlease check your Kavita version (requires v0.8.7+)
# ============== #
# Preferences #
@@ -15,32 +13,48 @@ pref_customsource_title=Source Display Name
pref_edit_customsource_summary=Customize how this Kavita instance appears in your library\nExample: "Home Server" or "Cloud Instance"
pref_group_tags_title=Tag Grouping (fork must support grouping)
pref_group_tags_summary_on=Show tags with category prefixes
pref_group_tags_summary_off=Display tags as flat list
pref_group_tags_summary_on=Show tags with category prefixes (e.g., "Genre: Action")
pref_group_tags_summary_off=Display all tags in a flat list
pref_last_volume_cover_title=Keep Cover Updated
pref_last_volume_cover_summary_off=Keep series cover
pref_last_volume_cover_summary_on=Always show current Volume/Issue cover on refresh
pref_last_volume_cover_title=Dynamic Cover Updates
pref_last_volume_cover_summary_off=Always show the main series cover
pref_last_volume_cover_summary_on=Update cover on refresh to show current volume/issue when reading
pref_show_epub_title=EPUB Visibility
pref_show_epub_summary_off=Hide EPUB entries
pref_show_epub_summary_on=Show EPUB entries (unreadable in Mihon)
pref_show_epub_summary_off=Hide EPUB files
pref_show_epub_summary_on=Show EPUB entries (not supported by Mihon)
pref_release_date_title=Chapters: Display Release Dates
pref_release_date_summary_off=Show when chapters were added to Kavita
pref_release_date_summary_on=Show original publication dates\nNote: May affect chapter sorting if dates are missing
pref_rd_date_title=Reading List: Display Release Date
pref_rd_date_summary_off=Hide publication dates
pref_rd_date_summary_off=Hide publication dates from reading lists
pref_rd_date_summary_on=Show publication dates\nNote: Once you toggle it on, if you toggle it off again, it will stays as N/A.
pref_reset_title=Reset Settings
pref_reset_title=Reset All Settings
pref_reset_summary=Clear all preferences for this Kavita instance
pref_filters_title=Default Filters
pref_filters_summary=Select which filters appear by default
pref_allowed_libraries_feed_title=Allowed Libraries on Latest/Popular Feeds & Suggestions
pref_allowed_libraries_feed_summary=Select which libraries appear in the Latest/Popular Feeds & Suggestions (Komikku only)
pref_allowed_libraries_search_title=Allowed Libraries in Search
pref_allowed_libraries_search_summary=Select which libraries appear in search results
pref_chapter_title_format_title=Chapter Title Format
pref_chapter_title_format_summary=Customize chapter titles using variables\nCurrent: %s
pref_chapter_title_format_dialog=Available variables: (Use variables without curly braces, e.g., $CleanTitle instead of ${CleanTitle})\n\n\n\${Type} - Chapter/Episode/Volume/Issue/Special\n\${No} - Chapter number\n\${Title} - Original chapter title\n\${CleanTitle} - Custom-cleaned chapter title (default)\n\${Pages} - Number of pages\n\${Size} - File size\n\${Volume} - Volume number (If available)\n\${SeriesName} - Series name\n\${LibraryName} - Library name\n\${Format} - File format (CBZ, PDF, etc.)\n\${Created} - Creation date\n\${ReleaseDate} - Release date
pref_scanlator_format_title=Scanlator Format
pref_scanlator_format_summary=Customize scanlator field using variables\nCurrent: %s
pref_scanlator_format_dialog=Available variables: (Use variables without curly braces, e.g., $CleanTitle instead of ${CleanTitle})\n\n\n\${Type} - Chapter/Episode/Volume/Issue/Special (default)\n\${No} - Chapter number\n\${Title} - Original chapter title\n\${CleanTitle} - Custom-cleaned chapter title\n\${Pages} - Number of pages\n\${Size} - File size\n\${Volume} - Volume number (If available)\n\${SeriesName} - Series name\n\${LibraryName} - Library name\n\${Format} - File format (CBZ, PDF, etc.)\n\${Created} - Creation date\n\${ReleaseDate} - Release date
# ============== #
# OPDS Setup #
# ============== #
pref_opds_must_setup_address=OPDS URL required for connection
pref_opds_badformed_url=Invalid OPDS address\nCopy from: User Settings > API Key / OPDS > OPDS URL (settings#clients)
pref_opds_must_setup_address=OPDS URL required\nPlease configure your Kavita server connection
pref_opds_badformed_url=Invalid OPDS address format\nGet this from: User Settings > API Key/OPDS > OPDS URL (settings#clients)
pref_opds_duplicated_source_url=URL already used in another source:
pref_opds_summary=Complete OPDS URL including API key\nFormat: http://address:port/opds?apiKey=XXX
@@ -48,9 +62,9 @@ pref_opds_summary=Complete OPDS URL including API key\nFormat: http://address:po
# System Messages #
# ============== #
restartapp_settings=Restart Mihon to apply changes
check_version=Requires Kavita v0.8.7+\nIf issues persist, contact support with logs
version_exceptions_chapters_parse=Chapter parsing error\nPlease report with logs
version_exceptions_smart_filter=Smart Filter error\nRequires Kavita v0.7.11+
check_version=Requires Kavita v0.8.7 or newer\nIf issues persist, open an issue on GitHub
version_exceptions_chapters_parse=Failed to parse chapter information\nPlease report this with error logs on Github
version_exceptions_smart_filter=Smart filter not supported\nRequires Kavita v0.7.11 or newer
# ============== #
@@ -102,6 +116,17 @@ filters_sort_release_date=Release Date
filters_sort_read_progress=Read Progress
# Error messages
http_errors_401=Authentication expired\nPlease log in again
http_errors_500=Server error occurred
http_errors_400=Bad request - Invalid parameters
http_errors_403=Access forbidden - Insufficient permissions
http_errors_404=Resource not found - The requested item doesn't exist
http_errors_429=Too many requests - Please try again later
http_errors_502=Bad gateway - Server communication error
http_errors_503=Service unavailable - Server is temporarily down
filters_error_loading=Failed to load filters
filters_empty_list=No filters available
error_chapter_not_found=Chapter not found in reading list
error_no_chapters_found=No chapters found
error_invalid_reading_list_item=Invalid reading list item
error_failed_parse_chapter=Failed to parse chapter details

View File

@@ -2,13 +2,16 @@ ext {
kmkVersionCode = 1
extName = 'Kavita'
extClass = '.KavitaFactory'
extVersionCode = 20
extVersionCode = 21
isNsfw = false
}
apply from: "$rootDir/common.gradle"
dependencies {
implementation("org.apache.commons:commons-text:1.11.0")
// implementation("org.apache.commons:commons-text:1.14.0")
// implementation(libs.lib.i18n)
implementation project(':lib:i18n')
implementation(libs.commons.text)
}

View File

@@ -6,67 +6,16 @@ object KavitaConstants {
const val UNNUMBERED_VOLUME_STR = "-100000"
const val SPECIAL_NUMBER = 100_000
const val VOLUME_NUMBER_OFFSET = 10000f
val PERSON_ROLES = listOf(
"Writer", "Penciller", "Inker", "Colorist",
"Letterer", "CoverArtist", "Editor",
"Publisher", "Character", "Translator",
)
// toggle filters
const val toggledFiltersPref = "toggledFilters"
val filterPrefEntries = arrayOf(
"Sort Options",
"Special Lists",
"Format",
"Libraries",
"Read Status",
"Genres",
"Tags",
"Collections",
"Languages",
"Publication Status",
"Rating",
"Age Rating",
"ReleaseYearRange",
"Random",
"Read Progress",
)
val filterPrefEntriesValue = arrayOf(
"Sort Options",
"Special Lists",
"Format",
"Libraries",
"Read Status",
"Genres",
"Tags",
"Collections",
"Languages",
"Publication Status",
"Rating",
"Age Rating",
"ReleaseYearRange",
"Random",
"Read Progress",
)
val defaultFilterPrefEntries = setOf(
"Sort Options",
"Special Lists",
"Format",
"Libraries",
"Read Status",
"Genres",
"Tags",
"Collections",
"Languages",
"Publication Status",
"Rating",
"Age Rating",
"ReleaseYearRange",
"Random",
"Read Progress",
)
const val SPECIAL_NUMBER_OFFSET = 10000000000f
const val customSourceNamePref = "customSourceName"
const val noSmartFilterSelected = "No smart filter loaded"
// Template formatting preferences
const val CHAPTER_TITLE_FORMAT_DEFAULT = "\$CleanTitle"
const val SCANLATOR_FORMAT_DEFAULT = "\$Type"
// Library filtering preferences
const val allowedLibrariesFeedPref = "allowedLibrariesFeed"
const val allowedLibrariesSearchPref = "allowedLibrariesSearch"
}

View File

@@ -1,19 +1,22 @@
package eu.kanade.tachiyomi.extension.all.kavita
import android.annotation.SuppressLint
import android.util.Log
import eu.kanade.tachiyomi.extension.all.kavita.dto.ChapterDto
import eu.kanade.tachiyomi.extension.all.kavita.dto.ChapterType
import eu.kanade.tachiyomi.extension.all.kavita.dto.LibraryTypeEnum
import eu.kanade.tachiyomi.extension.all.kavita.dto.PaginationInfo
import eu.kanade.tachiyomi.extension.all.kavita.dto.SeriesDto
import eu.kanade.tachiyomi.extension.all.kavita.dto.VolumeDto
import eu.kanade.tachiyomi.lib.i18n.Intl
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Response
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.ConcurrentHashMap
class KavitaHelper {
val json = Json {
@@ -26,6 +29,22 @@ class KavitaHelper {
prettyPrint = true
}
// Cache for series information
private val seriesCache = ConcurrentHashMap<Int, SeriesDto>()
// Inner class to provide access to series info
inner class SeriesMapper {
fun addSeries(series: SeriesDto) {
seriesCache[series.id] = series
}
fun getSeries(id: Int): SeriesDto? = seriesCache[id]
fun clear() = seriesCache.clear()
}
val seriesMap = SeriesMapper()
fun hasNextPage(response: Response): Boolean {
val paginationHeader = response.header("Pagination")
var hasNextPage = false
@@ -38,24 +57,18 @@ class KavitaHelper {
fun getIdFromUrl(url: String): Int {
return try {
val id = url.substringAfterLast("/").toInt()
// Log.d("KavitaHelper", "Extracted ID $id from URL: $url")
id
val lastSegment = url.substringAfterLast("/")
val cleaned = lastSegment
.substringBefore("?")
.substringBefore("#")
.substringBefore("/")
cleaned.toInt()
} catch (e: Exception) {
// Log.e("KavitaHelper", "Failed to extract ID from URL: $url", e)
Log.e("KavitaHelper", "Failed to extract ID from URL: $url", e)
-1
}
}
// @todo Rating Providers from Series-Details-Plus
// private fun getProviderName(provider: Int): String = when (provider) {
// 0 -> "User"
// 1 -> "AniList"
// 2 -> "MyAnimeList"
// 3 -> "MangaUpdates"
// else -> "Rating"
// }
fun createSeriesDto(obj: SeriesDto, baseUrl: String, apiKey: String): SManga =
SManga.create().apply {
url = "$baseUrl/Series/${obj.id}"
@@ -70,119 +83,232 @@ class KavitaHelper {
libraryType: LibraryTypeEnum? = null,
isWebtoon: Boolean = false,
mangaTitle: String = "",
useReleaseDate: Boolean = false,
chapterTitleFormat: String = "\$CleanTitle",
scanlatorFormat: String = "\$Type",
volumePageCount: Int? = null,
): SChapter =
SChapter.create().apply {
val type = ChapterType.of(chapter, volume, libraryType)
val titleName = chapter.titleName
val titleName = chapter.titleName ?: ""
val title = chapter.title
val range = chapter.range
// For webtoon
val chapterLabel = if (isWebtoon) "Episode" else "Chapter"
val volumeLabel = if (isWebtoon) "Season" else "Volume"
if (titleName != null) {
name = when (type) {
ChapterType.Regular -> {
val chapterNum = formatChapterNumber(chapter)
when {
titleName.isNotBlank() && titleName.any { it.isLetter() } ->
"$chapterNum - ${cleanChapterTitle(
titleName,
ChapterTitleContext(
mangaTitle = mangaTitle,
chapterNumber = chapterNum,
volumeName = volume.name.ifBlank { title },
),
)}"
// Always ensure we have the series name, even if mangaTitle is blank
val seriesName = mangaTitle.ifBlank {
seriesMap.getSeries(volume.seriesId)?.name ?: ""
}
else ->
"$volumeLabel ${formatVolumeNumber(volume)} $chapterLabel $chapterNum"
}
// Debug logging
Log.d("KavitaHelper", "Chapter ${chapter.id}: seriesName = '$seriesName', mangaTitle = '$mangaTitle'")
name = when (type) {
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 finalCleanTitle = cleanedTitle.ifBlank {
defaultCleanTitle(type, chapterNum, volNum, isWebtoon)
}
ChapterType.SingleFileVolume -> {
val volumeNumber = formatVolumeNumber(volume)
val cleanVolumeName = volume.name.trim()
val variables = ChapterTemplateVariables(
type = if (isWebtoon) "Episode" else "Chapter",
number = chapterNum,
title = titleName,
pages = chapter.pages,
fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0,
volumeNumber = volNum,
cleanTitle = finalCleanTitle,
seriesName = seriesName, // SeriesName should NOT be processed through cleanChapterTitle
libraryName = volume.let { v ->
seriesMap.getSeries(v.seriesId)?.libraryName ?: ""
},
formats = chapter.files?.firstOrNull()?.extension?.uppercase() ?: "",
created = chapter.created,
releaseDate = chapter.releaseDate,
)
// Always use the API's volume number, never parse from title
when {
// If name is empty or just numbers (Kavita default)
cleanVolumeName.isEmpty() || cleanVolumeName.none { it.isLetter() } -> {
cleanChapterTitle("Volume $volumeNumber")
// Debug logging for Chapter Title Format variables
Log.d("KavitaHelper", "ChapterTitleFormat variables for chapter ${chapter.id}:")
Log.d("KavitaHelper", " chapterTitleFormat: '$chapterTitleFormat'")
Log.d("KavitaHelper", " seriesName: '${variables.seriesName}'")
Log.d("KavitaHelper", " libraryName: '${variables.libraryName}'")
Log.d("KavitaHelper", " title: '${variables.title}'")
Log.d("KavitaHelper", " cleanTitle: '${variables.cleanTitle}'")
val processedName = processChapterTemplate(chapterTitleFormat, variables)
Log.d("KavitaHelper", "Final processed chapter name: '$processedName'")
processedName
}
ChapterType.SingleFileVolume -> {
val volumeNumber = formatVolumeNumber(volume)
val variables = ChapterTemplateVariables(
type = if (isWebtoon) "Season" else "Volume",
number = volumeNumber,
title = volume.name,
pages = volume.pages,
fileSize = volume.chapters.flatMap { it.files ?: emptyList() }.sumOf { it.bytes }.toDouble(),
volumeNumber = volumeNumber,
cleanTitle = 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")
if (isWebtoon) {
val volNum = volume.name.toIntOrNull()?.toString() ?: volume.name
"Season $volNum"
} else {
val volNum = volume.name.toIntOrNull()?.toString() ?: volume.name
"Volume $volNum"
}
}
// If name already contains volume info (Vol. 3, V3, etc.)
cleanVolumeName.contains(Regex("(?i)(vol|volume|v)[.\\s]*\\d+")) -> {
cleanChapterTitle(cleanVolumeName)
}
// Normal case - prefix with volume number
else -> {
cleanChapterTitle("$volumeNumber - $cleanVolumeName")
if (isWebtoon) "Season $volumeNumber" else "Volume $volumeNumber"
}
}
},
seriesName = seriesName,
libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "",
formats = volume.chapters.flatMap { it.files ?: emptyList() }.firstOrNull()?.extension?.uppercase() ?: "",
created = volume.created,
releaseDate = "",
)
val processedName = processChapterTemplate(chapterTitleFormat, variables)
Log.d("KavitaHelper", "Final processed SFV name: '$processedName'")
processedName
}
ChapterType.Special -> {
val specialTitle = when {
title.isNotBlank() -> title
range.isNotBlank() -> range
else -> "Special"
}
ChapterType.Special -> {
cleanChapterTitle(
when {
title.isNotBlank() -> title
range.isNotBlank() -> range
else -> "Special"
},
ChapterTitleContext(
mangaTitle = mangaTitle,
volumeName = volume.name,
),
)
val variables = ChapterTemplateVariables(
type = "Special",
number = "",
title = specialTitle,
pages = chapter.pages,
fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0,
volumeNumber = formatVolumeNumber(volume),
cleanTitle = 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")
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"
},
seriesName = seriesName,
libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "",
formats = chapter.files?.firstOrNull()?.extension?.uppercase() ?: "",
created = chapter.created,
releaseDate = chapter.releaseDate,
)
val processedName = processChapterTemplate(chapterTitleFormat, variables)
Log.d("KavitaHelper", "Final processed Special name: '$processedName'")
processedName
}
ChapterType.Chapter -> {
val chapterNum = formatChapterNumber(chapter)
val cleanedTitle = cleanChapterTitle(
titleName,
ChapterTitleContext(
mangaTitle = mangaTitle,
chapterNumber = chapterNum,
volumeName = volume.name,
isWebtoon = isWebtoon,
),
)
val finalCleanTitle = cleanedTitle.ifBlank {
defaultCleanTitle(type, chapterNum, formatVolumeNumber(volume), isWebtoon)
}
ChapterType.Chapter -> {
val chapterNum = formatChapterNumber(chapter)
when {
titleName.isNotBlank() && titleName.any { it.isLetter() } ->
"$chapterNum - ${cleanChapterTitle(
titleName,
ChapterTitleContext(
mangaTitle = mangaTitle,
chapterNumber = chapterNum,
volumeName = volume.name,
),
)}"
val variables = ChapterTemplateVariables(
type = if (isWebtoon) "Episode" else "Chapter",
number = chapterNum,
title = titleName,
pages = chapter.pages,
fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0,
volumeNumber = "",
cleanTitle = finalCleanTitle,
seriesName = seriesName,
libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "",
formats = chapter.files?.firstOrNull()?.extension?.uppercase() ?: "",
created = chapter.created,
releaseDate = chapter.releaseDate,
)
else ->
"$chapterLabel $chapterNum"
}
}
val processedName = processChapterTemplate(chapterTitleFormat, variables)
Log.d("KavitaHelper", "Final processed Chapter name: '$processedName'")
processedName
}
ChapterType.Issue -> {
val issueNum = chapter.number.toIntOrNull()?.toString()?.padStart(3, '0') ?: chapter.number
cleanChapterTitle(
when {
titleName.any { it.isLetter() } -> "$titleName (#$issueNum)"
else -> "Issue #$issueNum"
},
ChapterTitleContext(
mangaTitle = mangaTitle,
chapterNumber = issueNum,
volumeName = volume.name,
),
)
}
ChapterType.Issue -> {
val issueNum = chapter.number.toIntOrNull()?.toString()?.padStart(3, '0') ?: chapter.number
val variables = ChapterTemplateVariables(
type = "Issue",
number = issueNum,
title = titleName,
pages = chapter.pages,
fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0,
volumeNumber = formatVolumeNumber(volume),
cleanTitle = 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")
val num = titleName.toIntOrNull()?.toString()?.padStart(3, '0') ?: titleName
"Issue #$num"
}
else -> "Issue #$issueNum"
}.ifBlank {
defaultCleanTitle(type, issueNum, formatVolumeNumber(volume), isWebtoon)
},
seriesName = seriesName,
libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "",
formats = chapter.files?.firstOrNull()?.extension?.uppercase() ?: "",
created = chapter.created,
releaseDate = chapter.releaseDate,
)
val processedName = processChapterTemplate(chapterTitleFormat, variables)
Log.d("KavitaHelper", "Final processed Issue name: '$processedName'")
processedName
}
}
// Handle decimal chapter numbers properly
chapter_number = when {
type == ChapterType.SingleFileVolume && singleFileVolumeNumber != null ->
volume.minNumber.toFloat()
type == ChapterType.SingleFileVolume -> {
// For standalone volumes, use positive numbers
volume.minNumber.toFloat()
}
// For regular chapters
type != ChapterType.SingleFileVolume && type != ChapterType.Special -> {
// Handle decimal chapter numbers
// Regular, Chapter, Issue (1.0, 2.0...)
type == ChapterType.Regular ||
type == ChapterType.Chapter ||
type == ChapterType.Issue -> {
if (chapter.minNumber % 1 != 0.0) {
chapter.minNumber.toFloat()
} else {
@@ -190,9 +316,9 @@ class KavitaHelper {
}
}
// For volumes/specials in mixed content, place them below chapter 0
// Volumes and Specials (< 1.0)
else -> {
val volumeNum = try {
val rawNum = try {
if (volume.minNumber % 1 != 0.0) {
volume.minNumber.toFloat()
} else {
@@ -201,33 +327,116 @@ class KavitaHelper {
} catch (e: NumberFormatException) {
0f
}
volumeNum / KavitaConstants.VOLUME_NUMBER_OFFSET
when (type) {
// Volume 1 -> 0.0001
ChapterType.SingleFileVolume -> rawNum / KavitaConstants.VOLUME_NUMBER_OFFSET
// Special 100k -> 0.00001
ChapterType.Special -> rawNum / KavitaConstants.SPECIAL_NUMBER_OFFSET
else -> rawNum
}
}
}
url = when {
type == ChapterType.SingleFileVolume && singleFileVolumeNumber != null ->
"volume_${volume.id}"
else -> "chapter_${chapter.id}"
}
url = "/Chapter/${chapter.id}"
if (chapter.fileCount > 1) {
// salt/offset to recognize chapters with new merged part-chapters as new and hence unread
// Only apply salt to Chapters.
// DO NOT apply to Volume or Special as it corrupts the 0.0001/0.00001 sorting logic.
if (chapter.fileCount > 1 &&
(type == ChapterType.Regular || type == ChapterType.Chapter || type == ChapterType.Issue)
) {
chapter_number += 0.001f * chapter.fileCount
url = "${url}_${chapter.fileCount}"
url = "$url?split=${chapter.fileCount}"
}
date_upload = parseDateSafe(chapter.created)
scanlator = when (type) {
ChapterType.SingleFileVolume -> if (isWebtoon) "Season" else "Volume"
ChapterType.Special -> "Special"
ChapterType.Issue -> "Issue"
ChapterType.Chapter -> if (isWebtoon) "Episode" else "Chapter"
ChapterType.Regular -> if (isWebtoon) "Episode" else "Chapter"
date_upload = if (useReleaseDate && chapter.releaseDate.isNotBlank()) {
parseDateSafe(chapter.releaseDate)
} else {
parseDateSafe(chapter.created)
}
// Prepare template variables for scanlator
val scanlatorVariables = ChapterTemplateVariables(
type = when (type) {
ChapterType.SingleFileVolume -> if (isWebtoon) "Season" else "Volume"
ChapterType.Special -> "Special"
ChapterType.Issue -> "Issue"
ChapterType.Chapter -> if (isWebtoon) "Episode" else "Chapter"
ChapterType.Regular -> if (isWebtoon) "Episode" else "Chapter"
},
number = when (type) {
ChapterType.Regular, ChapterType.Chapter, ChapterType.Issue -> formatChapterNumber(chapter)
ChapterType.SingleFileVolume -> formatVolumeNumber(volume)
else -> ""
},
title = titleName,
pages = if (singleFileVolumeNumber != null && volumePageCount != null) {
volumePageCount
} else {
chapter.pages
},
fileSize = if (singleFileVolumeNumber != null) {
volume.chapters.flatMap { it.files ?: emptyList() }.sumOf { it.bytes }.toDouble()
} else {
chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0
},
volumeNumber = formatVolumeNumber(volume),
cleanTitle = when {
titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) -> titleName
titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> {
// Handle numeric titles
when (type) {
ChapterType.Issue -> {
val num = titleName.toIntOrNull()?.toString()?.padStart(3, '0') ?: titleName
"Issue #$num"
}
else -> {
val num = titleName.toIntOrNull()?.toString()?.padStart(2, '0') ?: titleName
if (isWebtoon) "Episode $num" else "Chapter $num"
}
}
}
else -> {
// Generate fallback based on chapter type and volume info
val chNum = when (type) {
ChapterType.Regular, ChapterType.Chapter -> formatChapterNumber(chapter)
ChapterType.Issue -> chapter.number.toIntOrNull()?.toString()?.padStart(3, '0') ?: chapter.number
else -> ""
}
val volNum = formatVolumeNumber(volume)
when {
isWebtoon && volNum != "0" -> "Season $volNum Episode $chNum"
isWebtoon -> "Episode $chNum"
volNum != "0" -> "Volume $volNum Chapter $chNum"
else -> "Chapter $chNum"
}
}
},
seriesName = seriesName,
libraryName = volume.let { v ->
seriesMap.getSeries(v.seriesId)?.libraryName ?: ""
},
formats = if (singleFileVolumeNumber != null) {
volume.chapters.flatMap { it.files ?: emptyList() }.firstOrNull()?.extension?.uppercase() ?: ""
} else {
chapter.files?.firstOrNull()?.extension?.uppercase() ?: ""
},
created = chapter.created,
releaseDate = chapter.releaseDate,
)
// Debug logging for Scanlator Format variables
Log.d("KavitaHelper", "ScanlatorFormat variables for chapter ${chapter.id}:")
Log.d("KavitaHelper", " scanlatorFormat: '$scanlatorFormat'")
Log.d("KavitaHelper", " seriesName: '${scanlatorVariables.seriesName}'")
Log.d("KavitaHelper", " libraryName: '${scanlatorVariables.libraryName}'")
Log.d("KavitaHelper", " title: '${scanlatorVariables.title}'")
Log.d("KavitaHelper", " cleanTitle: '${scanlatorVariables.cleanTitle}'")
scanlator = processChapterTemplate(scanlatorFormat, scanlatorVariables)
}
private fun formatVolumeNumber(volume: VolumeDto): String {
internal fun formatVolumeNumber(volume: VolumeDto): String {
return when {
volume.maxNumber > volume.minNumber ->
"${removeTrailingZero(volume.minNumber)}-${removeTrailingZero(volume.maxNumber)}"
@@ -235,7 +444,7 @@ class KavitaHelper {
}
}
private fun formatChapterNumber(chapter: ChapterDto, padLength: Int = 2): String {
internal fun formatChapterNumber(chapter: ChapterDto, padLength: Int = 2): String {
val chapterNum = when {
chapter.maxNumber > chapter.minNumber ->
"${removeTrailingZero(chapter.minNumber)}-${removeTrailingZero(chapter.maxNumber)}"
@@ -252,7 +461,7 @@ class KavitaHelper {
// Helper function to remove .0 from whole numbers while preserving actual decimals
@SuppressLint("DefaultLocale")
private fun removeTrailingZero(number: Double): String {
internal fun removeTrailingZero(number: Double): String {
return if (number % 1 == 0.0) {
number.toInt().toString()
} else {
@@ -261,28 +470,158 @@ class KavitaHelper {
}
}
private fun cleanChapterTitle(
internal fun cleanChapterTitle(
originalTitle: String,
context: ChapterTitleContext? = null,
): String {
var title = originalTitle.trim()
val mangaTitle = context?.mangaTitle ?: ""
val isWebtoon = context?.isWebtoon ?: false
// Don't modify titles that start with a number followed by a pattern like "12. It's my fate"
if (title.matches(Regex("^\\d+\\..*"))) {
return title
}
// Remove manga name if present
if (mangaTitle.isNotBlank()) {
title = title.replace(Regex("(?i)\\b${Regex.escape(mangaTitle)}\\b[\\s\\-:]*"), "").trim()
}
// Remove chapter number patterns like c0116, c001, etc.
title = title.replace(Regex("""(?i)\bc\d+\b"""), "").trim()
// Helper function to check if we have to shorten terms
fun hasMoreContent(currentTitle: String, match: MatchResult): Boolean {
val beforeMatch = currentTitle.take(match.range.first).trim()
val afterMatch = currentTitle.substring(match.range.last + 1).trim()
return (beforeMatch.isNotEmpty() && beforeMatch.any { it.isLetterOrDigit() }) ||
(afterMatch.isNotEmpty() && afterMatch.any { it.isLetterOrDigit() })
}
// Clean up problematic hyphen/space patterns
title = title.replace(Regex("""\s*[-.]\s*[-.]\s*"""), " - ") // Handles -.-, - . -, etc.
.replace(Regex("""\s*-\s*-\s*"""), " - ") // Double hyphens with spaces
.replace(Regex("""^\s*[-.]\s*|\s*[-.]\s*$"""), "") // Leading/trailing hyphens or dots
.trim()
// Process patterns with conditional shortening
title = title.replace(Regex("""(?i)\bvolume\s+(\d+)""")) { match ->
val number = match.groupValues[1].padStart(2, '0')
if (isWebtoon) {
if (hasMoreContent(title, match)) "S. $number" else "Season $number"
} else {
if (hasMoreContent(title, match)) "Vol. $number" else "Volume $number"
}
}
return title.ifBlank { originalTitle }
title = title.replace(Regex("""(?i)\bvol\s+(\d+)""")) { match ->
val number = match.groupValues[1].padStart(2, '0')
if (isWebtoon) {
if (hasMoreContent(title, match)) "S. $number" else "Season $number"
} else {
if (hasMoreContent(title, match)) "Vol. $number" else "Vol. $number"
}
}
// Chapter/Episode handling
title = title.replace(Regex("""(?i)\bchapter\s+(\d+)""")) { match ->
val number = match.groupValues[1].padStart(2, '0')
if (isWebtoon) {
if (hasMoreContent(title, match)) "Ep. $number" else "Episode $number"
} else {
if (hasMoreContent(title, match)) "Ch. $number" else "Chapter $number"
}
}
title = title.replace(Regex("""(?i)\bch\s+(\d+)""")) { match ->
val number = match.groupValues[1].padStart(2, '0')
val word = if (isWebtoon && hasMoreContent(title, match)) "Ep." else if (isWebtoon) "Episode" else "Ch."
"$word $number"
}
title = title.replace(Regex("""(?i)\bepisode\s+(\d+)""")) { match ->
val number = match.groupValues[1].padStart(2, '0')
if (hasMoreContent(title, match)) "Ep. $number" else "Episode $number"
}
title = title.replace(Regex("""(?i)\bep\s+(\d+)""")) { match ->
val number = match.groupValues[1].padStart(2, '0')
if (hasMoreContent(title, match)) "Ep. $number" else "Ep. $number"
}
// cXXX pattern always shortened if more content
title = title.replace(Regex("""(?i)\bc(\d+)\b""")) { match ->
val number = match.groupValues[1].padStart(2, '0')
val term = if (isWebtoon) "Ep." else "Ch."
if (hasMoreContent(title, match)) "$term $number" else "c$number"
}
// Clean up spaces
title = title.replace(Regex("""\s+"""), " ").trim()
// Final check: If title is just "Ch. 08" or "Ep. 08" with no other content, expand it
val justNumberedPattern = Regex("""^(Ch|Ep|Vol|S)\. (\d{2})$""", RegexOption.IGNORE_CASE)
title = title.replace(justNumberedPattern) { match ->
val type = match.groupValues[1].lowercase()
val number = match.groupValues[2]
when {
isWebtoon -> {
when (type) {
"ep", "ch" -> "Episode ${number.toInt()}"
"vol", "s" -> "Season ${number.toInt()}"
else -> "Episode ${number.toInt()}"
}
}
else -> {
when (type) {
"ep" -> "Episode ${number.toInt()}"
"vol" -> "Volume ${number.toInt()}"
"s" -> "Season ${number.toInt()}"
else -> "Chapter ${number.toInt()}"
}
}
}
}
// Remove leading numbers if no other patterns present
if (!title.contains(Regex("""(?i)(vol|volume|ch|chapter|ep|episode|c\d+)"""))) {
title = title.replace(Regex("""^\d+(\s*-\s*)?"""), "").trim()
}
// If title becomes blank, return the original title to prevent empty titles
val finalTitle = title.ifBlank { originalTitle }
// Debug logging for troubleshooting
if (finalTitle.isBlank() && originalTitle.isNotBlank()) {
Log.d("KavitaHelper", "Warning: Title processing resulted in blank title for '$originalTitle'. Using original.")
}
return finalTitle
}
/**
* Checks if a title contains chapter/episode/volume numbering patterns
*/
internal fun hasNumberingPattern(title: String, isWebtoon: Boolean = false): Boolean {
return title.contains(Regex("""(?i)(ch\.?\s*\d+|\bchapter\s+\d+|\bc\d+\b|\bep\.?\s*\d+|\bepisode\s+\d+)""")) ||
title.contains(Regex("""(?i)(vol\.?\s*\d+|\bvolume\s+\d+)"""))
}
internal fun defaultCleanTitle(
type: ChapterType,
number: String,
volumeNumber: String,
isWebtoon: Boolean,
): String {
val safeNumber = number.ifBlank { "01" }
val safeVolume = volumeNumber.ifBlank { "0" }
return when (type) {
ChapterType.Regular -> {
when {
isWebtoon -> "Episode $safeNumber"
safeVolume != "0" -> "Volume $safeVolume Chapter $safeNumber"
else -> "Chapter $safeNumber"
}
}
ChapterType.Chapter -> {
if (isWebtoon) "Episode $safeNumber" else "Chapter $safeNumber"
}
ChapterType.Issue -> "Issue #${safeNumber.padStart(3, '0')}"
ChapterType.SingleFileVolume -> if (isWebtoon) "Season $safeVolume" else "Volume $safeVolume"
ChapterType.Special -> "Special ${safeNumber.ifBlank { safeVolume.padStart(2, '0') }}"
}
}
/**
@@ -291,27 +630,260 @@ class KavitaHelper {
data class ChapterTitleContext(
val mangaTitle: String = "",
val chapterNumber: String = "",
val volumeNumber: String = "",
val volumeName: String = "",
val isWebtoon: Boolean = false,
)
fun isWebtoonOrLongStrip(tags: List<String>): Boolean {
return tags.any {
val normalized = it.trim().lowercase()
fun isWebtoonOrLongStrip(
genres: List<String>,
tags: List<String>,
libraryName: String? = null,
format: String? = null,
demographic: String? = null,
allTags: List<String>? = null,
): Boolean {
val allFields = mutableListOf<String>().apply {
addAll(genres)
addAll(tags)
libraryName?.let { add(it) }
format?.let { add(it) }
demographic?.let { add(it) }
allTags?.let { addAll(it) }
}
return allFields.any { field ->
val normalized = field.trim().lowercase()
normalized.contains("webtoon") || normalized.contains("long strip")
}
}
val intl = Intl(
language = Locale.getDefault().toString(),
baseLanguage = "en",
availableLanguages = KavitaInt.AVAILABLE_LANGS,
classLoader = this::class.java.classLoader!!,
createMessageFileName = { lang ->
when (lang) {
KavitaInt.SPANISH_LATAM -> Intl.createDefaultMessageFileName(KavitaInt.SPANISH)
else -> Intl.createDefaultMessageFileName(lang)
// Legacy compatibility
fun isWebtoonOrLongStrip(tags: List<String>): Boolean {
return isWebtoonOrLongStrip(
genres = emptyList(),
tags = tags,
libraryName = null,
format = null,
demographic = null,
allTags = null,
)
}
/**
* Extracts demographic and format information from genres and tags
* @param genres List of genres
* @param tags List of tags
* @param format List of formats
* @return Triple of found demographic (if any), and filtered lists
*/
fun extractDemographicAndFormat(genres: List<String>, tags: List<String>): Triple<String?, List<String>, Pair<List<String>, List<String>>> {
val demographicKeywords = listOf("Shounen", "Seinen", "Josei", "Shoujo", "Hentai", "Doujinshi")
val formatKeywords = listOf("Long Strip", "4-koma", "4 Koma", "Full Color", "Full Colour", "Color", "Colour", "Graphic Novel", "Manga", "Manhua", "Manhwa")
val foundDemographic = demographicKeywords.firstOrNull { demo ->
genres.any { it.equals(demo, ignoreCase = true) } ||
tags.any { it.equals(demo, ignoreCase = true) }
}?.let { demo ->
// Get the actual matched keyword with correct case
genres.find { it.equals(demo, ignoreCase = true) }
?: tags.find { it.equals(demo, ignoreCase = true) }
}
// Find ALL matching formats, preserving original case
val foundFormats = formatKeywords.mapNotNull { formats ->
genres.find { it.equals(formats, ignoreCase = true) }
?: tags.find { it.equals(formats, ignoreCase = true) }
}.distinct()
val filteredGenres = genres.filterNot { genre ->
genre.equals(foundDemographic, ignoreCase = true) ||
foundFormats.any { it.equals(genre, ignoreCase = true) }
}
val filteredTags = tags
.filterNot { tag ->
tag.equals(foundDemographic, ignoreCase = true) ||
foundFormats.any { it.equals(tag, ignoreCase = true) }
}
},
.filterNot { tag -> filteredGenres.any { genre -> genre.equals(tag, ignoreCase = true) } }
return Triple(foundDemographic, foundFormats, filteredGenres to filteredTags)
}
/**
* Builds genre string based on user preferences
* @param libraryName Library name for grouping
* @param demographic Demographic information
* @param formats Format information (list of all formats)
* @param genres List of genres
* @param tags List of tags
* @param groupTags Whether to group tags with prefixes
* @return Formatted genre string
*/
fun buildGenreString(
libraryName: String?,
demographic: String?,
formats: List<String>,
genres: List<String>,
tags: List<String>,
groupTags: Boolean,
): String {
return if (groupTags) {
buildList {
libraryName?.takeIf { it.isNotEmpty() }?.let { add("Type:$it") }
demographic?.let { add("Demographic:$it") }
formats.forEach { format ->
if (format.isNotBlank()) {
add("Formats:$format")
}
}
genres.forEach { add("Genres:$it") }
tags.forEach { add("Tags:$it") }
}.joinToString(", ")
} else {
(genres + tags + formats).toSet().toList().sorted().joinToString(", ")
}
}
/**
* Extracts demographic information from genres and tags (legacy compatibility)
* @param genres List of genre titles
* @param tags List of tag titles
* @return Pair of found demographic (if any) and filtered lists
*/
fun extractDemographic(genres: List<String>, tags: List<String>): Pair<String?, Pair<List<String>, List<String>>> {
val (demographic, _, filteredPair) = extractDemographicAndFormat(genres, tags)
return demographic to filteredPair
}
/**
* Safely parses a string to Int with null safety
*/
fun parseStringToIntSafely(value: String?): Int? {
return value?.toIntOrNull()
}
/**
* Safely extracts series ID from URL with null safety
*/
fun extractSeriesIdFromUrl(url: String): Int? {
return try {
url.substringAfterLast("/").toIntOrNull()
} catch (e: Exception) {
null
}
}
/**
* Safely parses date string with null safety
*/
fun parseDateSafe(date: String?): Long {
return date?.let {
try {
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
sdf.timeZone = TimeZone.getTimeZone("UTC")
sdf.parse(it)?.time ?: 0L
} catch (_: Exception) {
0L
}
} ?: 0L
}
/**
* Data class to hold template variables for chapter formatting
*/
data class ChapterTemplateVariables(
val type: String,
val number: String,
val title: String,
val pages: Int,
val fileSize: Double,
val volumeNumber: String = "",
val cleanTitle: String = "",
val seriesName: String = "",
val libraryName: String = "",
val formats: String = "",
val created: String = "",
val releaseDate: String = "",
)
/**
* Processes a template string with chapter variables
* Available variables: $Type, $No, $Title, $CleanTitle, $Pages, $Size, $Volume, $SeriesName, $LibraryName, $Format, $Created, $ReleaseDate
*/
fun processChapterTemplate(
template: String,
variables: ChapterTemplateVariables,
): String {
if (template.isBlank()) return ""
val fileSizeMB = if (variables.fileSize > 0) {
"%.1f".format(variables.fileSize / (1024.0 * 1024.0))
} else {
""
}
val formattedSize = if (fileSizeMB.isNotEmpty()) "$fileSizeMB MB" else ""
// Create replacement map
val replacements = mutableMapOf<String, String>()
replacements["Type"] = variables.type
replacements["No"] = variables.number
replacements["Title"] = variables.title
replacements["CleanTitle"] = variables.cleanTitle
replacements["Pages"] = if (variables.pages > 0) variables.pages.toString() else ""
replacements["Size"] = formattedSize
replacements["Volume"] = variables.volumeNumber
replacements["SeriesName"] = variables.seriesName.ifEmpty { "" }
replacements["LibraryName"] = variables.libraryName
replacements["Format"] = variables.formats
replacements["Created"] = variables.created
replacements["ReleaseDate"] = variables.releaseDate
// Debug logging before processing
Log.d("KavitaHelper", "Template processing - BEFORE: template='$template', seriesName='${variables.seriesName}', libraryName='${variables.libraryName}'")
// Process the template
var result = template
// First replace escaped dollar signs with temporary placeholder
result = result.replace("\\$", "\u0000TEMP_DOLLAR\u0000")
// Replace all variables using regex for more reliable matching
replacements.forEach { (key, value) ->
// Fixed regex pattern - use negative lookahead to prevent partial matches
val pattern = Regex("\\\$${Regex.escape(key)}(?!\\w)")
val before = result
result = result.replace(pattern, value)
// Debug logging for all variables
if (before != result) {
Log.d("KavitaHelper", "Template processing: Replaced \$$key with: '$value'")
}
}
// Remove any remaining unrecognized variables
result = result.replace(Regex("\\$\\w+"), "")
// Normalize whitespace
result = result.replace(Regex("\\s+"), " ").trim()
// Restore escaped dollar signs
result = result.replace("\u0000TEMP_DOLLAR\u0000", "$")
// Debug logging after processing
Log.d("KavitaHelper", "Template processing - AFTER: result='$result'")
// WORKAROUND: If the result starts with the series name, add a zero-width space to prevent truncation
val finalResult = if (result.startsWith(variables.seriesName)) {
"\u200B$result" // Zero-width space prevents UI truncation
} else {
result
}
if (finalResult != result) {
Log.d("KavitaHelper", "Template processing - WORKAROUND applied (zero-width space)")
}
return finalResult
}
}

View File

@@ -111,6 +111,7 @@ enum class FilterField(val type: Int) {
Team(30),
Location(31),
ReadLast(32),
People(33),
;
companion object {

View File

@@ -88,11 +88,6 @@ data class SeriesDetailPlusDto(
seriesDto?.libraryName
}
}
// Helper function to get the library ID from SeriesDto if needed
fun getLibraryId(seriesDto: SeriesDto?): Int? {
return libraryId ?: seriesDto?.libraryId
}
}
@Serializable
@@ -253,6 +248,7 @@ data class ChapterDto(
val volumeId: Int,
val created: String,
val lastModifiedUtc: String,
val releaseDate: String,
val files: List<FileDto>? = null,
) {
val fileCount: Int
@@ -262,6 +258,12 @@ data class ChapterDto(
@Serializable
data class FileDto(
val id: Int,
val bytes: Long = 0,
val pages: Int = 0,
val filePath: String = "",
val format: Int = 0,
val created: String = "",
val extension: String = "",
)
@Serializable