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("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.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: with REMOTE_REPO.joinpath("index.min.json").open() as remote_index_file:
remote_index = json.load(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. 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) ### - [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 ## 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"> <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> </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 # Requests
To request a new feature or bug fix, [create an issue](https://github.com/Kareadita/tach-extensions/issues/new/choose). 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] [versions]
kotlin_version = "1.7.21" commonsText = "1.14.0"
coroutines_version = "1.6.4" kotlin = "1.7.21"
serialization_version = "1.4.0" coroutines = "1.6.4"
coreVersion = "1.16.0" serialization = "1.4.0"
[libraries] [libraries]
gradle-agp = { module = "com.android.tools.build:gradle", version = "8.10.1" } commons-text = { module = "org.apache.commons:commons-text", version.ref = "commonsText" }
gradle-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin_version" } gradle-agp = { module = "com.android.tools.build:gradle", version = "8.13.0" }
gradle-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin_version" } 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" } 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" } 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-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" }
kotlin-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "serialization_version" } kotlin-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "serialization" }
kotlin-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_version" } 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-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines_version" } coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
injekt-core = { module = "com.github.null2264.injekt:injekt-core", version = "4135455a2a" } injekt-core = { module = "com.github.null2264.injekt:injekt-core", version = "4135455a2a" }
rxjava = { module = "io.reactivex:rxjava", version = "1.3.8" } 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" } okhttp = { module = "com.squareup.okhttp3:okhttp", version = "5.0.0-alpha.11" }
quickjs = { module = "app.cash.quickjs:quickjs-android", version = "0.9.2" } quickjs = { module = "app.cash.quickjs:quickjs-android", version = "0.9.2" }
core = { group = "androidx.core", name = "core", version.ref = "coreVersion" }
[bundles] [bundles]
common = ["kotlin-stdlib", "coroutines-core", "coroutines-android", "injekt-core", "rxjava", "kotlin-protobuf", "kotlin-json", "jsoup", "okhttp", "tachiyomi-lib", "quickjs"] 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 # # Authentication #
# ================ # # ================ #
login_errors_failed_login=Authentication failed.\nPlease check your credentials login_errors_failed_login=Authentication failed.\nPlease check your credentials and server URL
login_errors_header_token_empty=Authorization token missing\nPlease open the extension first to generate a token login_errors_header_token_empty=Authentication required\nPlease open this extension once to generate a login token
login_errors_invalid_url=Invalid server URL: login_errors_invalid_url=Invalid server URL format:
login_errors_parse_tokendto=Failed to process authentication token login_errors_parse_tokendto=Server response invalid\nPlease check your Kavita version (requires v0.8.7+)
http_errors_401=Authentication expired\nPlease log in again
http_errors_500=Server error occurred
# ============== # # ============== #
# Preferences # # 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_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_title=Tag Grouping (fork must support grouping)
pref_group_tags_summary_on=Show tags with category prefixes pref_group_tags_summary_on=Show tags with category prefixes (e.g., "Genre: Action")
pref_group_tags_summary_off=Display tags as flat list pref_group_tags_summary_off=Display all tags in a flat list
pref_last_volume_cover_title=Keep Cover Updated pref_last_volume_cover_title=Dynamic Cover Updates
pref_last_volume_cover_summary_off=Keep series cover pref_last_volume_cover_summary_off=Always show the main series cover
pref_last_volume_cover_summary_on=Always show current Volume/Issue cover on refresh 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_title=EPUB Visibility
pref_show_epub_summary_off=Hide EPUB entries pref_show_epub_summary_off=Hide EPUB files
pref_show_epub_summary_on=Show EPUB entries (unreadable in Mihon) 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_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_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_reset_summary=Clear all preferences for this Kavita instance
pref_filters_title=Default Filters pref_allowed_libraries_feed_title=Allowed Libraries on Latest/Popular Feeds & Suggestions
pref_filters_summary=Select which filters appear by default 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 # # OPDS Setup #
# ============== # # ============== #
pref_opds_must_setup_address=OPDS URL required for connection pref_opds_must_setup_address=OPDS URL required\nPlease configure your Kavita server connection
pref_opds_badformed_url=Invalid OPDS address\nCopy from: User Settings > API Key / OPDS > OPDS URL (settings#clients) 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_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 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 # # System Messages #
# ============== # # ============== #
restartapp_settings=Restart Mihon to apply changes restartapp_settings=Restart Mihon to apply changes
check_version=Requires Kavita v0.8.7+\nIf issues persist, contact support with logs check_version=Requires Kavita v0.8.7 or newer\nIf issues persist, open an issue on GitHub
version_exceptions_chapters_parse=Chapter parsing error\nPlease report with logs version_exceptions_chapters_parse=Failed to parse chapter information\nPlease report this with error logs on Github
version_exceptions_smart_filter=Smart Filter error\nRequires Kavita v0.7.11+ 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 filters_sort_read_progress=Read Progress
# Error messages # 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_error_loading=Failed to load filters
filters_empty_list=No filters available 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 kmkVersionCode = 1
extName = 'Kavita' extName = 'Kavita'
extClass = '.KavitaFactory' extClass = '.KavitaFactory'
extVersionCode = 20 extVersionCode = 21
isNsfw = false isNsfw = false
} }
apply from: "$rootDir/common.gradle" apply from: "$rootDir/common.gradle"
dependencies { 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 project(':lib:i18n')
implementation(libs.commons.text)
} }

View File

@@ -6,67 +6,16 @@ object KavitaConstants {
const val UNNUMBERED_VOLUME_STR = "-100000" const val UNNUMBERED_VOLUME_STR = "-100000"
const val SPECIAL_NUMBER = 100_000 const val SPECIAL_NUMBER = 100_000
const val VOLUME_NUMBER_OFFSET = 10000f const val VOLUME_NUMBER_OFFSET = 10000f
const val SPECIAL_NUMBER_OFFSET = 10000000000f
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 customSourceNamePref = "customSourceName" const val customSourceNamePref = "customSourceName"
const val noSmartFilterSelected = "No smart filter loaded" 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 package eu.kanade.tachiyomi.extension.all.kavita
import android.annotation.SuppressLint 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.ChapterDto
import eu.kanade.tachiyomi.extension.all.kavita.dto.ChapterType 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.LibraryTypeEnum
import eu.kanade.tachiyomi.extension.all.kavita.dto.PaginationInfo 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.SeriesDto
import eu.kanade.tachiyomi.extension.all.kavita.dto.VolumeDto 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.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Response import okhttp3.Response
import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.ConcurrentHashMap
class KavitaHelper { class KavitaHelper {
val json = Json { val json = Json {
@@ -26,6 +29,22 @@ class KavitaHelper {
prettyPrint = true 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 { fun hasNextPage(response: Response): Boolean {
val paginationHeader = response.header("Pagination") val paginationHeader = response.header("Pagination")
var hasNextPage = false var hasNextPage = false
@@ -38,24 +57,18 @@ class KavitaHelper {
fun getIdFromUrl(url: String): Int { fun getIdFromUrl(url: String): Int {
return try { return try {
val id = url.substringAfterLast("/").toInt() val lastSegment = url.substringAfterLast("/")
// Log.d("KavitaHelper", "Extracted ID $id from URL: $url") val cleaned = lastSegment
id .substringBefore("?")
.substringBefore("#")
.substringBefore("/")
cleaned.toInt()
} catch (e: Exception) { } 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 -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 = fun createSeriesDto(obj: SeriesDto, baseUrl: String, apiKey: String): SManga =
SManga.create().apply { SManga.create().apply {
url = "$baseUrl/Series/${obj.id}" url = "$baseUrl/Series/${obj.id}"
@@ -70,119 +83,232 @@ class KavitaHelper {
libraryType: LibraryTypeEnum? = null, libraryType: LibraryTypeEnum? = null,
isWebtoon: Boolean = false, isWebtoon: Boolean = false,
mangaTitle: String = "", mangaTitle: String = "",
useReleaseDate: Boolean = false,
chapterTitleFormat: String = "\$CleanTitle",
scanlatorFormat: String = "\$Type",
volumePageCount: Int? = null,
): SChapter = ): SChapter =
SChapter.create().apply { SChapter.create().apply {
val type = ChapterType.of(chapter, volume, libraryType) val type = ChapterType.of(chapter, volume, libraryType)
val titleName = chapter.titleName val titleName = chapter.titleName ?: ""
val title = chapter.title val title = chapter.title
val range = chapter.range val range = chapter.range
// For webtoon
val chapterLabel = if (isWebtoon) "Episode" else "Chapter"
val volumeLabel = if (isWebtoon) "Season" else "Volume"
if (titleName != null) { // Always ensure we have the series name, even if mangaTitle is blank
name = when (type) { val seriesName = mangaTitle.ifBlank {
ChapterType.Regular -> { seriesMap.getSeries(volume.seriesId)?.name ?: ""
val chapterNum = formatChapterNumber(chapter) }
when {
titleName.isNotBlank() && titleName.any { it.isLetter() } ->
"$chapterNum - ${cleanChapterTitle(
titleName,
ChapterTitleContext(
mangaTitle = mangaTitle,
chapterNumber = chapterNum,
volumeName = volume.name.ifBlank { title },
),
)}"
else -> // Debug logging
"$volumeLabel ${formatVolumeNumber(volume)} $chapterLabel $chapterNum" 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 variables = ChapterTemplateVariables(
val volumeNumber = formatVolumeNumber(volume) type = if (isWebtoon) "Episode" else "Chapter",
val cleanVolumeName = volume.name.trim() 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 // Debug logging for Chapter Title Format variables
when { Log.d("KavitaHelper", "ChapterTitleFormat variables for chapter ${chapter.id}:")
// If name is empty or just numbers (Kavita default) Log.d("KavitaHelper", " chapterTitleFormat: '$chapterTitleFormat'")
cleanVolumeName.isEmpty() || cleanVolumeName.none { it.isLetter() } -> { Log.d("KavitaHelper", " seriesName: '${variables.seriesName}'")
cleanChapterTitle("Volume $volumeNumber") 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 -> { 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 -> { val variables = ChapterTemplateVariables(
cleanChapterTitle( type = "Special",
when { number = "",
title.isNotBlank() -> title title = specialTitle,
range.isNotBlank() -> range pages = chapter.pages,
else -> "Special" fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0,
}, volumeNumber = formatVolumeNumber(volume),
ChapterTitleContext( cleanTitle = when {
mangaTitle = mangaTitle, titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) -> titleName
volumeName = volume.name, 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 variables = ChapterTemplateVariables(
val chapterNum = formatChapterNumber(chapter) type = if (isWebtoon) "Episode" else "Chapter",
when { number = chapterNum,
titleName.isNotBlank() && titleName.any { it.isLetter() } -> title = titleName,
"$chapterNum - ${cleanChapterTitle( pages = chapter.pages,
titleName, fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0,
ChapterTitleContext( volumeNumber = "",
mangaTitle = mangaTitle, cleanTitle = finalCleanTitle,
chapterNumber = chapterNum, seriesName = seriesName,
volumeName = volume.name, libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "",
), formats = chapter.files?.firstOrNull()?.extension?.uppercase() ?: "",
)}" created = chapter.created,
releaseDate = chapter.releaseDate,
)
else -> val processedName = processChapterTemplate(chapterTitleFormat, variables)
"$chapterLabel $chapterNum" Log.d("KavitaHelper", "Final processed Chapter name: '$processedName'")
} processedName
} }
ChapterType.Issue -> { ChapterType.Issue -> {
val issueNum = chapter.number.toIntOrNull()?.toString()?.padStart(3, '0') ?: chapter.number val issueNum = chapter.number.toIntOrNull()?.toString()?.padStart(3, '0') ?: chapter.number
cleanChapterTitle(
when { val variables = ChapterTemplateVariables(
titleName.any { it.isLetter() } -> "$titleName (#$issueNum)" type = "Issue",
else -> "Issue #$issueNum" number = issueNum,
}, title = titleName,
ChapterTitleContext( pages = chapter.pages,
mangaTitle = mangaTitle, fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0,
chapterNumber = issueNum, volumeNumber = formatVolumeNumber(volume),
volumeName = volume.name, 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 { chapter_number = when {
type == ChapterType.SingleFileVolume && singleFileVolumeNumber != null -> // Regular, Chapter, Issue (1.0, 2.0...)
volume.minNumber.toFloat() type == ChapterType.Regular ||
type == ChapterType.Chapter ||
type == ChapterType.SingleFileVolume -> { type == ChapterType.Issue -> {
// For standalone volumes, use positive numbers
volume.minNumber.toFloat()
}
// For regular chapters
type != ChapterType.SingleFileVolume && type != ChapterType.Special -> {
// Handle decimal chapter numbers
if (chapter.minNumber % 1 != 0.0) { if (chapter.minNumber % 1 != 0.0) {
chapter.minNumber.toFloat() chapter.minNumber.toFloat()
} else { } else {
@@ -190,9 +316,9 @@ class KavitaHelper {
} }
} }
// For volumes/specials in mixed content, place them below chapter 0 // Volumes and Specials (< 1.0)
else -> { else -> {
val volumeNum = try { val rawNum = try {
if (volume.minNumber % 1 != 0.0) { if (volume.minNumber % 1 != 0.0) {
volume.minNumber.toFloat() volume.minNumber.toFloat()
} else { } else {
@@ -201,33 +327,116 @@ class KavitaHelper {
} catch (e: NumberFormatException) { } catch (e: NumberFormatException) {
0f 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 { url = "/Chapter/${chapter.id}"
type == ChapterType.SingleFileVolume && singleFileVolumeNumber != null ->
"volume_${volume.id}"
else -> "chapter_${chapter.id}"
}
if (chapter.fileCount > 1) { // Only apply salt to Chapters.
// salt/offset to recognize chapters with new merged part-chapters as new and hence unread // 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 chapter_number += 0.001f * chapter.fileCount
url = "${url}_${chapter.fileCount}" url = "$url?split=${chapter.fileCount}"
} }
date_upload = parseDateSafe(chapter.created) date_upload = if (useReleaseDate && chapter.releaseDate.isNotBlank()) {
scanlator = when (type) { parseDateSafe(chapter.releaseDate)
ChapterType.SingleFileVolume -> if (isWebtoon) "Season" else "Volume" } else {
ChapterType.Special -> "Special" parseDateSafe(chapter.created)
ChapterType.Issue -> "Issue"
ChapterType.Chapter -> if (isWebtoon) "Episode" else "Chapter"
ChapterType.Regular -> if (isWebtoon) "Episode" else "Chapter"
} }
// 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 { return when {
volume.maxNumber > volume.minNumber -> volume.maxNumber > volume.minNumber ->
"${removeTrailingZero(volume.minNumber)}-${removeTrailingZero(volume.maxNumber)}" "${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 { val chapterNum = when {
chapter.maxNumber > chapter.minNumber -> chapter.maxNumber > chapter.minNumber ->
"${removeTrailingZero(chapter.minNumber)}-${removeTrailingZero(chapter.maxNumber)}" "${removeTrailingZero(chapter.minNumber)}-${removeTrailingZero(chapter.maxNumber)}"
@@ -252,7 +461,7 @@ class KavitaHelper {
// Helper function to remove .0 from whole numbers while preserving actual decimals // Helper function to remove .0 from whole numbers while preserving actual decimals
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
private fun removeTrailingZero(number: Double): String { internal fun removeTrailingZero(number: Double): String {
return if (number % 1 == 0.0) { return if (number % 1 == 0.0) {
number.toInt().toString() number.toInt().toString()
} else { } else {
@@ -261,28 +470,158 @@ class KavitaHelper {
} }
} }
private fun cleanChapterTitle( internal fun cleanChapterTitle(
originalTitle: String, originalTitle: String,
context: ChapterTitleContext? = null, context: ChapterTitleContext? = null,
): String { ): String {
var title = originalTitle.trim() var title = originalTitle.trim()
val mangaTitle = context?.mangaTitle ?: "" 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 // Remove manga name if present
if (mangaTitle.isNotBlank()) { if (mangaTitle.isNotBlank()) {
title = title.replace(Regex("(?i)\\b${Regex.escape(mangaTitle)}\\b[\\s\\-:]*"), "").trim() title = title.replace(Regex("(?i)\\b${Regex.escape(mangaTitle)}\\b[\\s\\-:]*"), "").trim()
} }
// Remove chapter number patterns like c0116, c001, etc. // Helper function to check if we have to shorten terms
title = title.replace(Regex("""(?i)\bc\d+\b"""), "").trim() 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 // Process patterns with conditional shortening
title = title.replace(Regex("""\s*[-.]\s*[-.]\s*"""), " - ") // Handles -.-, - . -, etc. title = title.replace(Regex("""(?i)\bvolume\s+(\d+)""")) { match ->
.replace(Regex("""\s*-\s*-\s*"""), " - ") // Double hyphens with spaces val number = match.groupValues[1].padStart(2, '0')
.replace(Regex("""^\s*[-.]\s*|\s*[-.]\s*$"""), "") // Leading/trailing hyphens or dots if (isWebtoon) {
.trim() 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( data class ChapterTitleContext(
val mangaTitle: String = "", val mangaTitle: String = "",
val chapterNumber: String = "", val chapterNumber: String = "",
val volumeNumber: String = "",
val volumeName: String = "", val volumeName: String = "",
val isWebtoon: Boolean = false, val isWebtoon: Boolean = false,
) )
fun isWebtoonOrLongStrip(tags: List<String>): Boolean { fun isWebtoonOrLongStrip(
return tags.any { genres: List<String>,
val normalized = it.trim().lowercase() 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") normalized.contains("webtoon") || normalized.contains("long strip")
} }
} }
val intl = Intl( // Legacy compatibility
language = Locale.getDefault().toString(), fun isWebtoonOrLongStrip(tags: List<String>): Boolean {
baseLanguage = "en", return isWebtoonOrLongStrip(
availableLanguages = KavitaInt.AVAILABLE_LANGS, genres = emptyList(),
classLoader = this::class.java.classLoader!!, tags = tags,
createMessageFileName = { lang -> libraryName = null,
when (lang) { format = null,
KavitaInt.SPANISH_LATAM -> Intl.createDefaultMessageFileName(KavitaInt.SPANISH) demographic = null,
else -> Intl.createDefaultMessageFileName(lang) 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), Team(30),
Location(31), Location(31),
ReadLast(32), ReadLast(32),
People(33),
; ;
companion object { companion object {

View File

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