From d4704900877cdd789716637635e70a7db9f52f02 Mon Sep 17 00:00:00 2001 From: Tim Schneeberger Date: Sun, 12 Jan 2025 10:28:52 +0100 Subject: [PATCH] Add NamiComi (#7057) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Namicomi * Remove conditional; already handled by the intent-filter * Simplify error handling Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com> * Update old comment * Remove hardcoded /en/ url path * Chapters missing from the accessMap should be considered inaccessible * Remove setOnPreferenceChangeListener * Remove unused i18n key * Rename Namicomi to NamiComi * Revert accidental change to settings.gradle.kts * Remove remaining setOnPreferenceListener * Close response on error Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com> * Require nonnull chapter access map * Change abstract to sealed in EntityDto Co-authored-by: FourTOne5 <107297513+FourTOne5@users.noreply.github.com> * Change abstract to sealed in MangaDto Co-authored-by: FourTOne5 <107297513+FourTOne5@users.noreply.github.com> * Change SManga.PUBLISHING_FINISHED to SManga.COMPLETED Co-authored-by: FourTOne5 <107297513+FourTOne5@users.noreply.github.com> * Set initialized = true Co-authored-by: FourTOne5 <107297513+FourTOne5@users.noreply.github.com> * Remove allowSpecialFloatingPointValues and prettyPrint * Remove markdown cleanup functions * Cleanup import * Remove constructors for new sealed interface * Fix PaginatedResponseDto structure * Simplify and remove createBasicManga * Remove old MangaDex code * Use 🔒 for locked chapters * Update NamiComiHelper.kt Co-authored-by: FourTOne5 <107297513+FourTOne5@users.noreply.github.com> * Remove data modifier from dto classes * Apply suggestions from code review Co-authored-by: FourTOne5 <107297513+FourTOne5@users.noreply.github.com> * Update URL/ID handling * Move companion object to bottom --------- Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com> Co-authored-by: FourTOne5 <107297513+FourTOne5@users.noreply.github.com> --- src/all/namicomi/AndroidManifest.xml | 27 ++ .../assets/i18n/messages_en.properties | 114 ++++++ src/all/namicomi/build.gradle | 12 + .../namicomi/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3333 bytes .../namicomi/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1874 bytes .../namicomi/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4649 bytes .../res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 8117 bytes .../res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 10993 bytes .../extension/all/namicomi/NamiComi.kt | 354 ++++++++++++++++++ .../all/namicomi/NamiComiConstants.kt | 63 ++++ .../extension/all/namicomi/NamiComiFactory.kt | 96 +++++ .../extension/all/namicomi/NamiComiFilters.kt | 289 ++++++++++++++ .../extension/all/namicomi/NamiComiHelper.kt | 182 +++++++++ .../all/namicomi/NamiComiUrlActivity.kt | 45 +++ .../extension/all/namicomi/dto/ChapterDto.kt | 20 + .../extension/all/namicomi/dto/CoverArtDto.kt | 15 + .../all/namicomi/dto/EntityAccessMapDto.kt | 30 ++ .../extension/all/namicomi/dto/EntityDto.kt | 16 + .../extension/all/namicomi/dto/MangaDto.kt | 70 ++++ .../all/namicomi/dto/OrganizationDto.kt | 12 + .../all/namicomi/dto/PageListDataDto.kt | 26 ++ .../extension/all/namicomi/dto/ResponseDto.kt | 27 ++ 22 files changed, 1398 insertions(+) create mode 100644 src/all/namicomi/AndroidManifest.xml create mode 100644 src/all/namicomi/assets/i18n/messages_en.properties create mode 100644 src/all/namicomi/build.gradle create mode 100644 src/all/namicomi/res/mipmap-hdpi/ic_launcher.png create mode 100644 src/all/namicomi/res/mipmap-mdpi/ic_launcher.png create mode 100644 src/all/namicomi/res/mipmap-xhdpi/ic_launcher.png create mode 100644 src/all/namicomi/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 src/all/namicomi/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComi.kt create mode 100644 src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiConstants.kt create mode 100644 src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiFactory.kt create mode 100644 src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiFilters.kt create mode 100644 src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiHelper.kt create mode 100644 src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiUrlActivity.kt create mode 100644 src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/ChapterDto.kt create mode 100644 src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/CoverArtDto.kt create mode 100644 src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/EntityAccessMapDto.kt create mode 100644 src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/EntityDto.kt create mode 100644 src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/MangaDto.kt create mode 100644 src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/OrganizationDto.kt create mode 100644 src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/PageListDataDto.kt create mode 100644 src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/ResponseDto.kt diff --git a/src/all/namicomi/AndroidManifest.xml b/src/all/namicomi/AndroidManifest.xml new file mode 100644 index 000000000..64995a98d --- /dev/null +++ b/src/all/namicomi/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/all/namicomi/assets/i18n/messages_en.properties b/src/all/namicomi/assets/i18n/messages_en.properties new file mode 100644 index 000000000..1d250c929 --- /dev/null +++ b/src/all/namicomi/assets/i18n/messages_en.properties @@ -0,0 +1,114 @@ +content=Content +content_rating=Content rating +content_rating_genre=Content rating: %s +content_rating_mature=Mature +content_rating_restricted=Restricted +content_rating_safe=Safe +content_warnings_drugs=Drugs +content_warnings_gambling=Gambling +content_warnings_gore=Gore +content_warnings_mental_disorders=Mental Disorders +content_warnings_physical_abuse=Physical Abuse +content_warnings_racism=Racism +content_warnings_self_harm=Self-harm +content_warnings_sexual_abuse=Sexual Abuse +content_warnings_verbal_abuse=Verbal Abuse +cover_quality=Cover quality +cover_quality_low=Low +cover_quality_medium=Medium +cover_quality_original=Original +data_saver=Data saver +data_saver_summary=Enables smaller, more compressed images +error_payment_required=Payment required. Chapter requires a premium subscription +excluded_tags_mode=Excluded tags mode +format=Format +format_4_koma=4-Koma +format_adaptation=Adaptation +format_anthology=Anthology +format_full_color=Full Color +format_oneshot=Oneshot +format_silent=Silent +genre=Genre +genre_action=Action +genre_adventure=Adventure +genre_boys_love=Boys' Love +genre_comedy=Comedy +genre_crime=Crime +genre_drama=Drama +genre_fantasy=Fantasy +genre_girls_love=Girls' Love +genre_historical=Historical +genre_horror=Horror +genre_isekai=Isekai +genre_mecha=Mecha +genre_medical=Medical +genre_mystery=Mystery +genre_philosophical=Philosophical +genre_psychological=Psychological +genre_romance=Romance +genre_sci_fi=Sci-Fi +genre_slice_of_life=Slice of Life +genre_sports=Sports +genre_superhero=Superhero +genre_thriller=Thriller +genre_tragedy=Tragedy +genre_wuxia=Wuxia +has_available_chapters=Has available chapters +included_tags_mode=Included tags mode +invalid_manga_id=Not a valid title ID +mode_and=And +mode_or=Or +show_locked_chapters=Show locked/paywalled chapters +show_locked_chapters_summary=Display chapters that require an account with a premium subscription +sort=Sort +sort_alphabetic=Alphabetic +sort_content_created_at=Content created at +sort_number_of_chapters=Chapter count +sort_number_of_comments=Comment count +sort_number_of_follows=Followers +sort_number_of_likes=Likes +sort_rating=Rating +sort_views=Views +sort_year=Year +status=Status +status_cancelled=Cancelled +status_completed=Completed +status_hiatus=Hiatus +status_ongoing=Ongoing +tags_mode=Tags mode +theme=Theme +theme_aliens=Aliens +theme_animals=Animals +theme_cooking=Cooking +theme_crossdressing=Crossdressing +theme_delinquents=Delinquents +theme_demons=Demons +theme_genderswap=Genderswap +theme_ghosts=Ghosts +theme_gyaru=Gyaru +theme_harem=Harem +theme_mafia=Mafia +theme_magic=Magic +theme_magical_girls=Magical Girls +theme_martial_arts=Martial Arts +theme_military=Military +theme_monster_girls=Monster Girls +theme_monsters=Monsters +theme_music=Music +theme_ninja=Ninja +theme_office_workers=Office Workers +theme_police=Police +theme_post_apocalyptic=Post-Apocalyptic +theme_reincarnation=Reincarnation +theme_reverse_harem=Reverse Harem +theme_samurai=Samurai +theme_school_life=School Life +theme_supernatural=Supernatural +theme_survival=Survival +theme_time_travel=Time Travel +theme_traditional_games=Traditional Games +theme_vampires=Vampires +theme_video_games=Video Games +theme_villainess=Villainess +theme_virtual_reality=Virtual Reality +theme_zombies=Zombies diff --git a/src/all/namicomi/build.gradle b/src/all/namicomi/build.gradle new file mode 100644 index 000000000..11a531ba5 --- /dev/null +++ b/src/all/namicomi/build.gradle @@ -0,0 +1,12 @@ +ext { + extName = 'NamiComi' + extClass = '.NamiComiFactory' + extVersionCode = 1 + isNsfw = false +} + +apply from: "$rootDir/common.gradle" + +dependencies { + implementation(project(":lib:i18n")) +} diff --git a/src/all/namicomi/res/mipmap-hdpi/ic_launcher.png b/src/all/namicomi/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..fc8d6dcfc3d9e86047d32e8b29f5e508521e567a GIT binary patch literal 3333 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 P00000NkvXXu0mjf=JQXb literal 0 HcmV?d00001 diff --git a/src/all/namicomi/res/mipmap-mdpi/ic_launcher.png b/src/all/namicomi/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..fb113908c686706c9e46b928b006b2b8b2e29bcc GIT binary patch literal 1874 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 literal 0 HcmV?d00001 diff --git a/src/all/namicomi/res/mipmap-xxhdpi/ic_launcher.png b/src/all/namicomi/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..042f96f89619a105349dc58d0c0f792cffc989e6 GIT binary patch literal 8117 zcmV;mA4=efP)Py9Z%IT!RCr$PT?=>=)xH1CW_Poj7vU8M2?^mP2m%(dP^1q~KOfjDR(jidi~3m= zZ-r8-rPfPi4EfLsvRl7I^!TjFyY zy8v=AU`qlnfNY7+ZR`Tb#egjdxB#*xKDV(8AQuC+B;W$bmiXMpE`VGN*ph$?AY0;d z8>b6E>FU?LWA-G#+iTvvwUakm>Pz{r(_?n~vnBxoj~qabfXOKUQ9tsJC;m50)v0S( z6WiI{N(q=)97NLhN>G`Wmff410L{7Qo}06D>C!&ar%#{QuV24;e!u_I9LEg@;1y8B zrCy5X#W`f+d*wB095f!g-?R3iS5HeOR>u~L#p(gHhQr}k8yg!}Z``FXNx*Au^+m6g_* zVzJoKNF?&l=H}*?h71|91^|Ib$x5cCWCuV59Qt?<95^s*=+L3hcs!m`Q&)E@_qYUj zo{#Z7zqh`={=xF{@;?ITvI8Qk07`&o^XAQyCrp^|XE}JJC&nyQJEcmT>PM+&wdSSw zy;(JFRaHqyWF2!P64|(>9CGiT06hYug_vL;V*lSny;p7`#UQ zC>c?%(eFL}Sem?Gy2hWbe$^f*VxqRTwwIrJ>ZxxnS+eAqf{B-ID{rzh=#v z^De*q@;3kkn6Kzo%o+(qqtT{q+qNy5G-=Xb0T2t3Iy2>&Spg8Sj}Jg@ZEfw+ii(Ow za)j$WMP^l+k>PCgyPVcZH7M&FsU+o{c6$6~`d9N-<=B zlUS?(h~hfA0Pu~_Vg zbQLx4L1HHQSBrwJsyw^iiXLmSsz#-XWKl_a0aP<8NlhJV$yrbS@!!e;w8dFS?$#TE z$O?eS{vo5efam!`hF7py!Abfz3kZutPD?Ypa$uP|$8i+^PRjtqYzLVIP{RI+1E@AF zQgb`)Q3-GyS1AJ!d1ILckQPnLrQ(!F!=gsiC|pa0>8ThaRUq%v36IaJN@A6>q%t!B zAX@7p6?UMf9u=!pZnYijywcr2i(RE>&#}ydPa=g;+2@SA<|Lbu1w9e z&lrGu(grdk5Vx*531l6BwAN*mPpb5{UJ%j>?VAlw(&}N1=h4M;VsZ=*M0qKJn1=@( zJRIQV#C06{mxKKbMm^(KZ4RvLX?9&K>j1=pe>-Z+PFrbg1d!8FV0%08UMtWX1=^zG zcO5Yx91}rAnYg|;v397$_u)>SZNk0AlgN)YO)i%3`5( zv+9Z1q%p!{?MV)QIu2}XP3~=xygPwrWJT}$q}Q2jBaO+&@%UUH4qqDx%;*F3_ww+Q z?o%Y}v#KI#NLGTIS{|jE>@yCaY<%EI1rf`10jMWs(iQ_AI0QV`D7xS&#<0#nECI`e z`qBd;d2Y812EJDg1hW7{83T|NFU-lcvxZI%x7Pq`o8xv)+}qLeq6`gZ6_u9gOR>dY z9;}nALDugHH?#WzPYmZV$gl0GDOI;okkD-XxVM&>)p!X&tc=5A53%$W<$0`c;jr`| z@FC^VDT>}kZ*BFYXDeyDP-dA-S$}#VaNjWC(}iL%G7}|Z43sG^?EMzt>wCpOpz?+o zof2RTTee&kO^xdSP*z)RYeE+8N!j9qquZZJe;& z(2aRadR$uSCdG~tEN5$;SGRsNy+IBIIl#LUfH8UE)mS`JW&c*z4Ku33oMIpr)kX`P z6h;2#K45=`Nk-5Bmeh+bD>-C1vv_u+gTT`xfoVkof@~i*!^&q_$3UzDH&gFG2QY6B zu(eIy&9chaz z)xbL~se;j4tFoiE^ink}2PbW)$j=o4zZ(sd`b8L~>paP<29UL9LTp8W!bcOk@5S$o zSjVW2vOO)3({jQfKaUb0C;Xo>5nH0b5lT4g1j4*{kf2sE%}QFHor;QFQV!f(Va_8n zEAulFKonuVayt-`-8sA95uRcI!vnyKB4APhaA5%u@Q5F>Hz=2f?jvO=(zPQzK&$-g zTg3F&wI|7_Pu?OcUYDMlRQ8wW0sb)_xG;@D#*6_-zLde@Rmqt%qF z8&IK@*l0let&Fn1s@h!U9{Dx~Hpu=&~v;FTs|Kb`L-g(oz|V}^O8VG_Xl`8dHAx~#85 zgI9EOMgfT2_Iql9ryJN0>r+!B<+;Fp6~Lu^AuJQ5A){2d4TNzDa{m4(@Kl4iv%|F8 zu9t%pSG(UV1s)uh9=AU00Hn-FRM4gFW3;`E$EDlic8122tha{M#|wO^KY@oAfuj=i zqnmy_%%i@8$DY<0YTIKt)yd;sH_Ee7OVn!ZYL1;UycNBEK&E!?8rRp7|7~m$atQ(S@e4Jiki?T znJS;20Lh6%t>6GLGllywHXjCpfGoU+=$a8!5 z6fk$UsM4(1K0+IX5K8^Pp2?!e=w!(lfXE9|PDF3dm%Ogr3lo`|!-f{%vhDJxHueHZyS%pBy!3m4`IYECMY$Mot6VD2vVdFcUb ze4dziMh=2gCF>Z-iWj!275Lh2pdn%$JR}3A)X!n%Sm3<8B=eAMv%0Pm-`diFrf@8| zFHDfMwIHO(ek$ydKxFEV*Oqzl$fO`ja=8=_@WI2ty|wYvEA=ub4fe3nKK#Fo5hK%j z=^bWH=Fd6+S$Q)4avVa=53NBN6$tB8`d5M?mg8Wi8Gfpq=(=BtPZvX9R$j z)r$c{@?eb0)%}3wBLO;YMJgXQM{&)Db~MR4Fj8Ais4lg!`aHm`!~Ft?q(amBOA|1^ zT0J$zcr0R1gh_gMzBR;1O6f|W^#U6w za-c}FB=nloF??xbJNC9lWok=3_NH7|z z!!Oa9TEx1OmPOmEPjyzBzd5GkGYL1#ZaJoAHTw z3R&Wj?pJpKn^ZOl%BlT=0^kql0OkHQXl1mkJ3uEzJYzkcNy%3*Q< zJz@+lyOuJ{dSSI;;EL_Q!EmZ-6T}mLI_YM7zZ|%uENM8?`qh+ww&F;KKJ)aH+EP;) zpBdoAi&OJ3NXA06W;W{+Iz}%JR+A@$ROWJ6cOEcI_L@Jl9+`ZsSqC7q&js!Cx@4Oe zY|{!FmgNF3o&!wEPxk(UZ5{ae9)ooc<}5}nZ_Bz`q`BmKIs9!#KBo3hz7>m>YOdW0 zysyEydQ3?SNJ|>Duu6$>2^Q1XN5*n5dOd(@LLz{Uc4@n~SM~+|cT95s_|Y(r%ieCo z?pFO*TN&9rPOGhMECgWA5HDVwmP9@g&wYo0`wwX^8tbKmP+k)OXk^mKW5J1Los0p< zDKD(1gQCzJj;ZXtkY@dO6_1++iw8!La39-8#3ej`Ny z0d#_@TA{$<-pM@rkRe2G1?l3kaB~M4@97w3zS)Ko zDp5zf;30@oB3WNQCmwPDUf7vYABqXPHCS%8atzeZsjG_+kh$H|*YvI3kEdbDW~9TZ z9v$vnT1VCa$gCH3C=6V+1E}su4bMwV@WV>ryJg9n_m74y%-JBWx~2r&R8)$3oz&-+ z7=m(quB-41IX(fe2p)N%VCVO@hOoN66Nf@PI_0}XWCpC3>TJBK5PcPA+TFenSYEGx zmZLO&DgH!>a*quMzA;$;1P@UZY;mHj1dy2tG<5+t?1`@vsVq{`VFTCnPu@?ZjEC0ICKu=6))9VOKG-YVU_Fu3(2hlTJa-_1Wz`WhD$bbEW52rDMHrBq+WYfI zfgARUqtsW4$#!LaVD&ih7-6SM)&a=u#AyE&ZD0OfJh&zg*3m-1hKayY3uC29`Gn=Fhi>ithd4ZRxVtcEkN8^?fDyTIss~W@S__WG zx6x<`F%dizs4%q_G0$a#yjVGn-kT>Dy?tS7zf=Ef8oF@v2kkN=u;qK=(#z8=~PyJP?ZoDLfPi+wo&C&v&^!?Z%uxeat z^tsooFgXJ$J+G92o`IU6pUDHGxX8hd)I|DmdBDr#I1m$2ITq$o`C4m2b}WHGxuDN4 z_Tcpy`6#5M9gU)u_MkOK3MbsSDTKc@bgCxqk1O(E%caT9?ec!^tN|X4r?Di}ZYovf zdwBd}B!{mL)Mg*8F|&ZISE?}O#3C_Z&Q9Qslc@`gl=uAFxa5&ipeKBYw zs`964d6-@5;2!7rME-iD6SsUA!bzHlVlbDNda-J{=mjWmG*;PpeY1FYS(1*}m5R$SA-_d(6jw zl)Au3X{+leB)9F*Lgb)TEy8k!9_w@qFoMHp2YT_3&nDvmS}I8{U}ZmA>rK?=WT&WJ zrQ9U?uGj~xpIL}e1*u8)(lyqdN*z-*m@0sj3z_O z^QE1@-`@|rlrGgYts>VhEVRhrAf(#o?@0~vj{WGkhZ zBx4M-OTBn@N}jMZqE>da^SE#emvRmg)%(FvVBrv;ug05wjAUu%_cI1SH0ifa0$uvjl3|{;MS2ak85{v_?XUvjvECF z%Q!qlNtf~R{J~CqeJvo`YOsiw_*g3cKC0smVMm0OZ)?0AODTxMGKxBQ3GVzi2%yzVblEDlyP& z_3`yEh6ORQKt8&p0Atgv!km;tPxaMn0A%Ea$p!ZCVPc`=H{UMh@sldi5bcWbxOYbf zezrS|n1(CXJ;2D}naM%S8>VhVvl`1r>cc1qX`~kQysdPwPuAE#PCqTQK6LFa2uHq9 zp)EbHkE39k7>#}ZL=<1%*oFpSM}~Hh|N8lXUc4@IzYOAzi?cnH<@*t%pS@ zEOlrHCoOMKp_kTD8UyVKr$WHEc4IqUtJ6qhR7v%qK;r%@(-TN!nNPOX$(RaL9`mcV z{$)h@N})7Nu&(y~QJ3H{> zo#O5c23s0m7?^`srspHqp>4ss>eI$vEvLZ2($5(MAhii^YXdI$Sp0=jTVFUQS&Pz2 zZ`c&VI@(mmP=*9KVPR7`^njts8DFK>0LY9Nro3w}9p`cDJ`VJn?-iqXTr1^Ba~%F& z--(+)3<;?oQc=AYCnn3!tMuXVNkQbv@VQX%&}$1gtQakx$V!C*alfhw<9k~>x@9y9Dp8UC+0W16rxOAx>tc57A^DhD z`muY%oo?T20AzRK^pcbRIRHG<2rMWC?v)wT(h|bAKMp02PSLqwv7f^qF3iKFgOYpJ z?T)Xvw$B)V)Lyot6r$N>4J|b%^+U$;UA5v1PadfP{;MRpL!l$e3)`I^+1&v#uS-2y zUFwTYwSVBefN)fAP{XKBuCJL-yjL<4{jHS@Prqe~y)p_QHKEvf5?La`bRgB^b-=R? zz|u&?KD1N1^R@ohZPGAXgtdncl}eRBxgP9=}}O&Zv#SD{po@}LJ_tMFsND8F#R z>e*DX8bC^wWHs2RrP@Aer!-g&ca;M(i^W4IrE)AB!wv6;u;pZzz(%5vrhRrIjg596 z&n@@idt-71FOBwc(M}F!E299Qo=Rfz=6VvqSq&hkJrt##K%k)0J$~0HZv7$mzMm}p znD(%5XzId>+6eyM(1oK6Yi9%&I&pqz&?BtgPb$vA;2=($Q`ArAEw*-vOhoUMP&7X95d}ig7(!jiTx`lmCL4(Mqtm%QcG(6c7a$DI7!q5!l4TwExh_A@qvAvKl}vUKoqIv$Kw7*6U$WGiCyePRGie zMrGCmNSZJUP>h6QMHN_SV>Awy-!pYxma)m}WHo@yPR6a@^AgAyfYe@jX_v0mR23_8 zv3NsvJR>uzjdjdD50~y&%ou>O%?mTDGR)LBQ#TtxVP*_x6oAZ3+^y@31hNi5ES{HH z!#WdQnBEx8dUnp->h$!IbpTS@8FuX=HM_-(DAS>SSnMG)W6WgTtOigf1>;_uO#&GM zke!#UXt9b?HJD*9t6msggN3D7f`@FkEW2uD3_$7d!t98a1q620p`Gz%Yg-pU*?MPs z$+$8BAbQnVVK^MF_WS+9wgx@V%W4*)o|=>YW)!qD8=O3oJS-y&hr{8nKp-#@0G(VK z0ziii&>Lu3Hu|z0j++<)CF)Uj4W()kfC}pB>fS6ZEv-@xG99*poj%QaOlDP~(M`(c z-wz!+^hrfU#cX;FTTcOq7)Jomh7B8jIBnXro3dFz?%4Dtuzvmer!TtbqWfh4qCMhi zV<6i2Knz5$d(B_IeEAoyyY9N*6ciMgJNi^HCp$qxBX3Smd}dYKnFhwwr%#8UfByMz z%%4C14a)kZ)G3(?W16nn{VSxx=tBU?n>lmlkSCsa;^8r4#>~vgv48rhUDaW?J(I-F zkV?^L6uWlqTDNfF!aLWkTUQ5wPAw$>5&O`Gsh9;2F%Thz7$+DA1PT`~UOeTt+irWZ zyu7@G>56WptdPL|{ritT_uO*}9)9@YO&uK_k^xO;Clh$|Uxh83ffWErYK&g_O2@tz zEL*m0&a7Fp7FShO4fOfM?GRd?mvn6-&q>WNx9ZKE?W#(WCCMQYiJ-Q&_SnjmEC1*2 zyYGH0Zu^k>q9aYo10(RH4L}6A6c!>?MnL+`cfND})mLAAeN|P}jDmuKKwe%RBrrMg z$eHz|l(vx^)J}}2xAZ}(Di(_&91f$oxw)gdx_a%ZRjZzV?6Jq_#3cfbWcv{NkO58Z zJCh49$kc)$BzK;?F=8RX9`gJBd1J?p?K^w+?D12kOu4A6tZYUg5GeC_JeG|kx$7<; zCkuPoX=_0yS=%HW4j+$3qwUSj&7Z7Tv0}xhO`DGF-o2a7#V4DG;yz^e5c`<&z7m?m z!psRkN)Qo{$hD_`=|g%uDZ{h2xuk~3o*@7c!_bESLm-lXBWE6F)n$i)p?{SiBF2$eM+zF629Fc0AgL!wrAdq=0f+vT>?|wxR_4Ut z`>!kkLIRKkBFRlx0w*H?l7mnJi{z=v!PWb3NAH}097Gb3BxaIabxXi;LWLQlT&s9@Edj0Z%n$j+2zVd zr0e1?3xIBx>deT0;^Vj|rbYmn>y{{_xL5tL=3z9GY3&3muqz`)v=#yg+}q#pQBM}w zjQ{iJPhN=ZZ~ot!J5ZD8Hkk?Hy!aQ#3NFI#JPlHLDEIx`$GqdTl{I(csf^+e-vmVu zLiE?)FvTvueCOzRd~0uS&sA$bf4R2S7488HmG}4jx)Row1VJ#j2ArQ)xgXBK1V0a6 zLG|<|OVS6$c33PKWgufqOD)bF!B71~kb{CA&Ve$J)q0i_ZBTg&*6yz3jY+xh?PXPW zH2q`FV$>{*X-dive{aiM+@VLLjAc6N4mGdarf4-P?uW#9TmcyEOa)mbNg>n(?G6=bOn+)$KgV9+O`1nLT!YTJ!1 z7F>&kY4DG&t*ov-nwTs;14wEyov6{CeFb=Ku!A@tnmPM7r++I$GLcOIGUy+bu1M## zu~t;p^2Iv=s~E$1y!_-l;AfjHgTJlqei;`R*Q4G`F1<9<>a=OG^0ZFpIOy*9m<6!_ zS>FysSUPfmWh9nak&Z?H+WQvJPR^v%N2insC@$0>f)kreN3*<*YYPEFAcPFh%fDRg zrHqIlH%)4R7~Db&WR(>oni&Ay_t@9rQ)M6g?^8ux{A@0q^POE2-34gh<%Uoz{YPYG%O7ssXxKGQIZ%^in<}F^qDh_%j9o zU>W1+_;_VaZtj*gz(h+gR6_2#GLRPw-XwLdgO~Y|2EWzNz(C}C5TdtBp0fh5XW0!cTc}m`S^H4zAWj9}5wzhJDQOg94tX$FJT}Q?mj-W9C4yycGb! zK)g)jX}A6rTy$*f0CeniZb*!_1j8qY*(7UP{!&R`%3G|3YI;^VS6uk(PAK{>JYY+^W`IXR)K82L&fgn@uiCtwXr zCO751$E(>(_nF^oiKi;Ey>=nG%L6RB0`~(eX~p)4d2Z9;6lOpr@F&;5RtRBv2>@g& zi$FmL!{psh{Je!5TH<-EH#s;P>ePhwB6)jn`hrmoZcbE~9whW90=2y;Nap?*Gq7jh z+1Hjeh81rR12)6@&E0pXSbpyt&9UctuKIe5A99Tbw&D+uE%r5jbg)m99izDw031+f zhg7BmpbgoyJxQ~eh4=j|>S8y_L2`4-0>b-B(^q{;LR3B$`~6=)O6(pdM$64&qjbPu z-f(6~86ASrCEULkpfn=*5=G!3cRTS7EeL}g_zegCI!HzVs7VKs?Gr1y#~>uwig$yU z{jo}RIC{6e-Aq;}sw6O+7-27CXkbD_Qvbmb{NQZRL(=nV{ZU?n-q-`v3y?2U^gtc0{aGu@BXd+>dk) z-;q}w_`vEQL_YD#k66U!w=)f~D^pPD8i3REx2|)Lu4se)A-)ILUnI+1kL1Lr=*en5 z@@dRJv1)I6dd0A5e@~IbAov8PRUMSUX^1eh5Fznlm2^1&A_yg`%`9@!uFA5Bovd|hwXqffLa`0WerKLh+}+bVIG6K9rqjeb_okpddzu-U{>O?=uCoHiGwAb zm)EMyaPjW^o~QEViOTN0%kj`tNgy9gMXb*NnL-M#vQr=N+tcHq#m9oMj@ z%cf+USCZY*w4RNp&#x%+u3=&hvPILaMJ?ZBZ_r62WM&cD2TJ19&Zb;K03)kw2teQ* zCd{3KQQLV^l~|seq-Kymxi?pRUqI#I;9gf0l=Vp&k{%aaC1mqPrILBOc5HfGv_@o( zYof1f->K}j@duwawSu5_dF@a*K`Q^1mv8QjnE&KMRXX4%yq<7;WEt{%E?x!?Cti1L zkMo|xmBp6HyNs#eC(8OG)yZdC&Aka|RxvApXj^|0Nww|P@BFvp)w9|cS>~&3W@Yl* z9%mDct96An0ki57E{1oE4pKr3%^Dy3mW&PvRC2v9NHYa~ne4HoHOCNg)=(QcP7%XF zf@-SqB+UrDd3ci1#$u6^Hkm`=!W(lLLy}qV#QMIPMXZ@9W`b8F}gI}Rh3_9tqUlJ$F_LomMB5-uY3OXSs2 zAHqWs$k>*wwp3Gz#D^J+n=i-RqYG$-op+R2ioj6kg~wE>$frlquV(Zy}NB zH83ZlkRJBZPmt*9A79WDi~O)-Y`8KUy2=<%^ZvHF>V?v>H1Y*9lZxT^R(VedX z_No)FGQFj)C@(`iVI>L&NokrzbqS#DAZ;7N_u1`#ekKXLdJQ6+Hfo}>vhwJ5pz?6- z7}FD}DfrT7_)V6?`tEfBLDx*jAkKu~M#vhes2t#9+M?lzFcF8;neBxv;n({Iz7q@8 zfH%nw3n2$n#tdZY+@oFgw^f<3g!lr(FD~{tQk7h+zunn@PN?L_zH7Z@bMqfgH`^Sm z;-Y;&jB2v3C?d(9U!nP}w;EMQv#2K?6hQV1nsAG392X&=R8=^+J?;B~o%ySgdE$kF z7#rir=2JxYP(_(V{mUAmps)5Tq{8`lz*kQYIRdmg{cy&vXPmMC*465B(0n8O#UWyq zhxU-Dke)@_w%9#gW+^&eU>*7`Rh-cC<1dF^{K?)DS|5vBTa?s3HbdIrdch||^>L`8 z=4!Enn;*JIsu+0&7<`V&=1VCr<_M>f*Y@Rju{$KRa`Dd0gW}{$3t8(n%xSA>lbwGX zzV$d=fcR5g$c1c4LnkI0Tz9%Y@BA@*(yGUcqj|gi1;TddhhP)_O??1DdG8L4yDJm+ zd8dow>EBopV)b3p*<5II*)e3QX2(jx{D)S;6cur?-WJN^(>G02-t_3}+jG5?7>l4A zbb$=up6_U_Ohks!C7M)EwquyxhF$HEVynrSs|fHW z;F+F3MC%OR$lae_r30EGz$cTmP=u6kRn*YoSc4>S=?IDy*;rNc4cASAU#=sEn3i?w zqquQMp-|MI#*$)!nrs^j{J4cc!kyc6jq*|Z>|K4CVCTrUNWofS17UGLMd<$NUh^?_ zrZfv+cSLpR`|h9s&sMO@J3j-RZD~bt2JKdcSSs~=fH(>iO|yyhx}6c4x~z$5;VZvP zLZ#(o=a(4q!s`*82E=gm$Tt;M)r5-)k}-a7a6}=#dMaN)whB{I=SD&LaCFb!oAjnW z=%&}LuCjxFUQ}d{_t?LZ>}-AE2j3NW2(6@;>?nVxNZ@llMqSciO;dnc2@m4$9w;Gp zmAl}uuWbF4?dkGD!6*GBAJg##=3TD<&*y?Qof6_Q`{vPjQUxt}FxlUlgqSN~K~b-W zfv$ZsNpPxJUu_dKrBl_tX2cBM$rmmDWT^;|_RGVKJ|-b0)u!=QdVVr7Yw=UKoRE0{ z4hn%&EN>>&fD=$q%KoehQZr`TkG>inRGP+SId8qlj=lWpN%|c?nv=1I0IT~4%iFij z2ABEW;9znv^*+2m=C1hk_K+y`UY2_9%d5W^F@ie$Mta13FL10WHH%l5>i^W4d1}e} zYRH*{sql5{Wr$+<_)zCzTq+Y=_EO%nP#Ly((>Niq;$Nn&feHnFm$ia3q%NW67PGx`L@PwMimgBBF27d zYFM=H&)&0!IaSdcFL;qG^mpPZ6?W_=N7siLWdTfRBog1c&e4xbil1!^WKq-BRvxT; z@%f18vDc1q*4q>`y6lSC_M<)w`R?L&fT?u~C8_}^ObuK}7o5uLUfj|HX$ek7ID zpxx=VT-uRsL^Q`)57Wy@AbG|9)LA((rb{-f6-Stc(V!aW+fnGvPW+?z=D zME5~W-@$jsAxYzlm2xoy%&RwcK4)i5l1VnJEMNGs>iy5vDIAvPf`r8&==6jcU2#l_(@L(`Dy8l=wfSQ0X`+$i8;yHa^;3$r3_2bQRjtHIZUnk%*Oq!cM1tBRQc;%TP2 z%yVsFz8=@)w6^azJlt8&v-j&ZS)OvK3yDTv7*-f3pqIB0!nCGN?e z%~+Tc@h||=NtbBP#Pz>c_tjD4)jeX@48=8AaUTY(&$!6w7|)a3bp-bXq%mMJiu8qQx^t2ffILuqb>lS_S5PscEl^)K9o)H zRZGCQ+-nre&Gf|8&*j&SW)o?jY`d8n%D+1D2^=~n*)B1C^{bys$kZUn+}K1;sFhjr zS?)iu{f{g}<#kc#6RTiMiXBSmrx3orm4rpxZB*@10{YTK0)yWOtrXWTAmdX)z#b8* zcSlx9QM(&^e?7g&+b-T>|8BR8FJjyS^km%Rv9HN+2Tes=4;H8FZh~I;0t?h8xdu;U} zBoZn7_-^IyO;xA+V{<0WbR^G#5y7{`F+RRry3B~lz*F5}5)t{zM6| z;o?Po;kdd^Ju?<}H&|p6I*E>6C{)I&ii#He-X)TgrLX$+lMl_uiTcEDWx)Wjg@wm%S~4b7S#+Ljoc%^3T>hjZcA_8!Gk-p*yI_lyTVCt{g~t0%wg)YwEV6T#G5N-hMGOeHo9GfS?~VD1I1m#+QUm5`p4ko~Jwk<#}NGkJN9FIi;vV|W@|AshFTmA$1U zZ`c21WV{{yQ`%x?#%L-!&jKhaaov6QbmZl%i`{x;zo%A85E0B?bw}1jS4Ufpw`BFMVt{oT9sg-_FKnr`svG)OLy_x;c22WgV0BM|0zv zTVW@X=lI7m;skmJNU}3!hW@?KTD?=(F5Eq{(IsTXq7xUFu}~|TK3Kd)T;b_P4Wn_! zgzsbxE@yiWoNJMO+WHeqvykVJ<{-kXUB}+{ku`#u115w2mgcAE#RtOi@}pv$><5DT z7dGn6LCgq!A=j0bw6W|YVp?NX;^xOV&VS^#?Ss>-j$mFoePnJ|$8*0Yg8cz+T~d)( zB&?ZtJXl0ypCy5H(Bk&3<(Vf7E{GQP?lih&{8VX%mPK%5M_q-~o~+8s+L7gU=XFw= zabVe79)9Ck8@5JKKU;#6@mM%EDsyeJmL_bB8&52jOEtIHSheH0v`bJFT_HN27aei8^t2 zmvo$IRR$2I#)zUV?$eDjqSRsMs_vF*;8l|T{OLx3XI}GO6Ux{)(p(5~a?Za}S+b?X zc^_8ysP`=Z^NH3abgJ+y@_&1JmVs3-b;j|r0*0xR=|c_SF%)|ez-XKG3v2FUHd*5w z^LS{aZ&d8P(2LR+2&82vW;&EebJ93j)zqP*DkMuj(>ZtgXKt`ulHMmxnA(%IX$6bv zo~cG8%1HlljhmLfMCeif-Rw|lB+gou5PD?b+5@Cy$ZfV>%KC^()dm(QSW*diKBR{%-qZl zXUX!Zt@iFX<3_}y7IUuL8*=e z?Op;{js6*K5Qeq z(D`dUN)tNgjk|X#vRy*u;&Q5qT<>1~j6JARD(}J-@-PEVz2Mi$IutoQLj{l-s|4!! z7t>cgOTDX6$`eE!X)WI|?PD>_2KBexGT=q{$}M~kJlp&R$K+lL`<5owZG^?}I#Kg1 zq_d^L70UR~q?rnPZX{QEyvscRZ+(@`wF>w4i4M2HJsCF~#&nye!5pq8rL0jQ-j`TT zCQuuUK&S#@kFjN3RdW*8i=4gKA&ly7ZRAgRg^@(Fls$R#co`K5($|@)JwMq=SY?() z1WcCOm2u^s;hWfG*sOGV9ZuyI3QE`TMiiOuudv9hZM5=NLzXkNh?CN+gYKzI5LqHg z0&Np?gajIs6|UvhMxuw=2fme^=qXQc0!1i5X>8@OO6Q;>-4yuGs7WT_LE`*Wp4mWe zy4GQC=8Uk$STj+J7b`_hzbuMhqk7Gt*1`MNVRLvPUw7$=_22Bbhlr-gibT=0h-{p> z4C+|!y)Q_UVG_7XsirPP=a!HeV^6au;l!lm@lf=%ud*#|XAwS+s9&)^vST8IbWJpT zd3ktCYxSx)GNP)|J!d@9yHL-d*Vc^4X-A+R;&wTHAo2UtTFTwQT7c~SbKcR2#_<+H zg$K2(+ryPaBxEI6BZf!fqshk#_PuY!3@H?>ILNM|`HFy6*{&ftIqyKUWzts(nfq&Q z_OBrfQpVq#KuKMoc|#I zmsy4tMpf=iQ9L|W|teN~Zt_RE(EXhlW3*;4Ww*CSm!xRB*6mZS0Okx?cf z>+9AAxuc{l8kU4zKyK0lY`)^ugE$N`Hy4SX(ifmh9wnNi2#UFrZhjrhxXq@Sw?8V= zjLJfl0kc_gt9P8S|DbmG$q8$#S(Q9C+2eV|yoo3@ovL*MJ>m9N;nK{ArLnH33Dv#Q z8V3aHd(&}cm@2-pena@FMRvxc-PBlUhO7$%8@#^ReY5hL`SkHNnNjR0nKay3`w_Fx z<;;}}86$oa;aeILeB;8&U&PjO_H2K!|J1@*VDgf)UP0YOGf}hfu<13b)6_W~pba#Z z3-OZX$zYO8#3`QkjEi)dv#EGAw($ALOC+PE>Nub@khC*`NW|DL5J4{xCJ0X}Egl!Wp3d+>33r}&Kjr1+pB>7^g$sou|$nU;_E|vU64A*#D zq&xn$V;`ZE6O0TaA#j|WK&kbKU1pt7P@t+sK-9$Ht$YPkiePM5|;IHtL zq%VhB$-`yD(N5SkIVrbjk$?$^)sESiZ6Tet(&9>ZLh<@(N@8Optb=;Z2F59_V3;Hv zNntErb_N^mCWHK!u`qBbL;Y4=6fX6k2w-yaZUVX}!qZ2$=CDXaoAG`+KoP~hM7b)G z!IAXhpKGyn-ZPyd65PPL$=~tTBV|Uf=*-wz7ov`Fyd|wt(YyqVSZl~^N?)eMc<{xP zsdmf#0laYA@&Z2m{*pTJFa{D6DmXJR5?$=h8vii*<|#;25g-u7J7HmACfG#tE10*b zX#Ifz9$LD($B3Y3WA{_Wws1+e?a(T9{Q^>S9W8N8*0p2F9$TOKFtUHgDb_T9S$K*) z=`K*0x9>YlB+~$3-9zPiytS73j7P|yu=~0QEg7fDkzND{_dkXl>?3b zLE*E%veU2QIUwlCN~N9VzWSH8)e=4S1J{pYvdVuZ{VV7*?S|jFe4J)KO?A9KMF&~K zAK_Y}kU?gOy-r|)jVuThum>ySG+uu%H{Gh;W(E6y9aJx@ixfN;W3$g+mp`% zYmb3`()g6r$7{n{ax(N z?*^s{{TLp3u#+zL5!TfIz0N!F@*g?dRITIQ6l_&`zG8{dglWu{xT>^3UMx}V?tJE> z!TvHap-1m9^~V7!>85}O`X0FWC8#zE z#1ru*TmdP0x!QK~;c%zhSG=L3w&S|}yNJ5O`dcP-ft;6tr)1AQ>jv@FWC1Lmh^fdu zE2Yvo$QP=Q6o#e@JF+$uvje1^i1I{T-@qT|zzIQWvQGUM$6-6351Ir=HV1FyRLee* z8nQ;oAUUzy>h#W1&7ah{*%VlML|)S#6(W(6Ky>=X(O~j(1QpK1C_^O3r(j1$pJDm@ ztga)aNb)fc$6EQ5_--DF2H}YE(ro?dh;v-=4fYXDpet#xETtjN0)G|hHXg5h;-9(H z!CbAOt%Iuf&~UA;Za=GSbpP^(8sR{X4fAjVY0!PsqMTYSEUDu`TS%5lew0W3aDoh( z)6z_yAx z-CPOZqf){|;M60DlN!9Y@1Q!pEwB*n*-0_96w-*G9h`C`M3Z>Aon~xp`}^j$$mx#5 zH=Y(;Idjn}rLhxpo?)xim_skr?nlwW6`^Oq9h|LR91~qz9QZ4&+s>7Y#q;Tdb-OE9 z4z8ERBb&;JFMTxt0T3m(@NdUy3^5*%%(Yi4!4u-M91bEi`Silec{a=&)b$&*z>Rqf z%zd|D_y=sbLrCUFrcDzQ6Y=lipOO^vot?z}JvjLRH$rJJIJC(i;DfXm@$u2o8)fA_ zPk`%y=%Us5Q#g-X<~WV3x^nfU%rZ+{^YZ9>icr3#d|cP_oiTs>XD@ehHj=1(sAOdD z0>GH~2h^g@8`fH&opV;8-BQ!_NY&?|_c0ERk+gBWA}vPVZAErEwW88MA{N=qmX-%c zTypNKXbuz#eI?~@lfo`{-t6E1@xsX6yF360X$nSUJe=np8+$Ce`Tr#Z=v5Ea;mF%& z&tc`@Xjyk=<>TnOuSW{2->ee&qt(fpsr?J;`#uq$20IMBzSYD|CESzBx`}MX=ryC| zo2j_lqsC*CD8P=N(GrGJb34=?&@n;53W@{B19a*C@dfIaymyO?=MJN?++cN1-&X)n0S(O5@v}D zQ)f$tV(eP4)60&Xy%T>43kj`ZN?)~Iqn7<2y~)8pvEcvcb3xB9dW2OH0arL<@&I3L zv+J|Xyk*bz$W|&*=bU}zC+N?c5zbWGQusaIF8d$V%sY6`w%_Zx2MD}kGqe)a=coc- z3GC+JBubu-%6&+e^uDyrQ%h$0x2RApQ4+8GgQUt&*A7GY6GY6fa%b&9eC>|6>7@NE z#i3z$HGfH?#sBOf=0>i;Uj@Ud5Q3No9;5@r z3hN?E8cG#K2KV=?5b`Jd`0+KGGEXGBWu$p))D*QnDdzHr{`=AR#N=eq)YLohDK$0q zqvdR+MQaq3IHLs2xBEJIFJ1tJk_8$30U)u6i1@O99)Q-DRg-umz53!RQp>ITj!h zfElz!^${ZTm`fi={4e+)8@cL<4Nu>p?1`Sg41Du&)N!kiF^6q3I3|?{@R+h>=;Cj@ z{df}owhI?}7~&O(RX|0=ml(<@egC2RRNgd@I{RfW}vR~ zBJOr&MAET#9D{eJmeuZ8^)g)t*>$L&MXb|-njVG;Q~U&>mVt-}6Wf+(UTioc|H!Nt z)UrWZC&{1@aJ|T^S>w+b2RiqcXg3m-dW>Y>*GO8jSFr2eEeRdz)|i%%0BVmazh^+W zIGN^IrlxOSr87$zX^9byd|zpbiZjwR7m zP`oo(*kQy9G74mGXrYxCi1bXR;(_6^|@PCo#M t1lNDno0Iv^;`!?T>(l(dEuN3`@_pF9cby4*(H>TSvVyvNsjS)8{{dwe9!>xN literal 0 HcmV?d00001 diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComi.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComi.kt new file mode 100644 index 000000000..5290e2a9e --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComi.kt @@ -0,0 +1,354 @@ +package eu.kanade.tachiyomi.extension.all.namicomi + +import android.app.Application +import android.content.SharedPreferences +import androidx.preference.ListPreference +import androidx.preference.PreferenceScreen +import androidx.preference.SwitchPreferenceCompat +import eu.kanade.tachiyomi.extension.all.namicomi.dto.ChapterListDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.EntityAccessMapDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.EntityAccessRequestDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.EntityAccessRequestItemDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.MangaDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.MangaListDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.PageListDto +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.interceptor.rateLimit +import eu.kanade.tachiyomi.source.ConfigurableSource +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.encodeToString +import okhttp3.CacheControl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okio.IOException +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +abstract class NamiComi(final override val lang: String, private val extLang: String = lang) : + ConfigurableSource, HttpSource() { + + override val name = "NamiComi" + override val baseUrl = NamiComiConstants.webUrl + override val supportsLatest = true + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + private val helper = NamiComiHelper(lang) + + final override fun headersBuilder() = super.headersBuilder().apply { + set("Referer", "$baseUrl/") + set("Origin", baseUrl) + } + + override val client = network.client.newBuilder() + .rateLimit(3) + .addNetworkInterceptor { chain -> + val response = chain.proceed(chain.request()) + + if (response.code == 402) { + response.close() + throw IOException(helper.intl["error_payment_required"]) + } + + return@addNetworkInterceptor response + } + .build() + + private fun sortedMangaRequest(page: Int, orderBy: String): Request { + val url = NamiComiConstants.apiSearchUrl.toHttpUrl().newBuilder() + .addQueryParameter("order[$orderBy]", "desc") + .addQueryParameter("availableTranslatedLanguages[]", extLang) + .addQueryParameter("limit", NamiComiConstants.mangaLimit.toString()) + .addQueryParameter("offset", helper.getMangaListOffset(page)) + .addQueryParameter("includes[]", NamiComiConstants.coverArt) + .addQueryParameter("includes[]", NamiComiConstants.primaryTag) + .addQueryParameter("includes[]", NamiComiConstants.secondaryTag) + .addQueryParameter("includes[]", NamiComiConstants.tag) + .build() + + return GET(url, headers, CacheControl.FORCE_NETWORK) + } + + // Popular manga section + + override fun popularMangaRequest(page: Int): Request = + sortedMangaRequest(page, "views") + + override fun popularMangaParse(response: Response): MangasPage = + mangaListParse(response) + + // Latest manga section + + override fun latestUpdatesRequest(page: Int): Request = + sortedMangaRequest(page, "publishedAt") + + override fun latestUpdatesParse(response: Response): MangasPage = + mangaListParse(response) + + private fun mangaListParse(response: Response): MangasPage { + if (response.code == 204) { + return MangasPage(emptyList(), false) + } + + val mangaListDto = response.parseAs() + val mangaList = mangaListDto.data.map { mangaDataDto -> + helper.createManga( + mangaDataDto, + extLang, + preferences.coverQuality, + ) + } + + return MangasPage(mangaList, mangaListDto.meta.hasNextPage) + } + + // Search manga section + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + if (query.startsWith(NamiComiConstants.prefixIdSearch)) { + val mangaId = query.removePrefix(NamiComiConstants.prefixIdSearch) + + if (mangaId.isEmpty()) { + throw Exception(helper.intl["invalid_manga_id"]) + } + + // If the query is an ID, return the manga directly + val url = NamiComiConstants.apiSearchUrl.toHttpUrl().newBuilder() + .addQueryParameter("ids[]", query.removePrefix(NamiComiConstants.prefixIdSearch)) + .addQueryParameter("includes[]", NamiComiConstants.coverArt) + .build() + + return GET(url, headers, CacheControl.FORCE_NETWORK) + } + + val tempUrl = NamiComiConstants.apiSearchUrl.toHttpUrl().newBuilder() + .addQueryParameter("limit", NamiComiConstants.mangaLimit.toString()) + .addQueryParameter("offset", helper.getMangaListOffset(page)) + .addQueryParameter("includes[]", NamiComiConstants.coverArt) + + val actualQuery = query.replace(NamiComiConstants.whitespaceRegex, " ") + if (actualQuery.isNotBlank()) { + tempUrl.addQueryParameter("title", actualQuery) + } + + val finalUrl = helper.filters.addFiltersToUrl( + url = tempUrl, + filters = filters.ifEmpty { getFilterList() }, + extLang = extLang, + ) + + return GET(finalUrl, headers, CacheControl.FORCE_NETWORK) + } + + override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response) + + // Manga Details section + + override fun getMangaUrl(manga: SManga): String = + "$baseUrl/$extLang/title/${manga.url}/${helper.titleToSlug(manga.title)}" + + /** + * Get the API endpoint URL for the entry details. + */ + override fun mangaDetailsRequest(manga: SManga): Request { + val url = (NamiComiConstants.apiMangaUrl + manga.url).toHttpUrl().newBuilder() + .addQueryParameter("includes[]", NamiComiConstants.coverArt) + .addQueryParameter("includes[]", NamiComiConstants.organization) + .addQueryParameter("includes[]", NamiComiConstants.tag) + .addQueryParameter("includes[]", NamiComiConstants.primaryTag) + .addQueryParameter("includes[]", NamiComiConstants.secondaryTag) + .build() + + return GET(url, headers, CacheControl.FORCE_NETWORK) + } + + override fun mangaDetailsParse(response: Response): SManga { + val manga = response.parseAs() + + return helper.createManga( + manga.data!!, + extLang, + preferences.coverQuality, + ) + } + + // Chapter list section + + /** + * Get the API endpoint URL for the first page of chapter list. + */ + override fun chapterListRequest(manga: SManga): Request { + return paginatedChapterListRequest(manga.url, 0) + } + + /** + * Required because the chapter list API endpoint is paginated. + */ + private fun paginatedChapterListRequest(mangaId: String, offset: Int): Request { + val url = NamiComiConstants.apiChapterUrl.toHttpUrl().newBuilder() + .addQueryParameter("titleId", mangaId) + .addQueryParameter("includes[]", NamiComiConstants.organization) + .addQueryParameter("limit", "500") + .addQueryParameter("offset", offset.toString()) + .addQueryParameter("translatedLanguages[]", extLang) + .addQueryParameter("order[volume]", "desc") + .addQueryParameter("order[chapter]", "desc") + .toString() + + return GET(url, headers, CacheControl.FORCE_NETWORK) + } + + /** + * Requests information about gated chapters (requiring payment & login). + */ + private fun accessibleChapterListRequest(chapterIds: List): Request { + return POST( + NamiComiConstants.apiGatingCheckUrl, + headers, + chapterIds + .map { EntityAccessRequestItemDto(it, NamiComiConstants.chapter) } + .let { helper.json.encodeToString(EntityAccessRequestDto(it)) } + .toRequestBody(), + CacheControl.FORCE_NETWORK, + ) + } + + override fun chapterListParse(response: Response): List { + if (response.code == 204) { + return emptyList() + } + + val mangaId = response.request.url.toString() + .substringBefore("/chapter") + .substringAfter("${NamiComiConstants.apiMangaUrl}/") + + val chapterListResponse = response.parseAs() + val chapterListResults = chapterListResponse.data.toMutableList() + var offset = chapterListResponse.meta.offset + var hasNextPage = chapterListResponse.meta.hasNextPage + + // Max results that can be returned is 500 so need to make more API + // calls if the chapter list response has a next page. + while (hasNextPage) { + offset += chapterListResponse.meta.limit + + val newRequest = paginatedChapterListRequest(mangaId, offset) + val newResponse = client.newCall(newRequest).execute() + val newChapterList = newResponse.parseAs() + chapterListResults.addAll(newChapterList.data) + + hasNextPage = newChapterList.meta.hasNextPage + } + + // If there are no chapters, don't attempt to check gating + if (chapterListResults.isEmpty()) { + return emptyList() + } + + val gatingCheckRequest = accessibleChapterListRequest(chapterListResults.map { it.id }) + val gatingCheckResponse = client.newCall(gatingCheckRequest).execute() + val accessibleChapterMap = gatingCheckResponse.parseAs() + .data?.attributes?.map ?: emptyMap() + + return chapterListResults.mapNotNull { + val isAccessible = accessibleChapterMap[it.id]!! + when { + // Chapter can be viewed + isAccessible -> helper.createChapter(it) + // Chapter cannot be viewed and user wants to see locked chapters + preferences.showLockedChapters -> { + helper.createChapter(it).apply { + name = "${NamiComiConstants.lockSymbol} $name" + } + } + // Ignore locked chapters otherwise + else -> null + } + } + } + + override fun getChapterUrl(chapter: SChapter): String = + "$baseUrl/$extLang/chapter/${chapter.url}" + + override fun pageListRequest(chapter: SChapter): Request { + val chapterId = chapter.url + val url = "${NamiComiConstants.apiUrl}/images/chapter/$chapterId?newQualities=true" + return GET(url, headers, CacheControl.FORCE_NETWORK) + } + + override fun pageListParse(response: Response): List { + val chapterId = response.request.url.pathSegments.last() + val pageListDataDto = response.parseAs().data ?: return emptyList() + + val hash = pageListDataDto.hash + val prefix = "${pageListDataDto.baseUrl}/chapter/$chapterId/$hash" + + val urls = if (preferences.useDataSaver) { + pageListDataDto.low.map { prefix + "/low/${it.filename}" } + } else { + pageListDataDto.source.map { prefix + "/source/${it.filename}" } + } + + return urls.mapIndexed { index, url -> + Page(index, url, url) + } + } + + override fun imageUrlParse(response: Response): String = "" + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + val coverQualityPref = ListPreference(screen.context).apply { + key = NamiComiConstants.getCoverQualityPreferenceKey(extLang) + title = helper.intl["cover_quality"] + entries = NamiComiConstants.getCoverQualityPreferenceEntries(helper.intl) + entryValues = NamiComiConstants.getCoverQualityPreferenceEntryValues() + setDefaultValue(NamiComiConstants.getCoverQualityPreferenceDefaultValue()) + summary = "%s" + } + + val dataSaverPref = SwitchPreferenceCompat(screen.context).apply { + key = NamiComiConstants.getDataSaverPreferenceKey(extLang) + title = helper.intl["data_saver"] + summary = helper.intl["data_saver_summary"] + setDefaultValue(false) + } + + val showLockedChaptersPref = SwitchPreferenceCompat(screen.context).apply { + key = NamiComiConstants.getShowLockedChaptersPreferenceKey(extLang) + title = helper.intl["show_locked_chapters"] + summary = helper.intl["show_locked_chapters_summary"] + setDefaultValue(false) + } + + screen.addPreference(coverQualityPref) + screen.addPreference(dataSaverPref) + screen.addPreference(showLockedChaptersPref) + } + + override fun getFilterList(): FilterList = + helper.filters.getFilterList(helper.intl) + + private inline fun Response.parseAs(): T = use { + helper.json.decodeFromString(body.string()) + } + + private val SharedPreferences.coverQuality + get() = getString(NamiComiConstants.getCoverQualityPreferenceKey(extLang), "") + + private val SharedPreferences.useDataSaver + get() = getBoolean(NamiComiConstants.getDataSaverPreferenceKey(extLang), false) + + private val SharedPreferences.showLockedChapters + get() = getBoolean(NamiComiConstants.getShowLockedChaptersPreferenceKey(extLang), false) +} diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiConstants.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiConstants.kt new file mode 100644 index 000000000..082909a1d --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiConstants.kt @@ -0,0 +1,63 @@ +package eu.kanade.tachiyomi.extension.all.namicomi + +import eu.kanade.tachiyomi.lib.i18n.Intl +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +object NamiComiConstants { + const val mangaLimit = 20 + + val whitespaceRegex = "\\s".toRegex() + val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss+SSS", Locale.US) + .apply { timeZone = TimeZone.getTimeZone("UTC") } + + const val lockSymbol = "🔒" + + // Language codes used for translations + const val english = "en" + + // JSON discriminators + const val chapter = "chapter" + const val manga = "title" + const val coverArt = "cover_art" + const val organization = "organization" + const val tag = "tag" + const val primaryTag = "primary_tag" + const val secondaryTag = "secondary_tag" + const val imageData = "image_data" + const val entityAccessMap = "entity_access_map" + + // URLs & API endpoints + const val webUrl = "https://namicomi.com" + const val cdnUrl = "https://uploads.namicomi.com" + const val apiUrl = "https://api.namicomi.com" + const val apiMangaUrl = "$apiUrl/title" + const val apiSearchUrl = "$apiMangaUrl/search" + const val apiChapterUrl = "$apiUrl/chapter" + const val apiGatingCheckUrl = "$apiUrl/gating/check" + + // Search prefix for title ids + const val prefixIdSearch = "id:" + + // Preferences + private const val coverQualityPref = "thumbnailQuality" + fun getCoverQualityPreferenceKey(extLang: String): String = "${coverQualityPref}_$extLang" + fun getCoverQualityPreferenceEntries(intl: Intl) = + arrayOf(intl["cover_quality_original"], intl["cover_quality_medium"], intl["cover_quality_low"]) + fun getCoverQualityPreferenceEntryValues() = arrayOf("", ".512.jpg", ".256.jpg") + fun getCoverQualityPreferenceDefaultValue() = getCoverQualityPreferenceEntryValues()[0] + + private const val dataSaverPref = "dataSaver" + fun getDataSaverPreferenceKey(extLang: String): String = "${dataSaverPref}_$extLang" + + private const val showLockedChaptersPref = "showLockedChapters" + fun getShowLockedChaptersPreferenceKey(extLang: String): String = "${showLockedChaptersPref}_$extLang" + + // Tag types + private const val tagGroupContent = "content-warnings" + private const val tagGroupFormat = "format" + private const val tagGroupGenre = "genre" + private const val tagGroupTheme = "theme" + val tagGroupsOrder = arrayOf(tagGroupContent, tagGroupFormat, tagGroupGenre, tagGroupTheme) +} diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiFactory.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiFactory.kt new file mode 100644 index 000000000..7dcb6af2c --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiFactory.kt @@ -0,0 +1,96 @@ +package eu.kanade.tachiyomi.extension.all.namicomi + +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceFactory + +class NamiComiFactory : SourceFactory { + override fun createSources(): List = listOf( + NamiComiEnglish(), + NamiComiArabic(), + NamiComiBulgarian(), + NamiComiCatalan(), + NamiComiChineseSimplified(), + NamiComiChineseTraditional(), + NamiComiCroatian(), + NamiComiCzech(), + NamiComiDanish(), + NamiComiDutch(), + NamiComiEstonian(), + NamiComiFilipino(), + NamiComiFinnish(), + NamiComiFrench(), + NamiComiGerman(), + NamiComiGreek(), + NamiComiHebrew(), + NamiComiHindi(), + NamiComiHungarian(), + NamiComiIcelandic(), + NamiComiIrish(), + NamiComiIndonesian(), + NamiComiItalian(), + NamiComiJapanese(), + NamiComiKorean(), + NamiComiLithuanian(), + NamiComiMalay(), + NamiComiNepali(), + NamiComiNorwegian(), + NamiComiPanjabi(), + NamiComiPersian(), + NamiComiPolish(), + NamiComiPortugueseBrazil(), + NamiComiPortuguesePortugal(), + NamiComiRussian(), + NamiComiSlovak(), + NamiComiSlovenian(), + NamiComiSpanishLatinAmerica(), + NamiComiSpanishSpain(), + NamiComiSwedish(), + NamiComiThai(), + NamiComiTurkish(), + NamiComiUkrainian(), + ) +} + +class NamiComiArabic : NamiComi("ar") +class NamiComiBulgarian : NamiComi("bg") +class NamiComiCatalan : NamiComi("ca") +class NamiComiChineseSimplified : NamiComi("zh-Hans", "zh-hans") +class NamiComiChineseTraditional : NamiComi("zh-Hant", "zh-hant") +class NamiComiCroatian : NamiComi("hr") +class NamiComiCzech : NamiComi("cs") +class NamiComiDanish : NamiComi("da") +class NamiComiDutch : NamiComi("nl") +class NamiComiEnglish : NamiComi("en") +class NamiComiEstonian : NamiComi("et") +class NamiComiFilipino : NamiComi("fil") +class NamiComiFinnish : NamiComi("fi") +class NamiComiFrench : NamiComi("fr") +class NamiComiGerman : NamiComi("de") +class NamiComiGreek : NamiComi("el") +class NamiComiHebrew : NamiComi("he") +class NamiComiHindi : NamiComi("hi") +class NamiComiHungarian : NamiComi("hu") +class NamiComiIcelandic : NamiComi("is") +class NamiComiIrish : NamiComi("ga") +class NamiComiIndonesian : NamiComi("id") +class NamiComiItalian : NamiComi("it") +class NamiComiJapanese : NamiComi("ja") +class NamiComiKorean : NamiComi("ko") +class NamiComiLithuanian : NamiComi("lt") +class NamiComiMalay : NamiComi("ms") +class NamiComiNepali : NamiComi("ne") +class NamiComiNorwegian : NamiComi("no") +class NamiComiPanjabi : NamiComi("pa") +class NamiComiPersian : NamiComi("fa") +class NamiComiPolish : NamiComi("pl") +class NamiComiPortugueseBrazil : NamiComi("pt-BR", "pt-br") +class NamiComiPortuguesePortugal : NamiComi("pt", "pt-pt") +class NamiComiRussian : NamiComi("ru") +class NamiComiSlovak : NamiComi("sk") +class NamiComiSlovenian : NamiComi("sl") +class NamiComiSpanishLatinAmerica : NamiComi("es-419") +class NamiComiSpanishSpain : NamiComi("es", "es-es") +class NamiComiSwedish : NamiComi("sv") +class NamiComiThai : NamiComi("th") +class NamiComiTurkish : NamiComi("tr") +class NamiComiUkrainian : NamiComi("uk") diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiFilters.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiFilters.kt new file mode 100644 index 000000000..88223e1ba --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiFilters.kt @@ -0,0 +1,289 @@ +package eu.kanade.tachiyomi.extension.all.namicomi + +import eu.kanade.tachiyomi.extension.all.namicomi.dto.ContentRatingDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.StatusDto +import eu.kanade.tachiyomi.lib.i18n.Intl +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import okhttp3.HttpUrl + +class NamiComiFilters { + + internal fun getFilterList(intl: Intl): FilterList = FilterList( + HasAvailableChaptersFilter(intl), + ContentRatingList(intl, getContentRatings(intl)), + StatusList(intl, getStatus(intl)), + SortFilter(intl, getSortables(intl)), + TagsFilter(intl, getTagFilters(intl)), + TagList(intl["content"], getContents(intl)), + TagList(intl["format"], getFormats(intl)), + TagList(intl["genre"], getGenres(intl)), + TagList(intl["theme"], getThemes(intl)), + ) + + private interface UrlQueryFilter { + fun addQueryParameter(url: HttpUrl.Builder, extLang: String) + } + + private class HasAvailableChaptersFilter(intl: Intl) : + Filter.CheckBox(intl["has_available_chapters"]), + UrlQueryFilter { + + override fun addQueryParameter(url: HttpUrl.Builder, extLang: String) { + if (state) { + url.addQueryParameter("hasAvailableChapters", "true") + url.addQueryParameter("availableTranslatedLanguages[]", extLang) + } + } + } + + private class ContentRating(name: String, val value: String) : Filter.CheckBox(name) + private class ContentRatingList(intl: Intl, contentRating: List) : + Filter.Group(intl["content_rating"], contentRating), + UrlQueryFilter { + + override fun addQueryParameter(url: HttpUrl.Builder, extLang: String) { + state.filter(ContentRating::state) + .forEach { url.addQueryParameter("contentRatings[]", it.value) } + } + } + + private fun getContentRatings(intl: Intl) = listOf( + ContentRating(intl["content_rating_safe"], ContentRatingDto.SAFE.value), + ContentRating(intl["content_rating_restricted"], ContentRatingDto.RESTRICTED.value), + ContentRating(intl["content_rating_mature"], ContentRatingDto.MATURE.value), + ) + + private class Status(name: String, val value: String) : Filter.CheckBox(name) + private class StatusList(intl: Intl, status: List) : + Filter.Group(intl["status"], status), + UrlQueryFilter { + + override fun addQueryParameter(url: HttpUrl.Builder, extLang: String) { + state.filter(Status::state) + .forEach { url.addQueryParameter("publicationStatuses[]", it.value) } + } + } + + private fun getStatus(intl: Intl) = listOf( + Status(intl["status_ongoing"], StatusDto.ONGOING.value), + Status(intl["status_completed"], StatusDto.COMPLETED.value), + Status(intl["status_hiatus"], StatusDto.HIATUS.value), + Status(intl["status_cancelled"], StatusDto.CANCELLED.value), + ) + + data class Sortable(val title: String, val value: String) { + override fun toString(): String = title + } + + private fun getSortables(intl: Intl) = arrayOf( + Sortable(intl["sort_alphabetic"], "title"), + Sortable(intl["sort_number_of_chapters"], "chapterCount"), + Sortable(intl["sort_number_of_follows"], "followCount"), + Sortable(intl["sort_number_of_likes"], "reactions"), + Sortable(intl["sort_number_of_comments"], "commentCount"), + Sortable(intl["sort_content_created_at"], "publishedAt"), + Sortable(intl["sort_views"], "views"), + Sortable(intl["sort_year"], "year"), + Sortable(intl["sort_rating"], "rating"), + ) + + class SortFilter(intl: Intl, private val sortables: Array) : + Filter.Sort( + intl["sort"], + sortables.map(Sortable::title).toTypedArray(), + Selection(5, false), + ), + UrlQueryFilter { + + override fun addQueryParameter(url: HttpUrl.Builder, extLang: String) { + if (state != null) { + val query = sortables[state!!.index].value + val value = if (state!!.ascending) "asc" else "desc" + + url.addQueryParameter("order[$query]", value) + } + } + } + + internal class Tag(val id: String, name: String) : Filter.TriState(name) + + private class TagList(collection: String, tags: List) : + Filter.Group(collection, tags), + UrlQueryFilter { + + override fun addQueryParameter(url: HttpUrl.Builder, extLang: String) { + state.forEach { tag -> + if (tag.isIncluded()) { + url.addQueryParameter("includedTags[]", tag.id) + } else if (tag.isExcluded()) { + url.addQueryParameter("excludedTags[]", tag.id) + } + } + } + } + + private fun getContents(intl: Intl): List { + val tags = listOf( + Tag("drugs", intl["content_warnings_drugs"]), + Tag("gambling", intl["content_warnings_gambling"]), + Tag("gore", intl["content_warnings_gore"]), + Tag("mental-disorders", intl["content_warnings_mental_disorders"]), + Tag("physical-abuse", intl["content_warnings_physical_abuse"]), + Tag("racism", intl["content_warnings_racism"]), + Tag("self-harm", intl["content_warnings_self_harm"]), + Tag("sexual-abuse", intl["content_warnings_sexual_abuse"]), + Tag("verbal-abuse", intl["content_warnings_verbal_abuse"]), + ) + + return tags.sortIfTranslated(intl) + } + + private fun getFormats(intl: Intl): List { + val tags = listOf( + Tag("4-koma", intl["format_4_koma"]), + Tag("adaptation", intl["format_adaptation"]), + Tag("anthology", intl["format_anthology"]), + Tag("full-color", intl["format_full_color"]), + Tag("oneshot", intl["format_oneshot"]), + Tag("silent", intl["format_silent"]), + ) + + return tags.sortIfTranslated(intl) + } + + private fun getGenres(intl: Intl): List { + val tags = listOf( + Tag("action", intl["genre_action"]), + Tag("adventure", intl["genre_adventure"]), + Tag("boys-love", intl["genre_boys_love"]), + Tag("comedy", intl["genre_comedy"]), + Tag("crime", intl["genre_crime"]), + Tag("drama", intl["genre_drama"]), + Tag("fantasy", intl["genre_fantasy"]), + Tag("girls-love", intl["genre_girls_love"]), + Tag("historical", intl["genre_historical"]), + Tag("horror", intl["genre_horror"]), + Tag("isekai", intl["genre_isekai"]), + Tag("mecha", intl["genre_mecha"]), + Tag("medical", intl["genre_medical"]), + Tag("mystery", intl["genre_mystery"]), + Tag("philosophical", intl["genre_philosophical"]), + Tag("psychological", intl["genre_psychological"]), + Tag("romance", intl["genre_romance"]), + Tag("sci-fi", intl["genre_sci_fi"]), + Tag("slice-of-life", intl["genre_slice_of_life"]), + Tag("sports", intl["genre_sports"]), + Tag("superhero", intl["genre_superhero"]), + Tag("thriller", intl["genre_thriller"]), + Tag("tragedy", intl["genre_tragedy"]), + Tag("wuxia", intl["genre_wuxia"]), + ) + + return tags.sortIfTranslated(intl) + } + + private fun getThemes(intl: Intl): List { + val tags = listOf( + Tag("aliens", intl["theme_aliens"]), + Tag("animals", intl["theme_animals"]), + Tag("cooking", intl["theme_cooking"]), + Tag("crossdressing", intl["theme_crossdressing"]), + Tag("delinquents", intl["theme_delinquents"]), + Tag("demons", intl["theme_demons"]), + Tag("genderswap", intl["theme_genderswap"]), + Tag("ghosts", intl["theme_ghosts"]), + Tag("gyaru", intl["theme_gyaru"]), + Tag("harem", intl["theme_harem"]), + Tag("mafia", intl["theme_mafia"]), + Tag("magic", intl["theme_magic"]), + Tag("magical-girls", intl["theme_magical_girls"]), + Tag("martial-arts", intl["theme_martial_arts"]), + Tag("military", intl["theme_military"]), + Tag("monster-girls", intl["theme_monster_girls"]), + Tag("monsters", intl["theme_monsters"]), + Tag("music", intl["theme_music"]), + Tag("ninja", intl["theme_ninja"]), + Tag("office-workers", intl["theme_office_workers"]), + Tag("police", intl["theme_police"]), + Tag("post-apocalyptic", intl["theme_post_apocalyptic"]), + Tag("reincarnation", intl["theme_reincarnation"]), + Tag("reverse-harem", intl["theme_reverse_harem"]), + Tag("samurai", intl["theme_samurai"]), + Tag("school-life", intl["theme_school_life"]), + Tag("supernatural", intl["theme_supernatural"]), + Tag("survival", intl["theme_survival"]), + Tag("time-travel", intl["theme_time_travel"]), + Tag("traditional-games", intl["theme_traditional_games"]), + Tag("vampires", intl["theme_vampires"]), + Tag("video-games", intl["theme_video_games"]), + Tag("villainess", intl["theme_villainess"]), + Tag("virtual-reality", intl["theme_virtual_reality"]), + Tag("zombies", intl["theme_zombies"]), + ) + + return tags.sortIfTranslated(intl) + } + + // Tags taken from: https://api.namicomi.com/title/tags + internal fun getTags(intl: Intl): List { + return getContents(intl) + getFormats(intl) + getGenres(intl) + getThemes(intl) + } + + private data class TagMode(val title: String, val value: String) { + override fun toString(): String = title + } + + private fun getTagModes(intl: Intl) = arrayOf( + TagMode(intl["mode_and"], "and"), + TagMode(intl["mode_or"], "or"), + ) + + private class TagInclusionMode(intl: Intl, modes: Array) : + Filter.Select(intl["included_tags_mode"], modes, 0), + UrlQueryFilter { + + override fun addQueryParameter(url: HttpUrl.Builder, extLang: String) { + url.addQueryParameter("includedTagsMode", values[state].value) + } + } + + private class TagExclusionMode(intl: Intl, modes: Array) : + Filter.Select(intl["excluded_tags_mode"], modes, 1), + UrlQueryFilter { + + override fun addQueryParameter(url: HttpUrl.Builder, extLang: String) { + url.addQueryParameter("excludedTagsMode", values[state].value) + } + } + + private class TagsFilter(intl: Intl, innerFilters: FilterList) : + Filter.Group>(intl["tags_mode"], innerFilters), + UrlQueryFilter { + + override fun addQueryParameter(url: HttpUrl.Builder, extLang: String) { + state.filterIsInstance() + .forEach { filter -> filter.addQueryParameter(url, extLang) } + } + } + + private fun getTagFilters(intl: Intl): FilterList = FilterList( + TagInclusionMode(intl, getTagModes(intl)), + TagExclusionMode(intl, getTagModes(intl)), + ) + + internal fun addFiltersToUrl(url: HttpUrl.Builder, filters: FilterList, extLang: String): HttpUrl { + filters.filterIsInstance() + .forEach { filter -> filter.addQueryParameter(url, extLang) } + + return url.build() + } + + private fun List.sortIfTranslated(intl: Intl): List = apply { + if (intl.chosenLanguage == NamiComiConstants.english) { + return this + } + + return sortedWith(compareBy(intl.collator, Tag::name)) + } +} diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiHelper.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiHelper.kt new file mode 100644 index 000000000..9f586cae0 --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiHelper.kt @@ -0,0 +1,182 @@ +package eu.kanade.tachiyomi.extension.all.namicomi + +import eu.kanade.tachiyomi.extension.all.namicomi.dto.AbstractTagDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.ChapterDataDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.ContentRatingDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.CoverArtDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.EntityDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.MangaDataDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.OrganizationDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.StatusDto +import eu.kanade.tachiyomi.extension.all.namicomi.dto.UnknownEntity +import eu.kanade.tachiyomi.lib.i18n.Intl +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.plus +import kotlinx.serialization.modules.polymorphic +import java.util.Locale + +class NamiComiHelper(lang: String) { + + val filters = NamiComiFilters() + + val json = Json { + isLenient = true + ignoreUnknownKeys = true + serializersModule += SerializersModule { + polymorphic(EntityDto::class) { + defaultDeserializer { UnknownEntity.serializer() } + } + } + } + + val intl = Intl( + language = lang, + baseLanguage = NamiComiConstants.english, + availableLanguages = setOf(NamiComiConstants.english), + classLoader = this::class.java.classLoader!!, + createMessageFileName = { lang -> Intl.createDefaultMessageFileName(lang) }, + ) + + /** + * Get the manga offset pages are 1 based, so subtract 1 + */ + fun getMangaListOffset(page: Int): String = (NamiComiConstants.mangaLimit * (page - 1)).toString() + + private fun getPublicationStatus(mangaDataDto: MangaDataDto): Int { + return when (mangaDataDto.attributes!!.publicationStatus) { + StatusDto.ONGOING -> SManga.ONGOING + StatusDto.CANCELLED -> SManga.CANCELLED + StatusDto.COMPLETED -> SManga.COMPLETED + StatusDto.HIATUS -> SManga.ON_HIATUS + else -> SManga.UNKNOWN + } + } + + private fun parseDate(dateAsString: String): Long = + NamiComiConstants.dateFormatter.parse(dateAsString)?.time ?: 0 + + /** + * Create an [SManga] from the JSON element with all attributes filled. + */ + fun createManga( + mangaDataDto: MangaDataDto, + lang: String, + coverSuffix: String?, + ): SManga { + val attr = mangaDataDto.attributes!! + + // Things that will go with the genre tags but aren't actually genre + val extLocale = Locale.forLanguageTag(lang) + + val nonGenres = listOfNotNull( + attr.contentRating + .takeIf { it != ContentRatingDto.SAFE } + ?.let { intl.format("content_rating_genre", intl["content_rating_${it.name.lowercase()}"]) }, + attr.originalLanguage + ?.let { Locale.forLanguageTag(it) } + ?.getDisplayName(extLocale) + ?.replaceFirstChar { it.uppercase(extLocale) }, + ) + + val organization = mangaDataDto.relationships + .filterIsInstance() + .mapNotNull { it.attributes?.name } + .distinct() + + val coverFileName = mangaDataDto.relationships + .filterIsInstance() + .firstOrNull() + ?.attributes?.fileName + + val tags = filters.getTags(intl).associate { it.id to it.name } + + val genresMap = mangaDataDto.relationships + .filterIsInstance() + .groupBy({ it.attributes!!.group }) { tagDto -> tags[tagDto.id] } + .mapValues { it.value.filterNotNull().sortedWith(intl.collator) } + + val genreList = NamiComiConstants.tagGroupsOrder.flatMap { genresMap[it].orEmpty() } + nonGenres + + val desc = (attr.description[lang] ?: attr.description["en"]) + .orEmpty() + + return SManga.create().apply { + initialized = true + url = mangaDataDto.id + description = desc + author = organization.joinToString() + status = getPublicationStatus(mangaDataDto) + genre = genreList + .filter(String::isNotEmpty) + .joinToString() + + mangaDataDto.attributes.title.let { titleMap -> + title = titleMap[lang] ?: titleMap.values.first() + } + + coverFileName?.let { + thumbnail_url = when (!coverSuffix.isNullOrEmpty()) { + true -> "${NamiComiConstants.cdnUrl}/covers/${mangaDataDto.id}/$coverFileName$coverSuffix" + else -> "${NamiComiConstants.cdnUrl}/covers/${mangaDataDto.id}/$coverFileName" + } + } + } + } + + /** + * Create the [SChapter] from the JSON element. + */ + fun createChapter(chapterDataDto: ChapterDataDto): SChapter { + val attr = chapterDataDto.attributes!! + val chapterName = mutableListOf() + + attr.volume?.let { + if (it.isNotEmpty()) { + chapterName.add("Vol.$it") + } + } + + attr.chapter?.let { + if (it.isNotEmpty()) { + chapterName.add("Ch.$it") + } + } + + attr.name?.let { + if (it.isNotEmpty()) { + if (chapterName.isNotEmpty()) { + chapterName.add("-") + } + chapterName.add(it) + } + } + + return SChapter.create().apply { + url = chapterDataDto.id + name = chapterName.joinToString(" ") + date_upload = parseDate(attr.publishAt) + } + } + + fun titleToSlug(title: String) = title.trim() + .lowercase(Locale.US) + .replace(titleSpecialCharactersRegex, "-") + .replace(trailingHyphenRegex, "") + .split("-") + .reduce { accumulator, element -> + val currentSlug = "$accumulator-$element" + if (currentSlug.length > 100) { + accumulator + } else { + currentSlug + } + } + + companion object { + val titleSpecialCharactersRegex = "[^a-z0-9]+".toRegex() + val trailingHyphenRegex = "-+$".toRegex() + } +} diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiUrlActivity.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiUrlActivity.kt new file mode 100644 index 000000000..c5d2b57da --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/NamiComiUrlActivity.kt @@ -0,0 +1,45 @@ +package eu.kanade.tachiyomi.extension.all.namicomi + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import kotlin.system.exitProcess + +/** + * Springboard that accepts https://namicomi.com/xx/title/yyy intents and redirects them to + * the main tachiyomi process. The idea is to not install the intent filter unless + * you have this extension installed, but still let the main tachiyomi app control + * things. + */ +class NamiComiUrlActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val pathSegments = intent?.data?.pathSegments + + // Supported path: /en/title/12345 + if (pathSegments != null && pathSegments.size > 2) { + val titleId = pathSegments[2] + val mainIntent = Intent().apply { + action = "eu.kanade.tachiyomi.SEARCH" + putExtra("query", NamiComiConstants.prefixIdSearch + titleId) + putExtra("filter", packageName) + } + + try { + startActivity(mainIntent) + } catch (e: ActivityNotFoundException) { + Log.e("NamiComiUrlActivity", e.toString()) + } + } else { + Toast.makeText(this, "This URL cannot be handled by the Namicomi extension.", Toast.LENGTH_SHORT).show() + Log.e("NamiComiUrlActivity", "Could not parse URI from intent $intent") + } + + finish() + exitProcess(0) + } +} diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/ChapterDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/ChapterDto.kt new file mode 100644 index 000000000..204e6c7f6 --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/ChapterDto.kt @@ -0,0 +1,20 @@ +package eu.kanade.tachiyomi.extension.all.namicomi.dto + +import eu.kanade.tachiyomi.extension.all.namicomi.NamiComiConstants +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +typealias ChapterListDto = PaginatedResponseDto + +@Serializable +@SerialName(NamiComiConstants.chapter) +class ChapterDataDto(override val attributes: ChapterAttributesDto? = null) : EntityDto() + +@Serializable +class ChapterAttributesDto( + val name: String?, + val volume: String?, + val chapter: String?, + val pages: Int, + val publishAt: String, +) : AttributesDto diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/CoverArtDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/CoverArtDto.kt new file mode 100644 index 000000000..e476c705d --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/CoverArtDto.kt @@ -0,0 +1,15 @@ +package eu.kanade.tachiyomi.extension.all.namicomi.dto + +import eu.kanade.tachiyomi.extension.all.namicomi.NamiComiConstants +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName(NamiComiConstants.coverArt) +class CoverArtDto(override val attributes: CoverArtAttributesDto? = null) : EntityDto() + +@Serializable +class CoverArtAttributesDto( + val fileName: String? = null, + val locale: String? = null, +) : AttributesDto diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/EntityAccessMapDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/EntityAccessMapDto.kt new file mode 100644 index 000000000..c277d127f --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/EntityAccessMapDto.kt @@ -0,0 +1,30 @@ +package eu.kanade.tachiyomi.extension.all.namicomi.dto + +import eu.kanade.tachiyomi.extension.all.namicomi.NamiComiConstants +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +typealias EntityAccessMapDto = ResponseDto + +@Serializable +@SerialName(NamiComiConstants.entityAccessMap) +class EntityAccessMapDataDto( + override val attributes: EntityAccessMapAttributesDto? = null, +) : EntityDto() + +@Serializable +class EntityAccessMapAttributesDto( + // Map of entity IDs to whether the user has access to them + val map: Map, +) : AttributesDto + +@Serializable +class EntityAccessRequestDto( + val entities: List, +) + +@Serializable +class EntityAccessRequestItemDto( + val entityId: String, + val entityType: String, +) diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/EntityDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/EntityDto.kt new file mode 100644 index 000000000..8699f1876 --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/EntityDto.kt @@ -0,0 +1,16 @@ +package eu.kanade.tachiyomi.extension.all.namicomi.dto + +import kotlinx.serialization.Serializable + +@Serializable +sealed class EntityDto { + val id: String = "" + val relationships: List = emptyList() + abstract val attributes: AttributesDto? +} + +@Serializable +sealed interface AttributesDto + +@Serializable +class UnknownEntity(override val attributes: AttributesDto? = null) : EntityDto() diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/MangaDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/MangaDto.kt new file mode 100644 index 000000000..baf3d0ff1 --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/MangaDto.kt @@ -0,0 +1,70 @@ +package eu.kanade.tachiyomi.extension.all.namicomi.dto + +import eu.kanade.tachiyomi.extension.all.namicomi.NamiComiConstants +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +typealias MangaListDto = PaginatedResponseDto + +typealias MangaDto = ResponseDto + +@Serializable +@SerialName(NamiComiConstants.manga) +class MangaDataDto(override val attributes: MangaAttributesDto? = null) : EntityDto() + +@Serializable +class MangaAttributesDto( + // Title and description are maps of language codes to localized strings + val title: Map, + val description: Map, + val slug: String, + val originalLanguage: String?, + val year: Int?, + val contentRating: ContentRatingDto? = null, + val publicationStatus: StatusDto? = null, +) : AttributesDto + +@Serializable +enum class ContentRatingDto(val value: String) { + @SerialName("safe") + SAFE("safe"), + + @SerialName("restricted") + RESTRICTED("restricted"), + + @SerialName("mature") + MATURE("mature"), +} + +@Serializable +enum class StatusDto(val value: String) { + @SerialName("ongoing") + ONGOING("ongoing"), + + @SerialName("completed") + COMPLETED("completed"), + + @SerialName("hiatus") + HIATUS("hiatus"), + + @SerialName("cancelled") + CANCELLED("cancelled"), +} + +@Serializable +sealed class AbstractTagDto(override val attributes: TagAttributesDto? = null) : EntityDto() + +@Serializable +@SerialName(NamiComiConstants.tag) +class TagDto : AbstractTagDto() + +@Serializable +@SerialName(NamiComiConstants.primaryTag) +class PrimaryTagDto : AbstractTagDto() + +@Serializable +@SerialName(NamiComiConstants.secondaryTag) +class SecondaryTagDto : AbstractTagDto() + +@Serializable +class TagAttributesDto(val group: String) : AttributesDto diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/OrganizationDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/OrganizationDto.kt new file mode 100644 index 000000000..ec864ef8e --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/OrganizationDto.kt @@ -0,0 +1,12 @@ +package eu.kanade.tachiyomi.extension.all.namicomi.dto + +import eu.kanade.tachiyomi.extension.all.namicomi.NamiComiConstants +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName(NamiComiConstants.organization) +class OrganizationDto(override val attributes: OrganizationAttributesDto? = null) : EntityDto() + +@Serializable +class OrganizationAttributesDto(val name: String) : AttributesDto diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/PageListDataDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/PageListDataDto.kt new file mode 100644 index 000000000..0c5485931 --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/PageListDataDto.kt @@ -0,0 +1,26 @@ +package eu.kanade.tachiyomi.extension.all.namicomi.dto + +import eu.kanade.tachiyomi.extension.all.namicomi.NamiComiConstants +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +typealias PageListDto = ResponseDto + +@Serializable +@SerialName(NamiComiConstants.imageData) +class PageListDataDto( + override val attributes: AttributesDto? = null, + val baseUrl: String, + val hash: String, + val source: List, + val high: List, + val medium: List, + val low: List, +) : EntityDto() + +@Serializable +class PageImageDto( + val size: Int?, + val filename: String, + val resolution: String?, +) diff --git a/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/ResponseDto.kt b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/ResponseDto.kt new file mode 100644 index 000000000..3aa5c99f4 --- /dev/null +++ b/src/all/namicomi/src/eu/kanade/tachiyomi/extension/all/namicomi/dto/ResponseDto.kt @@ -0,0 +1,27 @@ +package eu.kanade.tachiyomi.extension.all.namicomi.dto + +import kotlinx.serialization.Serializable + +@Serializable +class PaginatedResponseDto( + val result: String, + val data: List = emptyList(), + val meta: PaginationStateDto, +) + +@Serializable +class ResponseDto( + val result: String, + val type: String, + val data: T? = null, +) + +@Serializable +class PaginationStateDto( + val limit: Int = 0, + val offset: Int = 0, + val total: Int = 0, +) { + val hasNextPage: Boolean + get() = limit + offset < total +}