BIN
.github/screenshots/Komikku_1.png
vendored
Normal file
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
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
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
BIN
.github/screenshots/Mihon_2.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
1
.github/scripts/merge-repo.py
vendored
1
.github/scripts/merge-repo.py
vendored
@@ -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
148
.github/workflows/create_release.yml
vendored
Normal 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."
|
||||
14
README.md
14
README.md
@@ -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).
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ enum class FilterField(val type: Int) {
|
||||
Team(30),
|
||||
Location(31),
|
||||
ReadLast(32),
|
||||
People(33),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user