Compare commits
28 Commits
51a67328eb
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 88c7b31ecc | |||
| 7df02d1a2a | |||
| 2ce0f2049a | |||
| f0de3aaea3 | |||
| 2ddbcc9fe5 | |||
| 0f6e579b0b | |||
| d8937783c5 | |||
| c85c009e17 | |||
| b7f98dee7e | |||
| c551317051 | |||
| b38824219f | |||
| ed33de3580 | |||
| e05060e57c | |||
| 5d2f8aa8ce | |||
| 40abc68a67 | |||
| 509c1b890a | |||
| ba96a8a2ee | |||
|
|
230cf3764a | ||
|
|
600d8ca4ff | ||
|
|
13657ba2dc | ||
|
|
6780871c0f | ||
|
|
05bf6b5904 | ||
|
|
dd0f7dd467 | ||
|
|
0168daab08 | ||
|
|
db005805d5 | ||
|
|
f207b2a3c2 | ||
|
|
a867c9caf5 | ||
|
|
f04e4c92e7 |
28
.github/workflows/create_release.yml
vendored
28
.github/workflows/create_release.yml
vendored
@@ -25,8 +25,8 @@ jobs:
|
|||||||
id: pr_info
|
id: pr_info
|
||||||
run: |
|
run: |
|
||||||
# Fetch and checkout master to analyze history
|
# Fetch and checkout master to analyze history
|
||||||
git fetch origin master:master
|
git fetch origin master
|
||||||
git checkout master
|
git checkout -B master origin/master
|
||||||
|
|
||||||
# Initialize variables
|
# Initialize variables
|
||||||
FOUND_PR=false
|
FOUND_PR=false
|
||||||
@@ -36,9 +36,11 @@ jobs:
|
|||||||
|
|
||||||
for COMMIT in $MERGE_COMMITS; do
|
for COMMIT in $MERGE_COMMITS; do
|
||||||
# Extract PR number
|
# Extract PR number
|
||||||
PR_NUMBER=$(git show --format=%B -s $COMMIT | grep -oP 'Merge pull request #\K\d+')
|
PR_NUMBER=$(git show --format=%B -s $COMMIT | grep -oP 'Merge pull request #\K\d+' || true)
|
||||||
|
|
||||||
|
# Fallback: Try to match squash merge pattern "Title (#123)"
|
||||||
if [ -z "$PR_NUMBER" ]; then
|
if [ -z "$PR_NUMBER" ]; then
|
||||||
PR_NUMBER=$(git show --format=%B -s $COMMIT | grep -oP '\(#\K\d+(?=\))')
|
PR_NUMBER=$(git show --format=%B -s $COMMIT | grep -oP '\(#\K\d+(?=\))' || true)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$PR_NUMBER" ]; then
|
if [ -n "$PR_NUMBER" ]; then
|
||||||
@@ -153,6 +155,12 @@ jobs:
|
|||||||
echo "EOF"
|
echo "EOF"
|
||||||
} >> $GITHUB_OUTPUT
|
} >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Switch back to 'repo' branch to upload the APK
|
||||||
|
- name: Final Switch to repo branch
|
||||||
|
if: steps.check_release.outputs.exists == 'false'
|
||||||
|
run: |
|
||||||
|
git checkout repo
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
if: steps.check_release.outputs.exists == 'false'
|
if: steps.check_release.outputs.exists == 'false'
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
@@ -163,15 +171,15 @@ jobs:
|
|||||||
body: |
|
body: |
|
||||||
## 🚀 Release ${{ steps.apk_info.outputs.version }}
|
## 🚀 Release ${{ steps.apk_info.outputs.version }}
|
||||||
|
|
||||||
### 📝 Pull Request
|
${{ steps.pr_info.outputs.pr_body }}
|
||||||
${{ 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) || '' }}
|
---
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>📦 View Full Commit Log</summary>
|
||||||
|
|
||||||
### 📦 Changes since last release:
|
|
||||||
${{ steps.changelog.outputs.changelog }}
|
${{ steps.changelog.outputs.changelog }}
|
||||||
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
🤖 *This release was automatically created*
|
🤖 *This release was automatically created*
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -11,3 +11,7 @@ apk/
|
|||||||
gen
|
gen
|
||||||
generated-src/
|
generated-src/
|
||||||
.kotlin
|
.kotlin
|
||||||
|
signingkey.jks
|
||||||
|
output.json
|
||||||
|
tmp/
|
||||||
|
Inspector.jar
|
||||||
|
|||||||
@@ -25,3 +25,5 @@ android.useAndroidX=true
|
|||||||
android.enableBuildConfigAsBytecode=true
|
android.enableBuildConfigAsBytecode=true
|
||||||
android.defaults.buildfeatures.resvalues=false
|
android.defaults.buildfeatures.resvalues=false
|
||||||
android.defaults.buildfeatures.shaders=false
|
android.defaults.buildfeatures.shaders=false
|
||||||
|
|
||||||
|
android.overridePathCheck=true
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ ext {
|
|||||||
kmkVersionCode = 1
|
kmkVersionCode = 1
|
||||||
extName = 'Kavita'
|
extName = 'Kavita'
|
||||||
extClass = '.KavitaFactory'
|
extClass = '.KavitaFactory'
|
||||||
extVersionCode = 21
|
extVersionCode = 37
|
||||||
isNsfw = false
|
isNsfw = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,34 +4,34 @@ import eu.kanade.tachiyomi.extension.all.kavita.KavitaConstants.noSmartFilterSel
|
|||||||
import eu.kanade.tachiyomi.source.model.Filter
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
|
||||||
class SmartFiltersFilter(smartFilters: Array<String>) :
|
class SmartFiltersFilter(smartFilters: Array<String>) :
|
||||||
Filter.Select<String>("Smart Filters", arrayOf(noSmartFilterSelected) + smartFilters)
|
Filter.Select<String>("스마트 필터", arrayOf(noSmartFilterSelected) + smartFilters)
|
||||||
class SortFilter(sortables: Array<String>) : Filter.Sort("Sort by", sortables, Selection(0, true))
|
class SortFilter(sortables: Array<String>) : Filter.Sort("정렬", sortables, Selection(0, true))
|
||||||
|
|
||||||
val sortableList = listOf(
|
val sortableList = listOf(
|
||||||
Pair("Sort name", 1),
|
Pair("정렬명", 1),
|
||||||
Pair("Created", 2),
|
Pair("생성일", 2),
|
||||||
Pair("Last modified", 3),
|
Pair("마지막 수정", 3),
|
||||||
Pair("Item added", 4),
|
Pair("추가일", 4),
|
||||||
Pair("Time to Read", 5),
|
Pair("읽는 시간", 5),
|
||||||
Pair("Release year", 6),
|
Pair("발매 연도", 6),
|
||||||
Pair("Read Progress", 7),
|
Pair("읽은 진행률", 7),
|
||||||
Pair("Average Rating", 8),
|
Pair("평균 평점", 8),
|
||||||
Pair("Random", 9),
|
Pair("무작위", 9),
|
||||||
)
|
)
|
||||||
|
|
||||||
class SpecialListFilter(name: String = "Special Lists") : Filter.Select<String>(
|
class SpecialListFilter(name: String = "특수 목록") : Filter.Select<String>(
|
||||||
name,
|
name,
|
||||||
arrayOf("None", "Want to Read", "Reading Lists"),
|
arrayOf("없음", "읽고 싶음", "읽기 목록"),
|
||||||
)
|
)
|
||||||
|
|
||||||
class StatusFilter : Filter.Select<String>(
|
class StatusFilter : Filter.Select<String>(
|
||||||
"Status",
|
"읽기 상태",
|
||||||
arrayOf("Any", "Unread", "In Progress", "Read"),
|
arrayOf("전체", "안 읽음", "읽는 중", "읽음"),
|
||||||
)
|
)
|
||||||
class StatusSeparator :
|
class StatusSeparator :
|
||||||
Filter.Separator()
|
Filter.Separator()
|
||||||
|
|
||||||
class StatusFilterGroup(name: String = "Status") : Filter.Group<StatusFilter>(
|
class StatusFilterGroup(name: String = "읽기 상태") : Filter.Group<StatusFilter>(
|
||||||
name,
|
name,
|
||||||
listOf(StatusFilter()),
|
listOf(StatusFilter()),
|
||||||
)
|
)
|
||||||
@@ -39,56 +39,56 @@ class StatusFilterGroup(name: String = "Status") : Filter.Group<StatusFilter>(
|
|||||||
// Use Filter.Text directly for release year range
|
// Use Filter.Text directly for release year range
|
||||||
class ReleaseYearRange(name: String) : Filter.Text(name, "")
|
class ReleaseYearRange(name: String) : Filter.Text(name, "")
|
||||||
class ReleaseYearRangeGroup(filters: List<ReleaseYearRange>) :
|
class ReleaseYearRangeGroup(filters: List<ReleaseYearRange>) :
|
||||||
Filter.Group<ReleaseYearRange>("Release Year", filters)
|
Filter.Group<ReleaseYearRange>("발매 연도", filters)
|
||||||
|
|
||||||
// Use Filter.TriState for genres/tags/age ratings/collections/languages/libraries
|
// Use Filter.TriState for genres/tags/age ratings/collections/languages/libraries
|
||||||
class GenreFilter(name: String) : Filter.TriState(name)
|
class GenreFilter(name: String) : Filter.TriState(name)
|
||||||
class GenreFilterGroup(genres: List<GenreFilter>) :
|
class GenreFilterGroup(genres: List<GenreFilter>) :
|
||||||
Filter.Group<GenreFilter>("Genres", genres)
|
Filter.Group<GenreFilter>("장르", genres)
|
||||||
|
|
||||||
class TagFilter(name: String) : Filter.TriState(name)
|
class TagFilter(name: String) : Filter.TriState(name)
|
||||||
class TagFilterGroup(tags: List<TagFilter>) :
|
class TagFilterGroup(tags: List<TagFilter>) :
|
||||||
Filter.Group<TagFilter>("Tags", tags)
|
Filter.Group<TagFilter>("태그", tags)
|
||||||
|
|
||||||
class AgeRatingFilter(name: String) : Filter.TriState(name)
|
class AgeRatingFilter(name: String) : Filter.TriState(name)
|
||||||
class AgeRatingFilterGroup(ageRatings: List<AgeRatingFilter>) :
|
class AgeRatingFilterGroup(ageRatings: List<AgeRatingFilter>) :
|
||||||
Filter.Group<AgeRatingFilter>("Age Rating", ageRatings)
|
Filter.Group<AgeRatingFilter>("연령 등급", ageRatings)
|
||||||
|
|
||||||
class FormatFilter(name: String) : Filter.CheckBox(name, false)
|
class FormatFilter(name: String) : Filter.CheckBox(name, false)
|
||||||
class FormatsFilterGroup(formats: List<FormatFilter>) :
|
class FormatsFilterGroup(formats: List<FormatFilter>) :
|
||||||
Filter.Group<FormatFilter>("Formats", formats)
|
Filter.Group<FormatFilter>("형식", formats)
|
||||||
|
|
||||||
class CollectionFilter(name: String) : Filter.TriState(name)
|
class CollectionFilter(name: String) : Filter.TriState(name)
|
||||||
class CollectionFilterGroup(collections: List<CollectionFilter>) :
|
class CollectionFilterGroup(collections: List<CollectionFilter>) :
|
||||||
Filter.Group<CollectionFilter>("Collection", collections)
|
Filter.Group<CollectionFilter>("컬렉션", collections)
|
||||||
|
|
||||||
class LanguageFilter(name: String) : Filter.TriState(name)
|
class LanguageFilter(name: String) : Filter.TriState(name)
|
||||||
class LanguageFilterGroup(languages: List<LanguageFilter>) :
|
class LanguageFilterGroup(languages: List<LanguageFilter>) :
|
||||||
Filter.Group<LanguageFilter>("Language", languages)
|
Filter.Group<LanguageFilter>("언어", languages)
|
||||||
|
|
||||||
class LibraryFilter(library: String) : Filter.TriState(library)
|
class LibraryFilter(library: String) : Filter.TriState(library)
|
||||||
class LibrariesFilterGroup(libraries: List<LibraryFilter>) :
|
class LibrariesFilterGroup(libraries: List<LibraryFilter>) :
|
||||||
Filter.Group<LibraryFilter>("Libraries", libraries)
|
Filter.Group<LibraryFilter>("라이브러리", libraries)
|
||||||
|
|
||||||
class PubStatusFilter(name: String) : Filter.CheckBox(name, false)
|
class PubStatusFilter(name: String) : Filter.CheckBox(name, false)
|
||||||
class PubStatusFilterGroup(status: List<PubStatusFilter>) :
|
class PubStatusFilterGroup(status: List<PubStatusFilter>) :
|
||||||
Filter.Group<PubStatusFilter>("Publication Status", status)
|
Filter.Group<PubStatusFilter>("연재 상태", status)
|
||||||
|
|
||||||
class UserRating : Filter.Select<Int>(
|
class UserRating : Filter.Select<Int>(
|
||||||
"Minimum Rating",
|
"최소 평점",
|
||||||
arrayOf(
|
arrayOf(
|
||||||
"Any",
|
"전체",
|
||||||
"1 star",
|
"1점",
|
||||||
"2 stars",
|
"2점",
|
||||||
"3 stars",
|
"3점",
|
||||||
"4 stars",
|
"4점",
|
||||||
"5 stars",
|
"5점",
|
||||||
).mapIndexed { index, _ -> index }.toTypedArray(),
|
).mapIndexed { index, _ -> index }.toTypedArray(),
|
||||||
) {
|
) {
|
||||||
override fun toString(): String {
|
override fun toString(): String {
|
||||||
return when (state) {
|
return when (state) {
|
||||||
0 -> "Any"
|
0 -> "전체"
|
||||||
else -> "$state star${if (state > 1) "s" else ""}"
|
else -> "${state}점"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,40 +102,40 @@ class PeopleSeparatorFilter :
|
|||||||
|
|
||||||
class WriterPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
class WriterPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
||||||
class WriterPeopleFilterGroup(peoples: List<WriterPeopleFilter>) :
|
class WriterPeopleFilterGroup(peoples: List<WriterPeopleFilter>) :
|
||||||
Filter.Group<WriterPeopleFilter>("Writer", peoples)
|
Filter.Group<WriterPeopleFilter>("작가", peoples)
|
||||||
|
|
||||||
class PencillerPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
class PencillerPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
||||||
class PencillerPeopleFilterGroup(peoples: List<PencillerPeopleFilter>) :
|
class PencillerPeopleFilterGroup(peoples: List<PencillerPeopleFilter>) :
|
||||||
Filter.Group<PencillerPeopleFilter>("Penciller", peoples)
|
Filter.Group<PencillerPeopleFilter>("펜슬러", peoples)
|
||||||
|
|
||||||
class InkerPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
class InkerPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
||||||
class InkerPeopleFilterGroup(peoples: List<InkerPeopleFilter>) :
|
class InkerPeopleFilterGroup(peoples: List<InkerPeopleFilter>) :
|
||||||
Filter.Group<InkerPeopleFilter>("Inker", peoples)
|
Filter.Group<InkerPeopleFilter>("잉커", peoples)
|
||||||
|
|
||||||
class ColoristPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
class ColoristPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
||||||
class ColoristPeopleFilterGroup(peoples: List<ColoristPeopleFilter>) :
|
class ColoristPeopleFilterGroup(peoples: List<ColoristPeopleFilter>) :
|
||||||
Filter.Group<ColoristPeopleFilter>("Colorist", peoples)
|
Filter.Group<ColoristPeopleFilter>("컬러리스트", peoples)
|
||||||
|
|
||||||
class LettererPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
class LettererPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
||||||
class LettererPeopleFilterGroup(peoples: List<LettererPeopleFilter>) :
|
class LettererPeopleFilterGroup(peoples: List<LettererPeopleFilter>) :
|
||||||
Filter.Group<LettererPeopleFilter>("Letterer", peoples)
|
Filter.Group<LettererPeopleFilter>("레터러", peoples)
|
||||||
|
|
||||||
class CoverArtistPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
class CoverArtistPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
||||||
class CoverArtistPeopleFilterGroup(peoples: List<CoverArtistPeopleFilter>) :
|
class CoverArtistPeopleFilterGroup(peoples: List<CoverArtistPeopleFilter>) :
|
||||||
Filter.Group<CoverArtistPeopleFilter>("Cover Artist", peoples)
|
Filter.Group<CoverArtistPeopleFilter>("표지 작가", peoples)
|
||||||
|
|
||||||
class EditorPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
class EditorPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
||||||
class EditorPeopleFilterGroup(peoples: List<EditorPeopleFilter>) :
|
class EditorPeopleFilterGroup(peoples: List<EditorPeopleFilter>) :
|
||||||
Filter.Group<EditorPeopleFilter>("Editor", peoples)
|
Filter.Group<EditorPeopleFilter>("편집자", peoples)
|
||||||
|
|
||||||
class PublisherPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
class PublisherPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
||||||
class PublisherPeopleFilterGroup(peoples: List<PublisherPeopleFilter>) :
|
class PublisherPeopleFilterGroup(peoples: List<PublisherPeopleFilter>) :
|
||||||
Filter.Group<PublisherPeopleFilter>("Publisher", peoples)
|
Filter.Group<PublisherPeopleFilter>("출판사", peoples)
|
||||||
|
|
||||||
class CharacterPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
class CharacterPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
||||||
class CharacterPeopleFilterGroup(peoples: List<CharacterPeopleFilter>) :
|
class CharacterPeopleFilterGroup(peoples: List<CharacterPeopleFilter>) :
|
||||||
Filter.Group<CharacterPeopleFilter>("Character", peoples)
|
Filter.Group<CharacterPeopleFilter>("캐릭터", peoples)
|
||||||
|
|
||||||
class TranslatorPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
class TranslatorPeopleFilter(name: String) : Filter.CheckBox(name, false)
|
||||||
class TranslatorPeopleFilterGroup(peoples: List<TranslatorPeopleFilter>) :
|
class TranslatorPeopleFilterGroup(peoples: List<TranslatorPeopleFilter>) :
|
||||||
Filter.Group<TranslatorPeopleFilter>("Translator", peoples)
|
Filter.Group<TranslatorPeopleFilter>("번역자", peoples)
|
||||||
|
|||||||
@@ -81,9 +81,11 @@ import uy.kohesive.injekt.Injekt
|
|||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.net.URLEncoder
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlin.let
|
import kotlin.let
|
||||||
import kotlin.runCatching
|
import kotlin.runCatching
|
||||||
@@ -122,13 +124,14 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
private val preferences: SharedPreferences by lazy {
|
private val preferences: SharedPreferences by lazy {
|
||||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||||
}
|
}
|
||||||
|
private val application: Application by lazy { Injekt.get() }
|
||||||
override val name = "${KavitaInt.KAVITA_NAME} (${preferences.getString(KavitaConstants.customSourceNamePref, suffix)})"
|
override val name = "${KavitaInt.KAVITA_NAME} (${preferences.getString(KavitaConstants.customSourceNamePref, suffix)})"
|
||||||
override val lang = "all"
|
override val lang = "all"
|
||||||
override val supportsLatest = true
|
override val supportsLatest = true
|
||||||
private val apiUrl: String by lazy { getPrefApiUrl() }
|
private val apiUrl: String get() = getPrefApiUrl()
|
||||||
private val apiKey: String by lazy { getPrefApiKey() }
|
private val apiKey: String get() = getPrefApiKey()
|
||||||
override val baseUrl by lazy { getPrefBaseUrl() }
|
override val baseUrl: String get() = getPrefBaseUrl()
|
||||||
private val address by lazy { getPrefAddress() } // Address for the Kavita OPDS url. Should be http(s)://host:(port)/api/opds/api-key
|
private val address: String get() = getPrefAddress() // Address for the Kavita OPDS url. Should be http(s)://host:(port)/api/opds/api-key
|
||||||
private var jwtToken = "" // * JWT Token for authentication with the server. Stored in memory.
|
private var jwtToken = "" // * JWT Token for authentication with the server. Stored in memory.
|
||||||
private val LOG_TAG = """Kavita_${"[$suffix]_" + preferences.getString(KavitaConstants.customSourceNamePref, "[$suffix]")!!.replace(' ', '_')}"""
|
private val LOG_TAG = """Kavita_${"[$suffix]_" + preferences.getString(KavitaConstants.customSourceNamePref, "[$suffix]")!!.replace(' ', '_')}"""
|
||||||
private var isLogged = false // Used to know if login was correct and not send login requests anymore
|
private var isLogged = false // Used to know if login was correct and not send login requests anymore
|
||||||
@@ -265,14 +268,17 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
* Custom implementation for fetch popular, latest and search
|
* Custom implementation for fetch popular, latest and search
|
||||||
* Handles and logs errors to provide a more detailed exception to the users.
|
* Handles and logs errors to provide a more detailed exception to the users.
|
||||||
*/
|
*/
|
||||||
private suspend fun fetch(request: Request): MangasPage = withContext(Dispatchers.IO) {
|
private suspend fun fetch(
|
||||||
|
request: Request,
|
||||||
|
parser: (Response) -> MangasPage = ::popularMangaParse,
|
||||||
|
): MangasPage = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val response = client.newCall(request).execute()
|
val response = client.newCall(request).execute()
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
val code = response.code
|
val code = response.code
|
||||||
throw IOException("Http Error: $code\n ${intl["http_errors_$code"]}\n${intl["check_version"]}")
|
throw IOException("Http Error: $code\n ${intl["http_errors_$code"]}\n${intl["check_version"]}")
|
||||||
}
|
}
|
||||||
popularMangaParse(response)
|
parser(response)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(LOG_TAG, "Error fetching manga", e)
|
Log.e(LOG_TAG, "Error fetching manga", e)
|
||||||
throw e
|
throw e
|
||||||
@@ -288,7 +294,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
popularMangaParse(response)
|
popularMangaParse(response)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(LOG_TAG, "Error fetching popular manga", e)
|
Log.e(LOG_TAG, "Error fetching popular manga", e)
|
||||||
MangasPage(emptyList(), false)
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +309,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
readingListParse(response)
|
readingListParse(response)
|
||||||
}
|
}
|
||||||
else -> { // Regular searches (including Want to Read)
|
else -> { // Regular searches (including Want to Read)
|
||||||
fetch(searchMangaRequest(page, query, filters))
|
fetch(searchMangaRequest(page, query, filters), ::searchMangaParse)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -580,12 +586,12 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
filter.state.forEach { rangeFilter ->
|
filter.state.forEach { rangeFilter ->
|
||||||
if ((rangeFilter as Filter.Text).state.isNotEmpty()) {
|
if ((rangeFilter as Filter.Text).state.isNotEmpty()) {
|
||||||
when (rangeFilter.name) {
|
when (rangeFilter.name) {
|
||||||
"Min" -> filterV2.addStatement(
|
"Min", "최소" -> filterV2.addStatement(
|
||||||
FilterComparison.GreaterThanEqual,
|
FilterComparison.GreaterThanEqual,
|
||||||
FilterField.ReleaseYear,
|
FilterField.ReleaseYear,
|
||||||
rangeFilter.state,
|
rangeFilter.state,
|
||||||
)
|
)
|
||||||
"Max" -> filterV2.addStatement(
|
"Max", "최대" -> filterV2.addStatement(
|
||||||
FilterComparison.LessThanEqual,
|
FilterComparison.LessThanEqual,
|
||||||
FilterField.ReleaseYear,
|
FilterField.ReleaseYear,
|
||||||
rangeFilter.state,
|
rangeFilter.state,
|
||||||
@@ -647,12 +653,12 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
val included = filter.state
|
val included = filter.state
|
||||||
.filter { it.state == STATE_INCLUDE }
|
.filter { it.state == STATE_INCLUDE }
|
||||||
.mapNotNull { ageFilter ->
|
.mapNotNull { ageFilter ->
|
||||||
ageRatingsListMeta.find { it.title == ageFilter.name }?.value?.toString()
|
ageRatingsListMeta.find { it.title == ageFilter.name || translateAgeRating(it.title) == ageFilter.name }?.value?.toString()
|
||||||
}
|
}
|
||||||
val excluded = filter.state
|
val excluded = filter.state
|
||||||
.filter { it.state == STATE_EXCLUDE }
|
.filter { it.state == STATE_EXCLUDE }
|
||||||
.mapNotNull { ageFilter ->
|
.mapNotNull { ageFilter ->
|
||||||
ageRatingsListMeta.find { it.title == ageFilter.name }?.value?.toString()
|
ageRatingsListMeta.find { it.title == ageFilter.name || translateAgeRating(it.title) == ageFilter.name }?.value?.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (included.isNotEmpty()) {
|
if (included.isNotEmpty()) {
|
||||||
@@ -722,12 +728,12 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
val included = filter.state
|
val included = filter.state
|
||||||
.filter { it.state == STATE_INCLUDE }
|
.filter { it.state == STATE_INCLUDE }
|
||||||
.mapNotNull { languageFilter ->
|
.mapNotNull { languageFilter ->
|
||||||
languagesListMeta.find { it.title == languageFilter.name }?.isoCode
|
languagesListMeta.find { it.title == languageFilter.name || translateLanguage(it.title) == languageFilter.name }?.isoCode
|
||||||
}
|
}
|
||||||
val excluded = filter.state
|
val excluded = filter.state
|
||||||
.filter { it.state == STATE_EXCLUDE }
|
.filter { it.state == STATE_EXCLUDE }
|
||||||
.mapNotNull { languageFilter ->
|
.mapNotNull { languageFilter ->
|
||||||
languagesListMeta.find { it.title == languageFilter.name }?.isoCode
|
languagesListMeta.find { it.title == languageFilter.name || translateLanguage(it.title) == languageFilter.name }?.isoCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if (included.isNotEmpty()) {
|
if (included.isNotEmpty()) {
|
||||||
@@ -778,7 +784,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
is PubStatusFilterGroup -> {
|
is PubStatusFilterGroup -> {
|
||||||
filter.state.forEach { pubStatusFilter ->
|
filter.state.forEach { pubStatusFilter ->
|
||||||
if ((pubStatusFilter as Filter.CheckBox).state) {
|
if ((pubStatusFilter as Filter.CheckBox).state) {
|
||||||
val statusValue = when (pubStatusFilter.name) {
|
val statusValue = when (normalizePublicationStatus(pubStatusFilter.name)) {
|
||||||
"Ongoing" -> 0
|
"Ongoing" -> 0
|
||||||
"Hiatus" -> 1
|
"Hiatus" -> 1
|
||||||
"Completed" -> 2
|
"Completed" -> 2
|
||||||
@@ -1583,15 +1589,17 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
|
|
||||||
val allChapters = mutableListOf<SChapter>()
|
val allChapters = mutableListOf<SChapter>()
|
||||||
|
|
||||||
// Get the series name to use as mangaTitle
|
val seriesDtoForContext = seriesId?.let { id: Int ->
|
||||||
val seriesName = seriesId?.let { id: Int ->
|
series.find { it.id == id }
|
||||||
// First try from cache
|
|
||||||
series.find { it.id == id }?.name
|
|
||||||
?: runCatching {
|
?: runCatching {
|
||||||
client.newCall(GET("$apiUrl/Series/$id", headersBuilder().build()))
|
client.newCall(GET("$apiUrl/Series/$id", headersBuilder().build()))
|
||||||
.execute().parseAs<SeriesDto>().name
|
.execute().parseAs<SeriesDto>()
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
} ?: ""
|
}
|
||||||
|
|
||||||
|
// Get the series name to use as mangaTitle
|
||||||
|
val seriesName = seriesDtoForContext?.name ?: ""
|
||||||
|
val isPdfSeries = seriesDtoForContext?.format == MangaFormat.Pdf.format
|
||||||
|
|
||||||
Log.d(LOG_TAG, "Processing chapters for series $seriesId with name: '$seriesName'")
|
Log.d(LOG_TAG, "Processing chapters for series $seriesId with name: '$seriesName'")
|
||||||
|
|
||||||
@@ -1693,7 +1701,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
scanlatorFormat = preferences.scanlatorFormat,
|
scanlatorFormat = preferences.scanlatorFormat,
|
||||||
volumePageCount = volume.pages,
|
volumePageCount = volume.pages,
|
||||||
)
|
)
|
||||||
sChapter.url = "/Chapter/${chapter.id}"
|
sChapter.url = chapterUrlWithPageCount(chapter, volume.pages, forceExtractPdf = isPdfSeries)
|
||||||
|
|
||||||
// For singleFileVolume, ensure the scanlator field reflects webtoon terminology
|
// For singleFileVolume, ensure the scanlator field reflects webtoon terminology
|
||||||
sChapter.scanlator = if (isWebtoon) "Season" else "Volume"
|
sChapter.scanlator = if (isWebtoon) "Season" else "Volume"
|
||||||
@@ -1712,7 +1720,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
scanlatorFormat = preferences.scanlatorFormat,
|
scanlatorFormat = preferences.scanlatorFormat,
|
||||||
)
|
)
|
||||||
|
|
||||||
sChapter.url = "/Chapter/${chapter.id}"
|
sChapter.url = chapterUrlWithPageCount(chapter, forceExtractPdf = isPdfSeries)
|
||||||
|
|
||||||
allChapters.add(sChapter)
|
allChapters.add(sChapter)
|
||||||
}
|
}
|
||||||
@@ -1757,6 +1765,101 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
return GET("$apiUrl/$chapterId", headersBuilder().build())
|
return GET("$apiUrl/$chapterId", headersBuilder().build())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun chapterUrlWithPageCount(
|
||||||
|
chapter: ChapterDto,
|
||||||
|
pageCount: Int = chapter.pages,
|
||||||
|
forceExtractPdf: Boolean = false,
|
||||||
|
): String {
|
||||||
|
val params = mutableListOf<String>()
|
||||||
|
if (pageCount > 0) {
|
||||||
|
params += "pages=$pageCount"
|
||||||
|
}
|
||||||
|
params += "extractPdf=${shouldExtractPdf(chapter, forceExtractPdf)}"
|
||||||
|
if (isEpubChapter(chapter)) {
|
||||||
|
params += "epub=true"
|
||||||
|
}
|
||||||
|
if (chapter.fileCount > 1) {
|
||||||
|
params += "split=${chapter.fileCount}"
|
||||||
|
}
|
||||||
|
return buildString {
|
||||||
|
append("/Chapter/${chapter.id}")
|
||||||
|
if (params.isNotEmpty()) {
|
||||||
|
append("?")
|
||||||
|
append(params.joinToString("&"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pageCountFromChapterUrl(chapter: SChapter): Int? =
|
||||||
|
chapter.url.substringAfter("pages=", "")
|
||||||
|
.substringBefore("&")
|
||||||
|
.toIntOrNull()
|
||||||
|
?.takeIf { it > 0 }
|
||||||
|
|
||||||
|
private fun shouldExtractPdf(chapter: ChapterDto, forceExtractPdf: Boolean = false): Boolean =
|
||||||
|
forceExtractPdf || chapter.files.orEmpty().any { file ->
|
||||||
|
file.format == MangaFormat.Pdf.format || file.extension.equals("pdf", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isEpubChapter(chapter: ChapterDto): Boolean =
|
||||||
|
chapter.files.orEmpty().any { file ->
|
||||||
|
file.format == MangaFormat.Epub.format || file.extension.equals("epub", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isEpubFromChapterUrl(chapter: SChapter): Boolean =
|
||||||
|
chapter.url.substringAfter("epub=", "")
|
||||||
|
.substringBefore("&")
|
||||||
|
.equals("true", ignoreCase = true)
|
||||||
|
|
||||||
|
private fun isPdfSeries(seriesId: Int?): Boolean =
|
||||||
|
seriesId?.let { id ->
|
||||||
|
series.find { it.id == id }?.format == MangaFormat.Pdf.format ||
|
||||||
|
runCatching {
|
||||||
|
client.newCall(GET("$apiUrl/Series/$id", headersBuilder().build()))
|
||||||
|
.execute()
|
||||||
|
.parseAs<SeriesDto>()
|
||||||
|
.format == MangaFormat.Pdf.format
|
||||||
|
}.getOrDefault(false)
|
||||||
|
} ?: false
|
||||||
|
|
||||||
|
private fun extractPdfFromChapterUrl(chapter: SChapter): Boolean =
|
||||||
|
chapter.url.substringAfter("extractPdf=", "")
|
||||||
|
.substringBefore("&")
|
||||||
|
.equals("true", ignoreCase = true)
|
||||||
|
|
||||||
|
private fun readerImageUrl(chapterId: Any, page: Int, extractPdf: Boolean): String =
|
||||||
|
"$apiUrl/Reader/image".toHttpUrlOrNull()!!.newBuilder()
|
||||||
|
.addQueryParameter("chapterId", chapterId.toString())
|
||||||
|
.addQueryParameter("page", page.toString())
|
||||||
|
.addQueryParameter("extractPdf", extractPdf.toString())
|
||||||
|
.addQueryParameter("saveProgress", "true")
|
||||||
|
.addQueryParameter("apiKey", apiKey)
|
||||||
|
.addQueryParameter("kavitaClient", kavitaClientHeader())
|
||||||
|
.addQueryParameter("deviceId", deviceFingerprint())
|
||||||
|
.build()
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
private fun readerPage(index: Int, chapterId: Any, page: Int = index, extractPdf: Boolean = false): Page {
|
||||||
|
val imageUrl = readerImageUrl(chapterId, page, extractPdf)
|
||||||
|
return Page(index = index, url = imageUrl, imageUrl = imageUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readerEpubPage(chapterId: Any, pageCount: Int, title: String? = null): Page {
|
||||||
|
val params = mutableListOf(
|
||||||
|
"apiUrl=${apiUrl.urlEncode()}",
|
||||||
|
"apiKey=${apiKey.urlEncode()}",
|
||||||
|
"chapterId=${chapterId.toString().urlEncode()}",
|
||||||
|
"pages=${pageCount.coerceAtLeast(1)}",
|
||||||
|
)
|
||||||
|
if (!title.isNullOrBlank()) {
|
||||||
|
params += "title=${title.urlEncode()}"
|
||||||
|
}
|
||||||
|
val url = "kavita-epub://reader?${params.joinToString("&")}"
|
||||||
|
return Page(index = 0, url = url, imageUrl = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.urlEncode(): String = URLEncoder.encode(this, Charsets.UTF_8.name())
|
||||||
|
|
||||||
override suspend fun getPageList(chapter: SChapter): List<Page> = withContext(Dispatchers.IO) {
|
override suspend fun getPageList(chapter: SChapter): List<Page> = withContext(Dispatchers.IO) {
|
||||||
// Check if this is a reading list item (has readingListId in URL)
|
// Check if this is a reading list item (has readingListId in URL)
|
||||||
if (chapter.url.contains("readingListId=")) {
|
if (chapter.url.contains("readingListId=")) {
|
||||||
@@ -1804,12 +1907,11 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate pages with consistent URL format
|
// Generate pages with consistent URL format
|
||||||
return@withContext (0 until chapterDetails.pages).map { i ->
|
if (isEpubChapter(chapterDetails)) {
|
||||||
Page(
|
return@withContext listOf(readerEpubPage(chapterDetails.id, chapterDetails.pages, chapterDetails.titleName ?: chapterDetails.title))
|
||||||
index = i,
|
|
||||||
imageUrl = "$apiUrl/Reader/image?chapterId=${chapterDetails.id}&page=$i&extractPdf=true&apiKey=$apiKey",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
val extractPdf = shouldExtractPdf(chapterDetails, isPdfSeries(seriesId))
|
||||||
|
return@withContext (0 until chapterDetails.pages).map { i -> readerPage(i, chapterDetails.id, extractPdf = extractPdf) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(LOG_TAG, "Error processing reading list item", e)
|
Log.e(LOG_TAG, "Error processing reading list item", e)
|
||||||
throw e
|
throw e
|
||||||
@@ -1835,31 +1937,25 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
// Get all pages in this volume
|
// Get all pages in this volume
|
||||||
val volumeRequest = GET("$apiUrl/Volume/$volumeId", headersBuilder().build())
|
val volumeRequest = GET("$apiUrl/Volume/$volumeId", headersBuilder().build())
|
||||||
val volume = client.newCall(volumeRequest).execute().parseAs<VolumeDto>()
|
val volume = client.newCall(volumeRequest).execute().parseAs<VolumeDto>()
|
||||||
val matchingChapter = volume.chapters.firstOrNull() // or match a chapterId if available
|
if (volume.chapters.isEmpty()) {
|
||||||
?: throw IOException(intl["error_no_chapters_found"])
|
throw IOException(intl["error_no_chapters_found"])
|
||||||
|
|
||||||
val initialPages = (0 until matchingChapter.pages).map { i ->
|
|
||||||
Page(
|
|
||||||
index = i,
|
|
||||||
imageUrl = "$apiUrl/Reader/image?chapterId=${matchingChapter.id}&page=$i&extractPdf=true&apiKey=$apiKey",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val remainingPages = volume.chapters
|
val volumePages = volume.chapters
|
||||||
.sortedBy { it.number.toFloatOrNull() ?: 0f }
|
.sortedBy { it.number.toFloatOrNull() ?: 0f }
|
||||||
.runningFold(initialPages.size) { acc, chapter -> acc + chapter.pages }
|
.runningFold(0) { acc, chapter -> acc + chapter.pages }
|
||||||
.drop(1)
|
|
||||||
.zip(volume.chapters.sortedBy { it.number.toFloatOrNull() ?: 0f })
|
.zip(volume.chapters.sortedBy { it.number.toFloatOrNull() ?: 0f })
|
||||||
.flatMap { (offset, chapter) ->
|
.flatMap { (startIndex, chapter) ->
|
||||||
|
if (isEpubChapter(chapter)) {
|
||||||
|
return@withContext listOf(readerEpubPage(chapter.id, chapter.pages, chapter.titleName ?: chapter.title))
|
||||||
|
}
|
||||||
|
val extractPdf = shouldExtractPdf(chapter, isPdfSeries(volume.seriesId))
|
||||||
(0 until chapter.pages).map { i ->
|
(0 until chapter.pages).map { i ->
|
||||||
Page(
|
readerPage(startIndex + i, chapter.id, i, extractPdf)
|
||||||
index = offset - chapter.pages + i,
|
|
||||||
imageUrl = "$apiUrl/Reader/image?chapterId=${chapter.id}&page=$i&extractPdf=true&apiKey=$apiKey",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return@withContext (initialPages + remainingPages).toList()
|
return@withContext volumePages
|
||||||
} else {
|
} else {
|
||||||
// Original chapter handling
|
// Original chapter handling
|
||||||
val chapterId = when {
|
val chapterId = when {
|
||||||
@@ -1868,6 +1964,14 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
else -> throw IOException("Invalid chapter URL format")
|
else -> throw IOException("Invalid chapter URL format")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val extractPdf = extractPdfFromChapterUrl(chapter)
|
||||||
|
pageCountFromChapterUrl(chapter)?.let { pageCount ->
|
||||||
|
if (isEpubFromChapterUrl(chapter)) {
|
||||||
|
return@withContext listOf(readerEpubPage(chapterId, pageCount, chapter.name))
|
||||||
|
}
|
||||||
|
return@withContext (0 until pageCount).map { i -> readerPage(i, chapterId, extractPdf = extractPdf) }
|
||||||
|
}
|
||||||
|
|
||||||
val chapterRequest = GET("$apiUrl/Chapter?chapterId=$chapterId", headersBuilder().build())
|
val chapterRequest = GET("$apiUrl/Chapter?chapterId=$chapterId", headersBuilder().build())
|
||||||
val response = client.newCall(chapterRequest).execute()
|
val response = client.newCall(chapterRequest).execute()
|
||||||
|
|
||||||
@@ -1886,23 +1990,22 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
throw IOException(errorMessage)
|
throw IOException(errorMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
return@withContext (0 until chapterDetails.pages).map { i ->
|
val fetchedExtractPdf = extractPdf || shouldExtractPdf(chapterDetails)
|
||||||
Page(
|
if (isEpubChapter(chapterDetails)) {
|
||||||
index = i,
|
return@withContext listOf(readerEpubPage(chapterId, chapterDetails.pages, chapterDetails.titleName ?: chapterDetails.title))
|
||||||
imageUrl = "$apiUrl/Reader/image?chapterId=$chapterId&page=$i&extractPdf=true&apiKey=$apiKey",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
return@withContext (0 until chapterDetails.pages).map { i -> readerPage(i, chapterId, extractPdf = fetchedExtractPdf) }
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Fallback to using the scanlator field if we can't get chapter details
|
// Fallback to using the scanlator field if we can't get chapter details
|
||||||
Log.e(LOG_TAG, "Error fetching chapter details, using fallback", e)
|
Log.e(LOG_TAG, "Error fetching chapter details, using fallback", e)
|
||||||
val fallbackPageCount = chapter.scanlator?.replace(" pages", "")?.toIntOrNull() ?: 1
|
val fallbackPageCount = chapter.scanlator?.replace(" pages", "")?.toIntOrNull() ?: 1
|
||||||
return@withContext (0 until fallbackPageCount).map { i ->
|
val fallbackChapterId = chapter.url
|
||||||
Page(
|
.substringAfter("/Chapter/", chapter.url)
|
||||||
index = i,
|
.substringAfter("chapter_", chapter.url)
|
||||||
imageUrl = "$apiUrl/Reader/image?chapterId=${chapter.url.substringBefore("_")}&page=$i&extractPdf=true&apiKey=$apiKey",
|
.substringBefore("_")
|
||||||
)
|
.substringBefore("?")
|
||||||
}
|
return@withContext (0 until fallbackPageCount).map { i -> readerPage(i, fallbackChapterId, extractPdf = extractPdfFromChapterUrl(chapter)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1927,11 +2030,10 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
}
|
}
|
||||||
|
|
||||||
val mangaList = filteredSeries.map { helper.createSeriesDto(it, apiUrl, apiKey) }
|
val mangaList = filteredSeries.map { helper.createSeriesDto(it, apiUrl, apiKey) }
|
||||||
MangasPage(mangaList, false)
|
MangasPage(mangaList, helper.hasNextPage(response, result.size))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(LOG_TAG, "Error parsing search results", e)
|
Log.e(LOG_TAG, "Error parsing search results", e)
|
||||||
// Return empty result instead of throwing exception
|
throw IOException(intl["check_version"], e)
|
||||||
MangasPage(emptyList(), false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1976,8 +2078,8 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
// Release year filter
|
// Release year filter
|
||||||
ReleaseYearRangeGroup(
|
ReleaseYearRangeGroup(
|
||||||
listOf(
|
listOf(
|
||||||
ReleaseYearRange("Min"),
|
ReleaseYearRange("최소"),
|
||||||
ReleaseYearRange("Max"),
|
ReleaseYearRange("최대"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -1985,21 +2087,64 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
TagFilterGroup(tags.map { TagFilter(it) }),
|
TagFilterGroup(tags.map { TagFilter(it) }),
|
||||||
CollectionFilterGroup(collections.map { CollectionFilter(it) }),
|
CollectionFilterGroup(collections.map { CollectionFilter(it) }),
|
||||||
LibrariesFilterGroup(libraries.map { LibraryFilter(it) }),
|
LibrariesFilterGroup(libraries.map { LibraryFilter(it) }),
|
||||||
LanguageFilterGroup(languages.map { LanguageFilter(it) }),
|
LanguageFilterGroup(languages.map { LanguageFilter(translateLanguage(it)) }),
|
||||||
PubStatusFilterGroup(pubStatus.map { PubStatusFilter(it) }),
|
PubStatusFilterGroup(pubStatus.map { PubStatusFilter(translatePublicationStatus(it)) }),
|
||||||
AgeRatingFilterGroup(ageRatings.map { AgeRatingFilter(it) }),
|
AgeRatingFilterGroup(ageRatings.map { AgeRatingFilter(translateAgeRating(it)) }),
|
||||||
|
|
||||||
UserRating(),
|
UserRating(),
|
||||||
UserRatingSeparator(),
|
UserRatingSeparator(),
|
||||||
|
|
||||||
// Special filters section
|
// Special filters section
|
||||||
Filter.Header(intl["filters_special"]),
|
Filter.Header("특수 필터"),
|
||||||
SpecialListFilter(intl["filters_special_list"]),
|
SpecialListFilter("특수 목록"),
|
||||||
SmartFiltersFilter(smartFilters),
|
SmartFiltersFilter(smartFilters),
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun translatePublicationStatus(value: String): String =
|
||||||
|
when (value) {
|
||||||
|
"Ongoing" -> "연재중"
|
||||||
|
"Hiatus" -> "휴재"
|
||||||
|
"Completed" -> "완결"
|
||||||
|
"Cancelled" -> "취소됨"
|
||||||
|
"Ended" -> "종료됨"
|
||||||
|
else -> value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizePublicationStatus(value: String): String =
|
||||||
|
when (value) {
|
||||||
|
"연재중" -> "Ongoing"
|
||||||
|
"휴재" -> "Hiatus"
|
||||||
|
"완결" -> "Completed"
|
||||||
|
"취소됨" -> "Cancelled"
|
||||||
|
"종료됨" -> "Ended"
|
||||||
|
else -> value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun translateAgeRating(value: String): String =
|
||||||
|
when (value) {
|
||||||
|
"Unknown" -> "알 수 없음"
|
||||||
|
"Everyone" -> "전체 이용가"
|
||||||
|
"Teen" -> "청소년"
|
||||||
|
"Mature" -> "성인"
|
||||||
|
"Explicit" -> "노골적"
|
||||||
|
else -> value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun translateLanguage(value: String): String =
|
||||||
|
when (value) {
|
||||||
|
"Unknown" -> "알 수 없음"
|
||||||
|
"English" -> "영어"
|
||||||
|
"Korean" -> "한국어"
|
||||||
|
"Japanese" -> "일본어"
|
||||||
|
"Chinese" -> "중국어"
|
||||||
|
"Spanish" -> "스페인어"
|
||||||
|
"French" -> "프랑스어"
|
||||||
|
"German" -> "독일어"
|
||||||
|
else -> value
|
||||||
|
}
|
||||||
|
|
||||||
override fun getFilterList(): FilterList {
|
override fun getFilterList(): FilterList {
|
||||||
return getFilterList(
|
return getFilterList(
|
||||||
genres = genresListMeta.map { it.title },
|
genres = genresListMeta.map { it.title },
|
||||||
@@ -2037,6 +2182,8 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
}
|
}
|
||||||
return Headers.Builder()
|
return Headers.Builder()
|
||||||
.add("User-Agent", "Tachiyomi Kavita v${AppInfo.getVersionName()}")
|
.add("User-Agent", "Tachiyomi Kavita v${AppInfo.getVersionName()}")
|
||||||
|
.add("X-Kavita-Client", kavitaClientHeader())
|
||||||
|
.add("X-Device-Id", deviceFingerprint())
|
||||||
.add("Content-Type", "application/json")
|
.add("Content-Type", "application/json")
|
||||||
.add("Authorization", "Bearer $jwtToken")
|
.add("Authorization", "Bearer $jwtToken")
|
||||||
}
|
}
|
||||||
@@ -2044,10 +2191,34 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
private fun setupLoginHeaders(): Headers.Builder {
|
private fun setupLoginHeaders(): Headers.Builder {
|
||||||
return Headers.Builder()
|
return Headers.Builder()
|
||||||
.add("User-Agent", "Tachiyomi Kavita v${AppInfo.getVersionName()}")
|
.add("User-Agent", "Tachiyomi Kavita v${AppInfo.getVersionName()}")
|
||||||
|
.add("X-Kavita-Client", kavitaClientHeader())
|
||||||
|
.add("X-Device-Id", deviceFingerprint())
|
||||||
.add("Content-Type", "application/json")
|
.add("Content-Type", "application/json")
|
||||||
.add("Authorization", "Bearer $jwtToken")
|
.add("Authorization", "Bearer $jwtToken")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun kavitaClientHeader(): String {
|
||||||
|
val resources = application.resources
|
||||||
|
val configuration = resources.configuration
|
||||||
|
val smallestWidthDp = configuration.smallestScreenWidthDp
|
||||||
|
val deviceType = when {
|
||||||
|
smallestWidthDp >= 600 -> "Tablet"
|
||||||
|
else -> "Mobile"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "opds-client/${AppInfo.getVersionName()} (Kavita Extension; Android; $deviceType)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deviceFingerprint(): String {
|
||||||
|
val key = "kavita_extension_device_id"
|
||||||
|
val existing = preferences.getString(key, null)
|
||||||
|
if (!existing.isNullOrBlank()) return existing
|
||||||
|
|
||||||
|
val generated = UUID.randomUUID().toString()
|
||||||
|
preferences.edit().putString(key, generated).apply()
|
||||||
|
return generated
|
||||||
|
}
|
||||||
|
|
||||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||||
val opdsAddressPref = screen.editTextPreference(
|
val opdsAddressPref = screen.editTextPreference(
|
||||||
ADDRESS_TITLE,
|
ADDRESS_TITLE,
|
||||||
@@ -2233,7 +2404,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
return EditTextPreference(context).apply {
|
return EditTextPreference(context).apply {
|
||||||
key = preKey
|
key = preKey
|
||||||
this.title = title
|
this.title = title
|
||||||
val input = preferences.getString(title, null)
|
val input = preferences.getString(preKey, null)
|
||||||
this.summary = if (input == null || input.isEmpty()) summary else input
|
this.summary = if (input == null || input.isEmpty()) summary else input
|
||||||
this.setDefaultValue(default)
|
this.setDefaultValue(default)
|
||||||
dialogTitle = title
|
dialogTitle = title
|
||||||
@@ -2249,7 +2420,7 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
val opdsUrlInPref = opdsUrlInPreferences(newValue.toString()) // We don't allow hot have multiple sources with same ip or domain
|
val opdsUrlInPref = opdsUrlInPreferences(newValue.toString()) // We don't allow hot have multiple sources with same ip or domain
|
||||||
if (opdsUrlInPref.isNotEmpty()) {
|
if (opdsUrlInPref.isNotEmpty()) {
|
||||||
// TODO("Add option to allow multiple sources with same url at the cost of tracking")
|
// TODO("Add option to allow multiple sources with same url at the cost of tracking")
|
||||||
preferences.edit().putString(title, "").apply()
|
preferences.edit().putString(preKey, "").apply()
|
||||||
|
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
context,
|
context,
|
||||||
@@ -2259,7 +2430,9 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
throw OpdsurlExistsInPref(intl["pref_opds_duplicated_source_url"] + opdsUrlInPref)
|
throw OpdsurlExistsInPref(intl["pref_opds_duplicated_source_url"] + opdsUrlInPref)
|
||||||
}
|
}
|
||||||
|
|
||||||
val res = preferences.edit().putString(title, newValue as String).commit()
|
val res = preferences.edit().putString(preKey, newValue as String).commit()
|
||||||
|
jwtToken = ""
|
||||||
|
isLogged = false
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
context,
|
context,
|
||||||
intl["restartapp_settings"],
|
intl["restartapp_settings"],
|
||||||
@@ -2537,14 +2710,15 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
|
|
||||||
private fun doLogin() {
|
private fun doLogin() {
|
||||||
Log.d(LOG_TAG, "Attempting login with address: ${address.takeIf { it.isNotEmpty() } ?: "EMPTY"}")
|
Log.d(LOG_TAG, "Attempting login with address: ${address.takeIf { it.isNotEmpty() } ?: "EMPTY"}")
|
||||||
if (address.isEmpty()) {
|
val hasStoredLogin = apiUrl.isNotBlank() && getPrefKey().isNotBlank()
|
||||||
|
if (address.isEmpty() && !hasStoredLogin) {
|
||||||
Log.e(LOG_TAG, "OPDS URL is empty or null")
|
Log.e(LOG_TAG, "OPDS URL is empty or null")
|
||||||
throw IOException(intl["pref_opds_must_setup_address"])
|
throw IOException(intl["pref_opds_must_setup_address"])
|
||||||
}
|
}
|
||||||
if (address.split("/opds/").size != 2) {
|
if (address.isNotEmpty() && address.split("/opds/").size != 2) {
|
||||||
throw IOException(intl["pref_opds_badformed_url"])
|
throw IOException(intl["pref_opds_badformed_url"])
|
||||||
}
|
}
|
||||||
if (jwtToken.isEmpty()) setupLogin()
|
if (jwtToken.isEmpty() && address.isNotEmpty()) setupLogin()
|
||||||
Log.v(LOG_TAG, "[Login] Starting login")
|
Log.v(LOG_TAG, "[Login] Starting login")
|
||||||
val request = POST(
|
val request = POST(
|
||||||
"$apiUrl/Plugin/authenticate?apiKey=${getPrefKey()}&pluginName=Tachiyomi-Kavita",
|
"$apiUrl/Plugin/authenticate?apiKey=${getPrefKey()}&pluginName=Tachiyomi-Kavita",
|
||||||
@@ -2587,10 +2761,8 @@ class Kavita(private val suffix: String = "") : ConfigurableSource, UnmeteredSou
|
|||||||
try {
|
try {
|
||||||
Log.d(LOG_TAG, "Starting Kavita extension initialization")
|
Log.d(LOG_TAG, "Starting Kavita extension initialization")
|
||||||
|
|
||||||
// First attempt login with timeout
|
|
||||||
val loginJob = async { doLogin() }
|
|
||||||
try {
|
try {
|
||||||
loginJob.await()
|
doLogin()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(LOG_TAG, "Login failed during initialization", e)
|
Log.e(LOG_TAG, "Login failed during initialization", e)
|
||||||
initializationError = e
|
initializationError = e
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ object KavitaConstants {
|
|||||||
const val SPECIAL_NUMBER_OFFSET = 10000000000f
|
const val SPECIAL_NUMBER_OFFSET = 10000000000f
|
||||||
|
|
||||||
const val customSourceNamePref = "customSourceName"
|
const val customSourceNamePref = "customSourceName"
|
||||||
const val noSmartFilterSelected = "No smart filter loaded"
|
const val noSmartFilterSelected = "스마트 필터 없음"
|
||||||
|
|
||||||
// Template formatting preferences
|
// Template formatting preferences
|
||||||
const val CHAPTER_TITLE_FORMAT_DEFAULT = "\$CleanTitle"
|
const val CHAPTER_TITLE_FORMAT_DEFAULT = "\$CleanTitle"
|
||||||
|
|||||||
@@ -45,14 +45,14 @@ class KavitaHelper {
|
|||||||
|
|
||||||
val seriesMap = SeriesMapper()
|
val seriesMap = SeriesMapper()
|
||||||
|
|
||||||
fun hasNextPage(response: Response): Boolean {
|
fun hasNextPage(response: Response, itemCount: Int? = null, pageSize: Int = 20): Boolean {
|
||||||
val paginationHeader = response.header("Pagination")
|
val paginationHeader = response.header("Pagination")
|
||||||
var hasNextPage = false
|
|
||||||
if (!paginationHeader.isNullOrEmpty()) {
|
if (!paginationHeader.isNullOrEmpty()) {
|
||||||
val paginationInfo = json.decodeFromString<PaginationInfo>(paginationHeader)
|
val paginationInfo = json.decodeFromString<PaginationInfo>(paginationHeader)
|
||||||
hasNextPage = paginationInfo.currentPage < paginationInfo.totalPages
|
return paginationInfo.currentPage < paginationInfo.totalPages
|
||||||
}
|
}
|
||||||
return hasNextPage
|
|
||||||
|
return itemCount != null && itemCount >= pageSize
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getIdFromUrl(url: String): Int {
|
fun getIdFromUrl(url: String): Int {
|
||||||
@@ -106,16 +106,7 @@ class KavitaHelper {
|
|||||||
ChapterType.Regular -> {
|
ChapterType.Regular -> {
|
||||||
val chapterNum = formatChapterNumber(chapter)
|
val chapterNum = formatChapterNumber(chapter)
|
||||||
val volNum = formatVolumeNumber(volume)
|
val volNum = formatVolumeNumber(volume)
|
||||||
val cleanedTitle = cleanChapterTitle(
|
val cleanedTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon)
|
||||||
titleName,
|
|
||||||
ChapterTitleContext(
|
|
||||||
mangaTitle = mangaTitle,
|
|
||||||
chapterNumber = chapterNum,
|
|
||||||
volumeNumber = volNum,
|
|
||||||
volumeName = volume.name.ifBlank { title },
|
|
||||||
isWebtoon = isWebtoon,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val finalCleanTitle = cleanedTitle.ifBlank {
|
val finalCleanTitle = cleanedTitle.ifBlank {
|
||||||
defaultCleanTitle(type, chapterNum, volNum, isWebtoon)
|
defaultCleanTitle(type, chapterNum, volNum, isWebtoon)
|
||||||
}
|
}
|
||||||
@@ -160,20 +151,22 @@ class KavitaHelper {
|
|||||||
pages = volume.pages,
|
pages = volume.pages,
|
||||||
fileSize = volume.chapters.flatMap { it.files ?: emptyList() }.sumOf { it.bytes }.toDouble(),
|
fileSize = volume.chapters.flatMap { it.files ?: emptyList() }.sumOf { it.bytes }.toDouble(),
|
||||||
volumeNumber = volumeNumber,
|
volumeNumber = volumeNumber,
|
||||||
cleanTitle = when {
|
cleanTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon).ifBlank {
|
||||||
volume.name.isNotBlank() && !volume.name.matches(Regex("^\\d+$")) -> volume.name
|
when {
|
||||||
volume.name.isNotBlank() && volume.name.matches(Regex("^\\d+$")) -> {
|
volume.name.isNotBlank() && !volume.name.matches(Regex("^\\d+$")) -> volume.name
|
||||||
// Handle case where volume name is only a number (e.g., "2")
|
volume.name.isNotBlank() && volume.name.matches(Regex("^\\d+$")) -> {
|
||||||
if (isWebtoon) {
|
// Handle case where volume name is only a number (e.g., "2")
|
||||||
val volNum = volume.name.toIntOrNull()?.toString() ?: volume.name
|
if (isWebtoon) {
|
||||||
"Season $volNum"
|
val volNum = volume.name.toIntOrNull()?.toString() ?: volume.name
|
||||||
} else {
|
"Season $volNum"
|
||||||
val volNum = volume.name.toIntOrNull()?.toString() ?: volume.name
|
} else {
|
||||||
"Volume $volNum"
|
val volNum = volume.name.toIntOrNull()?.toString() ?: volume.name
|
||||||
|
"Volume $volNum"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
if (isWebtoon) "Season $volumeNumber" else "Volume $volumeNumber"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
if (isWebtoon) "Season $volumeNumber" else "Volume $volumeNumber"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
seriesName = seriesName,
|
seriesName = seriesName,
|
||||||
@@ -202,26 +195,28 @@ class KavitaHelper {
|
|||||||
pages = chapter.pages,
|
pages = chapter.pages,
|
||||||
fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0,
|
fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0,
|
||||||
volumeNumber = formatVolumeNumber(volume),
|
volumeNumber = formatVolumeNumber(volume),
|
||||||
cleanTitle = when {
|
cleanTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon).ifBlank {
|
||||||
titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) -> titleName
|
when {
|
||||||
titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> {
|
titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) -> titleName
|
||||||
// Handle case where titleName is only a number (e.g., "2")
|
titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> {
|
||||||
val num = titleName.toIntOrNull()?.toString()?.padStart(2, '0') ?: titleName
|
// Handle case where titleName is only a number (e.g., "2")
|
||||||
"Special $num"
|
val num = titleName.toIntOrNull()?.toString()?.padStart(2, '0') ?: titleName
|
||||||
|
"Special $num"
|
||||||
|
}
|
||||||
|
title.isNotBlank() && !title.matches(Regex("^\\d+$")) -> title
|
||||||
|
title.isNotBlank() && title.matches(Regex("^\\d+$")) -> {
|
||||||
|
// Handle case where title is only a number (e.g., "2")
|
||||||
|
val num = title.toIntOrNull()?.toString()?.padStart(2, '0') ?: title
|
||||||
|
"Special $num"
|
||||||
|
}
|
||||||
|
range.isNotBlank() && !range.matches(Regex("^\\d+$")) -> range
|
||||||
|
range.isNotBlank() && range.matches(Regex("^\\d+$")) -> {
|
||||||
|
// Handle case where range is only a number (e.g., "2")
|
||||||
|
val num = range.toIntOrNull()?.toString()?.padStart(2, '0') ?: range
|
||||||
|
"Special $num"
|
||||||
|
}
|
||||||
|
else -> "Special"
|
||||||
}
|
}
|
||||||
title.isNotBlank() && !title.matches(Regex("^\\d+$")) -> title
|
|
||||||
title.isNotBlank() && title.matches(Regex("^\\d+$")) -> {
|
|
||||||
// Handle case where title is only a number (e.g., "2")
|
|
||||||
val num = title.toIntOrNull()?.toString()?.padStart(2, '0') ?: title
|
|
||||||
"Special $num"
|
|
||||||
}
|
|
||||||
range.isNotBlank() && !range.matches(Regex("^\\d+$")) -> range
|
|
||||||
range.isNotBlank() && range.matches(Regex("^\\d+$")) -> {
|
|
||||||
// Handle case where range is only a number (e.g., "2")
|
|
||||||
val num = range.toIntOrNull()?.toString()?.padStart(2, '0') ?: range
|
|
||||||
"Special $num"
|
|
||||||
}
|
|
||||||
else -> "Special"
|
|
||||||
},
|
},
|
||||||
seriesName = seriesName,
|
seriesName = seriesName,
|
||||||
libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "",
|
libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "",
|
||||||
@@ -237,15 +232,7 @@ class KavitaHelper {
|
|||||||
|
|
||||||
ChapterType.Chapter -> {
|
ChapterType.Chapter -> {
|
||||||
val chapterNum = formatChapterNumber(chapter)
|
val chapterNum = formatChapterNumber(chapter)
|
||||||
val cleanedTitle = cleanChapterTitle(
|
val cleanedTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon)
|
||||||
titleName,
|
|
||||||
ChapterTitleContext(
|
|
||||||
mangaTitle = mangaTitle,
|
|
||||||
chapterNumber = chapterNum,
|
|
||||||
volumeName = volume.name,
|
|
||||||
isWebtoon = isWebtoon,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val finalCleanTitle = cleanedTitle.ifBlank {
|
val finalCleanTitle = cleanedTitle.ifBlank {
|
||||||
defaultCleanTitle(type, chapterNum, formatVolumeNumber(volume), isWebtoon)
|
defaultCleanTitle(type, chapterNum, formatVolumeNumber(volume), isWebtoon)
|
||||||
}
|
}
|
||||||
@@ -280,16 +267,18 @@ class KavitaHelper {
|
|||||||
pages = chapter.pages,
|
pages = chapter.pages,
|
||||||
fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0,
|
fileSize = chapter.files?.sumOf { it.bytes }?.toDouble() ?: 0.0,
|
||||||
volumeNumber = formatVolumeNumber(volume),
|
volumeNumber = formatVolumeNumber(volume),
|
||||||
cleanTitle = when {
|
cleanTitle = kavitaDisplayTitle(type, chapter, volume, libraryType, isWebtoon).ifBlank {
|
||||||
titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) && titleName.any { it.isLetter() } -> titleName
|
when {
|
||||||
titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> {
|
titleName.isNotBlank() && !titleName.matches(Regex("^\\d+$")) && titleName.any { it.isLetter() } -> titleName
|
||||||
// Handle case where titleName is only a number (e.g., "2")
|
titleName.isNotBlank() && titleName.matches(Regex("^\\d+$")) -> {
|
||||||
val num = titleName.toIntOrNull()?.toString()?.padStart(3, '0') ?: titleName
|
// Handle case where titleName is only a number (e.g., "2")
|
||||||
"Issue #$num"
|
val num = titleName.toIntOrNull()?.toString()?.padStart(3, '0') ?: titleName
|
||||||
|
"Issue #$num"
|
||||||
|
}
|
||||||
|
else -> "Issue #$issueNum"
|
||||||
|
}.ifBlank {
|
||||||
|
defaultCleanTitle(type, issueNum, formatVolumeNumber(volume), isWebtoon)
|
||||||
}
|
}
|
||||||
else -> "Issue #$issueNum"
|
|
||||||
}.ifBlank {
|
|
||||||
defaultCleanTitle(type, issueNum, formatVolumeNumber(volume), isWebtoon)
|
|
||||||
},
|
},
|
||||||
seriesName = seriesName,
|
seriesName = seriesName,
|
||||||
libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "",
|
libraryName = seriesMap.getSeries(volume.seriesId)?.libraryName ?: "",
|
||||||
@@ -470,6 +459,47 @@ class KavitaHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun kavitaDisplayTitle(
|
||||||
|
type: ChapterType,
|
||||||
|
chapter: ChapterDto,
|
||||||
|
volume: VolumeDto,
|
||||||
|
libraryType: LibraryTypeEnum? = null,
|
||||||
|
isWebtoon: Boolean = false,
|
||||||
|
): String {
|
||||||
|
val titleName = chapter.titleName?.trim().orEmpty()
|
||||||
|
val title = chapter.title.trim()
|
||||||
|
val range = chapter.range.trim()
|
||||||
|
val volumeName = volume.name.trim()
|
||||||
|
val usableVolumeName = volumeName.takeUnless { it.isBlank() || it == KavitaConstants.UNNUMBERED_VOLUME_STR }
|
||||||
|
|
||||||
|
fun fallbackChapterLabel(): String {
|
||||||
|
val number = range.takeUnless { it.isBlank() || it == KavitaConstants.UNNUMBERED_VOLUME_STR }
|
||||||
|
?: formatChapterNumber(chapter)
|
||||||
|
return when (libraryType) {
|
||||||
|
LibraryTypeEnum.Comic, LibraryTypeEnum.ComicVine -> "Issue #$number"
|
||||||
|
LibraryTypeEnum.Book, LibraryTypeEnum.LightNovel -> "Book $number"
|
||||||
|
else -> if (isWebtoon) "Episode $number" else "Chapter $number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun joinTitle(left: String?, right: String?): String =
|
||||||
|
listOfNotNull(
|
||||||
|
left?.takeIf { it.isNotBlank() },
|
||||||
|
right?.takeIf { it.isNotBlank() && it != left },
|
||||||
|
).joinToString(" - ")
|
||||||
|
|
||||||
|
return when (type) {
|
||||||
|
ChapterType.SingleFileVolume -> joinTitle(usableVolumeName, titleName)
|
||||||
|
.ifBlank { titleName }
|
||||||
|
.ifBlank { usableVolumeName.orEmpty() }
|
||||||
|
.ifBlank { title }
|
||||||
|
ChapterType.Special -> title.ifBlank { titleName }.ifBlank { range }
|
||||||
|
ChapterType.Regular -> joinTitle(usableVolumeName, titleName.ifBlank { fallbackChapterLabel() })
|
||||||
|
ChapterType.Chapter -> titleName.ifBlank { fallbackChapterLabel() }
|
||||||
|
ChapterType.Issue -> titleName.ifBlank { fallbackChapterLabel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal fun cleanChapterTitle(
|
internal fun cleanChapterTitle(
|
||||||
originalTitle: String,
|
originalTitle: String,
|
||||||
context: ChapterTitleContext? = null,
|
context: ChapterTitleContext? = null,
|
||||||
|
|||||||
Reference in New Issue
Block a user