re add kogma balls, lanraragi and kavita
							
								
								
									
										2
									
								
								src/all/kavita/AndroidManifest.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,2 @@ | |||||||
|  | <?xml version="1.0" encoding="utf-8"?> | ||||||
|  | <manifest /> | ||||||
							
								
								
									
										93
									
								
								src/all/kavita/CHANGELOG.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,93 @@ | |||||||
|  | ## 1.3.13 | ||||||
|  | 
 | ||||||
|  | ### Fixed | ||||||
|  | 
 | ||||||
|  |  * Fixed 'null cannot be cast to non-null type' exception | ||||||
|  | 
 | ||||||
|  | ## 1.3.12 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * Migrate filters to v2 | ||||||
|  | * Implemented smartFilters | ||||||
|  | * Added localization support | ||||||
|  | 
 | ||||||
|  | ### Fixed | ||||||
|  | 
 | ||||||
|  | * Fixed publication status not showing | ||||||
|  | 
 | ||||||
|  | ## 1.3.10 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * API Change for Kavita v0.7.2 | ||||||
|  | 
 | ||||||
|  | ## 1.3.9 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * Added pdf support | ||||||
|  | 
 | ||||||
|  | ## 1.3.8 | ||||||
|  | 
 | ||||||
|  | ### Fix | ||||||
|  | 
 | ||||||
|  | * Fixed `Expected URL scheme 'http' or 'https` when downloading | ||||||
|  | 
 | ||||||
|  | ## 1.3.7 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * New Sort filter: Time to read | ||||||
|  | * New Filter: Year release filter | ||||||
|  | 
 | ||||||
|  | ### Fix | ||||||
|  | 
 | ||||||
|  | * Filters can now be used together with search | ||||||
|  | * Epub and pdfs no longer show in format filter (currently not supported) | ||||||
|  | 
 | ||||||
|  | ## 1.3.6 | ||||||
|  | 
 | ||||||
|  | ### Fix | ||||||
|  | 
 | ||||||
|  | * Fixed "lateinit property title not initialized" | ||||||
|  | 
 | ||||||
|  | ## 1.3.5 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * Ignore DOH | ||||||
|  | * Added sort option `Item Added` | ||||||
|  | * Latest button now shows latest `Item Added` | ||||||
|  | 
 | ||||||
|  | ## 1.3.4 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * Exclude from bulk update warnings | ||||||
|  | 
 | ||||||
|  | ## 1.2.3 | ||||||
|  | 
 | ||||||
|  | ### Fix | ||||||
|  | 
 | ||||||
|  | * Fixed Rating filter  | ||||||
|  | * Fixed Chapter list not sorting correctly | ||||||
|  | * Fixed search | ||||||
|  | * Fixed manga details not showing correctly | ||||||
|  | * Fixed filters not populating if account was not admin | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | * The extension is now ready to implement tracking. | ||||||
|  | * Min required version for the extension to work properly: `v0.5.1.1` | ||||||
|  | 
 | ||||||
|  | ## 1.2.2 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * Add `CHANGELOG.md` & `README.md` | ||||||
|  | 
 | ||||||
|  | ## 1.2.1 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * first version | ||||||
							
								
								
									
										37
									
								
								src/all/kavita/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,37 @@ | |||||||
|  | # Kavita | ||||||
|  | 
 | ||||||
|  | Table of Content | ||||||
|  | - [FAQ](#FAQ) | ||||||
|  |   - [Why do I see no manga?](#why-do-i-see-no-manga) | ||||||
|  |   - [Where can I get more information about Kavita?](#where-can-i-get-more-information-about-kavita) | ||||||
|  |   - [The Kavita extension stopped working?](#the-kavita-extension-stopped-working) | ||||||
|  |   - [Can I add more than one Kavita server or user?](#can-i-add-more-than-one-kavita-server-or-user) | ||||||
|  |   - [Can I test the Kavita extension before setting up my own server?](#can-i-test-the-kavita-extension-before-setting-up-my-own-server) | ||||||
|  | - [Guides](#Guides) | ||||||
|  |   - [How do I add my Kavita server to Tachiyomi?](#how-do-i-add-my-kavita-server-to-tachiyomi) | ||||||
|  | 
 | ||||||
|  | Don't find the question you are look for go check out our general FAQs and Guides over at [Extension FAQ](https://tachiyomi.org/help/faq/#extensions) or [Getting Started](https://tachiyomi.org/help/guides/getting-started/#installation) | ||||||
|  | 
 | ||||||
|  | Kavita also has a documentation about the Tachiyomi Kavita extension at the [Kavita wiki](https://wiki.kavitareader.com/en/guides/misc/tachiyomi). | ||||||
|  | 
 | ||||||
|  | ## FAQ | ||||||
|  | 
 | ||||||
|  | ### Why do I see no manga? | ||||||
|  | Kavita is a self-hosted comic/manga media server. | ||||||
|  | 
 | ||||||
|  | ### Where can I get more information about Kavita? | ||||||
|  | You can visit the [Kavita](https://www.kavitareader.com/) website for for more information. | ||||||
|  | 
 | ||||||
|  | ### The Kavita extension stopped working? | ||||||
|  | Make sure that your Kavita server and extension are on the newest version. | ||||||
|  | 
 | ||||||
|  | ### Can I add more than one Kavita server or user? | ||||||
|  | Yes, currently you can add up to 3 different Kavita instances to Tachiyomi. | ||||||
|  | 
 | ||||||
|  | ### Can I test the Kavita extension before setting up my own server? | ||||||
|  | Yes, you can try it out with the DEMO servers OPDS url `https://demo.kavitareader.com/api/opds/aca1c50d-7e08-4f37-b356-aecd6bf69b72`. | ||||||
|  | 
 | ||||||
|  | ## Guides | ||||||
|  | 
 | ||||||
|  | ### How do I add my Kavita server to Tachiyomi? | ||||||
|  | Go into the settings of the Kavita extension from the Extension tab in Browse and fill in your OPDS url. | ||||||
							
								
								
									
										18
									
								
								src/all/kavita/assets/i18n/messages_en.properties
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,18 @@ | |||||||
|  | login_errors_failed_login=Login failed. Something went wrong | ||||||
|  | login_errors_header_token_empty="Error: The JSON Web Token is empty.\nTry opening the extension first." | ||||||
|  | login_errors_invalid_url=Invalid URL: | ||||||
|  | login_errors_parse_tokendto=There was an error parsing the auth token | ||||||
|  | pref_customsource_title=Displayed name for source | ||||||
|  | pref_edit_customsource_summary=Here you can change this source name.\nYou can write a descriptive name to identify this OPDS URL. | ||||||
|  | pref_filters_summary=Show these filters in the filter list | ||||||
|  | pref_filters_title=Default filters shown | ||||||
|  | pref_opds_badformed_url=Incorrect OPDS address. Please copy it from User settings \u2192 3rd party apps \u2192 OPDS URL | ||||||
|  | pref_opds_duplicated_source_url=The URL is configured in a different source ->  | ||||||
|  | pref_opds_must_setup_address=You must set up the address to communicate with Kavita | ||||||
|  | pref_opds_summary=The OPDS URL copied from User Settings. This should include address and end with the API key. | ||||||
|  | restartapp_settings=Restart Tachiyomi to apply new setting. | ||||||
|  | version_exceptions_chapters_parse=Unhandled exception parsing chapters. Send your logs to the Kavita devs. | ||||||
|  | check_version=Ensure you have the newest version of the extension and Kavita. (0.7.8 or newer.)\nIf the issue persists, report it to the Kavita developers with the accompanying logs. | ||||||
|  | version_exceptions_smart_filter=Could not decode SmartFilter. Ensure you are using Kavita version 0.7.11 or later. | ||||||
|  | http_errors_500=Something went wrong | ||||||
|  | http_errors_401=There was an error logging in. Try again or reload the app | ||||||
							
								
								
									
										18
									
								
								src/all/kavita/assets/i18n/messages_es_es.properties
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,18 @@ | |||||||
|  | pref_customsource_title=Nombre de la instancia | ||||||
|  | pref_edit_customsource_summary=Aqui puedes cambiar el nombre de la instancia.\nPuedes escribir un nombre descriptivo que identifique esta url/instancia | ||||||
|  | restartapp_settings=Reinicia la aplicación para aplicar los cambios | ||||||
|  | version_exceptions_chapters_parse=Algo ha ido mal al procesar los capitulos. Envia los registros de fallo a los desarrolladores de Kavita | ||||||
|  | check_version=Comprueba que tienes tanto Kavita como la extension actualizada. (Version minima: 0.7.8)\nSi el problema persiste, reportalo a los desarrolladores de Kavita aportando los registros de fallo. | ||||||
|  | version_exceptions_smart_filter=Fallo al decodificar los filtros inteligentes. Aseg\u00FArate que estas al menos en la version 0.7.11 de Kavita | ||||||
|  | http_errors_500=Algo ha ido mal | ||||||
|  | http_errors_401=Ha habido un error al iniciar sesi\u00F3n. Prueba otra vez o reinicia la aplicaci\u00F3n | ||||||
|  | pref_opds_summary=La url del OPDS copiada de la configuraci\u00F3n del usuario. Debe incluir la direcci\u00F3n y la clave api al final. | ||||||
|  | pref_filters_summary=Mostrar estos filtros en la lista de filtros | ||||||
|  | pref_filters_title=Filtros por defecto | ||||||
|  | pref_opds_badformed_url=La direcci\u00F3n OPDS no es correcta. Por favor, c\u00F3piela desde la Configuraci\u00F3n de usuario-> aplicaciones de terceros -> url de OPDS | ||||||
|  | login_errors_parse_tokendto=Se ha producido un error al procesar el token de autenticaci\u00F3n | ||||||
|  | pref_opds_duplicated_source_url=Url est\u00E1 configurado en una fuente diferente ->  | ||||||
|  | pref_opds_must_setup_address=Debe configurar la direcci\u00F3n para comunicarse con Kavita | ||||||
|  | login_errors_failed_login=Error en el inicio de sesi\u00F3n. Algo ha ido mal | ||||||
|  | login_errors_header_token_empty="Error: el token jwt est\u00E1 vac\u00EDo.\nIntente abrir primero la extensi\u00F3n" | ||||||
|  | login_errors_invalid_url=URL no v\u00E1lida: | ||||||
							
								
								
									
										20
									
								
								src/all/kavita/assets/i18n/messages_fr_fr.properties
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,20 @@ | |||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | version_exceptions_chapters_parse=Exception non trait\u00E9e durant l'analyse des chapitres. Envoyez les journaux aux d\u00E9velopeurs de Kavita | ||||||
|  | pref_customsource_title=Nom d'affichage pour la source | ||||||
|  | version_exceptions_smart_filter=\u00C9chec du d\u00E9codage de SmartFilter. Assurez-vous que vous utilisez au moins Kavita version 0.7.11 | ||||||
|  | pref_opds_summary=L'URL OPDS a \u00E9t\u00E9 copi\u00E9e \u00E0 partir des param\u00E8tres de l'utilisateur. Ceci devrait inclure l'adresse et la cl\u00E9 API. | ||||||
|  | pref_filters_summary=Afficher ces filtres dans la liste des filtres | ||||||
|  | check_version=Assurez-vous que vous avez l'extension et Kavita mises \u00E0 jour. (version Mini\u202F: 0.7.8)\nSi le probl\u00E8me persiste, signalez-le aux d\u00E9veloppeurs de Kavita en fournissant des journaux | ||||||
|  | pref_filters_title=Filtres par d\u00E9faut affich\u00E9s | ||||||
|  | pref_edit_customsource_summary=Ici vous pouvez changer ce nom source.\nVous pouvez \u00E9crire un nom descriptif pour identifier cette URL opds | ||||||
|  | pref_opds_badformed_url=L'adresse OPDS n'est pas correcte. Veuillez la copiez \u00E0 partir des param\u00E8tres de l'utilisateur - > Applis tierces -> URL OPDS | ||||||
|  | login_errors_parse_tokendto=Il y a eu une erreur pendant l'analyse du jeton d'authentification | ||||||
|  | restartapp_settings=Red\u00E9marrez Tachiyomi pour appliquer le nouveau r\u00E9glage. | ||||||
|  | pref_opds_duplicated_source_url=L'URL est configur\u00E9e dans une autre source ->  | ||||||
|  | pref_opds_must_setup_address=Vous devez configurer l'adresse pour communiquer avec Kavita | ||||||
|  | login_errors_failed_login=\u00C9chec de la connexion. Quelque chose s'est mal pass\u00E9 | ||||||
|  | http_errors_500=Quelque chose s'est mal pass\u00E9 | ||||||
|  | login_errors_header_token_empty=\u00AB\u00A0Erreur\u202F: le jeton jwt est vide.\nEssayez d'abord d'ouvrir l'extension\u00A0\u00BB | ||||||
|  | login_errors_invalid_url=URL invalide\u202F: | ||||||
|  | http_errors_401=Il y a eu une erreur. Essayez de nouveau ou rechargez l'application | ||||||
							
								
								
									
										21
									
								
								src/all/kavita/assets/i18n/messages_nb_no.properties
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,21 @@ | |||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | pref_customsource_title=Vist kildenavn | ||||||
|  | pref_edit_customsource_summary=Her kan du endre dette kildenavnet.\nDu kan skrive et beskrivende navn for \u00E5 identifisere denne OPDS-nettadressen. | ||||||
|  | restartapp_settings=Ny innstilling trer i kraft n\u00E5r du starter Tachiyomi p\u00E5 ny. | ||||||
|  | duplicated_source_url=Nettadressen er satt opp i en annen Kavita-instans | ||||||
|  | pref_filters_summary=Vis disse filterne i filterlisten | ||||||
|  | pref_filters_title=Forvalgte filtre valgt | ||||||
|  | login_errors_parse_tokendto=Kunne ikke tolke identifiseringssymbolet | ||||||
|  | login_errors_failed_login=Innlogging mislyktes. Noe gikk galt. | ||||||
|  | http_errors_500=Noe gikk galt | ||||||
|  | login_errors_header_token_empty="Feil: JSON-nettsymbol er tomt.\nPr\u00F8v \u00E5 \u00E5pne utvidelsen f\u00F8rst." | ||||||
|  | login_errors_invalid_url=Ugyldig nettadresse: | ||||||
|  | version_exceptions_chapters_parse=Uh\u00E5ndtert unntak i tolking av kapitler. Send loggene dine til Kavita-utviklerne. | ||||||
|  | version_exceptions_smart_filter=Kunne ikke dekode smartfilter. Forsikre deg om at du bruker Kavita versjon 0.7.11 eller nyere. | ||||||
|  | pref_opds_summary=OPDS-nettadressen kopiert fra brukerinnstillingene. Denne skal inkludere med adressen og slutte med API-n\u00F8kkelen. | ||||||
|  | check_version=Forsikre deg om at b\u00E5de utvidelsen og Kavita er av nyeste versjon. (Ihvertfall 0.7.8)\nHvis problemet vedvarer kan du rapportere det til Kavita-utviklerne med tilh\u00F8rende loggf\u00F8ring. | ||||||
|  | pref_opds_badformed_url=OPDS-adressen er ikke riktig. Kopier den fra brukerinnstillinger -> tredjepartsprogrammer -> OPDS-nettadresse | ||||||
|  | pref_opds_duplicated_source_url=Nettadressen er satt opp i en annen instans ->  | ||||||
|  | pref_opds_must_setup_address=Du m\u00E5 sette opp adressen som skal kommunisere med Kavita | ||||||
|  | http_errors_401=Feil med innlogging. Pr\u00F8v \u00E5 laste inn p\u00E5 ny, eller start programmet p\u00E5 ny. | ||||||
							
								
								
									
										12
									
								
								src/all/kavita/build.gradle
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,12 @@ | |||||||
|  | ext { | ||||||
|  |     extName = 'Kavita' | ||||||
|  |     extClass = '.KavitaFactory' | ||||||
|  |     extVersionCode = 13 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | dependencies { | ||||||
|  |     implementation 'info.debatty:java-string-similarity:2.0.0' | ||||||
|  |     implementation(project(':lib:i18n')) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | apply from: "$rootDir/common.gradle" | ||||||
							
								
								
									
										
											BIN
										
									
								
								src/all/kavita/res/mipmap-hdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.1 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/all/kavita/res/mipmap-mdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.8 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/all/kavita/res/mipmap-xhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.9 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/all/kavita/res/mipmap-xxhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.4 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/all/kavita/res/mipmap-xxxhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 9.0 KiB | 
| @ -0,0 +1,112 @@ | |||||||
|  | package eu.kanade.tachiyomi.extension.all.kavita | ||||||
|  | 
 | ||||||
|  | import eu.kanade.tachiyomi.extension.all.kavita.KavitaConstants.noSmartFilterSelected | ||||||
|  | import eu.kanade.tachiyomi.source.model.Filter | ||||||
|  | 
 | ||||||
|  | class UserRating : | ||||||
|  |     Filter.Select<String>( | ||||||
|  |         "Minimum Rating", | ||||||
|  |         arrayOf( | ||||||
|  |             "Any", | ||||||
|  |             "1 star", | ||||||
|  |             "2 stars", | ||||||
|  |             "3 stars", | ||||||
|  |             "4 stars", | ||||||
|  |             "5 stars", | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|  | class SmartFiltersFilter(smartFilters: Array<String>) : | ||||||
|  |     Filter.Select<String>("Smart Filters", arrayOf(noSmartFilterSelected) + smartFilters) | ||||||
|  | class SortFilter(sortables: Array<String>) : Filter.Sort("Sort by", sortables, Selection(0, true)) | ||||||
|  | 
 | ||||||
|  | val sortableList = listOf( | ||||||
|  |     Pair("Sort name", 1), | ||||||
|  |     Pair("Created", 2), | ||||||
|  |     Pair("Last modified", 3), | ||||||
|  |     Pair("Item added", 4), | ||||||
|  |     Pair("Time to Read", 5), | ||||||
|  |     Pair("Release year", 6), | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | class StatusFilter(name: String) : Filter.CheckBox(name, false) | ||||||
|  | class StatusFilterGroup(filters: List<StatusFilter>) : | ||||||
|  |     Filter.Group<StatusFilter>("Status", filters) | ||||||
|  | 
 | ||||||
|  | class ReleaseYearRange(name: String) : Filter.Text(name) | ||||||
|  | class ReleaseYearRangeGroup(filters: List<ReleaseYearRange>) : | ||||||
|  |     Filter.Group<ReleaseYearRange>("Release Year", filters) | ||||||
|  | class GenreFilter(name: String) : Filter.TriState(name) | ||||||
|  | class GenreFilterGroup(genres: List<GenreFilter>) : | ||||||
|  |     Filter.Group<GenreFilter>("Genres", genres) | ||||||
|  | 
 | ||||||
|  | class TagFilter(name: String) : Filter.TriState(name) | ||||||
|  | class TagFilterGroup(tags: List<TagFilter>) : Filter.Group<TagFilter>("Tags", tags) | ||||||
|  | 
 | ||||||
|  | class AgeRatingFilter(name: String) : Filter.TriState(name) | ||||||
|  | class AgeRatingFilterGroup(ageRatings: List<AgeRatingFilter>) : | ||||||
|  |     Filter.Group<AgeRatingFilter>("Age Rating", ageRatings) | ||||||
|  | 
 | ||||||
|  | class FormatFilter(name: String) : Filter.CheckBox(name, false) | ||||||
|  | class FormatsFilterGroup(formats: List<FormatFilter>) : | ||||||
|  |     Filter.Group<FormatFilter>("Formats", formats) | ||||||
|  | 
 | ||||||
|  | class CollectionFilter(name: String) : Filter.TriState(name) | ||||||
|  | class CollectionFilterGroup(collections: List<CollectionFilter>) : | ||||||
|  |     Filter.Group<CollectionFilter>("Collection", collections) | ||||||
|  | 
 | ||||||
|  | class LanguageFilter(name: String) : Filter.TriState(name) | ||||||
|  | class LanguageFilterGroup(languages: List<LanguageFilter>) : | ||||||
|  |     Filter.Group<LanguageFilter>("Language", languages) | ||||||
|  | 
 | ||||||
|  | class LibraryFilter(library: String) : Filter.TriState(library) | ||||||
|  | class LibrariesFilterGroup(libraries: List<LibraryFilter>) : | ||||||
|  |     Filter.Group<LibraryFilter>("Libraries", libraries) | ||||||
|  | 
 | ||||||
|  | class PubStatusFilter(name: String) : Filter.CheckBox(name, false) | ||||||
|  | class PubStatusFilterGroup(status: List<PubStatusFilter>) : | ||||||
|  |     Filter.Group<PubStatusFilter>("Publication Status", status) | ||||||
|  | 
 | ||||||
|  | class PeopleHeaderFilter(name: String) : | ||||||
|  |     Filter.Header(name) | ||||||
|  | class PeopleSeparatorFilter : | ||||||
|  |     Filter.Separator() | ||||||
|  | 
 | ||||||
|  | class WriterPeopleFilter(name: String) : Filter.CheckBox(name, false) | ||||||
|  | class WriterPeopleFilterGroup(peoples: List<WriterPeopleFilter>) : | ||||||
|  |     Filter.Group<WriterPeopleFilter>("Writer", peoples) | ||||||
|  | 
 | ||||||
|  | class PencillerPeopleFilter(name: String) : Filter.CheckBox(name, false) | ||||||
|  | class PencillerPeopleFilterGroup(peoples: List<PencillerPeopleFilter>) : | ||||||
|  |     Filter.Group<PencillerPeopleFilter>("Penciller", peoples) | ||||||
|  | 
 | ||||||
|  | class InkerPeopleFilter(name: String) : Filter.CheckBox(name, false) | ||||||
|  | class InkerPeopleFilterGroup(peoples: List<InkerPeopleFilter>) : | ||||||
|  |     Filter.Group<InkerPeopleFilter>("Inker", peoples) | ||||||
|  | 
 | ||||||
|  | class ColoristPeopleFilter(name: String) : Filter.CheckBox(name, false) | ||||||
|  | class ColoristPeopleFilterGroup(peoples: List<ColoristPeopleFilter>) : | ||||||
|  |     Filter.Group<ColoristPeopleFilter>("Colorist", peoples) | ||||||
|  | 
 | ||||||
|  | class LettererPeopleFilter(name: String) : Filter.CheckBox(name, false) | ||||||
|  | class LettererPeopleFilterGroup(peoples: List<LettererPeopleFilter>) : | ||||||
|  |     Filter.Group<LettererPeopleFilter>("Letterer", peoples) | ||||||
|  | 
 | ||||||
|  | class CoverArtistPeopleFilter(name: String) : Filter.CheckBox(name, false) | ||||||
|  | class CoverArtistPeopleFilterGroup(peoples: List<CoverArtistPeopleFilter>) : | ||||||
|  |     Filter.Group<CoverArtistPeopleFilter>("Cover Artist", peoples) | ||||||
|  | 
 | ||||||
|  | class EditorPeopleFilter(name: String) : Filter.CheckBox(name, false) | ||||||
|  | class EditorPeopleFilterGroup(peoples: List<EditorPeopleFilter>) : | ||||||
|  |     Filter.Group<EditorPeopleFilter>("Editor", peoples) | ||||||
|  | 
 | ||||||
|  | class PublisherPeopleFilter(name: String) : Filter.CheckBox(name, false) | ||||||
|  | class PublisherPeopleFilterGroup(peoples: List<PublisherPeopleFilter>) : | ||||||
|  |     Filter.Group<PublisherPeopleFilter>("Publisher", peoples) | ||||||
|  | 
 | ||||||
|  | class CharacterPeopleFilter(name: String) : Filter.CheckBox(name, false) | ||||||
|  | class CharacterPeopleFilterGroup(peoples: List<CharacterPeopleFilter>) : | ||||||
|  |     Filter.Group<CharacterPeopleFilter>("Character", peoples) | ||||||
|  | 
 | ||||||
|  | class TranslatorPeopleFilter(name: String) : Filter.CheckBox(name, false) | ||||||
|  | class TranslatorPeopleFilterGroup(peoples: List<TranslatorPeopleFilter>) : | ||||||
|  |     Filter.Group<TranslatorPeopleFilter>("Translator", peoples) | ||||||
| @ -0,0 +1,81 @@ | |||||||
|  | package eu.kanade.tachiyomi.extension.all.kavita | ||||||
|  | 
 | ||||||
|  | object KavitaConstants { | ||||||
|  |     // toggle filters | ||||||
|  |     const val toggledFiltersPref = "toggledFilters" | ||||||
|  |     val filterPrefEntries = arrayOf( | ||||||
|  |         "Sort Options", | ||||||
|  |         "Format", | ||||||
|  |         "Libraries", | ||||||
|  |         "Read Status", | ||||||
|  |         "Genres", | ||||||
|  |         "Tags", | ||||||
|  |         "Collections", | ||||||
|  |         "Languages", | ||||||
|  |         "Publication Status", | ||||||
|  |         "Rating", | ||||||
|  |         "Age Rating", | ||||||
|  |         "Writers", | ||||||
|  |         "Penciller", | ||||||
|  |         "Inker", | ||||||
|  |         "Colorist", | ||||||
|  |         "Letterer", | ||||||
|  |         "Cover Artist", | ||||||
|  |         "Editor", | ||||||
|  |         "Publisher", | ||||||
|  |         "Character", | ||||||
|  |         "Translators", | ||||||
|  |         "ReleaseYearRange", | ||||||
|  |     ) | ||||||
|  |     val filterPrefEntriesValue = arrayOf( | ||||||
|  |         "Sort Options", | ||||||
|  |         "Format", | ||||||
|  |         "Libraries", | ||||||
|  |         "Read Status", | ||||||
|  |         "Genres", | ||||||
|  |         "Tags", | ||||||
|  |         "Collections", | ||||||
|  |         "Languages", | ||||||
|  |         "Publication Status", | ||||||
|  |         "Rating", | ||||||
|  |         "Age Rating", | ||||||
|  |         "Writers", | ||||||
|  |         "Penciller", | ||||||
|  |         "Inker", | ||||||
|  |         "Colorist", | ||||||
|  |         "Letterer", | ||||||
|  |         "CoverArtist", | ||||||
|  |         "Editor", | ||||||
|  |         "Publisher", | ||||||
|  |         "Character", | ||||||
|  |         "Translators", | ||||||
|  |         "ReleaseYearRange", | ||||||
|  |     ) | ||||||
|  |     val defaultFilterPrefEntries = setOf( | ||||||
|  |         "Sort Options", | ||||||
|  |         "Format", | ||||||
|  |         "Libraries", | ||||||
|  |         "Read Status", | ||||||
|  |         "Genres", | ||||||
|  |         "Tags", | ||||||
|  |         "Collections", | ||||||
|  |         "Languages", | ||||||
|  |         "Publication Status", | ||||||
|  |         "Rating", | ||||||
|  |         "Age Rating", | ||||||
|  |         "Writers", | ||||||
|  |         "Penciller", | ||||||
|  |         "Inker", | ||||||
|  |         "Colorist", | ||||||
|  |         "Letterer", | ||||||
|  |         "CoverArtist", | ||||||
|  |         "Editor", | ||||||
|  |         "Publisher", | ||||||
|  |         "Character", | ||||||
|  |         "Translators", | ||||||
|  |         "ReleaseYearRange", | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     const val customSourceNamePref = "customSourceName" | ||||||
|  |     const val noSmartFilterSelected = "No smart filter loaded" | ||||||
|  | } | ||||||
| @ -0,0 +1,13 @@ | |||||||
|  | package eu.kanade.tachiyomi.extension.all.kavita | ||||||
|  | 
 | ||||||
|  | import eu.kanade.tachiyomi.source.Source | ||||||
|  | import eu.kanade.tachiyomi.source.SourceFactory | ||||||
|  | 
 | ||||||
|  | class KavitaFactory : SourceFactory { | ||||||
|  |     override fun createSources(): List<Source> = | ||||||
|  |         listOf( | ||||||
|  |             Kavita("1"), | ||||||
|  |             Kavita("2"), | ||||||
|  |             Kavita("3"), | ||||||
|  |         ) | ||||||
|  | } | ||||||
| @ -0,0 +1,141 @@ | |||||||
|  | package eu.kanade.tachiyomi.extension.all.kavita | ||||||
|  | 
 | ||||||
|  | import eu.kanade.tachiyomi.extension.all.kavita.dto.ChapterDto | ||||||
|  | import eu.kanade.tachiyomi.extension.all.kavita.dto.PaginationInfo | ||||||
|  | import eu.kanade.tachiyomi.extension.all.kavita.dto.SeriesDto | ||||||
|  | import eu.kanade.tachiyomi.extension.all.kavita.dto.VolumeDto | ||||||
|  | import eu.kanade.tachiyomi.lib.i18n.Intl | ||||||
|  | import eu.kanade.tachiyomi.source.model.SChapter | ||||||
|  | import eu.kanade.tachiyomi.source.model.SManga | ||||||
|  | import kotlinx.serialization.decodeFromString | ||||||
|  | import kotlinx.serialization.json.Json | ||||||
|  | import okhttp3.Response | ||||||
|  | import java.text.SimpleDateFormat | ||||||
|  | import java.util.Locale | ||||||
|  | import java.util.TimeZone | ||||||
|  | 
 | ||||||
|  | class KavitaHelper { | ||||||
|  |     val json = Json { | ||||||
|  |         isLenient = true | ||||||
|  |         ignoreUnknownKeys = true | ||||||
|  |         allowSpecialFloatingPointValues = true | ||||||
|  |         useArrayPolymorphism = true | ||||||
|  |         prettyPrint = true | ||||||
|  |     } | ||||||
|  |     inline fun <reified T : Enum<T>> safeValueOf(type: String): T { | ||||||
|  |         return java.lang.Enum.valueOf(T::class.java, type) | ||||||
|  |     } | ||||||
|  |     val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSS", Locale.US) | ||||||
|  |         .apply { timeZone = TimeZone.getTimeZone("UTC") } | ||||||
|  |     fun parseDate(dateAsString: String): Long = | ||||||
|  |         dateFormatter.parse(dateAsString)?.time ?: 0 | ||||||
|  | 
 | ||||||
|  |     fun hasNextPage(response: Response): Boolean { | ||||||
|  |         val paginationHeader = response.header("Pagination") | ||||||
|  |         var hasNextPage = false | ||||||
|  |         if (!paginationHeader.isNullOrEmpty()) { | ||||||
|  |             val paginationInfo = json.decodeFromString<PaginationInfo>(paginationHeader) | ||||||
|  |             hasNextPage = paginationInfo.currentPage + 1 > paginationInfo.totalPages | ||||||
|  |         } | ||||||
|  |         return !hasNextPage | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fun getIdFromUrl(url: String): Int { | ||||||
|  |         return url.split("/").last().toInt() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fun createSeriesDto(obj: SeriesDto, baseUrl: String, apiKey: String): SManga = | ||||||
|  |         SManga.create().apply { | ||||||
|  |             url = "$baseUrl/Series/${obj.id}" | ||||||
|  |             title = obj.name | ||||||
|  |             // Deprecated: description = obj.summary | ||||||
|  |             thumbnail_url = "$baseUrl/image/series-cover?seriesId=${obj.id}&apiKey=$apiKey" | ||||||
|  |         } | ||||||
|  |     class CompareChapters { | ||||||
|  |         companion object : Comparator<SChapter> { | ||||||
|  |             override fun compare(a: SChapter, b: SChapter): Int { | ||||||
|  |                 if (a.chapter_number < 1.0 && b.chapter_number < 1.0) { | ||||||
|  |                     // Both are volumes, multiply by 100 and do normal sort | ||||||
|  |                     return if ((a.chapter_number * 100) < (b.chapter_number * 100)) { | ||||||
|  |                         1 | ||||||
|  |                     } else { | ||||||
|  |                         -1 | ||||||
|  |                     } | ||||||
|  |                 } else { | ||||||
|  |                     if (a.chapter_number < 1.0 && b.chapter_number >= 1.0) { | ||||||
|  |                         // A is volume, b is not. A should sort first | ||||||
|  |                         return 1 | ||||||
|  |                     } else if (a.chapter_number >= 1.0 && b.chapter_number < 1.0) { | ||||||
|  |                         return -1 | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 if (a.chapter_number < b.chapter_number) return 1 | ||||||
|  |                 if (a.chapter_number > b.chapter_number) return -1 | ||||||
|  |                 return 0 | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |     fun chapterFromObject(obj: ChapterDto): SChapter = SChapter.create().apply { | ||||||
|  |         url = obj.id.toString() | ||||||
|  |         name = if (obj.number == "0" && obj.isSpecial) { | ||||||
|  |             // This is a special. Chapter name is special name | ||||||
|  |             obj.range | ||||||
|  |         } else { | ||||||
|  |             val cleanedName = obj.title.replaceFirst("^0+(?!$)".toRegex(), "") | ||||||
|  |             "Chapter $cleanedName" | ||||||
|  |         } | ||||||
|  |         date_upload = parseDate(obj.created) | ||||||
|  |         chapter_number = obj.number.toFloat() | ||||||
|  |         scanlator = "${obj.pages} pages" | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fun chapterFromVolume(obj: ChapterDto, volume: VolumeDto): SChapter = | ||||||
|  |         SChapter.create().apply { | ||||||
|  |             // If there are multiple chapters to this volume, then prefix with Volume number | ||||||
|  |             if (volume.chapters.isNotEmpty() && obj.number != "0") { | ||||||
|  |                 // This volume is not volume 0, hence they are not loose chapters | ||||||
|  |                 // We just add a nice Volume X to the chapter title | ||||||
|  |                 // Chapter-based Volume | ||||||
|  |                 name = "Volume ${volume.number} Chapter ${obj.number}" | ||||||
|  |                 chapter_number = obj.number.toFloat() | ||||||
|  |             } else if (obj.number == "0") { | ||||||
|  |                 // Both specials and volume has chapter number 0 | ||||||
|  |                 if (volume.number == 0) { | ||||||
|  |                     // Treat as special | ||||||
|  |                     // Special is not in a volume | ||||||
|  |                     if (obj.range == "") { | ||||||
|  |                         // Special does not have any Title | ||||||
|  |                         name = "Chapter 0" | ||||||
|  |                         chapter_number = obj.number.toFloat() | ||||||
|  |                     } else { | ||||||
|  |                         // We use it's own special tile | ||||||
|  |                         name = obj.range | ||||||
|  |                         chapter_number = obj.number.toFloat() | ||||||
|  |                     } | ||||||
|  |                 } else { | ||||||
|  |                     // Is a single-file volume | ||||||
|  |                     // We encode the chapter number to support tracking | ||||||
|  |                     name = "Volume ${volume.number}" | ||||||
|  |                     chapter_number = volume.number.toFloat() / 10000 | ||||||
|  |                 } | ||||||
|  |             } else { | ||||||
|  |                 name = "Unhandled Else Volume ${volume.number}" | ||||||
|  |             } | ||||||
|  |             url = obj.id.toString() | ||||||
|  |             date_upload = parseDate(obj.created) | ||||||
|  | 
 | ||||||
|  |             scanlator = "${obj.pages} pages" | ||||||
|  |         } | ||||||
|  |     val intl = Intl( | ||||||
|  |         language = Locale.getDefault().toString(), | ||||||
|  |         baseLanguage = "en", | ||||||
|  |         availableLanguages = KavitaInt.AVAILABLE_LANGS, | ||||||
|  |         classLoader = this::class.java.classLoader!!, | ||||||
|  |         createMessageFileName = { lang -> | ||||||
|  |             when (lang) { | ||||||
|  |                 KavitaInt.SPANISH_LATAM -> Intl.createDefaultMessageFileName(KavitaInt.SPANISH) | ||||||
|  |                 else -> Intl.createDefaultMessageFileName(lang) | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |     ) | ||||||
|  | } | ||||||
| @ -0,0 +1,17 @@ | |||||||
|  | package eu.kanade.tachiyomi.extension.all.kavita | ||||||
|  | 
 | ||||||
|  | object KavitaInt { | ||||||
|  |     const val ENGLISH = "en" | ||||||
|  |     const val SPANISH = "es_ES" | ||||||
|  |     const val SPANISH_LATAM = "es-419" | ||||||
|  |     const val FRENCH = "fr_FR" | ||||||
|  |     const val NORWEGIAN = "nb_NO" | ||||||
|  |     val AVAILABLE_LANGS = setOf( | ||||||
|  |         ENGLISH, | ||||||
|  |         SPANISH, | ||||||
|  |         SPANISH_LATAM, | ||||||
|  |         NORWEGIAN, | ||||||
|  |         FRENCH, | ||||||
|  |     ) | ||||||
|  |     const val KAVITA_NAME = "Kavita" | ||||||
|  | } | ||||||
| @ -0,0 +1,124 @@ | |||||||
|  | package eu.kanade.tachiyomi.extension.all.kavita.dto | ||||||
|  | 
 | ||||||
|  | import kotlinx.serialization.Serializable | ||||||
|  | import kotlin.Triple | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class FilterV2Dto( | ||||||
|  |     val id: Int? = null, | ||||||
|  |     val name: String? = null, | ||||||
|  |     val statements: MutableList<FilterStatementDto> = mutableListOf(), | ||||||
|  |     val combination: Int = 0, // FilterCombination = FilterCombination.And, | ||||||
|  |     val sortOptions: SortOptions = SortOptions(), | ||||||
|  |     val limitTo: Int = 0, | ||||||
|  | ) { | ||||||
|  |     fun addStatement(comparison: FilterComparison, field: FilterField, value: String) { | ||||||
|  |         if (value.isNotBlank()) { | ||||||
|  |             statements.add(FilterStatementDto(comparison.type, field.type, value)) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |     fun addStatement(comparison: FilterComparison, field: FilterField, values: java.util.ArrayList<out Any>) { | ||||||
|  |         if (values.isNotEmpty()) { | ||||||
|  |             statements.add(FilterStatementDto(comparison.type, field.type, values.joinToString(","))) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fun addContainsNotTriple(list: List<Triple<FilterField, java.util.ArrayList<out Any>, ArrayList<Int>>>) { | ||||||
|  |         list.map { | ||||||
|  |             addStatement(FilterComparison.Contains, it.first, it.second) | ||||||
|  |             addStatement(FilterComparison.NotContains, it.first, it.third) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |     fun addPeople(list: List<Pair<FilterField, ArrayList<Int>>>) { | ||||||
|  |         list.map { | ||||||
|  |             addStatement(FilterComparison.MustContains, it.first, it.second) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class FilterStatementDto( | ||||||
|  |     // todo: Create custom serializator for comparison and field and remove .type extension in Kavita.kt | ||||||
|  |     val comparison: Int, | ||||||
|  |     val field: Int, | ||||||
|  |     val value: String, | ||||||
|  | 
 | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | enum class SortFieldEnum(val type: Int) { | ||||||
|  |     SortName(1), | ||||||
|  |     CreatedDate(2), | ||||||
|  |     LastModifiedDate(3), | ||||||
|  |     LastChapterAdded(4), | ||||||
|  |     TimeToRead(5), | ||||||
|  |     ReleaseYear(6), | ||||||
|  |     ; | ||||||
|  | 
 | ||||||
|  |     companion object { | ||||||
|  |         private val map = SortFieldEnum.values().associateBy(SortFieldEnum::type) | ||||||
|  |         fun fromInt(type: Int) = map[type] | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class SortOptions( | ||||||
|  |     var sortField: Int = SortFieldEnum.SortName.type, | ||||||
|  |     var isAscending: Boolean = true, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | enum class FilterCombination { | ||||||
|  |     Or, | ||||||
|  |     And, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | enum class FilterField(val type: Int) { | ||||||
|  |     Summary(0), | ||||||
|  |     SeriesName(1), | ||||||
|  |     PublicationStatus(2), | ||||||
|  |     Languages(3), | ||||||
|  |     AgeRating(4), | ||||||
|  |     UserRating(5), | ||||||
|  |     Tags(6), | ||||||
|  |     CollectionTags(7), | ||||||
|  |     Translators(8), | ||||||
|  |     Characters(9), | ||||||
|  |     Publisher(10), | ||||||
|  |     Editor(11), | ||||||
|  |     CoverArtist(12), | ||||||
|  |     Letterer(13), | ||||||
|  |     Colorist(14), | ||||||
|  |     Inker(15), | ||||||
|  |     Penciller(16), | ||||||
|  |     Writers(17), | ||||||
|  |     Genres(18), | ||||||
|  |     Libraries(19), | ||||||
|  |     ReadProgress(20), | ||||||
|  |     Formats(21), | ||||||
|  |     ReleaseYear(22), | ||||||
|  |     ReadTime(23), | ||||||
|  |     Path(24), | ||||||
|  |     FilePath(25), | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | enum class FilterComparison(val type: Int) { | ||||||
|  |     Equal(0), | ||||||
|  |     GreaterThan(1), | ||||||
|  |     GreaterThanEqual(2), | ||||||
|  |     LessThan(3), | ||||||
|  |     LessThanEqual(4), | ||||||
|  |     Contains(5), | ||||||
|  |     MustContains(6), | ||||||
|  |     Matches(7), | ||||||
|  |     NotContains(8), | ||||||
|  |     NotEqual(9), | ||||||
|  |     BeginsWith(10), | ||||||
|  |     EndsWith(11), | ||||||
|  |     IsBefore(12), | ||||||
|  |     IsAfter(13), | ||||||
|  |     IsInLast(14), | ||||||
|  |     IsNotInLast(15), | ||||||
|  | } | ||||||
| @ -0,0 +1,103 @@ | |||||||
|  | package eu.kanade.tachiyomi.extension.all.kavita.dto | ||||||
|  | 
 | ||||||
|  | import kotlinx.serialization.Serializable | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | enum class MangaFormat(val format: Int) { | ||||||
|  |     Image(0), | ||||||
|  |     Archive(1), | ||||||
|  |     Unknown(2), | ||||||
|  |     Epub(3), | ||||||
|  |     Pdf(4), | ||||||
|  |     ; | ||||||
|  |     companion object { | ||||||
|  |         private val map = PersonRole.values().associateBy(PersonRole::role) | ||||||
|  |         fun fromInt(type: Int) = map[type] | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | enum class PersonRole(val role: Int) { | ||||||
|  |     Other(1), | ||||||
|  |     Writer(3), | ||||||
|  |     Penciller(4), | ||||||
|  |     Inker(5), | ||||||
|  |     Colorist(6), | ||||||
|  |     Letterer(7), | ||||||
|  |     CoverArtist(8), | ||||||
|  |     Editor(9), | ||||||
|  |     Publisher(10), | ||||||
|  |     Character(11), | ||||||
|  |     Translator(12), | ||||||
|  |     ; | ||||||
|  |     companion object { | ||||||
|  |         private val map = PersonRole.values().associateBy(PersonRole::role) | ||||||
|  |         fun fromInt(type: Int) = map[type] | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class SeriesDto( | ||||||
|  |     val id: Int, | ||||||
|  |     val name: String, | ||||||
|  |     val originalName: String = "", | ||||||
|  |     val thumbnail_url: String? = "", | ||||||
|  |     val localizedName: String? = "", | ||||||
|  |     val sortName: String? = "", | ||||||
|  |     val pages: Int, | ||||||
|  |     val coverImageLocked: Boolean = true, | ||||||
|  |     val pagesRead: Int, | ||||||
|  |     val userRating: Float, | ||||||
|  |     val userReview: String? = "", | ||||||
|  |     val format: Int, | ||||||
|  |     val created: String? = "", | ||||||
|  |     val libraryId: Int, | ||||||
|  |     val libraryName: String? = "", | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class SeriesMetadataDto( | ||||||
|  |     val id: Int, | ||||||
|  |     val summary: String? = "", | ||||||
|  |     val writers: List<Person> = emptyList(), | ||||||
|  |     val coverArtists: List<Person> = emptyList(), | ||||||
|  |     val genres: List<Genres> = emptyList(), | ||||||
|  |     val seriesId: Int, | ||||||
|  |     val ageRating: Int, | ||||||
|  |     val publicationStatus: Int, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class Genres( | ||||||
|  |     val title: String, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class Person( | ||||||
|  |     val name: String, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class VolumeDto( | ||||||
|  |     val id: Int, | ||||||
|  |     val number: Int, | ||||||
|  |     val name: String, | ||||||
|  |     val pages: Int, | ||||||
|  |     val pagesRead: Int, | ||||||
|  |     val lastModified: String, | ||||||
|  |     val created: String, | ||||||
|  |     val seriesId: Int, | ||||||
|  |     val chapters: List<ChapterDto> = emptyList(), | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class ChapterDto( | ||||||
|  |     val id: Int, | ||||||
|  |     val range: String, | ||||||
|  |     val number: String, | ||||||
|  |     val pages: Int, | ||||||
|  |     val isSpecial: Boolean, | ||||||
|  |     val title: String, | ||||||
|  |     val pagesRead: Int, | ||||||
|  |     val coverImageLocked: Boolean, | ||||||
|  |     val volumeId: Int, | ||||||
|  |     val created: String, | ||||||
|  | ) | ||||||
| @ -0,0 +1,104 @@ | |||||||
|  | package eu.kanade.tachiyomi.extension.all.kavita.dto | ||||||
|  | 
 | ||||||
|  | import kotlinx.serialization.Serializable | ||||||
|  | /** | ||||||
|  | * This file contains all class for filtering | ||||||
|  | *  */ | ||||||
|  | @Serializable | ||||||
|  | data class MetadataGenres( | ||||||
|  |     val id: Int, | ||||||
|  |     val title: String, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class MetadataPeople( | ||||||
|  |     val id: Int, | ||||||
|  |     val name: String, | ||||||
|  |     val role: Int, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class MetadataPubStatus( | ||||||
|  |     val value: Int, | ||||||
|  |     val title: String, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class MetadataTag( | ||||||
|  |     val id: Int, | ||||||
|  |     val title: String, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class MetadataAgeRatings( | ||||||
|  |     val value: Int, | ||||||
|  |     val title: String, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class MetadataLanguages( | ||||||
|  |     val isoCode: String, | ||||||
|  |     val title: String, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class MetadataLibrary( | ||||||
|  |     val id: Int, | ||||||
|  |     val name: String, | ||||||
|  |     val type: Int, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class MetadataCollections( | ||||||
|  |     val id: Int, | ||||||
|  |     val title: String, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | data class MetadataPayload( | ||||||
|  |     val forceUseMetadataPayload: Boolean = true, | ||||||
|  |     var sorting: Int = 1, | ||||||
|  |     var sorting_asc: Boolean = true, | ||||||
|  |     var readStatus: ArrayList<String> = arrayListOf<String>(), | ||||||
|  |     val readStatusList: List<String> = listOf("notRead", "inProgress", "read"), | ||||||
|  |     // _i = included, _e = excluded | ||||||
|  |     var genres_i: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  |     var genres_e: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  |     var tags_i: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  |     var tags_e: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  |     var ageRating_i: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  |     var ageRating_e: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  | 
 | ||||||
|  |     var formats: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  |     var collections_i: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  |     var collections_e: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  |     var userRating: Int = 0, | ||||||
|  |     var people: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  |     // _i = included, _e = excluded | ||||||
|  |     var language_i: ArrayList<String> = arrayListOf<String>(), | ||||||
|  |     var language_e: ArrayList<String> = arrayListOf<String>(), | ||||||
|  | 
 | ||||||
|  |     var libraries_i: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  |     var libraries_e: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  |     var pubStatus: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  |     var seriesNameQuery: String = "", | ||||||
|  |     var releaseYearRangeMin: Int = 0, | ||||||
|  |     var releaseYearRangeMax: Int = 0, | ||||||
|  | 
 | ||||||
|  |     var peopleWriters: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  |     var peoplePenciller: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  |     var peopleInker: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  |     var peoplePeoplecolorist: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  |     var peopleLetterer: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  |     var peopleCoverArtist: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  |     var peopleEditor: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  |     var peoplePublisher: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  |     var peopleCharacter: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  |     var peopleTranslator: ArrayList<Int> = arrayListOf<Int>(), | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class SmartFilter( | ||||||
|  |     val id: Int, | ||||||
|  |     val name: String, | ||||||
|  |     val filter: String, | ||||||
|  | ) | ||||||
| @ -0,0 +1,28 @@ | |||||||
|  | package eu.kanade.tachiyomi.extension.all.kavita.dto | ||||||
|  | 
 | ||||||
|  | import kotlinx.serialization.Serializable | ||||||
|  | 
 | ||||||
|  | @Serializable // Used to process login | ||||||
|  | data class AuthenticationDto( | ||||||
|  |     val username: String, | ||||||
|  |     val token: String, | ||||||
|  |     val apiKey: String, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class PaginationInfo( | ||||||
|  |     val currentPage: Int, | ||||||
|  |     val itemsPerPage: Int, | ||||||
|  |     val totalItems: Int, | ||||||
|  |     val totalPages: Int, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class ServerInfoDto( | ||||||
|  |     val installId: String, | ||||||
|  |     val os: String, | ||||||
|  |     val isDocker: Boolean, | ||||||
|  |     val dotnetVersion: String, | ||||||
|  |     val kavitaVersion: String, | ||||||
|  |     val numOfCores: Int, | ||||||
|  | ) | ||||||
							
								
								
									
										2
									
								
								src/all/komga/AndroidManifest.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,2 @@ | |||||||
|  | <?xml version="1.0" encoding="utf-8"?> | ||||||
|  | <manifest /> | ||||||
							
								
								
									
										383
									
								
								src/all/komga/CHANGELOG.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,383 @@ | |||||||
|  | ## 1.4.47 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.151.0` | ||||||
|  | 
 | ||||||
|  | ### Feat | ||||||
|  | 
 | ||||||
|  | * add support for AVIF and HEIF image types | ||||||
|  | 
 | ||||||
|  | ## 1.4.46 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.151.0` | ||||||
|  | 
 | ||||||
|  | ### Feat | ||||||
|  | 
 | ||||||
|  | * Update to extension-lib 1.4 | ||||||
|  |   - Clicking on chapter WebView should now open the chapter/book page. | ||||||
|  | 
 | ||||||
|  | ## 1.3.45 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.151.0` | ||||||
|  | 
 | ||||||
|  | ### Feat | ||||||
|  | 
 | ||||||
|  | * Edit source display name | ||||||
|  | 
 | ||||||
|  | ## 1.3.44 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.151.0` | ||||||
|  | 
 | ||||||
|  | ### Fix | ||||||
|  | 
 | ||||||
|  | * Better date/time parsing | ||||||
|  | 
 | ||||||
|  | ## 1.3.43 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.151.0` | ||||||
|  | 
 | ||||||
|  | ### Fix | ||||||
|  | 
 | ||||||
|  | * Requests failing if address preference is saved with a trailing slash | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * Add URL validation in the address preferences | ||||||
|  | * Use a URL-focused keyboard when available while editing the address preferences | ||||||
|  | 
 | ||||||
|  | ## 1.3.42 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.151.0` | ||||||
|  | 
 | ||||||
|  | ### Fix | ||||||
|  | 
 | ||||||
|  | * default sort broken since Komga 0.155.1 | ||||||
|  | * proper sort criteria for readlists | ||||||
|  | 
 | ||||||
|  | ## 1.3.41 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.151.0` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * Improve how the status is displayed | ||||||
|  | 
 | ||||||
|  | ## 1.3.40 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.151.0` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * Exclude from bulk update warnings | ||||||
|  | 
 | ||||||
|  | ## 1.2.39 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.151.0` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * Prepend series name in front of books within readlists | ||||||
|  | 
 | ||||||
|  | ## 1.2.38 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.113.0` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * Add `README.md` | ||||||
|  | 
 | ||||||
|  | ## 1.2.37 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.113.0` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * In app link to `CHANGELOG.md` | ||||||
|  | 
 | ||||||
|  | ## 1.2.36 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.113.0` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * Don't request conversion for JPEG XL images | ||||||
|  | 
 | ||||||
|  | ## 1.2.35 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.113.0` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * Display the Translators of a book in the scanlator chapter field | ||||||
|  | 
 | ||||||
|  | ## 1.2.34 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.113.0` | ||||||
|  | 
 | ||||||
|  | ### Fix | ||||||
|  | 
 | ||||||
|  | * Loading of filter values could fail in some cases | ||||||
|  | 
 | ||||||
|  | ## 1.2.33 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.113.0` | ||||||
|  | 
 | ||||||
|  | ### Fix | ||||||
|  | 
 | ||||||
|  | * Open in WebView and Share options now open regular browser link instead of showing JSON | ||||||
|  | * Note that Komga cannot be viewed using System WebView since there is no login prompt | ||||||
|  |   However, opening in a regular browser works. | ||||||
|  | 
 | ||||||
|  | ## 1.2.32 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.113.0` | ||||||
|  | 
 | ||||||
|  | ### Fix | ||||||
|  | 
 | ||||||
|  | * Source language, conventionally set to "en", is now changed to "all" | ||||||
|  | * Downloaded files, if any, will have to be moved to new location | ||||||
|  |     - `Komga (EN)` to `Komga (ALL)` | ||||||
|  |     - `Komga (3) (EN)` to `Komga (3) (ALL)` | ||||||
|  | 
 | ||||||
|  | ## 1.2.31 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.113.0` | ||||||
|  | 
 | ||||||
|  | ### Refactor | ||||||
|  | 
 | ||||||
|  | * replace Gson with kotlinx.serialization  | ||||||
|  | 
 | ||||||
|  | ## 1.2.30 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.113.0` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * display read list summary | ||||||
|  | * display aggregated tags on series | ||||||
|  | * search series by book tags | ||||||
|  | 
 | ||||||
|  | ## 1.2.29 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.97.0` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * filter deleted series and books | ||||||
|  | 
 | ||||||
|  | ## 1.2.28 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.97.0` | ||||||
|  | 
 | ||||||
|  | ### Fix | ||||||
|  | 
 | ||||||
|  | * incorrect User Agent | ||||||
|  | 
 | ||||||
|  | ## 1.2.27 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.97.0` | ||||||
|  | 
 | ||||||
|  | ### Fix | ||||||
|  | 
 | ||||||
|  | * filter series by read or in progress | ||||||
|  | 
 | ||||||
|  | ## 1.2.26 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.87.4` | ||||||
|  | 
 | ||||||
|  | ### Fix | ||||||
|  | 
 | ||||||
|  | * show series with only in progress books when searching for unread only | ||||||
|  | 
 | ||||||
|  | ## 1.2.25 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.87.4` | ||||||
|  | 
 | ||||||
|  | ### Fix | ||||||
|  | 
 | ||||||
|  | * sort order for read list books | ||||||
|  | 
 | ||||||
|  | ## 1.2.24 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.87.4` | ||||||
|  | 
 | ||||||
|  | ### Fix | ||||||
|  | 
 | ||||||
|  | * only show series tags in the filter panel | ||||||
|  | * set URL properly on series and read lists, so restoring from a backup can work properly | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | ## 1.2.23 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.75.0` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * ignore DNS over HTTPS so it can reach IP addresses | ||||||
|  | 
 | ||||||
|  | ## 1.2.22 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.75.0` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * add error logs and better catch exceptions | ||||||
|  | 
 | ||||||
|  | ## 1.2.21 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.75.0` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * browse read lists (from the filter menu) | ||||||
|  | * filter by collection, respecting the collection's ordering | ||||||
|  | 
 | ||||||
|  | ## 1.2.20 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.75.0` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * filter by authors, grouped by role | ||||||
|  | 
 | ||||||
|  | ## 1.2.19 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.68.0` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * display Series authors | ||||||
|  | * display Series summary from books if no summary exists for Series | ||||||
|  | 
 | ||||||
|  | ## 1.2.18 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.63.2` | ||||||
|  | 
 | ||||||
|  | ### Fix | ||||||
|  | 
 | ||||||
|  | * use metadata.releaseDate or fileLastModified for chapter date | ||||||
|  | 
 | ||||||
|  | ## 1.2.17 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.63.2` | ||||||
|  | 
 | ||||||
|  | ### Fix | ||||||
|  | 
 | ||||||
|  | * list of collections for filtering could be empty in some conditions | ||||||
|  | 
 | ||||||
|  | ## 1.2.16 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.59.0` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * filter by genres, tags and publishers | ||||||
|  | 
 | ||||||
|  | ## 1.2.15 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.56.0` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * remove the 1000 chapters limit | ||||||
|  | * display series description and tags (genres + tags) | ||||||
|  | 
 | ||||||
|  | ## 1.2.14 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.41.0` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * change chapter display name to use the display number instead of the sort number | ||||||
|  | 
 | ||||||
|  | ## 1.2.13 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.41.0` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * compatibility for the upcoming version of Komga which have changes in the API (IDs are String instead of Long) | ||||||
|  | 
 | ||||||
|  | ## 1.2.12 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.41.0` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * filter by collection | ||||||
|  | 
 | ||||||
|  | ## 1.2.11 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.35.2` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * Set password preferences inputTypes | ||||||
|  | 
 | ||||||
|  | ## 1.2.10 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.35.2` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * unread only filter (closes gotson/komga#180) | ||||||
|  | * prefix book titles with number (closes gotson/komga#169) | ||||||
|  | 
 | ||||||
|  | ## 1.2.9 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.22.0` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * use SourceFactory to have multiple Komga servers (3 for the moment) | ||||||
|  | 
 | ||||||
|  | ## 1.2.8 | ||||||
|  | 
 | ||||||
|  | Minimum Komga version required: `0.22.0` | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * use book metadata title for chapter display name | ||||||
|  | * use book metadata sort number for chapter number | ||||||
|  | 
 | ||||||
|  | ## 1.2.7 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * use series metadata title for display name | ||||||
|  | * filter on series status | ||||||
|  | 
 | ||||||
|  | ## 1.2.6 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * Add support for AndroidX preferences  | ||||||
|  | 
 | ||||||
|  | ## 1.2.5 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * add sort options in filter | ||||||
|  | 
 | ||||||
|  | ## 1.2.4 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * better handling of authentication | ||||||
|  | 
 | ||||||
|  | ## 1.2.3 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * filters by library | ||||||
|  | 
 | ||||||
|  | ## 1.2.2 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * request converted image from server if format is not supported | ||||||
|  | 
 | ||||||
|  | ## 1.2.1 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * first version | ||||||
							
								
								
									
										35
									
								
								src/all/komga/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,35 @@ | |||||||
|  | # 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 Tachiyomi?](#how-do-i-add-my-komga-server-to-tachiyomi) | ||||||
|  | 
 | ||||||
|  | Don't find the question you are look for go check out our general FAQs and Guides over at [Extension FAQ](https://tachiyomi.org/help/faq/#extensions) or [Getting Started](https://tachiyomi.org/help/guides/getting-started/#installation) | ||||||
|  | 
 | ||||||
|  | ## 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 3 different Komga instances to Tachiyomi. | ||||||
|  | 
 | ||||||
|  | ### 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`. | ||||||
|  | 
 | ||||||
|  | ## Guides | ||||||
|  | 
 | ||||||
|  | ### How do I add my Komga server to Tachiyomi? | ||||||
|  | Go into the settings of the Komga extension from the Extension tab in Browse and fill in your server address and login details. | ||||||
							
								
								
									
										7
									
								
								src/all/komga/build.gradle
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,7 @@ | |||||||
|  | ext { | ||||||
|  |     extName = 'Komga' | ||||||
|  |     extClass = '.KomgaFactory' | ||||||
|  |     extVersionCode = 50 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | apply from: "$rootDir/common.gradle" | ||||||
							
								
								
									
										
											BIN
										
									
								
								src/all/komga/res/mipmap-hdpi/ic_launcher.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						| After Width: | Height: | Size: 5.9 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/all/komga/res/mipmap-mdpi/ic_launcher.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						| After Width: | Height: | Size: 3.0 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/all/komga/res/mipmap-xhdpi/ic_launcher.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						| After Width: | Height: | Size: 8.0 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/all/komga/res/mipmap-xxhdpi/ic_launcher.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						| After Width: | Height: | Size: 16 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/all/komga/res/mipmap-xxxhdpi/ic_launcher.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						| After Width: | Height: | Size: 22 KiB | 
| @ -0,0 +1,637 @@ | |||||||
|  | package eu.kanade.tachiyomi.extension.all.komga | ||||||
|  | 
 | ||||||
|  | import android.app.Application | ||||||
|  | import android.content.SharedPreferences | ||||||
|  | import android.text.Editable | ||||||
|  | import android.text.InputType | ||||||
|  | import android.text.TextWatcher | ||||||
|  | import android.util.Log | ||||||
|  | import android.widget.Button | ||||||
|  | import android.widget.Toast | ||||||
|  | import androidx.preference.EditTextPreference | ||||||
|  | import androidx.preference.PreferenceScreen | ||||||
|  | import eu.kanade.tachiyomi.AppInfo | ||||||
|  | import eu.kanade.tachiyomi.extension.all.komga.dto.AuthorDto | ||||||
|  | import eu.kanade.tachiyomi.extension.all.komga.dto.BookDto | ||||||
|  | import eu.kanade.tachiyomi.extension.all.komga.dto.CollectionDto | ||||||
|  | import eu.kanade.tachiyomi.extension.all.komga.dto.LibraryDto | ||||||
|  | import eu.kanade.tachiyomi.extension.all.komga.dto.PageDto | ||||||
|  | import eu.kanade.tachiyomi.extension.all.komga.dto.PageWrapperDto | ||||||
|  | import eu.kanade.tachiyomi.extension.all.komga.dto.ReadListDto | ||||||
|  | import eu.kanade.tachiyomi.extension.all.komga.dto.SeriesDto | ||||||
|  | import eu.kanade.tachiyomi.network.GET | ||||||
|  | import eu.kanade.tachiyomi.network.asObservable | ||||||
|  | import eu.kanade.tachiyomi.network.await | ||||||
|  | import eu.kanade.tachiyomi.source.ConfigurableSource | ||||||
|  | import eu.kanade.tachiyomi.source.UnmeteredSource | ||||||
|  | import eu.kanade.tachiyomi.source.model.Filter | ||||||
|  | import eu.kanade.tachiyomi.source.model.FilterList | ||||||
|  | import eu.kanade.tachiyomi.source.model.MangasPage | ||||||
|  | import eu.kanade.tachiyomi.source.model.Page | ||||||
|  | import eu.kanade.tachiyomi.source.model.SChapter | ||||||
|  | import eu.kanade.tachiyomi.source.model.SManga | ||||||
|  | import eu.kanade.tachiyomi.source.online.HttpSource | ||||||
|  | import kotlinx.serialization.decodeFromString | ||||||
|  | import kotlinx.serialization.json.Json | ||||||
|  | import okhttp3.Credentials | ||||||
|  | import okhttp3.Dns | ||||||
|  | import okhttp3.Headers | ||||||
|  | import okhttp3.HttpUrl.Companion.toHttpUrlOrNull | ||||||
|  | import okhttp3.OkHttpClient | ||||||
|  | import okhttp3.Request | ||||||
|  | import okhttp3.Response | ||||||
|  | import rx.Observable | ||||||
|  | import rx.Single | ||||||
|  | import rx.schedulers.Schedulers | ||||||
|  | import uy.kohesive.injekt.Injekt | ||||||
|  | import uy.kohesive.injekt.api.get | ||||||
|  | import uy.kohesive.injekt.injectLazy | ||||||
|  | import java.security.MessageDigest | ||||||
|  | import java.util.Locale | ||||||
|  | 
 | ||||||
|  | open class Komga(private val suffix: String = "") : ConfigurableSource, UnmeteredSource, HttpSource() { | ||||||
|  |     override fun popularMangaRequest(page: Int): Request = | ||||||
|  |         GET("$baseUrl/api/v1/series?page=${page - 1}&deleted=false&sort=metadata.titleSort,asc", headers) | ||||||
|  | 
 | ||||||
|  |     override fun popularMangaParse(response: Response): MangasPage = | ||||||
|  |         processSeriesPage(response) | ||||||
|  | 
 | ||||||
|  |     override fun latestUpdatesRequest(page: Int): Request = | ||||||
|  |         GET("$baseUrl/api/v1/series/latest?page=${page - 1}&deleted=false", headers) | ||||||
|  | 
 | ||||||
|  |     override fun latestUpdatesParse(response: Response): MangasPage = | ||||||
|  |         processSeriesPage(response) | ||||||
|  | 
 | ||||||
|  |     override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { | ||||||
|  |         val collectionId = (filters.find { it is CollectionSelect } as? CollectionSelect)?.let { | ||||||
|  |             it.values[it.state].id | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         val type = when { | ||||||
|  |             collectionId != null -> "collections/$collectionId/series" | ||||||
|  |             filters.find { it is TypeSelect }?.state == 1 -> "readlists" | ||||||
|  |             else -> "series" | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         val url = "$baseUrl/api/v1/$type?search=$query&page=${page - 1}&deleted=false".toHttpUrlOrNull()!!.newBuilder() | ||||||
|  | 
 | ||||||
|  |         filters.forEach { filter -> | ||||||
|  |             when (filter) { | ||||||
|  |                 is UnreadFilter -> { | ||||||
|  |                     if (filter.state) { | ||||||
|  |                         url.addQueryParameter("read_status", "UNREAD") | ||||||
|  |                         url.addQueryParameter("read_status", "IN_PROGRESS") | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 is InProgressFilter -> { | ||||||
|  |                     if (filter.state) { | ||||||
|  |                         url.addQueryParameter("read_status", "IN_PROGRESS") | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 is ReadFilter -> { | ||||||
|  |                     if (filter.state) { | ||||||
|  |                         url.addQueryParameter("read_status", "READ") | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 is LibraryGroup -> { | ||||||
|  |                     val libraryToInclude = mutableListOf<String>() | ||||||
|  |                     filter.state.forEach { content -> | ||||||
|  |                         if (content.state) { | ||||||
|  |                             libraryToInclude.add(content.id) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     if (libraryToInclude.isNotEmpty()) { | ||||||
|  |                         url.addQueryParameter("library_id", libraryToInclude.joinToString(",")) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 is StatusGroup -> { | ||||||
|  |                     val statusToInclude = mutableListOf<String>() | ||||||
|  |                     filter.state.forEach { content -> | ||||||
|  |                         if (content.state) { | ||||||
|  |                             statusToInclude.add(content.name.uppercase(Locale.ROOT)) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     if (statusToInclude.isNotEmpty()) { | ||||||
|  |                         url.addQueryParameter("status", statusToInclude.joinToString(",")) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 is GenreGroup -> { | ||||||
|  |                     val genreToInclude = mutableListOf<String>() | ||||||
|  |                     filter.state.forEach { content -> | ||||||
|  |                         if (content.state) { | ||||||
|  |                             genreToInclude.add(content.name) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     if (genreToInclude.isNotEmpty()) { | ||||||
|  |                         url.addQueryParameter("genre", genreToInclude.joinToString(",")) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 is TagGroup -> { | ||||||
|  |                     val tagToInclude = mutableListOf<String>() | ||||||
|  |                     filter.state.forEach { content -> | ||||||
|  |                         if (content.state) { | ||||||
|  |                             tagToInclude.add(content.name) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     if (tagToInclude.isNotEmpty()) { | ||||||
|  |                         url.addQueryParameter("tag", tagToInclude.joinToString(",")) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 is PublisherGroup -> { | ||||||
|  |                     val publisherToInclude = mutableListOf<String>() | ||||||
|  |                     filter.state.forEach { content -> | ||||||
|  |                         if (content.state) { | ||||||
|  |                             publisherToInclude.add(content.name) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     if (publisherToInclude.isNotEmpty()) { | ||||||
|  |                         url.addQueryParameter("publisher", publisherToInclude.joinToString(",")) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 is AuthorGroup -> { | ||||||
|  |                     val authorToInclude = mutableListOf<AuthorDto>() | ||||||
|  |                     filter.state.forEach { content -> | ||||||
|  |                         if (content.state) { | ||||||
|  |                             authorToInclude.add(content.author) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     authorToInclude.forEach { | ||||||
|  |                         url.addQueryParameter("author", "${it.name},${it.role}") | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 is Filter.Sort -> { | ||||||
|  |                     var sortCriteria = when (filter.state?.index) { | ||||||
|  |                         0 -> if (type == "series") "metadata.titleSort" else "name" | ||||||
|  |                         1 -> "createdDate" | ||||||
|  |                         2 -> "lastModifiedDate" | ||||||
|  |                         else -> "" | ||||||
|  |                     } | ||||||
|  |                     if (sortCriteria.isNotEmpty()) { | ||||||
|  |                         sortCriteria += "," + if (filter.state?.ascending!!) "asc" else "desc" | ||||||
|  |                         url.addQueryParameter("sort", sortCriteria) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 else -> {} | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return GET(url.toString(), headers) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun searchMangaParse(response: Response): MangasPage = | ||||||
|  |         processSeriesPage(response) | ||||||
|  | 
 | ||||||
|  |     override fun fetchMangaDetails(manga: SManga): Observable<SManga> { | ||||||
|  |         return client.newCall(GET(manga.url, headers)) | ||||||
|  |             .asObservable() | ||||||
|  |             .map { response -> | ||||||
|  |                 mangaDetailsParse(response).apply { initialized = true } | ||||||
|  |             } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun mangaDetailsRequest(manga: SManga): Request = | ||||||
|  |         GET(manga.url.replaceFirst("api/v1/", "", ignoreCase = true), headers) | ||||||
|  | 
 | ||||||
|  |     override fun mangaDetailsParse(response: Response): SManga { | ||||||
|  |         return response.body.use { body -> | ||||||
|  |             if (response.fromReadList()) { | ||||||
|  |                 val readList = json.decodeFromString<ReadListDto>(body.string()) | ||||||
|  |                 readList.toSManga() | ||||||
|  |             } else { | ||||||
|  |                 val series = json.decodeFromString<SeriesDto>(body.string()) | ||||||
|  |                 series.toSManga() | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun chapterListRequest(manga: SManga): Request = | ||||||
|  |         GET("${manga.url}/books?unpaged=true&media_status=READY&deleted=false", headers) | ||||||
|  | 
 | ||||||
|  |     override fun chapterListParse(response: Response): List<SChapter> { | ||||||
|  |         val responseBody = response.body | ||||||
|  |         val page = responseBody.use { json.decodeFromString<PageWrapperDto<BookDto>>(it.string()).content } | ||||||
|  | 
 | ||||||
|  |         val r = page.mapIndexed { index, book -> | ||||||
|  |             SChapter.create().apply { | ||||||
|  |                 chapter_number = if (!response.fromReadList()) book.metadata.numberSort else index + 1F | ||||||
|  |                 name = "${if (!response.fromReadList()) "${book.metadata.number} - " else "${book.seriesTitle} ${book.metadata.number}: "}${book.metadata.title} (${book.size})" | ||||||
|  |                 url = "$baseUrl/api/v1/books/${book.id}" | ||||||
|  |                 scanlator = book.metadata.authors.groupBy({ it.role }, { it.name })["translator"]?.joinToString() | ||||||
|  |                 date_upload = book.metadata.releaseDate?.let { parseDate(it) } | ||||||
|  |                     ?: parseDateTime(book.fileLastModified) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         return r.sortedByDescending { it.chapter_number } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun pageListRequest(chapter: SChapter): Request = | ||||||
|  |         GET("${chapter.url}/pages") | ||||||
|  | 
 | ||||||
|  |     override fun pageListParse(response: Response): List<Page> { | ||||||
|  |         val responseBody = response.body | ||||||
|  |         val pages = responseBody.use { json.decodeFromString<List<PageDto>>(it.string()) } | ||||||
|  |         return pages.map { | ||||||
|  |             val url = "${response.request.url}/${it.number}" + | ||||||
|  |                 if (!supportedImageTypes.contains(it.mediaType)) { | ||||||
|  |                     "?convert=png" | ||||||
|  |                 } else { | ||||||
|  |                     "" | ||||||
|  |                 } | ||||||
|  |             Page( | ||||||
|  |                 index = it.number - 1, | ||||||
|  |                 imageUrl = url, | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun getMangaUrl(manga: SManga) = manga.url.replace("/api/v1", "") | ||||||
|  | 
 | ||||||
|  |     override fun getChapterUrl(chapter: SChapter) = chapter.url.replace("/api/v1/books", "/book") | ||||||
|  | 
 | ||||||
|  |     private fun processSeriesPage(response: Response): MangasPage { | ||||||
|  |         val responseBody = response.body | ||||||
|  |         return responseBody.use { body -> | ||||||
|  |             if (response.fromReadList()) { | ||||||
|  |                 with(json.decodeFromString<PageWrapperDto<ReadListDto>>(body.string())) { | ||||||
|  |                     MangasPage(content.map { it.toSManga() }, !last) | ||||||
|  |                 } | ||||||
|  |             } else { | ||||||
|  |                 with(json.decodeFromString<PageWrapperDto<SeriesDto>>(body.string())) { | ||||||
|  |                     MangasPage(content.map { it.toSManga() }, !last) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun SeriesDto.toSManga(): SManga = | ||||||
|  |         SManga.create().apply { | ||||||
|  |             title = metadata.title | ||||||
|  |             url = "$baseUrl/api/v1/series/$id" | ||||||
|  |             thumbnail_url = "$url/thumbnail" | ||||||
|  |             status = when { | ||||||
|  |                 metadata.status == "ENDED" && metadata.totalBookCount != null && booksCount < metadata.totalBookCount -> SManga.PUBLISHING_FINISHED | ||||||
|  |                 metadata.status == "ENDED" -> SManga.COMPLETED | ||||||
|  |                 metadata.status == "ONGOING" -> SManga.ONGOING | ||||||
|  |                 metadata.status == "ABANDONED" -> SManga.CANCELLED | ||||||
|  |                 metadata.status == "HIATUS" -> SManga.ON_HIATUS | ||||||
|  |                 else -> SManga.UNKNOWN | ||||||
|  |             } | ||||||
|  |             genre = (metadata.genres + metadata.tags + booksMetadata.tags).distinct().joinToString(", ") | ||||||
|  |             description = metadata.summary.ifBlank { booksMetadata.summary } | ||||||
|  |             booksMetadata.authors.groupBy { it.role }.let { map -> | ||||||
|  |                 author = map["writer"]?.map { it.name }?.distinct()?.joinToString() | ||||||
|  |                 artist = map["penciller"]?.map { it.name }?.distinct()?.joinToString() | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |     private fun ReadListDto.toSManga(): SManga = | ||||||
|  |         SManga.create().apply { | ||||||
|  |             title = name | ||||||
|  |             description = summary | ||||||
|  |             url = "$baseUrl/api/v1/readlists/$id" | ||||||
|  |             thumbnail_url = "$url/thumbnail" | ||||||
|  |             status = SManga.UNKNOWN | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |     private fun Response.fromReadList() = request.url.toString().contains("/api/v1/readlists") | ||||||
|  | 
 | ||||||
|  |     private fun parseDate(date: String?): Long = | ||||||
|  |         if (date == null) { | ||||||
|  |             0 | ||||||
|  |         } else { | ||||||
|  |             try { | ||||||
|  |                 KomgaHelper.formatterDate.parse(date)?.time ?: 0 | ||||||
|  |             } catch (ex: Exception) { | ||||||
|  |                 0 | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |     private fun parseDateTime(date: String?): Long = | ||||||
|  |         if (date == null) { | ||||||
|  |             0 | ||||||
|  |         } else { | ||||||
|  |             try { | ||||||
|  |                 KomgaHelper.formatterDateTime.parse(date)?.time ?: 0 | ||||||
|  |             } catch (ex: Exception) { | ||||||
|  |                 try { | ||||||
|  |                     KomgaHelper.formatterDateTimeMilli.parse(date)?.time ?: 0 | ||||||
|  |                 } catch (ex: Exception) { | ||||||
|  |                     0 | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |     override fun imageUrlParse(response: Response): String = "" | ||||||
|  | 
 | ||||||
|  |     private class TypeSelect : Filter.Select<String>("Search for", arrayOf(TYPE_SERIES, TYPE_READLISTS)) | ||||||
|  |     private class LibraryFilter(val id: String, name: String) : Filter.CheckBox(name, false) | ||||||
|  |     private class LibraryGroup(libraries: List<LibraryFilter>) : Filter.Group<LibraryFilter>("Libraries", libraries) | ||||||
|  |     private class CollectionSelect(collections: List<CollectionFilterEntry>) : Filter.Select<CollectionFilterEntry>("Collection", collections.toTypedArray()) | ||||||
|  |     private class SeriesSort : Filter.Sort("Sort", arrayOf("Alphabetically", "Date added", "Date updated"), Selection(0, true)) | ||||||
|  |     private class StatusFilter(name: String) : Filter.CheckBox(name, false) | ||||||
|  |     private class StatusGroup(filters: List<StatusFilter>) : Filter.Group<StatusFilter>("Status", filters) | ||||||
|  |     private class UnreadFilter : Filter.CheckBox("Unread", false) | ||||||
|  |     private class InProgressFilter : Filter.CheckBox("In Progress", false) | ||||||
|  |     private class ReadFilter : Filter.CheckBox("Read", false) | ||||||
|  |     private class GenreFilter(genre: String) : Filter.CheckBox(genre, false) | ||||||
|  |     private class GenreGroup(genres: List<GenreFilter>) : Filter.Group<GenreFilter>("Genres", genres) | ||||||
|  |     private class TagFilter(tag: String) : Filter.CheckBox(tag, false) | ||||||
|  |     private class TagGroup(tags: List<TagFilter>) : Filter.Group<TagFilter>("Tags", tags) | ||||||
|  |     private class PublisherFilter(publisher: String) : Filter.CheckBox(publisher, false) | ||||||
|  |     private class PublisherGroup(publishers: List<PublisherFilter>) : Filter.Group<PublisherFilter>("Publishers", publishers) | ||||||
|  |     private class AuthorFilter(val author: AuthorDto) : Filter.CheckBox(author.name, false) | ||||||
|  |     private class AuthorGroup(role: String, authors: List<AuthorFilter>) : Filter.Group<AuthorFilter>(role, authors) | ||||||
|  | 
 | ||||||
|  |     private data class CollectionFilterEntry( | ||||||
|  |         val name: String, | ||||||
|  |         val id: String? = null, | ||||||
|  |     ) { | ||||||
|  |         override fun toString() = name | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun getFilterList(): FilterList { | ||||||
|  |         val filters = try { | ||||||
|  |             mutableListOf<Filter<*>>( | ||||||
|  |                 UnreadFilter(), | ||||||
|  |                 InProgressFilter(), | ||||||
|  |                 ReadFilter(), | ||||||
|  |                 TypeSelect(), | ||||||
|  |                 CollectionSelect(listOf(CollectionFilterEntry("None")) + collections.map { CollectionFilterEntry(it.name, it.id) }), | ||||||
|  |                 LibraryGroup(libraries.map { LibraryFilter(it.id, it.name) }.sortedBy { it.name.lowercase(Locale.ROOT) }), | ||||||
|  |                 StatusGroup(listOf("Ongoing", "Ended", "Abandoned", "Hiatus").map { StatusFilter(it) }), | ||||||
|  |                 GenreGroup(genres.map { GenreFilter(it) }), | ||||||
|  |                 TagGroup(tags.map { TagFilter(it) }), | ||||||
|  |                 PublisherGroup(publishers.map { PublisherFilter(it) }), | ||||||
|  |             ).also { list -> | ||||||
|  |                 list.addAll(authors.map { (role, authors) -> AuthorGroup(role, authors.map { AuthorFilter(it) }) }) | ||||||
|  |                 list.add(SeriesSort()) | ||||||
|  |             } | ||||||
|  |         } catch (e: Exception) { | ||||||
|  |             Log.e(LOG_TAG, "error while creating filter list", e) | ||||||
|  |             emptyList() | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return FilterList(filters) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private var libraries = emptyList<LibraryDto>() | ||||||
|  |     private var collections = emptyList<CollectionDto>() | ||||||
|  |     private var genres = emptySet<String>() | ||||||
|  |     private var tags = emptySet<String>() | ||||||
|  |     private var publishers = emptySet<String>() | ||||||
|  |     private var authors = emptyMap<String, List<AuthorDto>>() // roles to list of authors | ||||||
|  | 
 | ||||||
|  |     // keep the previous ID when lang was "en", so that preferences and manga bindings are not lost | ||||||
|  |     override val id by lazy { | ||||||
|  |         val key = "komga${if (suffix.isNotBlank()) " ($suffix)" else ""}/en/$versionId" | ||||||
|  |         val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) | ||||||
|  |         (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private val displayName by lazy { preferences.displayName } | ||||||
|  |     final override val baseUrl by lazy { preferences.baseUrl } | ||||||
|  |     private val username by lazy { preferences.username } | ||||||
|  |     private val password by lazy { preferences.password } | ||||||
|  |     private val json: Json by injectLazy() | ||||||
|  | 
 | ||||||
|  |     override fun headersBuilder(): Headers.Builder = | ||||||
|  |         Headers.Builder() | ||||||
|  |             .add("User-Agent", "TachiyomiKomga/${AppInfo.getVersionName()}") | ||||||
|  | 
 | ||||||
|  |     private val preferences: SharedPreferences by lazy { | ||||||
|  |         Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override val name = "Komga${displayName.ifBlank { suffix }.let { if (it.isNotBlank()) " ($it)" else "" }}" | ||||||
|  |     override val lang = "all" | ||||||
|  |     override val supportsLatest = true | ||||||
|  |     private val LOG_TAG = "extension.all.komga${if (suffix.isNotBlank()) ".$suffix" else ""}" | ||||||
|  | 
 | ||||||
|  |     override val client: OkHttpClient = | ||||||
|  |         network.client.newBuilder() | ||||||
|  |             .authenticator { _, response -> | ||||||
|  |                 if (response.request.header("Authorization") != null) { | ||||||
|  |                     null // Give up, we've already failed to authenticate. | ||||||
|  |                 } else { | ||||||
|  |                     response.request.newBuilder() | ||||||
|  |                         .addHeader("Authorization", Credentials.basic(username, password)) | ||||||
|  |                         .build() | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             .dns(Dns.SYSTEM) // don't use DNS over HTTPS as it breaks IP addressing | ||||||
|  |             .build() | ||||||
|  | 
 | ||||||
|  |     override fun setupPreferenceScreen(screen: PreferenceScreen) { | ||||||
|  |         screen.addEditTextPreference( | ||||||
|  |             title = "Source display name", | ||||||
|  |             default = suffix, | ||||||
|  |             summary = displayName.ifBlank { "Here you can change the source displayed suffix" }, | ||||||
|  |             key = PREF_DISPLAYNAME, | ||||||
|  |         ) | ||||||
|  |         screen.addEditTextPreference( | ||||||
|  |             title = "Address", | ||||||
|  |             default = ADDRESS_DEFAULT, | ||||||
|  |             summary = baseUrl.ifBlank { "The server address" }, | ||||||
|  |             inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI, | ||||||
|  |             validate = { it.toHttpUrlOrNull() != null }, | ||||||
|  |             validationMessage = "The URL is invalid or malformed", | ||||||
|  |             key = PREF_ADDRESS, | ||||||
|  |         ) | ||||||
|  |         screen.addEditTextPreference( | ||||||
|  |             title = "Username", | ||||||
|  |             default = USERNAME_DEFAULT, | ||||||
|  |             summary = username.ifBlank { "The user account email" }, | ||||||
|  |             key = PREF_USERNAME, | ||||||
|  |         ) | ||||||
|  |         screen.addEditTextPreference( | ||||||
|  |             title = "Password", | ||||||
|  |             default = PASSWORD_DEFAULT, | ||||||
|  |             summary = if (password.isBlank()) "The user account password" else "*".repeat(password.length), | ||||||
|  |             inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD, | ||||||
|  |             key = PREF_PASSWORD, | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun PreferenceScreen.addEditTextPreference( | ||||||
|  |         title: String, | ||||||
|  |         default: String, | ||||||
|  |         summary: String, | ||||||
|  |         inputType: Int? = null, | ||||||
|  |         validate: ((String) -> Boolean)? = null, | ||||||
|  |         validationMessage: String? = null, | ||||||
|  |         key: String = title, | ||||||
|  |     ) { | ||||||
|  |         val preference = EditTextPreference(context).apply { | ||||||
|  |             this.key = key | ||||||
|  |             this.title = title | ||||||
|  |             this.summary = summary | ||||||
|  |             this.setDefaultValue(default) | ||||||
|  |             dialogTitle = title | ||||||
|  | 
 | ||||||
|  |             setOnBindEditTextListener { editText -> | ||||||
|  |                 if (inputType != null) { | ||||||
|  |                     editText.inputType = inputType | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 if (validate != null) { | ||||||
|  |                     editText.addTextChangedListener( | ||||||
|  |                         object : TextWatcher { | ||||||
|  |                             override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} | ||||||
|  | 
 | ||||||
|  |                             override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} | ||||||
|  | 
 | ||||||
|  |                             override fun afterTextChanged(editable: Editable?) { | ||||||
|  |                                 requireNotNull(editable) | ||||||
|  | 
 | ||||||
|  |                                 val text = editable.toString() | ||||||
|  | 
 | ||||||
|  |                                 val isValid = text.isBlank() || validate(text) | ||||||
|  | 
 | ||||||
|  |                                 editText.error = if (!isValid) validationMessage else null | ||||||
|  |                                 editText.rootView.findViewById<Button>(android.R.id.button1) | ||||||
|  |                                     ?.isEnabled = editText.error == null | ||||||
|  |                             } | ||||||
|  |                         }, | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             setOnPreferenceChangeListener { _, newValue -> | ||||||
|  |                 try { | ||||||
|  |                     val res = preferences.edit().putString(this.key, newValue as String).commit() | ||||||
|  |                     Toast.makeText(context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show() | ||||||
|  |                     res | ||||||
|  |                 } catch (e: Exception) { | ||||||
|  |                     e.printStackTrace() | ||||||
|  |                     false | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         addPreference(preference) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private val SharedPreferences.displayName | ||||||
|  |         get() = getString(PREF_DISPLAYNAME, "")!! | ||||||
|  | 
 | ||||||
|  |     private val SharedPreferences.baseUrl | ||||||
|  |         get() = getString(PREF_ADDRESS, ADDRESS_DEFAULT)!!.removeSuffix("/") | ||||||
|  | 
 | ||||||
|  |     private val SharedPreferences.username | ||||||
|  |         get() = getString(PREF_USERNAME, USERNAME_DEFAULT)!! | ||||||
|  | 
 | ||||||
|  |     private val SharedPreferences.password | ||||||
|  |         get() = getString(PREF_PASSWORD, PASSWORD_DEFAULT)!! | ||||||
|  | 
 | ||||||
|  |     init { | ||||||
|  |         if (baseUrl.isNotBlank()) { | ||||||
|  |             Single.fromCallable { | ||||||
|  |                 try { | ||||||
|  |                     client.newCall(GET("$baseUrl/api/v1/libraries", headers)).execute().use { response -> | ||||||
|  |                         libraries = try { | ||||||
|  |                             val responseBody = response.body | ||||||
|  |                             responseBody.use { json.decodeFromString(it.string()) } | ||||||
|  |                         } catch (e: Exception) { | ||||||
|  |                             Log.e(LOG_TAG, "error while decoding JSON for libraries filter", e) | ||||||
|  |                             emptyList() | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } catch (e: Exception) { | ||||||
|  |                     Log.e(LOG_TAG, "error while loading libraries for filters", e) | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 try { | ||||||
|  |                     client.newCall(GET("$baseUrl/api/v1/collections?unpaged=true", headers)).execute().use { response -> | ||||||
|  |                         collections = try { | ||||||
|  |                             val responseBody = response.body | ||||||
|  |                             responseBody.use { json.decodeFromString<PageWrapperDto<CollectionDto>>(it.string()).content } | ||||||
|  |                         } catch (e: Exception) { | ||||||
|  |                             Log.e(LOG_TAG, "error while decoding JSON for collections filter", e) | ||||||
|  |                             emptyList() | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } catch (e: Exception) { | ||||||
|  |                     Log.e(LOG_TAG, "error while loading collections for filters", e) | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 try { | ||||||
|  |                     client.newCall(GET("$baseUrl/api/v1/genres", headers)).execute().use { response -> | ||||||
|  |                         genres = try { | ||||||
|  |                             val responseBody = response.body | ||||||
|  |                             responseBody.use { json.decodeFromString(it.string()) } | ||||||
|  |                         } catch (e: Exception) { | ||||||
|  |                             Log.e(LOG_TAG, "error while decoding JSON for genres filter", e) | ||||||
|  |                             emptySet() | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } catch (e: Exception) { | ||||||
|  |                     Log.e(LOG_TAG, "error while loading genres for filters", e) | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 try { | ||||||
|  |                     client.newCall(GET("$baseUrl/api/v1/tags", headers)).execute().use { response -> | ||||||
|  |                         tags = try { | ||||||
|  |                             response.body.use { json.decodeFromString(it.string()) } | ||||||
|  |                         } catch (e: Exception) { | ||||||
|  |                             Log.e(LOG_TAG, "error while decoding JSON for tags filter", e) | ||||||
|  |                             emptySet() | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } catch (e: Exception) { | ||||||
|  |                     Log.e(LOG_TAG, "error while loading tags for filters", e) | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 try { | ||||||
|  |                     client.newCall(GET("$baseUrl/api/v1/publishers", headers)).execute().use { response -> | ||||||
|  |                         publishers = try { | ||||||
|  |                             response.body.use { json.decodeFromString(it.string()) } | ||||||
|  |                         } catch (e: Exception) { | ||||||
|  |                             Log.e(LOG_TAG, "error while decoding JSON for publishers filter", e) | ||||||
|  |                             emptySet() | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } catch (e: Exception) { | ||||||
|  |                     Log.e(LOG_TAG, "error while loading publishers for filters", e) | ||||||
|  |                 } | ||||||
|  | 
 | ||||||
|  |                 try { | ||||||
|  |                     client.newCall(GET("$baseUrl/api/v1/authors", headers)).execute().use { response -> | ||||||
|  |                         authors = try { | ||||||
|  |                             response.body | ||||||
|  |                                 .use { json.decodeFromString<List<AuthorDto>>(it.string()) } | ||||||
|  |                                 .groupBy { it.role } | ||||||
|  |                         } catch (e: Exception) { | ||||||
|  |                             Log.e(LOG_TAG, "error while decoding JSON for authors filter", e) | ||||||
|  |                             emptyMap() | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } catch (e: Exception) { | ||||||
|  |                     Log.e(LOG_TAG, "error while loading authors for filters", e) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |                 .subscribeOn(Schedulers.io()) | ||||||
|  |                 .observeOn(Schedulers.io()) | ||||||
|  |                 .subscribe( | ||||||
|  |                     {}, | ||||||
|  |                     { tr -> | ||||||
|  |                         Log.e(LOG_TAG, "error while doing initial calls", tr) | ||||||
|  |                     }, | ||||||
|  |                 ) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     companion object { | ||||||
|  |         private const val PREF_DISPLAYNAME = "Source display name" | ||||||
|  |         private const val PREF_ADDRESS = "Address" | ||||||
|  |         private const val ADDRESS_DEFAULT = "" | ||||||
|  |         private const val PREF_USERNAME = "Username" | ||||||
|  |         private const val USERNAME_DEFAULT = "" | ||||||
|  |         private const val PREF_PASSWORD = "Password" | ||||||
|  |         private const val PASSWORD_DEFAULT = "" | ||||||
|  | 
 | ||||||
|  |         private val supportedImageTypes = listOf("image/jpeg", "image/png", "image/gif", "image/webp", "image/jxl", "image/heif", "image/avif") | ||||||
|  | 
 | ||||||
|  |         private const val TYPE_SERIES = "Series" | ||||||
|  |         private const val TYPE_READLISTS = "Read lists" | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -0,0 +1,14 @@ | |||||||
|  | package eu.kanade.tachiyomi.extension.all.komga | ||||||
|  | 
 | ||||||
|  | import eu.kanade.tachiyomi.source.Source | ||||||
|  | import eu.kanade.tachiyomi.source.SourceFactory | ||||||
|  | 
 | ||||||
|  | class KomgaFactory : SourceFactory { | ||||||
|  | 
 | ||||||
|  |     override fun createSources(): List<Source> = | ||||||
|  |         listOf( | ||||||
|  |             Komga(), | ||||||
|  |             Komga("2"), | ||||||
|  |             Komga("3"), | ||||||
|  |         ) | ||||||
|  | } | ||||||
| @ -0,0 +1,14 @@ | |||||||
|  | package eu.kanade.tachiyomi.extension.all.komga | ||||||
|  | 
 | ||||||
|  | import java.text.SimpleDateFormat | ||||||
|  | import java.util.Locale | ||||||
|  | import java.util.TimeZone | ||||||
|  | 
 | ||||||
|  | object KomgaHelper { | ||||||
|  |     val formatterDate = SimpleDateFormat("yyyy-MM-dd", Locale.US) | ||||||
|  |         .apply { timeZone = TimeZone.getTimeZone("UTC") } | ||||||
|  |     val formatterDateTime = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US) | ||||||
|  |         .apply { timeZone = TimeZone.getTimeZone("UTC") } | ||||||
|  |     val formatterDateTimeMilli = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.S", Locale.US) | ||||||
|  |         .apply { timeZone = TimeZone.getTimeZone("UTC") } | ||||||
|  | } | ||||||
| @ -0,0 +1,132 @@ | |||||||
|  | package eu.kanade.tachiyomi.extension.all.komga.dto | ||||||
|  | 
 | ||||||
|  | import kotlinx.serialization.Serializable | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class LibraryDto( | ||||||
|  |     val id: String, | ||||||
|  |     val name: String, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class SeriesDto( | ||||||
|  |     val id: String, | ||||||
|  |     val libraryId: String, | ||||||
|  |     val name: String, | ||||||
|  |     val created: String?, | ||||||
|  |     val lastModified: String?, | ||||||
|  |     val fileLastModified: String, | ||||||
|  |     val booksCount: Int, | ||||||
|  |     val metadata: SeriesMetadataDto, | ||||||
|  |     val booksMetadata: BookMetadataAggregationDto, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class SeriesMetadataDto( | ||||||
|  |     val status: String, | ||||||
|  |     val created: String?, | ||||||
|  |     val lastModified: String?, | ||||||
|  |     val title: String, | ||||||
|  |     val titleSort: String, | ||||||
|  |     val summary: String, | ||||||
|  |     val summaryLock: Boolean, | ||||||
|  |     val readingDirection: String, | ||||||
|  |     val readingDirectionLock: Boolean, | ||||||
|  |     val publisher: String, | ||||||
|  |     val publisherLock: Boolean, | ||||||
|  |     val ageRating: Int?, | ||||||
|  |     val ageRatingLock: Boolean, | ||||||
|  |     val language: String, | ||||||
|  |     val languageLock: Boolean, | ||||||
|  |     val genres: Set<String>, | ||||||
|  |     val genresLock: Boolean, | ||||||
|  |     val tags: Set<String>, | ||||||
|  |     val tagsLock: Boolean, | ||||||
|  |     val totalBookCount: Int? = null, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class BookMetadataAggregationDto( | ||||||
|  |     val authors: List<AuthorDto> = emptyList(), | ||||||
|  |     val tags: Set<String> = emptySet(), | ||||||
|  |     val releaseDate: String?, | ||||||
|  |     val summary: String, | ||||||
|  |     val summaryNumber: String, | ||||||
|  | 
 | ||||||
|  |     val created: String, | ||||||
|  |     val lastModified: String, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class BookDto( | ||||||
|  |     val id: String, | ||||||
|  |     val seriesId: String, | ||||||
|  |     val seriesTitle: String, | ||||||
|  |     val name: String, | ||||||
|  |     val number: Float, | ||||||
|  |     val created: String?, | ||||||
|  |     val lastModified: String?, | ||||||
|  |     val fileLastModified: String, | ||||||
|  |     val sizeBytes: Long, | ||||||
|  |     val size: String, | ||||||
|  |     val media: MediaDto, | ||||||
|  |     val metadata: BookMetadataDto, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class MediaDto( | ||||||
|  |     val status: String, | ||||||
|  |     val mediaType: String, | ||||||
|  |     val pagesCount: Int, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class PageDto( | ||||||
|  |     val number: Int, | ||||||
|  |     val fileName: String, | ||||||
|  |     val mediaType: String, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class BookMetadataDto( | ||||||
|  |     val title: String, | ||||||
|  |     val titleLock: Boolean, | ||||||
|  |     val summary: String, | ||||||
|  |     val summaryLock: Boolean, | ||||||
|  |     val number: String, | ||||||
|  |     val numberLock: Boolean, | ||||||
|  |     val numberSort: Float, | ||||||
|  |     val numberSortLock: Boolean, | ||||||
|  |     val releaseDate: String?, | ||||||
|  |     val releaseDateLock: Boolean, | ||||||
|  |     val authors: List<AuthorDto>, | ||||||
|  |     val authorsLock: Boolean, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class AuthorDto( | ||||||
|  |     val name: String, | ||||||
|  |     val role: String, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class CollectionDto( | ||||||
|  |     val id: String, | ||||||
|  |     val name: String, | ||||||
|  |     val ordered: Boolean, | ||||||
|  |     val seriesIds: List<String>, | ||||||
|  |     val createdDate: String, | ||||||
|  |     val lastModifiedDate: String, | ||||||
|  |     val filtered: Boolean, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class ReadListDto( | ||||||
|  |     val id: String, | ||||||
|  |     val name: String, | ||||||
|  |     val summary: String, | ||||||
|  |     val bookIds: List<String>, | ||||||
|  |     val createdDate: String, | ||||||
|  |     val lastModifiedDate: String, | ||||||
|  |     val filtered: Boolean, | ||||||
|  | ) | ||||||
| @ -0,0 +1,16 @@ | |||||||
|  | package eu.kanade.tachiyomi.extension.all.komga.dto | ||||||
|  | 
 | ||||||
|  | import kotlinx.serialization.Serializable | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class PageWrapperDto<T>( | ||||||
|  |     val content: List<T>, | ||||||
|  |     val empty: Boolean, | ||||||
|  |     val first: Boolean, | ||||||
|  |     val last: Boolean, | ||||||
|  |     val number: Long, | ||||||
|  |     val numberOfElements: Long, | ||||||
|  |     val size: Long, | ||||||
|  |     val totalElements: Long, | ||||||
|  |     val totalPages: Long, | ||||||
|  | ) | ||||||
							
								
								
									
										2
									
								
								src/all/lanraragi/AndroidManifest.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,2 @@ | |||||||
|  | <?xml version="1.0" encoding="utf-8"?> | ||||||
|  | <manifest /> | ||||||
							
								
								
									
										79
									
								
								src/all/lanraragi/CHANGELOG.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,79 @@ | |||||||
|  | ## 1.3.12 | ||||||
|  | Minimum LANraragi version required: 0.8.2 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * Exclude from bulk update warnings | ||||||
|  | 
 | ||||||
|  | ## 1.2.9 | ||||||
|  | Minimum LANraragi version required: 0.8.2 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * Add `CHANGELOG.md` & `README.md` | ||||||
|  | 
 | ||||||
|  | ## 1.2.8 | ||||||
|  | Minimum LANraragi version required: 0.8.2 | ||||||
|  | 
 | ||||||
|  | ### Fix | ||||||
|  | 
 | ||||||
|  | * Nullpo | ||||||
|  | 
 | ||||||
|  | ### Refactor | ||||||
|  | 
 | ||||||
|  | * replace Gson with kotlinx.serialization | ||||||
|  | 
 | ||||||
|  | ## 1.2.7 | ||||||
|  | Minimum LANraragi version required: 0.8.2 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * Update Icon | ||||||
|  | * Search-aware random | ||||||
|  | * API Key warnig | ||||||
|  | * Items marked as `Completed` instead of `Unknown` | ||||||
|  | 
 | ||||||
|  | ### Fix | ||||||
|  | 
 | ||||||
|  | * Tag Separation | ||||||
|  | 
 | ||||||
|  | ## 1.2.6 | ||||||
|  | 
 | ||||||
|  | ### Fix | ||||||
|  | 
 | ||||||
|  | * categories not appearing from large responses | ||||||
|  | * Random item visibility | ||||||
|  | * Ignore DNS over HTTPS | ||||||
|  | 
 | ||||||
|  | ## 1.2.5 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * Add Random entry | ||||||
|  | * Clear new status on the server | ||||||
|  | 
 | ||||||
|  | ## 1.2.4 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * Safer tag parsing | ||||||
|  | 
 | ||||||
|  | ## 1.2.3 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * Filters and pagination manipulation | ||||||
|  | * New preferences | ||||||
|  | * API usage change to use its metadata endpoint and preserve WebView | ||||||
|  | 
 | ||||||
|  | ## 1.2.2 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * Update to API 0.7.2 | ||||||
|  | 
 | ||||||
|  | ## 1.2.1 | ||||||
|  | 
 | ||||||
|  | ### Features | ||||||
|  | 
 | ||||||
|  | * first version | ||||||
							
								
								
									
										35
									
								
								src/all/lanraragi/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,35 @@ | |||||||
|  | # LANraragi | ||||||
|  | 
 | ||||||
|  | Table of Content | ||||||
|  | - [FAQ](#FAQ) | ||||||
|  |   - [Why do I see no manga?](#why-do-i-see-no-manga) | ||||||
|  |   - [Where can I get more information about LANraragi?](#where-can-i-get-more-information-about-lanraragi) | ||||||
|  |   - [The LANraragi extension stopped working?](#the-lanraragi-extension-stopped-working) | ||||||
|  |   - [Can I add more than one LANraragi server or user?](#can-i-add-more-than-one-lanraragi-server-or-user) | ||||||
|  |   - [Can I test the LANraragi extension before setting up my own server?](#can-i-test-the-lanraragi-extension-before-setting-up-my-own-server) | ||||||
|  | - [Guides](#Guides) | ||||||
|  |   - [How do I add my LANraragi server to Tachiyomi?](#how-do-i-add-my-lanraragi-server-to-tachiyomi) | ||||||
|  | 
 | ||||||
|  | Don't find the question you are look for go check out our general FAQs and Guides over at [Extension FAQ](https://tachiyomi.org/help/faq/#extensions) or [Getting Started](https://tachiyomi.org/help/guides/getting-started/#installation) | ||||||
|  | 
 | ||||||
|  | ## FAQ | ||||||
|  | 
 | ||||||
|  | ### Why do I see no manga? | ||||||
|  | LANraragi is a self-hosted comic/manga media server. | ||||||
|  | 
 | ||||||
|  | ### Where can I get more information about LANraragi? | ||||||
|  | You can visit the [LANraragi](https://github.com/Difegue/LANraragi) github page for for more information. | ||||||
|  | 
 | ||||||
|  | ### The LANraragi extension stopped working? | ||||||
|  | Make sure that your LANraragi server and extension are on the newest version. | ||||||
|  | 
 | ||||||
|  | ### Can I add more than one LANraragi server or user? | ||||||
|  | No, currently there is only support for 1 instances in Tachiyomi, if you need more instances please open a feature request on [tachiyomi-extensions](https://github.com/tachiyomiorg/tachiyomi-extensions/issues/new/choose) repo. | ||||||
|  | 
 | ||||||
|  | ### Can I test the LANraragi extension before setting up my own server? | ||||||
|  | Yes, you can try it out with the DEMO server `https://lrr.tvc-16.science`. | ||||||
|  | 
 | ||||||
|  | ## Guides | ||||||
|  | 
 | ||||||
|  | ### How do I add my LANraragi server to Tachiyomi? | ||||||
|  | Go into the settings of the LANraragi extension from the Extension tab in Browse and fill in your server address and API key if needed. | ||||||
							
								
								
									
										7
									
								
								src/all/lanraragi/build.gradle
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,7 @@ | |||||||
|  | ext { | ||||||
|  |     extName = 'LANraragi' | ||||||
|  |     extClass = '.LANraragiFactory' | ||||||
|  |     extVersionCode = 15 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | apply from: "$rootDir/common.gradle" | ||||||
							
								
								
									
										
											BIN
										
									
								
								src/all/lanraragi/res/mipmap-hdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 4.7 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/all/lanraragi/res/mipmap-mdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.5 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/all/lanraragi/res/mipmap-xhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 7.4 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/all/lanraragi/res/mipmap-xxhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 14 KiB | 
							
								
								
									
										
											BIN
										
									
								
								src/all/lanraragi/res/mipmap-xxxhdpi/ic_launcher.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 23 KiB | 
| @ -0,0 +1,31 @@ | |||||||
|  | package eu.kanade.tachiyomi.extension.all.lanraragi | ||||||
|  | 
 | ||||||
|  | import kotlinx.serialization.Serializable | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class Archive( | ||||||
|  |     val arcid: String, | ||||||
|  |     val isnew: String, | ||||||
|  |     val tags: String?, | ||||||
|  |     val title: String, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class ArchivePage( | ||||||
|  |     val pages: List<String>, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class ArchiveSearchResult( | ||||||
|  |     val data: List<Archive>, | ||||||
|  |     val recordsFiltered: Int, | ||||||
|  |     val recordsTotal: Int, | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | @Serializable | ||||||
|  | data class Category( | ||||||
|  |     val id: String, | ||||||
|  |     val last_used: String, | ||||||
|  |     val name: String, | ||||||
|  |     val pinned: String, | ||||||
|  | ) | ||||||
| @ -0,0 +1,472 @@ | |||||||
|  | package eu.kanade.tachiyomi.extension.all.lanraragi | ||||||
|  | 
 | ||||||
|  | import android.app.Application | ||||||
|  | import android.content.SharedPreferences | ||||||
|  | import android.net.Uri | ||||||
|  | import android.text.InputType | ||||||
|  | import android.util.Base64 | ||||||
|  | import android.widget.Toast | ||||||
|  | import eu.kanade.tachiyomi.network.GET | ||||||
|  | import eu.kanade.tachiyomi.network.asObservableSuccess | ||||||
|  | import eu.kanade.tachiyomi.source.ConfigurableSource | ||||||
|  | import eu.kanade.tachiyomi.source.UnmeteredSource | ||||||
|  | import eu.kanade.tachiyomi.source.model.Filter | ||||||
|  | import eu.kanade.tachiyomi.source.model.FilterList | ||||||
|  | import eu.kanade.tachiyomi.source.model.MangasPage | ||||||
|  | import eu.kanade.tachiyomi.source.model.Page | ||||||
|  | import eu.kanade.tachiyomi.source.model.SChapter | ||||||
|  | import eu.kanade.tachiyomi.source.model.SManga | ||||||
|  | import eu.kanade.tachiyomi.source.online.HttpSource | ||||||
|  | import kotlinx.serialization.decodeFromString | ||||||
|  | import kotlinx.serialization.json.Json | ||||||
|  | import kotlinx.serialization.json.jsonArray | ||||||
|  | import kotlinx.serialization.json.jsonObject | ||||||
|  | import kotlinx.serialization.json.jsonPrimitive | ||||||
|  | import okhttp3.CacheControl | ||||||
|  | import okhttp3.Dns | ||||||
|  | import okhttp3.Headers | ||||||
|  | import okhttp3.OkHttpClient | ||||||
|  | import okhttp3.Request | ||||||
|  | import okhttp3.Response | ||||||
|  | import rx.Observable | ||||||
|  | import rx.Single | ||||||
|  | import rx.schedulers.Schedulers | ||||||
|  | import uy.kohesive.injekt.Injekt | ||||||
|  | import uy.kohesive.injekt.api.get | ||||||
|  | import java.io.IOException | ||||||
|  | import java.security.MessageDigest | ||||||
|  | import kotlin.math.max | ||||||
|  | 
 | ||||||
|  | open class LANraragi(private val suffix: String = "") : ConfigurableSource, UnmeteredSource, HttpSource() { | ||||||
|  |     override val baseUrl by lazy { getPrefBaseUrl() } | ||||||
|  | 
 | ||||||
|  |     override val lang = "all" | ||||||
|  | 
 | ||||||
|  |     override val name by lazy { "LANraragi (${getPrefCustomLabel()})" } | ||||||
|  | 
 | ||||||
|  |     override val supportsLatest = true | ||||||
|  | 
 | ||||||
|  |     private val apiKey by lazy { getPrefAPIKey() } | ||||||
|  | 
 | ||||||
|  |     private val latestNamespacePref by lazy { getPrefLatestNS() } | ||||||
|  | 
 | ||||||
|  |     private val json by lazy { Injekt.get<Json>() } | ||||||
|  | 
 | ||||||
|  |     private var randomArchiveID: String = "" | ||||||
|  | 
 | ||||||
|  |     override fun fetchMangaDetails(manga: SManga): Observable<SManga> { | ||||||
|  |         val id = if (manga.url.startsWith("/api/search/random")) randomArchiveID else getReaderId(manga.url) | ||||||
|  |         val uri = getApiUriBuilder("/api/archives/$id/metadata").build() | ||||||
|  | 
 | ||||||
|  |         if (manga.url.startsWith("/api/search/random")) { | ||||||
|  |             val randQuery = Uri.parse(manga.url).encodedQuery.toString() | ||||||
|  |             randomArchiveID = getRandomID(randQuery) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return client.newCall(GET(uri.toString(), headers)) | ||||||
|  |             .asObservableSuccess() | ||||||
|  |             .map { mangaDetailsParse(it).apply { initialized = true } } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun mangaDetailsRequest(manga: SManga): Request { | ||||||
|  |         // Catch-all that includes random's ID via thumbnail | ||||||
|  |         val id = getThumbnailId(manga.thumbnail_url!!) | ||||||
|  | 
 | ||||||
|  |         return GET("$baseUrl/reader?id=$id", headers) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun mangaDetailsParse(response: Response): SManga { | ||||||
|  |         val archive = json.decodeFromString<Archive>(response.body.string()) | ||||||
|  | 
 | ||||||
|  |         return archiveToSManga(archive) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun chapterListRequest(manga: SManga): Request { | ||||||
|  |         val id = if (manga.url.startsWith("/api/search/random")) randomArchiveID else getReaderId(manga.url) | ||||||
|  |         val uri = getApiUriBuilder("/api/archives/$id/metadata").build() | ||||||
|  | 
 | ||||||
|  |         return GET(uri.toString(), headers) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun chapterListParse(response: Response): List<SChapter> { | ||||||
|  |         val archive = json.decodeFromString<Archive>(response.body.string()) | ||||||
|  |         val uri = getApiUriBuilder("/api/archives/${archive.arcid}/files") | ||||||
|  |         val prefClearNew = preferences.getBoolean(NEW_ONLY_KEY, NEW_ONLY_DEFAULT) | ||||||
|  | 
 | ||||||
|  |         if (archive.isnew == "true" && prefClearNew) { | ||||||
|  |             val clearNew = Request.Builder() | ||||||
|  |                 .url("$baseUrl/api/archives/${archive.arcid}/isnew") | ||||||
|  |                 .headers(headers) | ||||||
|  |                 .delete() | ||||||
|  |                 .build() | ||||||
|  | 
 | ||||||
|  |             client.newCall(clearNew).execute() | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return listOf( | ||||||
|  |             SChapter.create().apply { | ||||||
|  |                 val uriBuild = uri.build() | ||||||
|  | 
 | ||||||
|  |                 url = uriBuild.toString() | ||||||
|  |                 chapter_number = 1F | ||||||
|  |                 name = "Chapter" | ||||||
|  | 
 | ||||||
|  |                 getDateAdded(archive.tags).toLongOrNull()?.let { | ||||||
|  |                     date_upload = it | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun pageListRequest(chapter: SChapter): Request { | ||||||
|  |         return GET(chapter.url, headers) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun pageListParse(response: Response): List<Page> { | ||||||
|  |         val archivePage = json.decodeFromString<ArchivePage>(response.body.string()) | ||||||
|  | 
 | ||||||
|  |         return archivePage.pages.mapIndexed { index, url -> | ||||||
|  |             val uri = Uri.parse("${baseUrl}${url.trimStart('.')}") | ||||||
|  |             Page(index, uri.toString(), uri.toString(), uri) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("imageUrlParse is unused") | ||||||
|  | 
 | ||||||
|  |     override fun popularMangaRequest(page: Int): Request { | ||||||
|  |         return searchMangaRequest(page, "", FilterList()) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun popularMangaParse(response: Response): MangasPage { | ||||||
|  |         return searchMangaParse(response) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun latestUpdatesRequest(page: Int): Request { | ||||||
|  |         val filters = mutableListOf<Filter<*>>() | ||||||
|  |         val prefNewOnly = preferences.getBoolean(NEW_ONLY_KEY, NEW_ONLY_DEFAULT) | ||||||
|  | 
 | ||||||
|  |         if (prefNewOnly) filters.add(NewArchivesOnly(true)) | ||||||
|  | 
 | ||||||
|  |         if (latestNamespacePref.isNotBlank()) { | ||||||
|  |             filters.add(SortByNamespace(latestNamespacePref)) | ||||||
|  |             filters.add(DescendingOrder(true)) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return searchMangaRequest(page, "", FilterList(filters)) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun latestUpdatesParse(response: Response): MangasPage { | ||||||
|  |         return searchMangaParse(response) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private var lastResultCount: Int = 100 | ||||||
|  |     private var lastRecordsFiltered: Int = 0 | ||||||
|  |     private var maxResultCount: Int = 0 | ||||||
|  |     private var totalRecords: Int = 0 | ||||||
|  | 
 | ||||||
|  |     override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { | ||||||
|  |         val uri = getApiUriBuilder("/api/search") | ||||||
|  |         var startPageOffset = 0 | ||||||
|  | 
 | ||||||
|  |         filters.forEach { filter -> | ||||||
|  |             when (filter) { | ||||||
|  |                 is StartingPage -> { | ||||||
|  |                     startPageOffset = filter.state.toIntOrNull() ?: 1 | ||||||
|  | 
 | ||||||
|  |                     // Exception for API wrapping around and user input of 0 | ||||||
|  |                     if (startPageOffset > 0) { | ||||||
|  |                         startPageOffset -= 1 | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 is NewArchivesOnly -> if (filter.state) uri.appendQueryParameter("newonly", "true") | ||||||
|  |                 is UntaggedArchivesOnly -> if (filter.state) uri.appendQueryParameter("untaggedonly", "true") | ||||||
|  |                 is DescendingOrder -> if (filter.state) uri.appendQueryParameter("order", "desc") | ||||||
|  |                 is SortByNamespace -> if (filter.state.isNotEmpty()) uri.appendQueryParameter("sortby", filter.state.trim()) | ||||||
|  |                 is CategorySelect -> if (filter.state > 0) uri.appendQueryParameter("category", filter.toUriPart()) | ||||||
|  |                 else -> {} | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         uri.appendQueryParameter("start", ((page - 1 + startPageOffset) * maxResultCount).toString()) | ||||||
|  | 
 | ||||||
|  |         if (query.isNotEmpty()) { | ||||||
|  |             uri.appendQueryParameter("filter", query) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return GET(uri.toString(), headers, CacheControl.FORCE_NETWORK) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun searchMangaParse(response: Response): MangasPage { | ||||||
|  |         val jsonResult = json.decodeFromString<ArchiveSearchResult>(response.body.string()) | ||||||
|  |         val currentStart = getStart(response) | ||||||
|  |         val archives = arrayListOf<SManga>() | ||||||
|  | 
 | ||||||
|  |         lastResultCount = jsonResult.data.size | ||||||
|  |         maxResultCount = max(lastResultCount, maxResultCount) | ||||||
|  |         lastRecordsFiltered = jsonResult.recordsFiltered | ||||||
|  |         totalRecords = jsonResult.recordsTotal | ||||||
|  | 
 | ||||||
|  |         if (lastResultCount > 1 && currentStart == 0) { | ||||||
|  |             val randQuery = response.request.url.encodedQuery.toString() | ||||||
|  |             randomArchiveID = getRandomID(randQuery) | ||||||
|  | 
 | ||||||
|  |             archives.add( | ||||||
|  |                 SManga.create().apply { | ||||||
|  |                     url = "/api/search/random?count=1&$randQuery" | ||||||
|  |                     title = "Random" | ||||||
|  |                     description = "Refresh for a random archive." | ||||||
|  |                     thumbnail_url = getThumbnailUri("tachiyomi") // noThumb | ||||||
|  |                 }, | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         jsonResult.data.map { | ||||||
|  |             archives.add(archiveToSManga(it)) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return MangasPage(archives, currentStart + lastResultCount < lastRecordsFiltered) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun archiveToSManga(archive: Archive) = SManga.create().apply { | ||||||
|  |         url = "/reader?id=${archive.arcid}" | ||||||
|  |         title = archive.title | ||||||
|  |         description = archive.title | ||||||
|  |         thumbnail_url = getThumbnailUri(archive.arcid) | ||||||
|  |         genre = archive.tags?.replace(",", ", ") | ||||||
|  |         artist = getArtist(archive.tags) | ||||||
|  |         author = artist | ||||||
|  |         status = SManga.COMPLETED | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     override fun headersBuilder() = Headers.Builder().apply { | ||||||
|  |         if (apiKey.isNotEmpty()) { | ||||||
|  |             val apiKey64 = Base64.encodeToString(apiKey.toByteArray(), Base64.NO_WRAP) | ||||||
|  |             add("Authorization", "Bearer $apiKey64") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private class DescendingOrder(overrideState: Boolean = false) : Filter.CheckBox("Descending Order", overrideState) | ||||||
|  |     private class NewArchivesOnly(overrideState: Boolean = false) : Filter.CheckBox("New Archives Only", overrideState) | ||||||
|  |     private class UntaggedArchivesOnly : Filter.CheckBox("Untagged Archives Only", false) | ||||||
|  |     private class StartingPage(stats: String) : Filter.Text("Starting Page$stats", "") | ||||||
|  |     private class SortByNamespace(defaultText: String = "") : Filter.Text("Sort by (namespace)", defaultText) | ||||||
|  |     private class CategorySelect(categories: Array<Pair<String?, String>>) : UriPartFilter("Category", categories) | ||||||
|  | 
 | ||||||
|  |     override fun getFilterList() = FilterList( | ||||||
|  |         CategorySelect(getCategoryPairs(categories)), | ||||||
|  |         Filter.Separator(), | ||||||
|  |         DescendingOrder(), | ||||||
|  |         NewArchivesOnly(), | ||||||
|  |         UntaggedArchivesOnly(), | ||||||
|  |         StartingPage(startingPageStats()), | ||||||
|  |         SortByNamespace(), | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  |     private var categories = emptyList<Category>() | ||||||
|  | 
 | ||||||
|  |     // Preferences | ||||||
|  |     override val id by lazy { | ||||||
|  |         // Retain previous ID for first entry | ||||||
|  |         val key = "lanraragi" + (if (suffix == "1") "" else "_$suffix") + "/all/$versionId" | ||||||
|  |         val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) | ||||||
|  |         (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private val preferences: SharedPreferences by lazy { | ||||||
|  |         Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun getPrefBaseUrl(): String = preferences.getString(HOSTNAME_KEY, HOSTNAME_DEFAULT)!! | ||||||
|  |     private fun getPrefAPIKey(): String = preferences.getString(APIKEY_KEY, "")!! | ||||||
|  |     private fun getPrefLatestNS(): String = preferences.getString(SORT_BY_NS_KEY, SORT_BY_NS_DEFAULT)!! | ||||||
|  |     private fun getPrefCustomLabel(): String = preferences.getString(CUSTOM_LABEL_KEY, suffix)!!.ifBlank { suffix } | ||||||
|  | 
 | ||||||
|  |     override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) { | ||||||
|  |         screen.addPreference(screen.editTextPreference(HOSTNAME_KEY, "Hostname", HOSTNAME_DEFAULT, baseUrl, refreshSummary = true)) | ||||||
|  |         screen.addPreference(screen.editTextPreference(APIKEY_KEY, "API Key", "", "Required if No-Fun Mode is enabled.", true)) | ||||||
|  |         screen.addPreference(screen.editTextPreference(CUSTOM_LABEL_KEY, "Custom Label", "", "Show the given label for the source instead of the default.")) | ||||||
|  |         screen.addPreference(screen.checkBoxPreference(CLEAR_NEW_KEY, "Clear New status", CLEAR_NEW_DEFAULT, "Clear an entry's New status when its details are viewed.")) | ||||||
|  |         screen.addPreference(screen.checkBoxPreference(NEW_ONLY_KEY, "Latest - New Only", NEW_ONLY_DEFAULT)) | ||||||
|  |         screen.addPreference(screen.editTextPreference(SORT_BY_NS_KEY, "Latest - Sort by Namespace", SORT_BY_NS_DEFAULT, "Sort by the given namespace for Latest, such as date_added.")) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun androidx.preference.PreferenceScreen.checkBoxPreference(key: String, title: String, default: Boolean, summary: String = ""): androidx.preference.CheckBoxPreference { | ||||||
|  |         return androidx.preference.CheckBoxPreference(context).apply { | ||||||
|  |             this.key = key | ||||||
|  |             this.title = title | ||||||
|  |             this.summary = summary | ||||||
|  |             setDefaultValue(default) | ||||||
|  | 
 | ||||||
|  |             setOnPreferenceChangeListener { _, newValue -> | ||||||
|  |                 preferences.edit().putBoolean(this.key, newValue as Boolean).commit() | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun androidx.preference.PreferenceScreen.editTextPreference(key: String, title: String, default: String, summary: String, isPassword: Boolean = false, refreshSummary: Boolean = false): androidx.preference.EditTextPreference { | ||||||
|  |         return androidx.preference.EditTextPreference(context).apply { | ||||||
|  |             this.key = key | ||||||
|  |             this.title = title | ||||||
|  |             this.summary = summary | ||||||
|  |             this.setDefaultValue(default) | ||||||
|  | 
 | ||||||
|  |             if (isPassword) { | ||||||
|  |                 setOnBindEditTextListener { | ||||||
|  |                     it.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             setOnPreferenceChangeListener { _, newValue -> | ||||||
|  |                 try { | ||||||
|  |                     val newString = newValue.toString() | ||||||
|  |                     val res = preferences.edit().putString(this.key, newString).commit() | ||||||
|  | 
 | ||||||
|  |                     if (refreshSummary) { | ||||||
|  |                         this.apply { | ||||||
|  |                             this.summary = newValue as String | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  | 
 | ||||||
|  |                     Toast.makeText(context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show() | ||||||
|  |                     res | ||||||
|  |                 } catch (e: Exception) { | ||||||
|  |                     e.printStackTrace() | ||||||
|  |                     false | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // Helper | ||||||
|  |     private fun getRandomID(query: String): String { | ||||||
|  |         val searchRandom = client.newCall(GET("$baseUrl/api/search/random?count=1&$query", headers)).execute() | ||||||
|  |         val data = json.parseToJsonElement(searchRandom.body.string()).jsonObject["data"] | ||||||
|  |         val archive = data!!.jsonArray.firstOrNull()?.jsonObject | ||||||
|  | 
 | ||||||
|  |         // 0.8.2~0.8.7 = id, 0.8.8+ = arcid | ||||||
|  |         return (archive?.get("arcid") ?: archive?.get("id"))?.jsonPrimitive?.content ?: "" | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     open class UriPartFilter(displayName: String, private val vals: Array<Pair<String?, String>>) : | ||||||
|  |         Filter.Select<String>(displayName, vals.map { it.second }.toTypedArray()) { | ||||||
|  |         fun toUriPart() = vals[state].first | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun getCategories() { | ||||||
|  |         Single.fromCallable { | ||||||
|  |             client.newCall(GET("$baseUrl/api/categories", headers)).execute() | ||||||
|  |         } | ||||||
|  |             .subscribeOn(Schedulers.io()) | ||||||
|  |             .observeOn(Schedulers.io()) | ||||||
|  |             .subscribe( | ||||||
|  |                 { | ||||||
|  |                     categories = try { | ||||||
|  |                         json.decodeFromString(it.body.string()) | ||||||
|  |                     } catch (e: Exception) { | ||||||
|  |                         emptyList() | ||||||
|  |                     } | ||||||
|  |                 }, | ||||||
|  |                 {}, | ||||||
|  |             ) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun getCategoryPairs(categories: List<Category>): Array<Pair<String?, String>> { | ||||||
|  |         // Empty pair to disable. Sort by pinned status then name for convenience. | ||||||
|  |         // Web client sort is pinned > last_used but reflects between page changes. | ||||||
|  | 
 | ||||||
|  |         val pin = "\uD83D\uDCCC " | ||||||
|  | 
 | ||||||
|  |         // Maintain categories sync for next FilterList reset. If there's demand for it, it's now | ||||||
|  |         // possible to sort by last_used similar to the web client. Maybe an option toggle? | ||||||
|  |         getCategories() | ||||||
|  | 
 | ||||||
|  |         return listOf(Pair("", "")) | ||||||
|  |             .plus( | ||||||
|  |                 categories | ||||||
|  |                     .sortedWith(compareByDescending<Category> { it.pinned }.thenBy { it.name }) | ||||||
|  |                     .map { | ||||||
|  |                         val pinned = if (it.pinned == "1") pin else "" | ||||||
|  |                         Pair(it.id, "$pinned${it.name}") | ||||||
|  |                     }, | ||||||
|  |             ) | ||||||
|  |             .toTypedArray() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun startingPageStats(): String { | ||||||
|  |         return if (maxResultCount > 0 && totalRecords > 0) " ($maxResultCount / $lastRecordsFiltered items)" else "" | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun getApiUriBuilder(path: String): Uri.Builder { | ||||||
|  |         return Uri.parse("$baseUrl$path").buildUpon() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun getThumbnailUri(id: String): String { | ||||||
|  |         val uri = getApiUriBuilder("/api/archives/$id/thumbnail") | ||||||
|  | 
 | ||||||
|  |         return uri.toString() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private tailrec fun getTopResponse(response: Response): Response { | ||||||
|  |         return if (response.priorResponse == null) response else getTopResponse(response.priorResponse!!) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun getStart(response: Response): Int { | ||||||
|  |         return getTopResponse(response).request.url.queryParameter("start")!!.toInt() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun getReaderId(url: String): String { | ||||||
|  |         return Regex("""/reader\?id=(\w{40})""").find(url)?.groupValues?.get(1) ?: "" | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun getThumbnailId(url: String): String { | ||||||
|  |         return Regex("""/(\w{40})/thumbnail""").find(url)?.groupValues?.get(1) ?: "" | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun getNSTag(tags: String?, tag: String): List<String>? { | ||||||
|  |         tags?.split(',')?.forEach { | ||||||
|  |             if (it.contains(':')) { | ||||||
|  |                 val temp = it.trim().split(":", limit = 2) | ||||||
|  |                 if (temp[0].equals(tag, true)) return temp | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return null | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private fun getArtist(tags: String?): String = getNSTag(tags, "artist")?.get(1) ?: "N/A" | ||||||
|  | 
 | ||||||
|  |     private fun getDateAdded(tags: String?): String { | ||||||
|  |         // Pad Date Added NS to milliseconds | ||||||
|  |         return getNSTag(tags, "date_added")?.get(1)?.padEnd(13, '0') ?: "" | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // Headers (currently auth) are done in headersBuilder | ||||||
|  |     override val client: OkHttpClient = network.cloudflareClient.newBuilder() | ||||||
|  |         .dns(Dns.SYSTEM) | ||||||
|  |         .addInterceptor { chain -> | ||||||
|  |             val response = chain.proceed(chain.request()) | ||||||
|  |             if (response.code == 401) throw IOException("If the server is in No-Fun Mode make sure the extension's API Key is correct.") | ||||||
|  |             response | ||||||
|  |         } | ||||||
|  |         .build() | ||||||
|  | 
 | ||||||
|  |     init { | ||||||
|  |         if (baseUrl.isNotBlank()) { | ||||||
|  |             // Save a FilterList reset | ||||||
|  |             getCategories() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     companion object { | ||||||
|  |         private const val HOSTNAME_DEFAULT = "http://127.0.0.1:3000" | ||||||
|  |         private const val HOSTNAME_KEY = "hostname" | ||||||
|  |         private const val APIKEY_KEY = "apiKey" | ||||||
|  |         private const val CUSTOM_LABEL_KEY = "customLabel" | ||||||
|  |         private const val NEW_ONLY_DEFAULT = true | ||||||
|  |         private const val NEW_ONLY_KEY = "latestNewOnly" | ||||||
|  |         private const val SORT_BY_NS_DEFAULT = "date_added" | ||||||
|  |         private const val SORT_BY_NS_KEY = "latestNamespacePref" | ||||||
|  |         private const val CLEAR_NEW_KEY = "clearNew" | ||||||
|  |         private const val CLEAR_NEW_DEFAULT = true | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -0,0 +1,13 @@ | |||||||
|  | package eu.kanade.tachiyomi.extension.all.lanraragi | ||||||
|  | 
 | ||||||
|  | import eu.kanade.tachiyomi.source.Source | ||||||
|  | import eu.kanade.tachiyomi.source.SourceFactory | ||||||
|  | 
 | ||||||
|  | class LANraragiFactory : SourceFactory { | ||||||
|  |     override fun createSources(): List<Source> = | ||||||
|  |         listOf( | ||||||
|  |             LANraragi("1"), | ||||||
|  |             LANraragi("2"), | ||||||
|  |             LANraragi("3"), | ||||||
|  |         ) | ||||||
|  | } | ||||||