## New
- Option to blacklist libraries from Latest/Popular Feeds & Suggestions - Option to customize Chapter Titles & Scanlator fields - Option to show the release date instead of added date for chapters/volumes (close #37) - Added `formats` for GroupTags - Action to create releases ## Fixes/Enhancements - Pagination bug (closes #33, thanks to @Freezy) - Optimized API calls - Better cache and cleanup - Better cleaned chapter/volumes titles - Fixed Chapter Parsing Logic - Refactored duplicated codes into multiple helpers - Fixed action to update version badge - More feedbacks on errors & graceful degradation for partial failures - Include Suwayomi install & screenshots in README (closes #41)
This commit is contained in:
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("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
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."
|
||||||
10
README.md
10
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.
|
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)
|
||||||
|
|
||||||
@@ -26,11 +26,17 @@ This repository contains the Kavita extension, compatible with [Komikku](https:/
|
|||||||
<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-extensions/repo/index.min.json
|
||||||
```
|
```
|
||||||
|
# Screenshots
|
||||||
|
<img src="https://files.catbox.moe/u41pb0.png" width="22%" alt="Komikku1">
|
||||||
|
<img src="https://files.catbox.moe/ixxs8u.png" width="22%" alt="Mihon1">
|
||||||
|
<img src="https://files.catbox.moe/j4cnlx.png" width="22%" alt="Komikku2">
|
||||||
|
<img src="https://files.catbox.moe/nbetf9.png" width="22%" alt="Mihon2">
|
||||||
|
|
||||||
|
|
||||||
# Requests
|
# Requests
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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 #
|
# 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\n?? 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: (Make sure to remove the {} around like the default)\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: (? remove the {} around like the default)\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=nvalid 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
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
|
||||||
|
|
||||||
class KavitaHelper {
|
class KavitaHelper {
|
||||||
val json = Json {
|
val json = Json {
|
||||||
@@ -26,36 +28,46 @@ class KavitaHelper {
|
|||||||
prettyPrint = true
|
prettyPrint = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache for series information
|
||||||
|
private val seriesCache = mutableMapOf<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
|
||||||
if (!paginationHeader.isNullOrEmpty()) {
|
if (!paginationHeader.isNullOrEmpty()) {
|
||||||
val paginationInfo = json.decodeFromString<PaginationInfo>(paginationHeader)
|
val paginationInfo = json.decodeFromString<PaginationInfo>(paginationHeader)
|
||||||
hasNextPage = paginationInfo.currentPage + 1 < paginationInfo.totalPages
|
hasNextPage = paginationInfo.currentPage < paginationInfo.totalPages
|
||||||
}
|
}
|
||||||
return hasNextPage
|
return hasNextPage
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +82,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
|
||||||
|
val seriesName = mangaTitle.ifBlank {
|
||||||
|
seriesMap.getSeries(volume.seriesId)?.name ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
Log.d("KavitaHelper", "Chapter ${chapter.id}: seriesName = '$seriesName', mangaTitle = '$mangaTitle'")
|
||||||
|
|
||||||
name = when (type) {
|
name = when (type) {
|
||||||
ChapterType.Regular -> {
|
ChapterType.Regular -> {
|
||||||
val chapterNum = formatChapterNumber(chapter)
|
val chapterNum = formatChapterNumber(chapter)
|
||||||
when {
|
val volNum = formatVolumeNumber(volume)
|
||||||
titleName.isNotBlank() && titleName.any { it.isLetter() } ->
|
val cleanedTitle = cleanChapterTitle(
|
||||||
"$chapterNum - ${cleanChapterTitle(
|
|
||||||
titleName,
|
titleName,
|
||||||
ChapterTitleContext(
|
ChapterTitleContext(
|
||||||
mangaTitle = mangaTitle,
|
mangaTitle = mangaTitle,
|
||||||
chapterNumber = chapterNum,
|
chapterNumber = chapterNum,
|
||||||
|
volumeNumber = volNum,
|
||||||
volumeName = volume.name.ifBlank { title },
|
volumeName = volume.name.ifBlank { title },
|
||||||
|
isWebtoon = isWebtoon,
|
||||||
),
|
),
|
||||||
)}"
|
)
|
||||||
|
val finalCleanTitle = cleanedTitle.ifBlank {
|
||||||
else ->
|
defaultCleanTitle(type, chapterNum, volNum, isWebtoon)
|
||||||
"$volumeLabel ${formatVolumeNumber(volume)} $chapterLabel $chapterNum"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 -> {
|
ChapterType.SingleFileVolume -> {
|
||||||
val volumeNumber = formatVolumeNumber(volume)
|
val volumeNumber = formatVolumeNumber(volume)
|
||||||
val cleanVolumeName = volume.name.trim()
|
|
||||||
|
|
||||||
// Always use the API's volume number, never parse from title
|
val variables = ChapterTemplateVariables(
|
||||||
when {
|
type = if (isWebtoon) "Season" else "Volume",
|
||||||
// If name is empty or just numbers (Kavita default)
|
number = volumeNumber,
|
||||||
cleanVolumeName.isEmpty() || cleanVolumeName.none { it.isLetter() } -> {
|
title = volume.name,
|
||||||
cleanChapterTitle("Volume $volumeNumber")
|
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 -> {
|
ChapterType.Special -> {
|
||||||
cleanChapterTitle(
|
val specialTitle = when {
|
||||||
when {
|
|
||||||
title.isNotBlank() -> title
|
title.isNotBlank() -> title
|
||||||
range.isNotBlank() -> range
|
range.isNotBlank() -> range
|
||||||
else -> "Special"
|
else -> "Special"
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
},
|
},
|
||||||
ChapterTitleContext(
|
seriesName = seriesName,
|
||||||
mangaTitle = mangaTitle,
|
libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "",
|
||||||
volumeName = volume.name,
|
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 -> {
|
ChapterType.Chapter -> {
|
||||||
val chapterNum = formatChapterNumber(chapter)
|
val chapterNum = formatChapterNumber(chapter)
|
||||||
when {
|
val cleanedTitle = cleanChapterTitle(
|
||||||
titleName.isNotBlank() && titleName.any { it.isLetter() } ->
|
|
||||||
"$chapterNum - ${cleanChapterTitle(
|
|
||||||
titleName,
|
titleName,
|
||||||
ChapterTitleContext(
|
ChapterTitleContext(
|
||||||
mangaTitle = mangaTitle,
|
mangaTitle = mangaTitle,
|
||||||
chapterNumber = chapterNum,
|
chapterNumber = chapterNum,
|
||||||
volumeName = volume.name,
|
volumeName = volume.name,
|
||||||
|
isWebtoon = isWebtoon,
|
||||||
),
|
),
|
||||||
)}"
|
)
|
||||||
|
val finalCleanTitle = cleanedTitle.ifBlank {
|
||||||
else ->
|
defaultCleanTitle(type, chapterNum, formatVolumeNumber(volume), isWebtoon)
|
||||||
"$chapterLabel $chapterNum"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
val processedName = processChapterTemplate(chapterTitleFormat, variables)
|
||||||
|
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",
|
||||||
|
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"
|
else -> "Issue #$issueNum"
|
||||||
|
}.ifBlank {
|
||||||
|
defaultCleanTitle(type, issueNum, formatVolumeNumber(volume), isWebtoon)
|
||||||
},
|
},
|
||||||
ChapterTitleContext(
|
seriesName = seriesName,
|
||||||
mangaTitle = mangaTitle,
|
libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "",
|
||||||
chapterNumber = issueNum,
|
formats = chapter.files?.firstOrNull()?.extension?.uppercase() ?: "",
|
||||||
volumeName = volume.name,
|
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 +315,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 +326,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)
|
||||||
|
} else {
|
||||||
|
parseDateSafe(chapter.created)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare template variables for scanlator
|
||||||
|
val scanlatorVariables = ChapterTemplateVariables(
|
||||||
|
type = when (type) {
|
||||||
ChapterType.SingleFileVolume -> if (isWebtoon) "Season" else "Volume"
|
ChapterType.SingleFileVolume -> if (isWebtoon) "Season" else "Volume"
|
||||||
ChapterType.Special -> "Special"
|
ChapterType.Special -> "Special"
|
||||||
ChapterType.Issue -> "Issue"
|
ChapterType.Issue -> "Issue"
|
||||||
ChapterType.Chapter -> if (isWebtoon) "Episode" else "Chapter"
|
ChapterType.Chapter -> if (isWebtoon) "Episode" else "Chapter"
|
||||||
ChapterType.Regular -> 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 +443,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 +460,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 +469,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+\\.\\s+.*"))) {
|
||||||
|
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 +629,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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user