mirror of
https://github.com/hkalexling/Mango.git
synced 2026-04-25 00:00:52 -04:00
Compare commits
586 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a7d9eb3a1 | |||
| 1fb48648ad | |||
| 7ceb91f051 | |||
| 9ea4ced729 | |||
| 4c2f802e2e | |||
| 7258b3cece | |||
| bf885a8b30 | |||
| 98a0c54499 | |||
| cb3df432d0 | |||
| 47af6ee284 | |||
| 9fe269ab13 | |||
| 75a30a88e0 | |||
| 5daeac72cb | |||
| dc3ac42dec | |||
| 624283643c | |||
| 6ddbe8d436 | |||
| db5e99b3f0 | |||
| 405b958deb | |||
| e7c4123dec | |||
| 2d2486a598 | |||
| b6a1ad889e | |||
| f2d6d28a72 | |||
| 49425ff714 | |||
| f3eb62a271 | |||
| 2e91028ead | |||
| 19a8f3100b | |||
| 3b5e764d36 | |||
| 32ce26a133 | |||
| 31df058f81 | |||
| fe440d82d4 | |||
| 44636e051e | |||
| a639392ca0 | |||
| 17a9c8ecd3 | |||
| bbc0c2cbb7 | |||
| be46dd1f86 | |||
| ae583cf2a9 | |||
| ea35faee91 | |||
| 5b58d8ac59 | |||
| 30d5ad0c19 | |||
| d9dce4a881 | |||
| 2d97faa7c0 | |||
| 9ce8e918f0 | |||
| 8e4bb995d3 | |||
| 39a331c879 | |||
| df618704ea | |||
| 2fb620211d | |||
| 5b23a112b2 | |||
| e6dbeb623b | |||
| 872e6dc6d6 | |||
| 82c60ccc1d | |||
| ae503ae099 | |||
| 648cdd772c | |||
| 238539c27d | |||
| 1f5aed64f7 | |||
| f18f6a5418 | |||
| 0ed565519b | |||
| 3da5d9ba4e | |||
| 3a60286c3a | |||
| 9f6be70995 | |||
| caf4cfb6cd | |||
| 137e84dfb6 | |||
| 3b3a0738e8 | |||
| 55ccd928a2 | |||
| 10587f48cb | |||
| ea6cbbd9ce | |||
| 883e01bbdd | |||
| 5f59b7ee42 | |||
| eac274a211 | |||
| 0e4169cb22 | |||
| 28656695c6 | |||
| 61dc92838a | |||
| ce1dcff229 | |||
| 4f599fb719 | |||
| c831879c23 | |||
| 171b44643c | |||
| a353029fcd | |||
| 75e26d8624 | |||
| ebe2c8efed | |||
| b8ce1cc7f1 | |||
| 24c90e7283 | |||
| 9ffc34e8e6 | |||
| d1de8b7a4e | |||
| 7ae0577e4e | |||
| e9b1bccbc9 | |||
| 293fb84e1d | |||
| 9c07944390 | |||
| 173d69eb26 | |||
| 21d8d0e8a7 | |||
| 61e85dd49f | |||
| c778364ca2 | |||
| 7ecdb1c0dd | |||
| a5a7396edd | |||
| 461398d219 | |||
| 0d52544617 | |||
| c3736d222c | |||
| 2091053221 | |||
| 703e6d076b | |||
| 1817efe608 | |||
| 8814778c22 | |||
| 6ab885499c | |||
| 91561ecd6b | |||
| 3c399fac4e | |||
| a101526672 | |||
| eca47e3d32 | |||
| ab3386546d | |||
| 857c11be85 | |||
| b3ea3c6154 | |||
| 84168b4f53 | |||
| 59528de44d | |||
| a29d6754e8 | |||
| 167e207fad | |||
| 3b52d72ebf | |||
| dc5edc0c1b | |||
| 7fa8ffa0bd | |||
| 85b57672e6 | |||
| 9b111b0ee8 | |||
| 8b1c301950 | |||
| 3df4675dd7 | |||
| 312de0e7b5 | |||
| d57ccc8f81 | |||
| fea6c04c4f | |||
| 77df418390 | |||
| 750fbbb8fe | |||
| cfe46b435d | |||
| b2329a79b4 | |||
| 2007f13ed6 | |||
| f70be435f9 | |||
| 1b32dc3de9 | |||
| b83ccf1ccc | |||
| a68783aa21 | |||
| 86beed0c5f | |||
| b6c8386caf | |||
| 27cc669012 | |||
| 4b302af2a1 | |||
| ab29a9eb80 | |||
| e7538bb7f2 | |||
| ecaec307d6 | |||
| b711072492 | |||
| 0f94288bab | |||
| bd2ed1b338 | |||
| 1cd777d27d | |||
| 1ec8dcbfda | |||
| 8fea35fa51 | |||
| 234b29bbdd | |||
| edfef80e5c | |||
| 45ffa3d428 | |||
| 162318cf4a | |||
| d4b58e91d1 | |||
| 546bd0138c | |||
| ab799af866 | |||
| 3a932d7b0a | |||
| 57683d1cfb | |||
| d7afd0969a | |||
| 4eda55552b | |||
| f9254c49a1 | |||
| 6d834e9164 | |||
| 70259d8e50 | |||
| 0fa2bfa744 | |||
| cc33fa6595 | |||
| 921628ba6d | |||
| 1199eb7a03 | |||
| f075511847 | |||
| 80344c3bf0 | |||
| 8a732804ae | |||
| 9df372f784 | |||
| cf7431b8b6 | |||
| 974b6cfe9b | |||
| 4fbe5b471c | |||
| 33e7e31fbc | |||
| 72fae7f5ed | |||
| f50a7e3b3e | |||
| 66c4037f2b | |||
| 2c022a07e7 | |||
| 91362dfc7d | |||
| 97168b65d8 | |||
| 6e04e249e7 | |||
| 16397050dd | |||
| 3f73591dd4 | |||
| ec25109fa5 | |||
| 96f1ef3dde | |||
| b56e16e1e1 | |||
| 9769e760a0 | |||
| 70ab198a33 | |||
| 44a6f822cd | |||
| 2c241a96bb | |||
| 219d4446d1 | |||
| d330db131e | |||
| de193906a2 | |||
| d13cfc045f | |||
| a3b2cdd372 | |||
| f4d7128b59 | |||
| 663c0c0b38 | |||
| 57b2f7c625 | |||
| 9489d6abfd | |||
| 670cf54957 | |||
| 2e09efbd62 | |||
| 523195d649 | |||
| be47f309b0 | |||
| 03e044a1aa | |||
| 4eaf271fa4 | |||
| 4b464ed361 | |||
| a9520d6f26 | |||
| a151ec486d | |||
| 8f1383a818 | |||
| f5933a48d9 | |||
| 7734dae138 | |||
| 8c90b46114 | |||
| cd48b45f11 | |||
| bdbdf9c94b | |||
| 7e36c91ea7 | |||
| 9309f51df6 | |||
| a8f729f5c1 | |||
| 4e8b561f70 | |||
| e6214ddc5d | |||
| 80e13abc4a | |||
| fb43abb950 | |||
| eb3e37b950 | |||
| 0a90e3b333 | |||
| 4409ed8f45 | |||
| 291a340cdd | |||
| 0667f01471 | |||
| d5847bb105 | |||
| 3d295e961e | |||
| e408398523 | |||
| 566cebfcdd | |||
| a190ae3ed6 | |||
| 17d7cefa12 | |||
| eaef0556fa | |||
| 53226eab61 | |||
| ccf558eaa7 | |||
| 0305433e46 | |||
| d2cad6c496 | |||
| 371796cce9 | |||
| d9adb49c27 | |||
| f67e4e6cb9 | |||
| 60a126024c | |||
| da8a485087 | |||
| d809c21ee1 | |||
| ca1e221b10 | |||
| 44d9c51ff9 | |||
| 15a54f4f23 | |||
| 51806f18db | |||
| 79ef7bcd1c | |||
| 5cb85ea857 | |||
| 9807db6ac0 | |||
| 565a535d22 | |||
| c5b6a8b5b9 | |||
| c75c71709f | |||
| 11976b15f9 | |||
| 847f516a65 | |||
| de410f42b8 | |||
| 0fd7caef4b | |||
| 5e919d3e19 | |||
| 9e90aa17b9 | |||
| 0a8fd993e5 | |||
| 365f71cd1d | |||
| 601346b209 | |||
| e988a8c121 | |||
| bf81a4e48b | |||
| 4a09aee177 | |||
| 00c9cc1fcd | |||
| 51a47b5ddd | |||
| 244f97a68e | |||
| 8d84a3c502 | |||
| a26b4b3965 | |||
| f2dd20cdec | |||
| 64d6cd293c | |||
| 08dc0601e8 | |||
| 9c983df7e9 | |||
| efc547f5b2 | |||
| 995ca3b40f | |||
| 864435d3f9 | |||
| 64c145cf80 | |||
| 6549253ed1 | |||
| d9565718a4 | |||
| 400c3024fd | |||
| a703175b3a | |||
| 83b122ab75 | |||
| 1e7d6ba5b1 | |||
| 4d1ad8fb38 | |||
| d544252e3e | |||
| b02b28d3e3 | |||
| d7efe1e553 | |||
| 1973564272 | |||
| 29923f6dc7 | |||
| 4a261d5ff8 | |||
| 31d425d462 | |||
| a21681a6d7 | |||
| 208019a0b9 | |||
| 54e2a54ecb | |||
| 2426ef05ec | |||
| 25b90a8724 | |||
| cd8944ed2d | |||
| 7f0c256fe6 | |||
| 46e6e41bfe | |||
| c9f55e7a8e | |||
| 741c3a4e20 | |||
| f6da20321d | |||
| 2764e955b2 | |||
| 00c15014a1 | |||
| c6fdbfd9fd | |||
| e03bf32358 | |||
| bbf1520c73 | |||
| 8950c3a1ed | |||
| 17837d8a29 | |||
| b4a69425c8 | |||
| a612500b0f | |||
| 9bb7144479 | |||
| ee52c52f46 | |||
| daec2bdac6 | |||
| e9a490676b | |||
| 757f7c8214 | |||
| eed1a9717e | |||
| 8829d2e237 | |||
| eec6ec60bf | |||
| 3a82effa40 | |||
| 0b3e78bcb7 | |||
| cb4e4437a6 | |||
| 6a275286ea | |||
| 2743868438 | |||
| d3f26ecbc9 | |||
| f62344806a | |||
| b7b7e6f718 | |||
| 05b4e77fa9 | |||
| 8aab113aab | |||
| 371c8056e7 | |||
| a9a2c9faa8 | |||
| 011768ed1f | |||
| c36d2608e8 | |||
| 1b25a1fa47 | |||
| df7e2270a4 | |||
| 3c3549a489 | |||
| 8160b0a18e | |||
| a7eff772be | |||
| bf3900f9a2 | |||
| 6fa575cf4f | |||
| 604c5d49a6 | |||
| 7449d19075 | |||
| c5c9305a0b | |||
| fdceab9060 | |||
| c18591c5cf | |||
| bb5cb9b94c | |||
| fb499a5caf | |||
| 154d85e197 | |||
| 933617503e | |||
| 31c6893bbb | |||
| 171125e8ac | |||
| d81334026b | |||
| 2b3b2eb8ba | |||
| ffd5f4454b | |||
| cb25d7ba00 | |||
| 3abd2924d0 | |||
| 21233df754 | |||
| c61eb7554e | |||
| edd9a2e093 | |||
| 1f50785e8f | |||
| 70d418d1a1 | |||
| 45e20c94f9 | |||
| ca8e9a164e | |||
| 4da263c594 | |||
| d67a24809b | |||
| cd268af9dd | |||
| 135fa9fde6 | |||
| 77333aaafd | |||
| 1fad530331 | |||
| a1bd87098c | |||
| a389fa7178 | |||
| b5db508005 | |||
| 30178c42ef | |||
| b712db9e8f | |||
| dd9c75d1c9 | |||
| 2d150c3bf2 | |||
| 40f74ea375 | |||
| adf260bc35 | |||
| 432d6f0cd5 | |||
| 3de314ae9a | |||
| c1c8cca877 | |||
| 07965b98b7 | |||
| 5779d225f6 | |||
| bf18a14016 | |||
| 605dc61777 | |||
| def64d9f98 | |||
| 0ba2409c9a | |||
| 2b0cf41336 | |||
| c51cb28df2 | |||
| 2b079c652d | |||
| 68050a9025 | |||
| 54cd15d542 | |||
| 781de97c68 | |||
| c7be0e0e7c | |||
| 667d390be4 | |||
| 7f76322377 | |||
| 377c4c6554 | |||
| 952aa0c6ca | |||
| bd81c2e005 | |||
| b471ed2fa0 | |||
| 7507ab64ad | |||
| e4587d36bc | |||
| 7d6d3640ad | |||
| 3071d44e32 | |||
| 7a09c9006a | |||
| 959560c7a7 | |||
| ff679b30d8 | |||
| f7a360c2d8 | |||
| 1065b430e3 | |||
| 5abf7032a5 | |||
| 18e8e88c66 | |||
| 44336c546a | |||
| a4c6e6611c | |||
| 0b457a2797 | |||
| 653751bede | |||
| a02bf4a81e | |||
| 5271d12f4c | |||
| c2e2f0b9b3 | |||
| 72d319902e | |||
| bbd0fd68cb | |||
| 0fb1e1598d | |||
| 4645582f5d | |||
| ac9c51dd33 | |||
| f51d27860a | |||
| 4a7439a1ea | |||
| 00e19399d7 | |||
| cb723acef7 | |||
| 794bed12bd | |||
| bae8220e75 | |||
| 0cc5e1626b | |||
| da0ca665a6 | |||
| a91cf21aa9 | |||
| 39b2636711 | |||
| 2618d8412b | |||
| 445ebdf357 | |||
| 60134dc364 | |||
| aa70752244 | |||
| 0f39535097 | |||
| e086bec9da | |||
| dcdcf29114 | |||
| c5c73ddff3 | |||
| f18ee4284f | |||
| 0fbc11386e | |||
| a68282b4bf | |||
| e64908ad06 | |||
| af0913df64 | |||
| 5685dd1cc5 | |||
| af2fd2a66a | |||
| db2a51a26b | |||
| cf930418cb | |||
| 911848ad11 | |||
| 93f745aecb | |||
| 981a1f0226 | |||
| 8188456788 | |||
| 1eace2c64c | |||
| c6ee5409f8 | |||
| b05ed57762 | |||
| 0f1d1099f6 | |||
| 40a24f4247 | |||
| a6862e86d4 | |||
| bfc1b697bd | |||
| 276f62cb76 | |||
| 45a81ad5f6 | |||
| ce88acb9e5 | |||
| bd34b803f1 | |||
| 2559f65f35 | |||
| 93c21ea659 | |||
| 85ad38c321 | |||
| b6a204f5bd | |||
| f7b8e2d852 | |||
| 946017c8bd | |||
| ec5256dabd | |||
| 4e707076a1 | |||
| 66a3cc268b | |||
| 96949905b9 | |||
| 30c0199039 | |||
| 7a7cb78f82 | |||
| 8931ba8c43 | |||
| d50981c151 | |||
| df4deb1415 | |||
| aa5e999ed4 | |||
| 84d4b0c529 | |||
| d3e5691478 | |||
| 1000b02ae0 | |||
| 1f795889a9 | |||
| d33b45233a | |||
| 4f6df5b9a3 | |||
| 341b586cb3 | |||
| 9dcc9665ce | |||
| 1cd90926df | |||
| ac1ff61e6d | |||
| 6ea41f79e9 | |||
| dad02a2a30 | |||
| 280490fb36 | |||
| 455315a362 | |||
| df51406638 | |||
| 531d42ef18 | |||
| 2645e8cd05 | |||
| b2dc44a919 | |||
| c8db397a3b | |||
| 6384d4b77a | |||
| 1039732d87 | |||
| 011123f690 | |||
| e602a35b0c | |||
| 7792d3426e | |||
| b59c8f85ad | |||
| 18834ac28e | |||
| bf68e32ac8 | |||
| 54eb041fe4 | |||
| 57d8c100f9 | |||
| 56d973b99d | |||
| 670e5cdf6a | |||
| 1b35392f9c | |||
| c4e1ffe023 | |||
| 44f4959477 | |||
| 0582b57d60 | |||
| 83d96fd2a1 | |||
| 8ac89c420c | |||
| 968c2f4ad5 | |||
| ad940f30d5 | |||
| 308ad4e063 | |||
| 4d709b7eb5 | |||
| 5760ad924e | |||
| fff171c8c9 | |||
| 44ff566a1d | |||
| 853f422964 | |||
| 3bb0917374 | |||
| a86f0d0f34 | |||
| 16a9d7fc2e | |||
| ee2b4abc85 | |||
| a6c2799521 | |||
| 2370e4d2c6 | |||
| 32b0384ea0 | |||
| 50d4ffdb7b | |||
| 96463641f9 | |||
| ddbba5d596 | |||
| 2a04f4531e | |||
| a5b6fb781f | |||
| 8dfdab9d73 | |||
| 3a95270dfb | |||
| 2960ca54df | |||
| f5fe3c6b1c | |||
| a612cc15fb | |||
| c9c0818069 | |||
| 2f8efc382f | |||
| a0fb1880bd | |||
| a408f14425 | |||
| 243b6c8927 | |||
| ff3a44d017 | |||
| 67ef1f7112 | |||
| 5d7b8a1ef9 | |||
| a68f3eea95 | |||
| 220fc42bf2 | |||
| a45e6ea3da | |||
| 88394d4636 | |||
| ef1ab940f5 | |||
| 97a1c408d8 | |||
| abbf77df13 | |||
| 3b4021f680 | |||
| 68b1923cb6 | |||
| 3cdd4b29a5 | |||
| af84c0f6de | |||
| 85a65f84d0 | |||
| 5027a911cd | |||
| ac63bf7599 | |||
| 30b0e0b8fb | |||
| ddda058d8d | |||
| 46db25e8e0 | |||
| c07f421322 | |||
| 99a77966ad | |||
| d00b917575 | |||
| 4fd8334c37 | |||
| 3aa4630558 | |||
| cde5af7066 | |||
| eb528e1726 | |||
| 5e01cc38fe | |||
| 9a787ccbc3 | |||
| 8a83c0df4e | |||
| 87dea01917 | |||
| 586ee4f0ba | |||
| 53f3387e1a | |||
| be5d1918aa | |||
| df2cc0ffa9 | |||
| b8cfc3a201 | |||
| 8dc60ac2ea | |||
| 1719335d02 | |||
| 0cd46abc66 | |||
| e4fd7c58ee | |||
| d4abee52db | |||
| d29c94e898 |
@@ -0,0 +1,138 @@
|
|||||||
|
{
|
||||||
|
"projectName": "Mango",
|
||||||
|
"projectOwner": "hkalexling",
|
||||||
|
"repoType": "github",
|
||||||
|
"repoHost": "https://github.com",
|
||||||
|
"files": [
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
"imageSize": 100,
|
||||||
|
"commit": false,
|
||||||
|
"commitConvention": "none",
|
||||||
|
"contributors": [
|
||||||
|
{
|
||||||
|
"login": "hkalexling",
|
||||||
|
"name": "Alex Ling",
|
||||||
|
"avatar_url": "https://avatars1.githubusercontent.com/u/7845831?v=4",
|
||||||
|
"profile": "https://github.com/hkalexling/",
|
||||||
|
"contributions": [
|
||||||
|
"code",
|
||||||
|
"doc",
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "jaredlt",
|
||||||
|
"name": "jaredlt",
|
||||||
|
"avatar_url": "https://avatars1.githubusercontent.com/u/8590311?v=4",
|
||||||
|
"profile": "https://github.com/jaredlt",
|
||||||
|
"contributions": [
|
||||||
|
"code",
|
||||||
|
"ideas",
|
||||||
|
"design"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "shincurry",
|
||||||
|
"name": "ココロ",
|
||||||
|
"avatar_url": "https://avatars1.githubusercontent.com/u/4946624?v=4",
|
||||||
|
"profile": "https://windisco.com/",
|
||||||
|
"contributions": [
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "noirscape",
|
||||||
|
"name": "Valentijn",
|
||||||
|
"avatar_url": "https://avatars0.githubusercontent.com/u/13433513?v=4",
|
||||||
|
"profile": "https://catgirlsin.space/",
|
||||||
|
"contributions": [
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "flying-sausages",
|
||||||
|
"name": "flying-sausages",
|
||||||
|
"avatar_url": "https://avatars1.githubusercontent.com/u/23618693?v=4",
|
||||||
|
"profile": "https://github.com/flying-sausages",
|
||||||
|
"contributions": [
|
||||||
|
"doc",
|
||||||
|
"ideas"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "XavierSchiller",
|
||||||
|
"name": "Xavier",
|
||||||
|
"avatar_url": "https://avatars1.githubusercontent.com/u/22575255?v=4",
|
||||||
|
"profile": "https://github.com/XavierSchiller",
|
||||||
|
"contributions": [
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "WROIATE",
|
||||||
|
"name": "Jarao",
|
||||||
|
"avatar_url": "https://avatars3.githubusercontent.com/u/44677306?v=4",
|
||||||
|
"profile": "https://github.com/WROIATE",
|
||||||
|
"contributions": [
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Leeingnyo",
|
||||||
|
"name": "이인용",
|
||||||
|
"avatar_url": "https://avatars0.githubusercontent.com/u/6760150?v=4",
|
||||||
|
"profile": "https://github.com/Leeingnyo",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "h45h74x",
|
||||||
|
"name": "Simon",
|
||||||
|
"avatar_url": "https://avatars1.githubusercontent.com/u/27204033?v=4",
|
||||||
|
"profile": "http://h45h74x.eu.org",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "davidkna",
|
||||||
|
"name": "David Knaack",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/835177?v=4",
|
||||||
|
"profile": "https://github.com/davidkna",
|
||||||
|
"contributions": [
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "lincolnthedev",
|
||||||
|
"name": "i use arch btw",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/41193328?v=4",
|
||||||
|
"profile": "https://lncn.dev",
|
||||||
|
"contributions": [
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "BradleyDS2",
|
||||||
|
"name": "BradleyDS2",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/2174921?v=4",
|
||||||
|
"profile": "https://github.com/BradleyDS2",
|
||||||
|
"contributions": [
|
||||||
|
"doc"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "nduja",
|
||||||
|
"name": "Robbo",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/69299134?v=4",
|
||||||
|
"profile": "https://github.com/nduja",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"contributorsPerLine": 7,
|
||||||
|
"skipCi": true
|
||||||
|
}
|
||||||
@@ -7,3 +7,9 @@ Lint/UnusedArgument:
|
|||||||
- src/routes/*
|
- src/routes/*
|
||||||
Metrics/CyclomaticComplexity:
|
Metrics/CyclomaticComplexity:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
Layout/LineLength:
|
||||||
|
Enabled: true
|
||||||
|
MaxLength: 80
|
||||||
|
Excluded:
|
||||||
|
- src/routes/api.cr
|
||||||
|
- spec/plugin_spec.cr
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
lib
|
||||||
|
Dockerfile
|
||||||
|
Dockerfile.arm32v7
|
||||||
|
Dockerfile.arm64v8
|
||||||
|
README.md
|
||||||
|
.all-contributorsrc
|
||||||
|
env.example
|
||||||
|
.github/
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from_owner:
|
||||||
|
- hkalexling
|
||||||
|
required_labels:
|
||||||
|
- autoapprove
|
||||||
|
apply_labels:
|
||||||
|
- autoapproved
|
||||||
@@ -2,7 +2,7 @@ name: Build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ master, dev ]
|
branches: [ master, dev, hotfix/* ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ master, dev ]
|
branches: [ master, dev ]
|
||||||
|
|
||||||
@@ -12,20 +12,29 @@ jobs:
|
|||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: crystallang/crystal:0.34.0-alpine
|
image: crystallang/crystal:1.0.0-alpine
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static
|
run: apk add --no-cache yarn yaml-static sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
|
||||||
- name: Build
|
- name: Build
|
||||||
run: make static
|
run: make static || make static
|
||||||
- name: Linter
|
- name: Linter
|
||||||
run: make check
|
run: make check
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: make test
|
run: make test
|
||||||
- name: Upload artifact
|
- name: Upload binary
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v2
|
||||||
with:
|
with:
|
||||||
name: mango
|
name: mango
|
||||||
path: mango
|
path: mango
|
||||||
|
- name: build arm32v7 object file
|
||||||
|
run: make arm32v7 || make arm32v7
|
||||||
|
- name: build arm64v8 object file
|
||||||
|
run: make arm64v8 || make arm64v8
|
||||||
|
- name: Upload object files
|
||||||
|
uses: actions/upload-artifact@v2
|
||||||
|
with:
|
||||||
|
name: object files
|
||||||
|
path: ./*.o
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ jobs:
|
|||||||
- uses: actions/checkout@master
|
- uses: actions/checkout@master
|
||||||
- name: Get release version
|
- name: Get release version
|
||||||
id: get_version
|
id: get_version
|
||||||
run: echo ::set-env name=RELEASE_VERSION::$(echo ${GITHUB_REF:10})
|
run: echo "RELEASE_VERSION=$(echo ${GITHUB_REF:10})" >> $GITHUB_ENV
|
||||||
- name: Publish to Dockerhub
|
- name: Publish to Dockerhub
|
||||||
uses: elgohr/Publish-Docker-Github-Action@master
|
uses: elgohr/Publish-Docker-Github-Action@master
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -12,3 +12,5 @@ mango
|
|||||||
public/css/uikit.css
|
public/css/uikit.css
|
||||||
public/img/*.svg
|
public/img/*.svg
|
||||||
public/js/*.min.js
|
public/js/*.min.js
|
||||||
|
public/css/*.css
|
||||||
|
public/webfonts
|
||||||
|
|||||||
+5
-6
@@ -1,16 +1,15 @@
|
|||||||
FROM crystallang/crystal:0.34.0-alpine AS builder
|
FROM crystallang/crystal:1.0.0-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /Mango
|
WORKDIR /Mango
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY package*.json .
|
RUN apk add --no-cache yarn yaml-static sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev
|
||||||
RUN apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static \
|
RUN make static || make static
|
||||||
&& make static
|
|
||||||
|
|
||||||
FROM library/alpine
|
FROM library/alpine
|
||||||
|
|
||||||
WORKDIR /
|
WORKDIR /
|
||||||
|
|
||||||
COPY --from=builder /Mango/mango .
|
COPY --from=builder /Mango/mango /usr/local/bin/mango
|
||||||
|
|
||||||
CMD ["./mango"]
|
CMD ["/usr/local/bin/mango"]
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
FROM arm32v7/ubuntu:18.04
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
|
||||||
|
|
||||||
|
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 1.0.0 && make deps && cd ..
|
||||||
|
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.8 && make && cd ..
|
||||||
|
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v1.0.0 && make && cd ..
|
||||||
|
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.5.0 && make && cd ..
|
||||||
|
|
||||||
|
COPY mango-arm32v7.o .
|
||||||
|
|
||||||
|
RUN cc 'mango-arm32v7.o' -o '/usr/local/bin/mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
|
||||||
|
|
||||||
|
CMD ["/usr/local/bin/mango"]
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
FROM arm64v8/ubuntu:18.04
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev
|
||||||
|
|
||||||
|
RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 1.0.0 && make deps && cd ..
|
||||||
|
RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.8 && make && cd ..
|
||||||
|
RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v1.0.0 && make && cd ..
|
||||||
|
RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.5.0 && make && cd ..
|
||||||
|
|
||||||
|
COPY mango-arm64v8.o .
|
||||||
|
|
||||||
|
RUN cc 'mango-arm64v8.o' -o '/usr/local/bin/mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/aarch64-linux-gnu/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
|
||||||
|
|
||||||
|
CMD ["/usr/local/bin/mango"]
|
||||||
@@ -12,10 +12,10 @@ setup: libs
|
|||||||
yarn gulp dev
|
yarn gulp dev
|
||||||
|
|
||||||
build: libs
|
build: libs
|
||||||
crystal build src/mango.cr --release --progress
|
crystal build src/mango.cr --release --progress --error-trace
|
||||||
|
|
||||||
static: uglify | libs
|
static: uglify | libs
|
||||||
crystal build src/mango.cr --release --progress --static
|
crystal build src/mango.cr --release --progress --static --error-trace
|
||||||
|
|
||||||
libs:
|
libs:
|
||||||
shards install --production
|
shards install --production
|
||||||
@@ -29,7 +29,12 @@ test:
|
|||||||
check:
|
check:
|
||||||
crystal tool format --check
|
crystal tool format --check
|
||||||
./bin/ameba
|
./bin/ameba
|
||||||
./dev/linewidth.sh
|
|
||||||
|
arm32v7:
|
||||||
|
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='arm-linux-gnueabihf' -o mango-arm32v7
|
||||||
|
|
||||||
|
arm64v8:
|
||||||
|
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='aarch64-linux-gnu' -o mango-arm64v8
|
||||||
|
|
||||||
install:
|
install:
|
||||||
cp mango $(INSTALL_DIR)/mango
|
cp mango $(INSTALL_DIR)/mango
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
# Mango
|
# Mango
|
||||||
|
|
||||||
[](https://www.patreon.com/hkalexling)  [](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
|
[](https://www.patreon.com/hkalexling)  [](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [](http://discord.com/invite/ezKtacCp9Q)
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> As of March 2025, Mango is no longer maintained. We are incredibly grateful to everyone who used it, contributed, or gave feedback along the way - thank you! Unfortunately, we just don't have the time to keep it going right now. That said, it's open source, so you're more than welcome to fork it, build on it, or maintain your own version. If you're looking for alternatives, check out the wiki for similar projects. We might return to it someday, but for now, we don't recommend using it as-is - running unmaintained software can introduce security risks.
|
||||||
|
|
||||||
Mango is a self-hosted manga server and reader. Its features include
|
Mango is a self-hosted manga server and reader. Its features include
|
||||||
|
|
||||||
@@ -12,8 +15,8 @@ Mango is a self-hosted manga server and reader. Its features include
|
|||||||
- Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar`
|
- Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar`
|
||||||
- Supports nested folders in library
|
- Supports nested folders in library
|
||||||
- Automatically stores reading progress
|
- Automatically stores reading progress
|
||||||
- Built-in [MangaDex](https://mangadex.org/) downloader
|
- Thumbnail generation
|
||||||
- [Plugins](https://github.com/hkalexling/mango-plugins) support
|
- Supports [plugins](https://github.com/hkalexling/mango-plugins) to download from third-party sites
|
||||||
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
|
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
|
||||||
- All the static files are embedded in the binary, so the deployment process is easy and painless
|
- All the static files are embedded in the binary, so the deployment process is easy and painless
|
||||||
|
|
||||||
@@ -51,7 +54,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
|
|||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
```
|
```
|
||||||
Mango - Manga Server and Web Reader. Version 0.9.0
|
Mango - Manga Server and Web Reader. Version 0.27.0
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
@@ -74,25 +77,33 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
|
host: 0.0.0.0
|
||||||
port: 9000
|
port: 9000
|
||||||
base_url: /
|
base_url: /
|
||||||
|
session_secret: mango-session-secret
|
||||||
library_path: ~/mango/library
|
library_path: ~/mango/library
|
||||||
db_path: ~/mango/mango.db
|
db_path: ~/mango/mango.db
|
||||||
|
queue_db_path: ~/mango/queue.db
|
||||||
scan_interval_minutes: 5
|
scan_interval_minutes: 5
|
||||||
|
thumbnail_generation_interval_hours: 24
|
||||||
log_level: info
|
log_level: info
|
||||||
upload_path: ~/mango/uploads
|
upload_path: ~/mango/uploads
|
||||||
mangadex:
|
plugin_path: ~/mango/plugins
|
||||||
base_url: https://mangadex.org
|
download_timeout_seconds: 30
|
||||||
api_url: https://mangadex.org/api
|
library_cache_path: ~/mango/library.yml.gz
|
||||||
download_wait_seconds: 5
|
cache_enabled: true
|
||||||
download_retries: 4
|
cache_size_mbs: 50
|
||||||
download_queue_db_path: ~/mango/queue.db
|
cache_log_enabled: true
|
||||||
chapter_rename_rule: '[Vol.{volume} ][Ch.{chapter} ]{title|id}'
|
disable_login: false
|
||||||
manga_rename_rule: '{title}'
|
default_username: ""
|
||||||
|
auth_proxy_header_name: ""
|
||||||
|
plugin_update_interval_hours: 24
|
||||||
```
|
```
|
||||||
|
|
||||||
- `scan_interval_minutes` can be any non-negative integer. Setting it to `0` disables the periodic scan
|
- `scan_interval_minutes`, `thumbnail_generation_interval_hours`, and `plugin_update_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
|
||||||
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
|
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
|
||||||
|
- You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work.
|
||||||
|
- By setting `cache_enabled` to `true`, you can enable an experimental feature where Mango caches library metadata to improve page load time. You can further fine-tune the feature with `cache_size_mbs` and `cache_log_enabled`.
|
||||||
|
|
||||||
### Library Structure
|
### Library Structure
|
||||||
|
|
||||||
@@ -139,8 +150,38 @@ Mobile UI:
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## Sponsors
|
||||||
|
|
||||||
|
<a href="https://casinoshunter.com/online-casinos/"><img src="https://i.imgur.com/EJb3wBo.png" width="150" height="auto"></a>
|
||||||
|
<a href="https://www.browserstack.com/open-source"><img src="https://i.imgur.com/hGJUJXD.png" width="150" height="auto"></a>
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interest in code contributions.
|
Please check the [development guideline](https://github.com/hkalexling/Mango/wiki/Development) if you are interested in code contributions.
|
||||||
|
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||||
|
<!-- prettier-ignore-start -->
|
||||||
|
<!-- markdownlint-disable -->
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><a href="https://github.com/hkalexling/"><img src="https://avatars1.githubusercontent.com/u/7845831?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alex Ling</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=hkalexling" title="Code">💻</a> <a href="https://github.com/hkalexling/Mango/commits?author=hkalexling" title="Documentation">📖</a> <a href="#infra-hkalexling" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/jaredlt"><img src="https://avatars1.githubusercontent.com/u/8590311?v=4?s=100" width="100px;" alt=""/><br /><sub><b>jaredlt</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=jaredlt" title="Code">💻</a> <a href="#ideas-jaredlt" title="Ideas, Planning, & Feedback">🤔</a> <a href="#design-jaredlt" title="Design">🎨</a></td>
|
||||||
|
<td align="center"><a href="https://windisco.com/"><img src="https://avatars1.githubusercontent.com/u/4946624?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ココロ</b></sub></a><br /><a href="#infra-shincurry" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center"><a href="https://catgirlsin.space/"><img src="https://avatars0.githubusercontent.com/u/13433513?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Valentijn</b></sub></a><br /><a href="#infra-noirscape" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/flying-sausages"><img src="https://avatars1.githubusercontent.com/u/23618693?v=4?s=100" width="100px;" alt=""/><br /><sub><b>flying-sausages</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=flying-sausages" title="Documentation">📖</a> <a href="#ideas-flying-sausages" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/XavierSchiller"><img src="https://avatars1.githubusercontent.com/u/22575255?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Xavier</b></sub></a><br /><a href="#infra-XavierSchiller" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/WROIATE"><img src="https://avatars3.githubusercontent.com/u/44677306?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jarao</b></sub></a><br /><a href="#infra-WROIATE" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><a href="https://github.com/Leeingnyo"><img src="https://avatars0.githubusercontent.com/u/6760150?v=4?s=100" width="100px;" alt=""/><br /><sub><b>이인용</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=Leeingnyo" title="Code">💻</a></td>
|
||||||
|
<td align="center"><a href="http://h45h74x.eu.org"><img src="https://avatars1.githubusercontent.com/u/27204033?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=h45h74x" title="Code">💻</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/davidkna"><img src="https://avatars.githubusercontent.com/u/835177?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David Knaack</b></sub></a><br /><a href="#infra-davidkna" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center"><a href="https://lncn.dev"><img src="https://avatars.githubusercontent.com/u/41193328?v=4?s=100" width="100px;" alt=""/><br /><sub><b>i use arch btw</b></sub></a><br /><a href="#infra-lincolnthedev" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/BradleyDS2"><img src="https://avatars.githubusercontent.com/u/2174921?v=4?s=100" width="100px;" alt=""/><br /><sub><b>BradleyDS2</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=BradleyDS2" title="Documentation">📖</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/nduja"><img src="https://avatars.githubusercontent.com/u/69299134?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Robbo</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=nduja" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/0)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/1)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/2)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/3)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/4)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/5)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/6)[](https://sourcerer.io/fame/hkalexling/hkalexling/Mango/links/7)
|
<!-- markdownlint-restore -->
|
||||||
|
<!-- prettier-ignore-end -->
|
||||||
|
|
||||||
|
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
[ ! -z "$(grep '.\{80\}' --exclude-dir=lib --include="*.cr" -nr --color=always . | tee /dev/tty)" ] \
|
|
||||||
&& echo "The above lines exceed the 80 characters limit" \
|
|
||||||
|| exit 0
|
|
||||||
+50
-32
@@ -1,15 +1,42 @@
|
|||||||
const gulp = require('gulp');
|
const gulp = require('gulp');
|
||||||
const minify = require("gulp-babel-minify");
|
const babel = require('gulp-babel');
|
||||||
|
const minify = require('gulp-babel-minify');
|
||||||
const minifyCss = require('gulp-minify-css');
|
const minifyCss = require('gulp-minify-css');
|
||||||
const less = require('gulp-less');
|
const less = require('gulp-less');
|
||||||
|
|
||||||
gulp.task('copy-uikit-js', () => {
|
gulp.task('copy-img', () => {
|
||||||
return gulp.src('node_modules/uikit/dist/js/*.min.js')
|
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
|
||||||
.pipe(gulp.dest('public/js'));
|
.pipe(gulp.dest('public/img'));
|
||||||
});
|
});
|
||||||
|
|
||||||
gulp.task('minify-js', () => {
|
gulp.task('copy-font', () => {
|
||||||
return gulp.src('public/js/*.js')
|
return gulp.src('node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff**')
|
||||||
|
.pipe(gulp.dest('public/webfonts'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy files from node_modules
|
||||||
|
gulp.task('node-modules-copy', gulp.parallel('copy-img', 'copy-font'));
|
||||||
|
|
||||||
|
// Compile less
|
||||||
|
gulp.task('less', () => {
|
||||||
|
return gulp.src([
|
||||||
|
'public/css/mango.less',
|
||||||
|
'public/css/tags.less'
|
||||||
|
])
|
||||||
|
.pipe(less())
|
||||||
|
.pipe(gulp.dest('public/css'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transpile and minify JS files and output to dist
|
||||||
|
gulp.task('babel', () => {
|
||||||
|
return gulp.src(['public/js/*.js', '!public/js/*.min.js'])
|
||||||
|
.pipe(babel({
|
||||||
|
presets: [
|
||||||
|
['@babel/preset-env', {
|
||||||
|
targets: '>0.25%, not dead, ios>=9'
|
||||||
|
}]
|
||||||
|
],
|
||||||
|
}))
|
||||||
.pipe(minify({
|
.pipe(minify({
|
||||||
removeConsole: true,
|
removeConsole: true,
|
||||||
builtIns: false
|
builtIns: false
|
||||||
@@ -17,40 +44,31 @@ gulp.task('minify-js', () => {
|
|||||||
.pipe(gulp.dest('dist/js'));
|
.pipe(gulp.dest('dist/js'));
|
||||||
});
|
});
|
||||||
|
|
||||||
gulp.task('less', () => {
|
// Minify CSS and output to dist
|
||||||
return gulp.src('public/css/*.less')
|
|
||||||
.pipe(less())
|
|
||||||
.pipe(gulp.dest('public/css'));
|
|
||||||
});
|
|
||||||
|
|
||||||
gulp.task('minify-css', () => {
|
gulp.task('minify-css', () => {
|
||||||
return gulp.src('public/css/*.css')
|
return gulp.src('public/css/*.css')
|
||||||
.pipe(minifyCss())
|
.pipe(minifyCss())
|
||||||
.pipe(gulp.dest('dist/css'));
|
.pipe(gulp.dest('dist/css'));
|
||||||
});
|
});
|
||||||
|
|
||||||
gulp.task('copy-uikit-icons', () => {
|
// Copy static files (includeing images) to dist
|
||||||
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
|
|
||||||
.pipe(gulp.dest('public/img'));
|
|
||||||
});
|
|
||||||
|
|
||||||
gulp.task('img', () => {
|
|
||||||
return gulp.src('public/img/*')
|
|
||||||
.pipe(gulp.dest('dist/img'));
|
|
||||||
});
|
|
||||||
|
|
||||||
gulp.task('copy-files', () => {
|
gulp.task('copy-files', () => {
|
||||||
return gulp.src('public/*.*')
|
return gulp.src([
|
||||||
|
'public/*.*',
|
||||||
|
'public/img/**',
|
||||||
|
'public/webfonts/*',
|
||||||
|
'public/js/*.min.js'
|
||||||
|
], {
|
||||||
|
base: 'public'
|
||||||
|
})
|
||||||
.pipe(gulp.dest('dist'));
|
.pipe(gulp.dest('dist'));
|
||||||
});
|
});
|
||||||
|
|
||||||
gulp.task('default', gulp.parallel(
|
// Set up the public folder for development
|
||||||
gulp.series('copy-uikit-js', 'minify-js'),
|
gulp.task('dev', gulp.parallel('node-modules-copy', 'less'));
|
||||||
gulp.series('less', 'minify-css'),
|
|
||||||
gulp.series('copy-uikit-icons', 'img'),
|
|
||||||
'copy-files'
|
|
||||||
));
|
|
||||||
|
|
||||||
gulp.task('dev', gulp.parallel(
|
// Set up the dist folder for deployment
|
||||||
'copy-uikit-js', 'less', 'copy-uikit-icons'
|
gulp.task('deploy', gulp.parallel('babel', 'minify-css', 'copy-files'));
|
||||||
));
|
|
||||||
|
// Default task
|
||||||
|
gulp.task('default', gulp.series('dev', 'deploy'));
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
class ForeignKeys < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
-- add foreign key to tags
|
||||||
|
ALTER TABLE tags RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
UNIQUE (id, tag),
|
||||||
|
FOREIGN KEY (id) REFERENCES titles (id)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO tags
|
||||||
|
SELECT * FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
CREATE INDEX tags_id_idx ON tags (id);
|
||||||
|
CREATE INDEX tags_tag_idx ON tags (tag);
|
||||||
|
|
||||||
|
-- add foreign key to thumbnails
|
||||||
|
ALTER TABLE thumbnails RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE thumbnails (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (id) REFERENCES ids (id)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO thumbnails
|
||||||
|
SELECT * FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
-- remove foreign key from thumbnails
|
||||||
|
ALTER TABLE thumbnails RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE thumbnails (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO thumbnails
|
||||||
|
SELECT * FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
|
||||||
|
|
||||||
|
-- remove foreign key from tags
|
||||||
|
ALTER TABLE tags RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
UNIQUE (id, tag)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO tags
|
||||||
|
SELECT * FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
CREATE INDEX tags_id_idx ON tags (id);
|
||||||
|
CREATE INDEX tags_tag_idx ON tags (tag);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
class CreateIds < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS ids (
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
is_title INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS path_idx ON ids (path);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS id_idx ON ids (id);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
DROP TABLE ids;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
class IDSignature < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
ALTER TABLE ids ADD COLUMN signature TEXT;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
-- remove signature column from ids
|
||||||
|
ALTER TABLE ids RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE ids (
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO ids
|
||||||
|
SELECT path, id
|
||||||
|
FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
-- recreate the indices
|
||||||
|
CREATE UNIQUE INDEX path_idx ON ids (path);
|
||||||
|
CREATE UNIQUE INDEX id_idx ON ids (id);
|
||||||
|
|
||||||
|
-- recreate the foreign key constraint on thumbnails
|
||||||
|
ALTER TABLE thumbnails RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE thumbnails (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (id) REFERENCES ids (id)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO thumbnails
|
||||||
|
SELECT * FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
class CreateMangaDexAccount < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
CREATE TABLE md_account (
|
||||||
|
username TEXT NOT NULL PRIMARY KEY,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
expire INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (username) REFERENCES users (username)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
DROP TABLE md_account;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
class RelativePath < MG::Base
|
||||||
|
def up : String
|
||||||
|
base = Config.current.library_path
|
||||||
|
# Escape single quotes in case the path contains them, and remove the
|
||||||
|
# trailing slash (this is a mistake, fixed in DB version 10)
|
||||||
|
base = base.gsub("'", "''").rstrip "/"
|
||||||
|
|
||||||
|
<<-SQL
|
||||||
|
-- update the path column in ids to relative paths
|
||||||
|
UPDATE ids
|
||||||
|
SET path = REPLACE(path, '#{base}', '');
|
||||||
|
|
||||||
|
-- update the path column in titles to relative paths
|
||||||
|
UPDATE titles
|
||||||
|
SET path = REPLACE(path, '#{base}', '');
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
base = Config.current.library_path
|
||||||
|
base = base.gsub("'", "''").rstrip "/"
|
||||||
|
|
||||||
|
<<-SQL
|
||||||
|
-- update the path column in ids to absolute paths
|
||||||
|
UPDATE ids
|
||||||
|
SET path = '#{base}' || path;
|
||||||
|
|
||||||
|
-- update the path column in titles to absolute paths
|
||||||
|
UPDATE titles
|
||||||
|
SET path = '#{base}' || path;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# In DB version 8, we replaced the absolute paths in DB with relative paths,
|
||||||
|
# but we mistakenly left the starting slashes. This migration removes them.
|
||||||
|
class RelativePathFix < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
-- remove leading slashes from the paths in ids
|
||||||
|
UPDATE ids
|
||||||
|
SET path = SUBSTR(path, 2, LENGTH(path) - 1)
|
||||||
|
WHERE path LIKE '/%';
|
||||||
|
|
||||||
|
-- remove leading slashes from the paths in titles
|
||||||
|
UPDATE titles
|
||||||
|
SET path = SUBSTR(path, 2, LENGTH(path) - 1)
|
||||||
|
WHERE path LIKE '/%';
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
-- add leading slashes to paths in ids
|
||||||
|
UPDATE ids
|
||||||
|
SET path = '/' || path
|
||||||
|
WHERE path NOT LIKE '/%';
|
||||||
|
|
||||||
|
-- add leading slashes to paths in titles
|
||||||
|
UPDATE titles
|
||||||
|
SET path = '/' || path
|
||||||
|
WHERE path NOT LIKE '/%';
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
class SortTitle < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
-- add sort_title column to ids and titles
|
||||||
|
ALTER TABLE ids ADD COLUMN sort_title TEXT;
|
||||||
|
ALTER TABLE titles ADD COLUMN sort_title TEXT;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
-- remove sort_title column from ids
|
||||||
|
ALTER TABLE ids RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE ids (
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
signature TEXT,
|
||||||
|
unavailable INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO ids
|
||||||
|
SELECT path, id, signature, unavailable
|
||||||
|
FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
-- recreate the indices
|
||||||
|
CREATE UNIQUE INDEX path_idx ON ids (path);
|
||||||
|
CREATE UNIQUE INDEX id_idx ON ids (id);
|
||||||
|
|
||||||
|
-- recreate the foreign key constraint on thumbnails
|
||||||
|
ALTER TABLE thumbnails RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE thumbnails (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (id) REFERENCES ids (id)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO thumbnails
|
||||||
|
SELECT * FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
|
||||||
|
|
||||||
|
-- remove sort_title column from titles
|
||||||
|
ALTER TABLE titles RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE titles (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
signature TEXT,
|
||||||
|
unavailable INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO titles
|
||||||
|
SELECT id, path, signature, unavailable
|
||||||
|
FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
-- recreate the indices
|
||||||
|
CREATE UNIQUE INDEX titles_id_idx on titles (id);
|
||||||
|
CREATE UNIQUE INDEX titles_path_idx on titles (path);
|
||||||
|
|
||||||
|
-- recreate the foreign key constraint on tags
|
||||||
|
ALTER TABLE tags RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
UNIQUE (id, tag),
|
||||||
|
FOREIGN KEY (id) REFERENCES titles (id)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO tags
|
||||||
|
SELECT * FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
CREATE INDEX tags_id_idx ON tags (id);
|
||||||
|
CREATE INDEX tags_tag_idx ON tags (tag);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
class CreateTags < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
UNIQUE (id, tag)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS tags_id_idx ON tags (id);
|
||||||
|
CREATE INDEX IF NOT EXISTS tags_tag_idx ON tags (tag);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
DROP TABLE tags;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
class CreateThumbnails < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS thumbnails (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS tn_index ON thumbnails (id);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
DROP TABLE thumbnails;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
class CreateTitles < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
-- create titles
|
||||||
|
CREATE TABLE titles (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
signature TEXT
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX titles_id_idx on titles (id);
|
||||||
|
CREATE UNIQUE INDEX titles_path_idx on titles (path);
|
||||||
|
|
||||||
|
-- migrate data from ids to titles
|
||||||
|
INSERT INTO titles
|
||||||
|
SELECT id, path, null
|
||||||
|
FROM ids
|
||||||
|
WHERE is_title = 1;
|
||||||
|
|
||||||
|
DELETE FROM ids
|
||||||
|
WHERE is_title = 1;
|
||||||
|
|
||||||
|
-- remove the is_title column from ids
|
||||||
|
ALTER TABLE ids RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE ids (
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO ids
|
||||||
|
SELECT path, id
|
||||||
|
FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
-- recreate the indices
|
||||||
|
CREATE UNIQUE INDEX path_idx ON ids (path);
|
||||||
|
CREATE UNIQUE INDEX id_idx ON ids (id);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
-- insert the is_title column
|
||||||
|
ALTER TABLE ids ADD COLUMN is_title INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- migrate data from titles to ids
|
||||||
|
INSERT INTO ids
|
||||||
|
SELECT path, id, 1
|
||||||
|
FROM titles;
|
||||||
|
|
||||||
|
-- remove titles
|
||||||
|
DROP TABLE titles;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
class UnavailableIDs < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
-- add unavailable column to ids
|
||||||
|
ALTER TABLE ids ADD COLUMN unavailable INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- add unavailable column to titles
|
||||||
|
ALTER TABLE titles ADD COLUMN unavailable INTEGER NOT NULL DEFAULT 0;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
-- remove unavailable column from ids
|
||||||
|
ALTER TABLE ids RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE ids (
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
signature TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO ids
|
||||||
|
SELECT path, id, signature
|
||||||
|
FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
-- recreate the indices
|
||||||
|
CREATE UNIQUE INDEX path_idx ON ids (path);
|
||||||
|
CREATE UNIQUE INDEX id_idx ON ids (id);
|
||||||
|
|
||||||
|
-- recreate the foreign key constraint on thumbnails
|
||||||
|
ALTER TABLE thumbnails RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE thumbnails (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (id) REFERENCES ids (id)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO thumbnails
|
||||||
|
SELECT * FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX tn_index ON thumbnails (id);
|
||||||
|
|
||||||
|
-- remove unavailable column from titles
|
||||||
|
ALTER TABLE titles RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE titles (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
signature TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO titles
|
||||||
|
SELECT path, id, signature
|
||||||
|
FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
-- recreate the indices
|
||||||
|
CREATE UNIQUE INDEX titles_id_idx on titles (id);
|
||||||
|
CREATE UNIQUE INDEX titles_path_idx on titles (path);
|
||||||
|
|
||||||
|
-- recreate the foreign key constraint on tags
|
||||||
|
ALTER TABLE tags RENAME TO tmp;
|
||||||
|
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
UNIQUE (id, tag),
|
||||||
|
FOREIGN KEY (id) REFERENCES titles (id)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO tags
|
||||||
|
SELECT * FROM tmp;
|
||||||
|
|
||||||
|
DROP TABLE tmp;
|
||||||
|
|
||||||
|
CREATE INDEX tags_id_idx ON tags (id);
|
||||||
|
CREATE INDEX tags_tag_idx ON tags (tag);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
class CreateUsers < MG::Base
|
||||||
|
def up : String
|
||||||
|
<<-SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
token TEXT,
|
||||||
|
admin INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS username_idx ON users (username);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS token_idx ON users (token);
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def down : String
|
||||||
|
<<-SQL
|
||||||
|
DROP TABLE users;
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
+23
-19
@@ -1,21 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "mango",
|
"name": "mango",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"repository": "https://github.com/hkalexling/Mango.git",
|
"repository": "https://github.com/hkalexling/Mango.git",
|
||||||
"author": "Alex Ling <hkalexling@gmail.com>",
|
"author": "Alex Ling <hkalexling@gmail.com>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"gulp": "^4.0.2",
|
"@babel/preset-env": "^7.11.5",
|
||||||
"gulp-babel-minify": "^0.5.1",
|
"all-contributors-cli": "^6.19.0",
|
||||||
"gulp-less": "^4.0.1",
|
"gulp": "^4.0.2",
|
||||||
"gulp-minify-css": "^1.2.4",
|
"gulp-babel": "^8.0.0",
|
||||||
"less": "^3.11.3"
|
"gulp-babel-minify": "^0.5.1",
|
||||||
},
|
"gulp-less": "^4.0.1",
|
||||||
"scripts": {
|
"gulp-minify-css": "^1.2.4",
|
||||||
"uglify": "gulp"
|
"less": "^3.11.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"scripts": {
|
||||||
"uikit": "^3.5.4"
|
"uglify": "gulp"
|
||||||
}
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-free": "^5.14.0",
|
||||||
|
"uikit": "^3.5.4"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
.uk-alert-close {
|
|
||||||
color: black !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-card-body {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-card-media-top {
|
|
||||||
height: 250px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 600px) {
|
|
||||||
.uk-card-media-top {
|
|
||||||
height: 300px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-card-media-top>img {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-card-title {
|
|
||||||
max-height: 3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.acard:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-list li:not(.nopointer) {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
#scan-status {
|
|
||||||
cursor: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reader-bg {
|
|
||||||
background-color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.break-word {
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-logo>img {
|
|
||||||
height: 90px;
|
|
||||||
width: 90px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-search {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#selectable .ui-selecting {
|
|
||||||
background: #EEE6B9;
|
|
||||||
}
|
|
||||||
|
|
||||||
#selectable .ui-selected {
|
|
||||||
background: #F4E487;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-light #selectable .ui-selecting {
|
|
||||||
background: #5E5731;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-light #selectable .ui-selected {
|
|
||||||
background: #9D9252;
|
|
||||||
}
|
|
||||||
|
|
||||||
td>.uk-dropdown {
|
|
||||||
white-space: pre-line;
|
|
||||||
}
|
|
||||||
|
|
||||||
#edit-modal .uk-grid>div {
|
|
||||||
height: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#edit-modal #cover {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
#edit-modal #cover-upload {
|
|
||||||
height: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
#edit-modal .uk-modal-body .uk-inline {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item .uk-card-title {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grayscale {
|
|
||||||
filter: grayscale(100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-light .uk-navbar-dropdown,
|
|
||||||
.uk-light .uk-modal-header,
|
|
||||||
.uk-light .uk-modal-body,
|
|
||||||
.uk-light .uk-modal-footer {
|
|
||||||
background: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-light .uk-dropdown {
|
|
||||||
background: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-light .uk-navbar-dropdown,
|
|
||||||
.uk-light .uk-dropdown {
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uk-light .uk-nav-header,
|
|
||||||
.uk-light .uk-description-list>dt {
|
|
||||||
color: #555;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// UIKit
|
||||||
|
@import "./uikit.less";
|
||||||
|
|
||||||
|
// FontAwesome
|
||||||
|
@import "../../node_modules/@fortawesome/fontawesome-free/less/fontawesome.less";
|
||||||
|
@import "../../node_modules/@fortawesome/fontawesome-free/less/solid.less";
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
src: url('@{fa-font-path}/fa-solid-900.woff2');
|
||||||
|
src: url('@{fa-font-path}/fa-solid-900.woff2') format('woff2'),
|
||||||
|
url('@{fa-font-path}/fa-solid-900.woff') format('woff');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item cards
|
||||||
|
.item .uk-card {
|
||||||
|
cursor: pointer;
|
||||||
|
.uk-card-media-top {
|
||||||
|
width: 100%;
|
||||||
|
height: 250px;
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
|
||||||
|
&.grayscale {
|
||||||
|
filter: grayscale(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.uk-card-body {
|
||||||
|
padding: 20px;
|
||||||
|
.uk-card-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.uk-card-title:not(.free-height) {
|
||||||
|
max-height: 3em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jQuery selectable
|
||||||
|
#selectable {
|
||||||
|
.ui-selecting {
|
||||||
|
background: #EEE6B9;
|
||||||
|
}
|
||||||
|
.ui-selected {
|
||||||
|
background: #F4E487;
|
||||||
|
}
|
||||||
|
.uk-light & {
|
||||||
|
.ui-selecting {
|
||||||
|
background: #5E5731;
|
||||||
|
}
|
||||||
|
.ui-selected {
|
||||||
|
background: #9D9252;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit modal
|
||||||
|
#edit-modal {
|
||||||
|
.uk-grid > div {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
#cover {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
#cover-upload {
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.uk-modal-body .uk-inline {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark theme
|
||||||
|
.uk-light {
|
||||||
|
.uk-modal-header,
|
||||||
|
.uk-modal-body,
|
||||||
|
.uk-modal-footer {
|
||||||
|
background: #222;
|
||||||
|
}
|
||||||
|
.uk-navbar-dropdown,
|
||||||
|
.uk-dropdown {
|
||||||
|
color: #ccc;
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
.uk-nav-header,
|
||||||
|
.uk-description-list > dt {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alpine magic
|
||||||
|
[x-cloak] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch select bar on title page
|
||||||
|
#select-bar-controls {
|
||||||
|
a {
|
||||||
|
transform: scale(1.5, 1.5);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: orange;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Totop button
|
||||||
|
#totop-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 100vh;
|
||||||
|
right: 2em;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
a {
|
||||||
|
position: fixed;
|
||||||
|
position: sticky;
|
||||||
|
top: calc(100vh - 5em);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Misc
|
||||||
|
.uk-alert-close {
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
.break-word {
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.uk-search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
@light-gray: #e5e5e5;
|
||||||
|
@gray: #666666;
|
||||||
|
@black: #141414;
|
||||||
|
@blue: rgb(30, 135, 240);
|
||||||
|
@white1: rgba(255, 255, 255, .1);
|
||||||
|
@white2: rgba(255, 255, 255, .2);
|
||||||
|
@white7: rgba(255, 255, 255, .7);
|
||||||
|
|
||||||
|
.select2-container--default {
|
||||||
|
.select2-selection--multiple {
|
||||||
|
border: 1px solid @light-gray;
|
||||||
|
.select2-selection__choice,
|
||||||
|
.select2-selection__choice__remove,
|
||||||
|
.select2-selection__choice__remove:hover
|
||||||
|
{
|
||||||
|
background-color: @blue;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.select2-dropdown {
|
||||||
|
.select2-results__option--highlighted.select2-results__option--selectable {
|
||||||
|
background-color: @blue;
|
||||||
|
}
|
||||||
|
.select2-results__option--selected:not(.select2-results__option--highlighted) {
|
||||||
|
background-color: @light-gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.uk-light {
|
||||||
|
.select2-container--default {
|
||||||
|
.select2-selection {
|
||||||
|
background-color: @white1;
|
||||||
|
}
|
||||||
|
.select2-selection--multiple {
|
||||||
|
border: 1px solid @white2;
|
||||||
|
.select2-selection__choice,
|
||||||
|
.select2-selection__choice__remove,
|
||||||
|
.select2-selection__choice__remove:hover
|
||||||
|
{
|
||||||
|
background-color: white;
|
||||||
|
color: @gray;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.select2-search__field {
|
||||||
|
color: @white7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.select2-dropdown {
|
||||||
|
background-color: @black;
|
||||||
|
.select2-results__option--selected:not(.select2-results__option--highlighted) {
|
||||||
|
background-color: @white2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,3 +43,22 @@
|
|||||||
@internal-list-bullet-image: "../img/list-bullet.svg";
|
@internal-list-bullet-image: "../img/list-bullet.svg";
|
||||||
@internal-accordion-open-image: "../img/accordion-open.svg";
|
@internal-accordion-open-image: "../img/accordion-open.svg";
|
||||||
@internal-accordion-close-image: "../img/accordion-close.svg";
|
@internal-accordion-close-image: "../img/accordion-close.svg";
|
||||||
|
|
||||||
|
.hook-card-default() {
|
||||||
|
.uk-light & {
|
||||||
|
background: @card-secondary-background;
|
||||||
|
color: @card-secondary-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-card-default-title() {
|
||||||
|
.uk-light & {
|
||||||
|
color: @card-secondary-title-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hook-card-default-hover() {
|
||||||
|
.uk-light & {
|
||||||
|
background-color: @card-secondary-hover-background;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
+56
-38
@@ -1,40 +1,58 @@
|
|||||||
let scanning = false;
|
const component = () => {
|
||||||
|
return {
|
||||||
|
progress: 1.0,
|
||||||
|
generating: false,
|
||||||
|
scanning: false,
|
||||||
|
scanTitles: 0,
|
||||||
|
scanMs: -1,
|
||||||
|
themeSetting: '',
|
||||||
|
|
||||||
const scan = () => {
|
init() {
|
||||||
scanning = true;
|
this.getProgress();
|
||||||
$('#scan-status > div').removeAttr('hidden');
|
setInterval(() => {
|
||||||
$('#scan-status > span').attr('hidden', '');
|
this.getProgress();
|
||||||
const color = $('#scan').css('color');
|
}, 5000);
|
||||||
$('#scan').css('color', 'gray');
|
|
||||||
$.post(base_url + 'api/admin/scan', (data) => {
|
|
||||||
const ms = data.milliseconds;
|
|
||||||
const titles = data.titles;
|
|
||||||
$('#scan-status > span').text('Scanned ' + titles + ' titles in ' + ms + 'ms');
|
|
||||||
$('#scan-status > span').removeAttr('hidden');
|
|
||||||
$('#scan').css('color', color);
|
|
||||||
$('#scan-status > div').attr('hidden', '');
|
|
||||||
scanning = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
String.prototype.capitalize = function() {
|
const setting = loadThemeSetting();
|
||||||
return this.charAt(0).toUpperCase() + this.slice(1);
|
this.themeSetting = setting.charAt(0).toUpperCase() + setting.slice(1);
|
||||||
}
|
},
|
||||||
|
themeChanged(event) {
|
||||||
$(() => {
|
const newSetting = $(event.currentTarget).val().toLowerCase();
|
||||||
$('li').click((e) => {
|
saveThemeSetting(newSetting);
|
||||||
const url = $(e.currentTarget).attr('data-url');
|
setTheme();
|
||||||
if (url) {
|
},
|
||||||
$(location).attr('href', url);
|
scan() {
|
||||||
}
|
if (this.scanning) return;
|
||||||
});
|
this.scanning = true;
|
||||||
|
this.scanMs = -1;
|
||||||
const setting = loadThemeSetting();
|
this.scanTitles = 0;
|
||||||
$('#theme-select').val(setting.capitalize());
|
$.post(`${base_url}api/admin/scan`)
|
||||||
|
.then(data => {
|
||||||
$('#theme-select').change((e) => {
|
this.scanMs = data.milliseconds;
|
||||||
const newSetting = $(e.currentTarget).val().toLowerCase();
|
this.scanTitles = data.titles;
|
||||||
saveThemeSetting(newSetting);
|
})
|
||||||
setTheme();
|
.catch(e => {
|
||||||
});
|
alert('danger', `Failed to trigger a scan. Error: ${e}`);
|
||||||
});
|
})
|
||||||
|
.always(() => {
|
||||||
|
this.scanning = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
generateThumbnails() {
|
||||||
|
if (this.generating) return;
|
||||||
|
this.generating = true;
|
||||||
|
this.progress = 0.0;
|
||||||
|
$.post(`${base_url}api/admin/generate_thumbnails`)
|
||||||
|
.then(() => {
|
||||||
|
this.getProgress()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getProgress() {
|
||||||
|
$.get(`${base_url}api/admin/thumbnail_progress`)
|
||||||
|
.then(data => {
|
||||||
|
this.progress = data.progress;
|
||||||
|
this.generating = data.progress > 0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* --- Alpine helper functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an alpine.js property
|
||||||
|
*
|
||||||
|
* @function setProp
|
||||||
|
* @param {string} key - Key of the data property
|
||||||
|
* @param {*} prop - The data property
|
||||||
|
* @param {string} selector - The jQuery selector to the root element
|
||||||
|
*/
|
||||||
|
const setProp = (key, prop, selector = '#root') => {
|
||||||
|
$(selector).get(0).__x.$data[key] = prop;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an alpine.js property
|
||||||
|
*
|
||||||
|
* @function getProp
|
||||||
|
* @param {string} key - Key of the data property
|
||||||
|
* @param {string} selector - The jQuery selector to the root element
|
||||||
|
* @return {*} The data property
|
||||||
|
*/
|
||||||
|
const getProp = (key, selector = '#root') => {
|
||||||
|
return $(selector).get(0).__x.$data[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* --- Theme related functions
|
||||||
|
* Note: In the comments below we treat "theme" and "theme setting"
|
||||||
|
* differently. A theme can have only two values, either "dark" or
|
||||||
|
* "light", while a theme setting can have the third value "system".
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the system setting prefers dark theme.
|
||||||
|
* from https://flaviocopes.com/javascript-detect-dark-mode/
|
||||||
|
*
|
||||||
|
* @function preferDarkMode
|
||||||
|
* @return {bool}
|
||||||
|
*/
|
||||||
|
const preferDarkMode = () => {
|
||||||
|
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a given string represents a valid theme setting
|
||||||
|
*
|
||||||
|
* @function validThemeSetting
|
||||||
|
* @param {string} theme - The string representing the theme setting
|
||||||
|
* @return {bool}
|
||||||
|
*/
|
||||||
|
const validThemeSetting = (theme) => {
|
||||||
|
return ['dark', 'light', 'system'].indexOf(theme) >= 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load theme setting from local storage, or use 'light'
|
||||||
|
*
|
||||||
|
* @function loadThemeSetting
|
||||||
|
* @return {string} A theme setting ('dark', 'light', or 'system')
|
||||||
|
*/
|
||||||
|
const loadThemeSetting = () => {
|
||||||
|
let str = localStorage.getItem('theme');
|
||||||
|
if (!str || !validThemeSetting(str)) str = 'system';
|
||||||
|
return str;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the current theme (not theme setting)
|
||||||
|
*
|
||||||
|
* @function loadTheme
|
||||||
|
* @return {string} The current theme to use ('dark' or 'light')
|
||||||
|
*/
|
||||||
|
const loadTheme = () => {
|
||||||
|
let setting = loadThemeSetting();
|
||||||
|
if (setting === 'system') {
|
||||||
|
setting = preferDarkMode() ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
return setting;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a theme setting
|
||||||
|
*
|
||||||
|
* @function saveThemeSetting
|
||||||
|
* @param {string} setting - A theme setting
|
||||||
|
*/
|
||||||
|
const saveThemeSetting = setting => {
|
||||||
|
if (!validThemeSetting(setting)) setting = 'system';
|
||||||
|
localStorage.setItem('theme', setting);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the current theme. When the current theme setting is 'system', it
|
||||||
|
* will be changed to either 'light' or 'dark'
|
||||||
|
*
|
||||||
|
* @function toggleTheme
|
||||||
|
*/
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const theme = loadTheme();
|
||||||
|
const newTheme = theme === 'dark' ? 'light' : 'dark';
|
||||||
|
saveThemeSetting(newTheme);
|
||||||
|
setTheme(newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a theme, or load a theme and then apply it
|
||||||
|
*
|
||||||
|
* @function setTheme
|
||||||
|
* @param {string?} theme - (Optional) The theme to apply. When omitted, use
|
||||||
|
* `loadTheme` to get a theme and apply it.
|
||||||
|
*/
|
||||||
|
const setTheme = (theme) => {
|
||||||
|
if (!theme) theme = loadTheme();
|
||||||
|
if (theme === 'dark') {
|
||||||
|
$('html').css('background', 'rgb(20, 20, 20)');
|
||||||
|
$('body').addClass('uk-light');
|
||||||
|
$('.ui-widget-content').addClass('dark');
|
||||||
|
} else {
|
||||||
|
$('html').css('background', '');
|
||||||
|
$('body').removeClass('uk-light');
|
||||||
|
$('.ui-widget-content').removeClass('dark');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// do it before document is ready to prevent the initial flash of white on
|
||||||
|
// most pages
|
||||||
|
setTheme();
|
||||||
|
$(() => {
|
||||||
|
// hack for the reader page
|
||||||
|
setTheme();
|
||||||
|
|
||||||
|
// on system dark mode setting change
|
||||||
|
if (window.matchMedia) {
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
.addEventListener('change', event => {
|
||||||
|
if (loadThemeSetting() === 'system')
|
||||||
|
setTheme(event.matches ? 'dark' : 'light');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
+22
-13
@@ -1,17 +1,26 @@
|
|||||||
const truncate = () => {
|
/**
|
||||||
$('.uk-card-title').each((i, e) => {
|
* Truncate a .uk-card-title element
|
||||||
$(e).dotdotdot({
|
*
|
||||||
truncate: 'letter',
|
* @function truncate
|
||||||
watch: true,
|
* @param {object} e - The title element to truncate
|
||||||
callback: (truncated) => {
|
*/
|
||||||
if (truncated) {
|
const truncate = (e) => {
|
||||||
$(e).attr('uk-tooltip', $(e).attr('data-title'));
|
$(e).dotdotdot({
|
||||||
} else {
|
truncate: 'letter',
|
||||||
$(e).removeAttr('uk-tooltip');
|
watch: true,
|
||||||
}
|
callback: (truncated) => {
|
||||||
|
if (truncated) {
|
||||||
|
$(e).attr('uk-tooltip', $(e).attr('data-title'));
|
||||||
|
} else {
|
||||||
|
$(e).removeAttr('uk-tooltip');
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
truncate();
|
$('.uk-card-title').each((i, e) => {
|
||||||
|
// Truncate the title when it first enters the view
|
||||||
|
$(e).one('inview', () => {
|
||||||
|
truncate(e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+109
-138
@@ -1,145 +1,116 @@
|
|||||||
$(() => {
|
const component = () => {
|
||||||
$('input.uk-checkbox').each((i, e) => {
|
return {
|
||||||
$(e).change(() => {
|
jobs: [],
|
||||||
loadConfig();
|
paused: undefined,
|
||||||
});
|
loading: false,
|
||||||
});
|
toggling: false,
|
||||||
loadConfig();
|
ws: undefined,
|
||||||
load();
|
|
||||||
|
|
||||||
const intervalMS = 5000;
|
wsConnect(secure = true) {
|
||||||
setTimeout(() => {
|
const url = `${secure ? 'wss' : 'ws'}://${location.host}${base_url}api/admin/mangadex/queue`;
|
||||||
setInterval(() => {
|
console.log(`Connecting to ${url}`);
|
||||||
if (globalConfig.autoRefresh !== true) return;
|
this.ws = new WebSocket(url);
|
||||||
load();
|
this.ws.onmessage = event => {
|
||||||
}, intervalMS);
|
const data = JSON.parse(event.data);
|
||||||
}, intervalMS);
|
this.jobs = data.jobs;
|
||||||
});
|
this.paused = data.paused;
|
||||||
var globalConfig = {};
|
};
|
||||||
var loading = false;
|
this.ws.onclose = () => {
|
||||||
|
if (this.ws.failed)
|
||||||
const loadConfig = () => {
|
return this.wsConnect(false);
|
||||||
globalConfig.autoRefresh = $('#auto-refresh').prop('checked');
|
alert('danger', 'Socket connection closed');
|
||||||
};
|
};
|
||||||
const remove = (id) => {
|
this.ws.onerror = () => {
|
||||||
var url = base_url + 'api/admin/mangadex/queue/delete';
|
if (secure)
|
||||||
if (id !== undefined)
|
return this.ws.failed = true;
|
||||||
url += '?' + $.param({
|
alert('danger', 'Socket connection failed');
|
||||||
id: id
|
};
|
||||||
});
|
},
|
||||||
console.log(url);
|
init() {
|
||||||
$.ajax({
|
this.wsConnect();
|
||||||
type: 'POST',
|
this.load();
|
||||||
url: url,
|
},
|
||||||
dataType: 'json'
|
load() {
|
||||||
})
|
this.loading = true;
|
||||||
.done(data => {
|
$.ajax({
|
||||||
if (!data.success && data.error) {
|
type: 'GET',
|
||||||
alert('danger', `Failed to remove job from download queue. Error: ${data.error}`);
|
url: base_url + 'api/admin/mangadex/queue',
|
||||||
return;
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (!data.success && data.error) {
|
||||||
|
alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.jobs = data.jobs;
|
||||||
|
this.paused = data.paused;
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
.always(() => {
|
||||||
|
this.loading = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
jobAction(action, event) {
|
||||||
|
let url = `${base_url}api/admin/mangadex/queue/${action}`;
|
||||||
|
if (event) {
|
||||||
|
const id = event.currentTarget.closest('tr').id.split('-').slice(1).join('-');
|
||||||
|
url = `${url}?${$.param({
|
||||||
|
id: id
|
||||||
|
})}`;
|
||||||
}
|
}
|
||||||
load();
|
console.log(url);
|
||||||
})
|
$.ajax({
|
||||||
.fail((jqXHR, status) => {
|
type: 'POST',
|
||||||
alert('danger', `Failed to remove job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
url: url,
|
||||||
});
|
dataType: 'json'
|
||||||
};
|
})
|
||||||
const refresh = (id) => {
|
.done(data => {
|
||||||
var url = base_url + 'api/admin/mangadex/queue/retry';
|
if (!data.success && data.error) {
|
||||||
if (id !== undefined)
|
alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`);
|
||||||
url += '?' + $.param({
|
return;
|
||||||
id: id
|
}
|
||||||
});
|
this.load();
|
||||||
console.log(url);
|
})
|
||||||
$.ajax({
|
.fail((jqXHR, status) => {
|
||||||
type: 'POST',
|
alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
url: url,
|
});
|
||||||
dataType: 'json'
|
},
|
||||||
})
|
toggle() {
|
||||||
.done(data => {
|
this.toggling = true;
|
||||||
if (!data.success && data.error) {
|
const action = this.paused ? 'resume' : 'pause';
|
||||||
alert('danger', `Failed to restart download job. Error: ${data.error}`);
|
const url = `${base_url}api/admin/mangadex/queue/${action}`;
|
||||||
return;
|
$.ajax({
|
||||||
}
|
type: 'POST',
|
||||||
load();
|
url: url,
|
||||||
})
|
dataType: 'json'
|
||||||
.fail((jqXHR, status) => {
|
})
|
||||||
alert('danger', `Failed to restart download job. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
.fail((jqXHR, status) => {
|
||||||
});
|
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
};
|
})
|
||||||
const toggle = () => {
|
.always(() => {
|
||||||
$('#pause-resume-btn').attr('disabled', '');
|
this.load();
|
||||||
const paused = $('#pause-resume-btn').text() === 'Resume download';
|
this.toggling = false;
|
||||||
const action = paused ? 'resume' : 'pause';
|
});
|
||||||
const url = `${base_url}api/admin/mangadex/queue/${action}`;
|
},
|
||||||
$.ajax({
|
statusClass(status) {
|
||||||
type: 'POST',
|
let cls = 'label ';
|
||||||
url: url,
|
switch (status) {
|
||||||
dataType: 'json'
|
case 'Pending':
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
load();
|
|
||||||
$('#pause-resume-btn').removeAttr('disabled');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const load = () => {
|
|
||||||
if (loading) return;
|
|
||||||
loading = true;
|
|
||||||
console.log('fetching');
|
|
||||||
$.ajax({
|
|
||||||
type: 'GET',
|
|
||||||
url: base_url + 'api/admin/mangadex/queue',
|
|
||||||
dataType: 'json'
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
if (!data.success && data.error) {
|
|
||||||
alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(data);
|
|
||||||
const btnText = data.paused ? "Resume download" : "Pause download";
|
|
||||||
$('#pause-resume-btn').text(btnText);
|
|
||||||
$('#pause-resume-btn').removeAttr('hidden');
|
|
||||||
const rows = data.jobs.map(obj => {
|
|
||||||
var cls = 'label ';
|
|
||||||
if (obj.status === 'Pending')
|
|
||||||
cls += 'label-pending';
|
cls += 'label-pending';
|
||||||
if (obj.status === 'Completed')
|
break;
|
||||||
|
case 'Completed':
|
||||||
cls += 'label-success';
|
cls += 'label-success';
|
||||||
if (obj.status === 'Error')
|
break;
|
||||||
|
case 'Error':
|
||||||
cls += 'label-danger';
|
cls += 'label-danger';
|
||||||
if (obj.status === 'MissingPages')
|
break;
|
||||||
|
case 'MissingPages':
|
||||||
cls += 'label-warning';
|
cls += 'label-warning';
|
||||||
|
break;
|
||||||
const info = obj.status_message.length > 0 ? '<span uk-icon="info"></span>' : '';
|
}
|
||||||
const statusSpan = `<span class="${cls}">${obj.status} ${info}</span>`;
|
return cls;
|
||||||
const dropdown = obj.status_message.length > 0 ? `<div uk-dropdown>${obj.status_message}</div>` : '';
|
}
|
||||||
const retryBtn = obj.status_message.length > 0 ? `<a onclick="refresh('${obj.id}')" uk-icon="refresh"></a>` : '';
|
};
|
||||||
return `<tr id="chapter-${obj.id}">
|
|
||||||
<td>${obj.plugin_id ? obj.title : `<a href="${baseURL}/chapter/${obj.id}">${obj.title}</a>`}</td>
|
|
||||||
<td>${obj.plugin_id ? obj.manga_title : `<a href="${baseURL}/manga/${obj.manga_id}">${obj.manga_title}</a>`}</td>
|
|
||||||
<td>${obj.success_count}/${obj.pages}</td>
|
|
||||||
<td>${moment(obj.time).fromNow()}</td>
|
|
||||||
<td>${statusSpan} ${dropdown}</td>
|
|
||||||
<td>${obj.plugin_id || ""}</td>
|
|
||||||
<td>
|
|
||||||
<a onclick="remove('${obj.id}')" uk-icon="trash"></a>
|
|
||||||
${retryBtn}
|
|
||||||
</td>
|
|
||||||
</tr>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const tbody = `<tbody>${rows.join('')}</tbody>`;
|
|
||||||
$('tbody').remove();
|
|
||||||
$('table').append(tbody);
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
loading = false;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,305 +0,0 @@
|
|||||||
$(() => {
|
|
||||||
$('#search-input').keypress(event => {
|
|
||||||
if (event.which === 13) {
|
|
||||||
search();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$('.filter-field').each((i, ele) => {
|
|
||||||
$(ele).change(() => {
|
|
||||||
buildTable();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const selectAll = () => {
|
|
||||||
$('tbody > tr').each((i, e) => {
|
|
||||||
$(e).addClass('ui-selected');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const unselect = () => {
|
|
||||||
$('tbody > tr').each((i, e) => {
|
|
||||||
$(e).removeClass('ui-selected');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const download = () => {
|
|
||||||
const selected = $('tbody > tr.ui-selected');
|
|
||||||
if (selected.length === 0) return;
|
|
||||||
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
|
||||||
$('#download-btn').attr('hidden', '');
|
|
||||||
$('#download-spinner').removeAttr('hidden');
|
|
||||||
const ids = selected.map((i, e) => {
|
|
||||||
return $(e).find('td').first().text();
|
|
||||||
}).get();
|
|
||||||
const chapters = globalChapters.filter(c => ids.indexOf(c.id) >= 0);
|
|
||||||
console.log(ids);
|
|
||||||
$.ajax({
|
|
||||||
type: 'POST',
|
|
||||||
url: base_url + 'api/admin/mangadex/download',
|
|
||||||
data: JSON.stringify({
|
|
||||||
chapters: chapters
|
|
||||||
}),
|
|
||||||
contentType: "application/json",
|
|
||||||
dataType: 'json'
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
console.log(data);
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const successCount = parseInt(data.success);
|
|
||||||
const failCount = parseInt(data.fail);
|
|
||||||
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
|
|
||||||
window.location.href = base_url + 'admin/downloads';
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
$('#download-spinner').attr('hidden', '');
|
|
||||||
$('#download-btn').removeAttr('hidden');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const toggleSpinner = () => {
|
|
||||||
var attr = $('#spinner').attr('hidden');
|
|
||||||
if (attr) {
|
|
||||||
$('#spinner').removeAttr('hidden');
|
|
||||||
$('#search-btn').attr('hidden', '');
|
|
||||||
} else {
|
|
||||||
$('#search-btn').removeAttr('hidden');
|
|
||||||
$('#spinner').attr('hidden', '');
|
|
||||||
}
|
|
||||||
searching = !searching;
|
|
||||||
};
|
|
||||||
var searching = false;
|
|
||||||
var globalChapters;
|
|
||||||
const search = () => {
|
|
||||||
if (searching) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$('#manga-details').attr('hidden', '');
|
|
||||||
$('#filter-form').attr('hidden', '');
|
|
||||||
$('table').attr('hidden', '');
|
|
||||||
$('#selection-controls').attr('hidden', '');
|
|
||||||
$('#filter-notification').attr('hidden', '');
|
|
||||||
toggleSpinner();
|
|
||||||
const input = $('input').val();
|
|
||||||
|
|
||||||
if (input === "") {
|
|
||||||
toggleSpinner();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var int_id = -1;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const path = new URL(input).pathname;
|
|
||||||
const match = /\/title\/([0-9]+)/.exec(path);
|
|
||||||
int_id = parseInt(match[1]);
|
|
||||||
} catch (e) {
|
|
||||||
int_id = parseInt(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (int_id <= 0 || isNaN(int_id)) {
|
|
||||||
alert('danger', 'Please make sure you are using a valid manga ID or manga URL from Mangadex.');
|
|
||||||
toggleSpinner();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`)
|
|
||||||
.done((data) => {
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', 'Failed to get manga info. Error: ' + data.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cover = baseURL + data.cover_url;
|
|
||||||
$('#cover').attr("src", cover);
|
|
||||||
$('#title').text("Title: " + data.title);
|
|
||||||
$('#artist').text("Artist: " + data.artist);
|
|
||||||
$('#author').text("Author: " + data.author);
|
|
||||||
|
|
||||||
$('#manga-details').removeAttr('hidden');
|
|
||||||
|
|
||||||
console.log(data.chapters);
|
|
||||||
globalChapters = data.chapters;
|
|
||||||
|
|
||||||
let langs = new Set();
|
|
||||||
let group_names = new Set();
|
|
||||||
data.chapters.forEach(chp => {
|
|
||||||
Object.entries(chp.groups).forEach(([k, v]) => {
|
|
||||||
group_names.add(k);
|
|
||||||
});
|
|
||||||
langs.add(chp.language);
|
|
||||||
});
|
|
||||||
|
|
||||||
const comp = (a, b) => {
|
|
||||||
var ai;
|
|
||||||
var bi;
|
|
||||||
try {
|
|
||||||
ai = parseFloat(a);
|
|
||||||
} catch (e) {}
|
|
||||||
try {
|
|
||||||
bi = parseFloat(b);
|
|
||||||
} catch (e) {}
|
|
||||||
if (typeof ai === 'undefined') return -1;
|
|
||||||
if (typeof bi === 'undefined') return 1;
|
|
||||||
if (ai < bi) return 1;
|
|
||||||
if (ai > bi) return -1;
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
langs = [...langs].sort();
|
|
||||||
group_names = [...group_names].sort();
|
|
||||||
|
|
||||||
langs.unshift('All');
|
|
||||||
group_names.unshift('All');
|
|
||||||
|
|
||||||
$('select#lang-select').append(langs.map(e => `<option>${e}</option>`).join(''));
|
|
||||||
$('select#group-select').append(group_names.map(e => `<option>${e}</option>`).join(''));
|
|
||||||
|
|
||||||
$('#filter-form').removeAttr('hidden');
|
|
||||||
|
|
||||||
buildTable();
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
toggleSpinner();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const parseRange = str => {
|
|
||||||
const regex = /^[\t ]*(?:(?:(<|<=|>|>=)[\t ]*([0-9]+))|(?:([0-9]+))|(?:([0-9]+)[\t ]*-[\t ]*([0-9]+))|(?:[\t ]*))[\t ]*$/m;
|
|
||||||
const matches = str.match(regex);
|
|
||||||
var num;
|
|
||||||
|
|
||||||
if (!matches) {
|
|
||||||
alert('danger', `Failed to parse filter input ${str}`);
|
|
||||||
return [null, null];
|
|
||||||
} else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
|
|
||||||
// e.g., <= 30
|
|
||||||
num = parseInt(matches[2]);
|
|
||||||
if (isNaN(num)) {
|
|
||||||
alert('danger', `Failed to parse filter input ${str}`);
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
switch (matches[1]) {
|
|
||||||
case '<':
|
|
||||||
return [null, num - 1];
|
|
||||||
case '<=':
|
|
||||||
return [null, num];
|
|
||||||
case '>':
|
|
||||||
return [num + 1, null];
|
|
||||||
case '>=':
|
|
||||||
return [num, null];
|
|
||||||
}
|
|
||||||
} else if (typeof matches[3] !== 'undefined') {
|
|
||||||
// a single number
|
|
||||||
num = parseInt(matches[3]);
|
|
||||||
if (isNaN(num)) {
|
|
||||||
alert('danger', `Failed to parse filter input ${str}`);
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
return [num, num];
|
|
||||||
} else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
|
|
||||||
// e.g., 10 - 23
|
|
||||||
num = parseInt(matches[4]);
|
|
||||||
const n2 = parseInt(matches[5]);
|
|
||||||
if (isNaN(num) || isNaN(n2) || num > n2) {
|
|
||||||
alert('danger', `Failed to parse filter input ${str}`);
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
return [num, n2];
|
|
||||||
} else {
|
|
||||||
// empty or space only
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const getFilters = () => {
|
|
||||||
const filters = {};
|
|
||||||
$('.uk-select').each((i, ele) => {
|
|
||||||
const id = $(ele).attr('id');
|
|
||||||
const by = id.split('-')[0];
|
|
||||||
const choice = $(ele).val();
|
|
||||||
filters[by] = choice;
|
|
||||||
});
|
|
||||||
filters.volume = parseRange($('#volume-range').val());
|
|
||||||
filters.chapter = parseRange($('#chapter-range').val());
|
|
||||||
return filters;
|
|
||||||
};
|
|
||||||
const buildTable = () => {
|
|
||||||
$('table').attr('hidden', '');
|
|
||||||
$('#selection-controls').attr('hidden', '');
|
|
||||||
$('#filter-notification').attr('hidden', '');
|
|
||||||
console.log('rebuilding table');
|
|
||||||
const filters = getFilters();
|
|
||||||
console.log('filters:', filters);
|
|
||||||
var chapters = globalChapters.slice();
|
|
||||||
Object.entries(filters).forEach(([k, v]) => {
|
|
||||||
if (v === 'All') return;
|
|
||||||
if (k === 'group') {
|
|
||||||
chapters = chapters.filter(c => {
|
|
||||||
unescaped_groups = Object.entries(c.groups).map(([g, id]) => unescapeHTML(g));
|
|
||||||
return unescaped_groups.indexOf(v) >= 0;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (k === 'lang') {
|
|
||||||
chapters = chapters.filter(c => c.language === v);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lb = parseFloat(v[0]);
|
|
||||||
const ub = parseFloat(v[1]);
|
|
||||||
if (isNaN(lb) && isNaN(ub)) return;
|
|
||||||
chapters = chapters.filter(c => {
|
|
||||||
const val = parseFloat(c[k]);
|
|
||||||
if (isNaN(val)) return false;
|
|
||||||
if (isNaN(lb))
|
|
||||||
return val <= ub;
|
|
||||||
else if (isNaN(ub))
|
|
||||||
return val >= lb;
|
|
||||||
else
|
|
||||||
return val >= lb && val <= ub;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
console.log('filtered chapters:', chapters);
|
|
||||||
$('#count-text').text(`${chapters.length} chapters found`);
|
|
||||||
|
|
||||||
const chaptersLimit = 1000;
|
|
||||||
if (chapters.length > chaptersLimit) {
|
|
||||||
$('#filter-notification').text(`Mango can only list ${chaptersLimit} chapters, but we found ${chapters.length} chapters in this manga. Please use the filter options above to narrow down your search.`);
|
|
||||||
$('#filter-notification').removeAttr('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const inner = chapters.map(chp => {
|
|
||||||
const group_str = Object.entries(chp.groups).map(([k, v]) => {
|
|
||||||
return `<a href="${baseURL }/group/${v}">${k}</a>`;
|
|
||||||
}).join(' | ');
|
|
||||||
return `<tr class="ui-widget-content">
|
|
||||||
<td><a href="${baseURL}/chapter/${chp.id}">${chp.id}</a></td>
|
|
||||||
<td>${chp.title}</td>
|
|
||||||
<td>${chp.language}</td>
|
|
||||||
<td>${group_str}</td>
|
|
||||||
<td>${chp.volume}</td>
|
|
||||||
<td>${chp.chapter}</td>
|
|
||||||
<td>${moment.unix(chp.time).fromNow()}</td>
|
|
||||||
</tr>`;
|
|
||||||
}).join('');
|
|
||||||
const tbody = `<tbody id="selectable">${inner}</tbody>`;
|
|
||||||
$('tbody').remove();
|
|
||||||
$('table').append(tbody);
|
|
||||||
$('table').removeAttr('hidden');
|
|
||||||
$("#selectable").selectable({
|
|
||||||
filter: 'tr'
|
|
||||||
});
|
|
||||||
$('#selection-controls').removeAttr('hidden');
|
|
||||||
};
|
|
||||||
|
|
||||||
const unescapeHTML = (str) => {
|
|
||||||
var elt = document.createElement("span");
|
|
||||||
elt.innerHTML = str;
|
|
||||||
return elt.innerText;
|
|
||||||
};
|
|
||||||
Vendored
-5
File diff suppressed because one or more lines are too long
@@ -0,0 +1,60 @@
|
|||||||
|
const component = () => {
|
||||||
|
return {
|
||||||
|
empty: true,
|
||||||
|
titles: [],
|
||||||
|
entries: [],
|
||||||
|
loading: true,
|
||||||
|
|
||||||
|
load() {
|
||||||
|
this.loading = true;
|
||||||
|
this.request('GET', `${base_url}api/admin/titles/missing`, data => {
|
||||||
|
this.titles = data.titles;
|
||||||
|
this.request('GET', `${base_url}api/admin/entries/missing`, data => {
|
||||||
|
this.entries = data.entries;
|
||||||
|
this.loading = false;
|
||||||
|
this.empty = this.entries.length === 0 && this.titles.length === 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
rm(event) {
|
||||||
|
const rawID = event.currentTarget.closest('tr').id;
|
||||||
|
const [type, id] = rawID.split('-');
|
||||||
|
const url = `${base_url}api/admin/${type === 'title' ? 'titles' : 'entries'}/missing/${id}`;
|
||||||
|
this.request('DELETE', url, () => {
|
||||||
|
this.load();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
rmAll() {
|
||||||
|
UIkit.modal.confirm('Are you sure? All metadata associated with these items, including their tags and thumbnails, will be deleted from the database.', {
|
||||||
|
labels: {
|
||||||
|
ok: 'Yes, delete them',
|
||||||
|
cancel: 'Cancel'
|
||||||
|
}
|
||||||
|
}).then(() => {
|
||||||
|
this.request('DELETE', `${base_url}api/admin/titles/missing`, () => {
|
||||||
|
this.request('DELETE', `${base_url}api/admin/entries/missing`, () => {
|
||||||
|
this.load();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
request(method, url, cb) {
|
||||||
|
console.log(url);
|
||||||
|
$.ajax({
|
||||||
|
type: method,
|
||||||
|
url: url,
|
||||||
|
contentType: 'application/json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', `Failed to ${method} ${url}. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cb) cb(data);
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to ${method} ${url}. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
+443
-133
@@ -1,142 +1,452 @@
|
|||||||
const loadPlugin = id => {
|
const component = () => {
|
||||||
localStorage.setItem('plugin', id);
|
return {
|
||||||
const url = `${location.protocol}//${location.host}${location.pathname}`;
|
plugins: [],
|
||||||
const newURL = `${url}?${$.param({
|
subscribable: false,
|
||||||
plugin: id
|
info: undefined,
|
||||||
})}`;
|
pid: undefined,
|
||||||
window.location.href = newURL;
|
chapters: undefined, // undefined: not searched yet, []: empty
|
||||||
};
|
manga: undefined, // undefined: not searched yet, []: empty
|
||||||
|
mid: undefined, // id of the selected manga
|
||||||
|
allChapters: [],
|
||||||
|
query: "",
|
||||||
|
mangaTitle: "",
|
||||||
|
searching: false,
|
||||||
|
adding: false,
|
||||||
|
sortOptions: [],
|
||||||
|
showFilters: false,
|
||||||
|
appliedFilters: [],
|
||||||
|
chaptersLimit: 500,
|
||||||
|
listManga: false,
|
||||||
|
subscribing: false,
|
||||||
|
subscriptionName: "",
|
||||||
|
|
||||||
$(() => {
|
init() {
|
||||||
var storedID = localStorage.getItem('plugin');
|
const tableObserver = new MutationObserver(() => {
|
||||||
if (storedID && storedID !== pid) {
|
console.log("table mutated");
|
||||||
loadPlugin(storedID);
|
$("#selectable").selectable({
|
||||||
} else {
|
filter: "tr",
|
||||||
$('#controls').removeAttr('hidden');
|
});
|
||||||
}
|
});
|
||||||
|
tableObserver.observe($("table").get(0), {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
fetch(`${base_url}api/admin/plugin`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (!data.success) throw new Error(data.error);
|
||||||
|
this.plugins = data.plugins;
|
||||||
|
|
||||||
$('#search-input').keypress(event => {
|
const pid = localStorage.getItem("plugin");
|
||||||
if (event.which === 13) {
|
if (pid && this.plugins.map((p) => p.id).includes(pid))
|
||||||
search();
|
return this.loadPlugin(pid);
|
||||||
}
|
|
||||||
});
|
|
||||||
$('#plugin-select').val(pid);
|
|
||||||
$('#plugin-select').change(() => {
|
|
||||||
const id = $('#plugin-select').val();
|
|
||||||
loadPlugin(id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let mangaTitle = "";
|
if (this.plugins.length > 0)
|
||||||
let searching = false;
|
this.loadPlugin(this.plugins[0].id);
|
||||||
const search = () => {
|
})
|
||||||
if (searching)
|
.catch((e) => {
|
||||||
return;
|
alert(
|
||||||
|
"danger",
|
||||||
|
`Failed to list the available plugins. Error: ${e}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
loadPlugin(pid) {
|
||||||
|
fetch(
|
||||||
|
`${base_url}api/admin/plugin/info?${new URLSearchParams({
|
||||||
|
plugin: pid,
|
||||||
|
})}`
|
||||||
|
)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (!data.success) throw new Error(data.error);
|
||||||
|
this.info = data.info;
|
||||||
|
this.subscribable = data.subscribable;
|
||||||
|
this.pid = pid;
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
alert(
|
||||||
|
"danger",
|
||||||
|
`Failed to get plugin metadata. Error: ${e}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
pluginChanged() {
|
||||||
|
this.manga = undefined;
|
||||||
|
this.chapters = undefined;
|
||||||
|
this.mid = undefined;
|
||||||
|
this.loadPlugin(this.pid);
|
||||||
|
localStorage.setItem("plugin", this.pid);
|
||||||
|
},
|
||||||
|
get chapterKeys() {
|
||||||
|
if (this.allChapters.length < 1) return [];
|
||||||
|
return Object.keys(this.allChapters[0]).filter(
|
||||||
|
(k) => !["manga_title"].includes(k)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
searchChapters(query) {
|
||||||
|
this.searching = true;
|
||||||
|
this.allChapters = [];
|
||||||
|
this.sortOptions = [];
|
||||||
|
this.chapters = undefined;
|
||||||
|
this.listManga = false;
|
||||||
|
fetch(
|
||||||
|
`${base_url}api/admin/plugin/list?${new URLSearchParams({
|
||||||
|
plugin: this.pid,
|
||||||
|
query: query,
|
||||||
|
})}`
|
||||||
|
)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (!data.success) throw new Error(data.error);
|
||||||
|
try {
|
||||||
|
this.mangaTitle = data.chapters[0].manga_title;
|
||||||
|
if (!this.mangaTitle) throw new Error();
|
||||||
|
} catch (e) {
|
||||||
|
this.mangaTitle = data.title;
|
||||||
|
}
|
||||||
|
|
||||||
const query = $('#search-input').val();
|
this.allChapters = data.chapters;
|
||||||
$.ajax({
|
this.chapters = data.chapters;
|
||||||
type: 'POST',
|
})
|
||||||
url: base_url + 'api/admin/plugin/list',
|
.catch((e) => {
|
||||||
data: JSON.stringify({
|
alert("danger", `Failed to list chapters. Error: ${e}`);
|
||||||
query: query,
|
})
|
||||||
plugin: pid
|
.finally(() => {
|
||||||
}),
|
this.searching = false;
|
||||||
contentType: "application/json",
|
});
|
||||||
dataType: 'json'
|
},
|
||||||
})
|
searchManga(query) {
|
||||||
.done(data => {
|
this.searching = true;
|
||||||
console.log(data);
|
this.allChapters = [];
|
||||||
if (data.error) {
|
this.chapters = undefined;
|
||||||
alert('danger', `Search failed. Error: ${data.error}`);
|
this.manga = undefined;
|
||||||
|
fetch(
|
||||||
|
`${base_url}api/admin/plugin/search?${new URLSearchParams({
|
||||||
|
plugin: this.pid,
|
||||||
|
query: query,
|
||||||
|
})}`
|
||||||
|
)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (!data.success) throw new Error(data.error);
|
||||||
|
this.manga = data.manga;
|
||||||
|
this.listManga = true;
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
alert("danger", `Search failed. Error: ${e}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.searching = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
search() {
|
||||||
|
const query = this.query.trim();
|
||||||
|
if (!query) return;
|
||||||
|
|
||||||
|
this.manga = undefined;
|
||||||
|
this.mid = undefined;
|
||||||
|
if (this.info.version === 1) {
|
||||||
|
this.searchChapters(query);
|
||||||
|
} else {
|
||||||
|
this.searchManga(query);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectAll() {
|
||||||
|
$("tbody > tr").each((i, e) => {
|
||||||
|
$(e).addClass("ui-selected");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clearSelection() {
|
||||||
|
$("tbody > tr").each((i, e) => {
|
||||||
|
$(e).removeClass("ui-selected");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
download() {
|
||||||
|
const selected = $("tbody > tr.ui-selected").get();
|
||||||
|
if (selected.length === 0) return;
|
||||||
|
|
||||||
|
UIkit.modal
|
||||||
|
.confirm(`Download ${selected.length} selected chapters?`)
|
||||||
|
.then(() => {
|
||||||
|
const ids = selected.map((e) => e.id);
|
||||||
|
const chapters = this.chapters.filter((c) =>
|
||||||
|
ids.includes(c.id)
|
||||||
|
);
|
||||||
|
console.log(chapters);
|
||||||
|
this.adding = true;
|
||||||
|
fetch(`${base_url}api/admin/plugin/download`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
chapters,
|
||||||
|
plugin: this.pid,
|
||||||
|
title: this.mangaTitle,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (!data.success) throw new Error(data.error);
|
||||||
|
const successCount = parseInt(data.success);
|
||||||
|
const failCount = parseInt(data.fail);
|
||||||
|
alert(
|
||||||
|
"success",
|
||||||
|
`${successCount} of ${
|
||||||
|
successCount + failCount
|
||||||
|
} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
alert(
|
||||||
|
"danger",
|
||||||
|
`Failed to add chapters to the download queue. Error: ${e}`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.adding = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
thClicked(event) {
|
||||||
|
const idx = parseInt(event.currentTarget.id.split("-")[1]);
|
||||||
|
if (idx === undefined || isNaN(idx)) return;
|
||||||
|
const curOption = this.sortOptions[idx];
|
||||||
|
let option;
|
||||||
|
this.sortOptions = [];
|
||||||
|
switch (curOption) {
|
||||||
|
case 1:
|
||||||
|
option = -1;
|
||||||
|
break;
|
||||||
|
case -1:
|
||||||
|
option = 0;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
option = 1;
|
||||||
|
}
|
||||||
|
this.sortOptions[idx] = option;
|
||||||
|
this.sort(this.chapterKeys[idx], option);
|
||||||
|
},
|
||||||
|
// Returns an array of filtered but unsorted chapters. Useful when
|
||||||
|
// reseting the sort options.
|
||||||
|
get filteredChapters() {
|
||||||
|
let ary = this.allChapters.slice();
|
||||||
|
|
||||||
|
console.log("initial size:", ary.length);
|
||||||
|
for (let filter of this.appliedFilters) {
|
||||||
|
if (!filter.value) continue;
|
||||||
|
if (filter.type === "array" && filter.value === "all") continue;
|
||||||
|
if (filter.type.startsWith("number") && isNaN(filter.value))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (filter.type === "string") {
|
||||||
|
ary = ary.filter((ch) =>
|
||||||
|
ch[filter.key]
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(filter.value.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filter.type === "number-min") {
|
||||||
|
ary = ary.filter(
|
||||||
|
(ch) => Number(ch[filter.key]) >= Number(filter.value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filter.type === "number-max") {
|
||||||
|
ary = ary.filter(
|
||||||
|
(ch) => Number(ch[filter.key]) <= Number(filter.value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filter.type === "date-min") {
|
||||||
|
ary = ary.filter(
|
||||||
|
(ch) => Number(ch[filter.key]) >= Number(filter.value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filter.type === "date-max") {
|
||||||
|
ary = ary.filter(
|
||||||
|
(ch) => Number(ch[filter.key]) <= Number(filter.value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filter.type === "array") {
|
||||||
|
ary = ary.filter((ch) =>
|
||||||
|
ch[filter.key]
|
||||||
|
.map((s) =>
|
||||||
|
typeof s === "string" ? s.toLowerCase() : s
|
||||||
|
)
|
||||||
|
.includes(filter.value.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("filtered size:", ary.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ary;
|
||||||
|
},
|
||||||
|
// option:
|
||||||
|
// - 1: asending
|
||||||
|
// - -1: desending
|
||||||
|
// - 0: unsorted
|
||||||
|
sort(key, option) {
|
||||||
|
if (option === 0) {
|
||||||
|
this.chapters = this.filteredChapters;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mangaTitle = data.title;
|
|
||||||
$('#title-text').text(data.title);
|
|
||||||
buildTable(data.chapters);
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildTable = (chapters) => {
|
this.chapters = this.filteredChapters.sort((a, b) => {
|
||||||
$('#table').attr('hidden', '');
|
const comp = this.compare(a[key], b[key]);
|
||||||
$('table').empty();
|
return option < 0 ? comp * -1 : comp;
|
||||||
|
|
||||||
const keys = Object.keys(chapters[0]).map(k => `<th>${k}</th>`).join('');
|
|
||||||
const thead = `<thead><tr>${keys}</tr></thead>`;
|
|
||||||
$('table').append(thead);
|
|
||||||
|
|
||||||
const rows = chapters.map(ch => {
|
|
||||||
const tds = Object.values(ch).map(v => `<td>${v}</td>`).join('');
|
|
||||||
return `<tr data-id="${ch.id}" data-title="${ch.title}">${tds}</tr>`;
|
|
||||||
});
|
|
||||||
const tbody = `<tbody id="selectable">${rows}</tbody>`;
|
|
||||||
$('table').append(tbody);
|
|
||||||
|
|
||||||
$('#selectable').selectable({
|
|
||||||
filter: 'tr'
|
|
||||||
});
|
|
||||||
|
|
||||||
$('#table table').tablesorter();
|
|
||||||
$('#table').removeAttr('hidden');
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectAll = () => {
|
|
||||||
$('tbody > tr').each((i, e) => {
|
|
||||||
$(e).addClass('ui-selected');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const unselect = () => {
|
|
||||||
$('tbody > tr').each((i, e) => {
|
|
||||||
$(e).removeClass('ui-selected');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const download = () => {
|
|
||||||
const selected = $('tbody > tr.ui-selected');
|
|
||||||
if (selected.length === 0) return;
|
|
||||||
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
|
||||||
$('#download-btn').attr('hidden', '');
|
|
||||||
$('#download-spinner').removeAttr('hidden');
|
|
||||||
const chapters = selected.map((i, e) => {
|
|
||||||
return {
|
|
||||||
id: $(e).attr('data-id'),
|
|
||||||
title: $(e).attr('data-title')
|
|
||||||
}
|
|
||||||
}).get();
|
|
||||||
console.log(chapters);
|
|
||||||
$.ajax({
|
|
||||||
type: 'POST',
|
|
||||||
url: base_url + 'api/admin/plugin/download',
|
|
||||||
data: JSON.stringify({
|
|
||||||
plugin: pid,
|
|
||||||
chapters: chapters,
|
|
||||||
title: mangaTitle
|
|
||||||
}),
|
|
||||||
contentType: "application/json",
|
|
||||||
dataType: 'json'
|
|
||||||
})
|
|
||||||
.done(data => {
|
|
||||||
console.log(data);
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const successCount = parseInt(data.success);
|
|
||||||
const failCount = parseInt(data.fail);
|
|
||||||
UIkit.modal.confirm(`${successCount} of ${successCount + failCount} chapters added to the download queue. Proceed to the download manager?`).then(() => {
|
|
||||||
window.location.href = base_url + 'admin/downloads';
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
$('#download-spinner').attr('hidden', '');
|
|
||||||
$('#download-btn').removeAttr('hidden');
|
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
compare(a, b) {
|
||||||
|
if (a === b) return 0;
|
||||||
|
|
||||||
|
// try numbers (also covers dates)
|
||||||
|
if (!isNaN(a) && !isNaN(b)) return Number(a) - Number(b);
|
||||||
|
|
||||||
|
const preprocessString = (val) => {
|
||||||
|
if (typeof val !== "string") return val;
|
||||||
|
return val.toLowerCase().replace(/\s\s/g, " ").trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
return preprocessString(a) > preprocessString(b) ? 1 : -1;
|
||||||
|
},
|
||||||
|
fieldType(values) {
|
||||||
|
if (values.every((v) => this.numIsDate(v))) return "date";
|
||||||
|
if (values.every((v) => !isNaN(v))) return "number";
|
||||||
|
if (values.every((v) => Array.isArray(v))) return "array";
|
||||||
|
return "string";
|
||||||
|
},
|
||||||
|
get filters() {
|
||||||
|
if (this.allChapters.length < 1) return [];
|
||||||
|
const keys = Object.keys(this.allChapters[0]).filter(
|
||||||
|
(k) => !["manga_title", "id"].includes(k)
|
||||||
|
);
|
||||||
|
return keys.map((k) => {
|
||||||
|
let values = this.allChapters.map((c) => c[k]);
|
||||||
|
const type = this.fieldType(values);
|
||||||
|
|
||||||
|
if (type === "array") {
|
||||||
|
// if the type is an array, return the list of available elements
|
||||||
|
// example: an array of groups or authors
|
||||||
|
values = Array.from(
|
||||||
|
new Set(
|
||||||
|
values.flat().map((v) => {
|
||||||
|
if (typeof v === "string")
|
||||||
|
return v.toLowerCase();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: k,
|
||||||
|
type: type,
|
||||||
|
values: values,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
get filterSettings() {
|
||||||
|
return $("#filter-form input:visible, #filter-form select:visible")
|
||||||
|
.get()
|
||||||
|
.map((i) => {
|
||||||
|
const type = i.getAttribute("data-filter-type");
|
||||||
|
let value = i.value.trim();
|
||||||
|
if (type.startsWith("date"))
|
||||||
|
value = value ? Date.parse(value).toString() : "";
|
||||||
|
return {
|
||||||
|
key: i.getAttribute("data-filter-key"),
|
||||||
|
value: value,
|
||||||
|
type: type,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
applyFilters() {
|
||||||
|
this.appliedFilters = this.filterSettings;
|
||||||
|
this.chapters = this.filteredChapters;
|
||||||
|
this.sortOptions = [];
|
||||||
|
},
|
||||||
|
clearFilters() {
|
||||||
|
$("#filter-form input")
|
||||||
|
.get()
|
||||||
|
.forEach((i) => (i.value = ""));
|
||||||
|
$("#filter-form select").val("all");
|
||||||
|
this.appliedFilters = [];
|
||||||
|
this.chapters = this.filteredChapters;
|
||||||
|
this.sortOptions = [];
|
||||||
|
},
|
||||||
|
mangaSelected(event) {
|
||||||
|
const mid = event.currentTarget.getAttribute("data-id");
|
||||||
|
this.mid = mid;
|
||||||
|
this.searchChapters(mid);
|
||||||
|
},
|
||||||
|
subscribe(modal) {
|
||||||
|
this.subscribing = true;
|
||||||
|
fetch(`${base_url}api/admin/plugin/subscriptions`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
filters: this.filterSettings,
|
||||||
|
plugin: this.pid,
|
||||||
|
name: this.subscriptionName.trim(),
|
||||||
|
manga: this.mangaTitle,
|
||||||
|
manga_id: this.mid,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (!data.success) throw new Error(data.error);
|
||||||
|
alert("success", "Subscription created");
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
alert("danger", `Failed to subscribe. Error: ${e}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.subscribing = false;
|
||||||
|
UIkit.modal(modal).hide();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
numIsDate(num) {
|
||||||
|
return !isNaN(num) && Number(num) > 328896000000; // 328896000000 => 1 Jan, 1980
|
||||||
|
},
|
||||||
|
renderCell(value) {
|
||||||
|
if (this.numIsDate(value))
|
||||||
|
return `<span>${moment(Number(value)).format(
|
||||||
|
"MMM D, YYYY"
|
||||||
|
)}</span>`;
|
||||||
|
const maxLength = 40;
|
||||||
|
if (value && value.length > maxLength)
|
||||||
|
return `<span>${value.substr(
|
||||||
|
0,
|
||||||
|
maxLength
|
||||||
|
)}...</span><div uk-dropdown>${value}</div>`;
|
||||||
|
return `<span>${value}</span>`;
|
||||||
|
},
|
||||||
|
renderFilterRow(ft) {
|
||||||
|
const key = ft.key;
|
||||||
|
let type = ft.type;
|
||||||
|
switch (type) {
|
||||||
|
case "number-min":
|
||||||
|
type = "number (minimum value)";
|
||||||
|
break;
|
||||||
|
case "number-max":
|
||||||
|
type = "number (maximum value)";
|
||||||
|
break;
|
||||||
|
case "date-min":
|
||||||
|
type = "minimum date";
|
||||||
|
break;
|
||||||
|
case "date-max":
|
||||||
|
type = "maximum date";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let value = ft.value;
|
||||||
|
|
||||||
|
if (ft.type.startsWith("number") && isNaN(value)) value = "";
|
||||||
|
else if (ft.type.startsWith("date") && value)
|
||||||
|
value = moment(Number(value)).format("MMM D, YYYY");
|
||||||
|
|
||||||
|
return `<td>${key}</td><td>${type}</td><td>${value}</td>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+352
-75
@@ -1,84 +1,361 @@
|
|||||||
$(function() {
|
const readerComponent = () => {
|
||||||
function bind() {
|
return {
|
||||||
var controller = new ScrollMagic.Controller();
|
loading: true,
|
||||||
|
mode: 'continuous', // Can be 'continuous', 'height' or 'width'
|
||||||
|
msg: 'Loading the web reader. Please wait...',
|
||||||
|
alertClass: 'uk-alert-primary',
|
||||||
|
items: [],
|
||||||
|
curItem: {},
|
||||||
|
enableFlipAnimation: true,
|
||||||
|
flipAnimation: null,
|
||||||
|
longPages: false,
|
||||||
|
lastSavedPage: page,
|
||||||
|
selectedIndex: 0, // 0: not selected; 1: the first page
|
||||||
|
margin: 30,
|
||||||
|
preloadLookahead: 3,
|
||||||
|
enableRightToLeft: false,
|
||||||
|
fitType: 'vert',
|
||||||
|
|
||||||
// replace history on scroll
|
/**
|
||||||
$('img').each(function(idx) {
|
* Initialize the component by fetching the page dimensions
|
||||||
var scene = new ScrollMagic.Scene({
|
*/
|
||||||
triggerElement: $(this).get(),
|
init(nextTick) {
|
||||||
triggerHook: 'onEnter',
|
$.get(`${base_url}api/dimensions/${tid}/${eid}`)
|
||||||
reverse: true
|
.then(data => {
|
||||||
})
|
if (!data.success && data.error)
|
||||||
.addTo(controller)
|
throw new Error(resp.error);
|
||||||
.on('enter', function(event) {
|
const dimensions = data.dimensions;
|
||||||
current = $(event.target.triggerElement()).attr('id');
|
|
||||||
replaceHistory(current);
|
|
||||||
})
|
|
||||||
.on('leave', function(event) {
|
|
||||||
var prev = $(event.target.triggerElement()).prev();
|
|
||||||
current = $(prev).attr('id');
|
|
||||||
replaceHistory(current);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// poor man's infinite scroll
|
this.items = dimensions.map((d, i) => {
|
||||||
var scene = new ScrollMagic.Scene({
|
return {
|
||||||
triggerElement: $('.next-url').get(),
|
id: i + 1,
|
||||||
triggerHook: 'onEnter',
|
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
|
||||||
offset: -500
|
width: d.width == 0 ? "100%" : d.width,
|
||||||
})
|
height: d.height == 0 ? "100%" : d.height,
|
||||||
.addTo(controller)
|
};
|
||||||
.on('enter', function() {
|
});
|
||||||
var nextURL = $('.next-url').attr('href');
|
|
||||||
$('.next-url').remove();
|
// Note: for image types not supported by image_size.cr, the width and height will be 0, and so `avgRatio` will be `Infinity`.
|
||||||
if (!nextURL) {
|
// TODO: support more image types in image_size.cr
|
||||||
console.log('No .next-url found. Reached end of page');
|
const avgRatio = dimensions.reduce((acc, cur) => {
|
||||||
var lastURL = $('img').last().attr('id');
|
return acc + cur.height / cur.width
|
||||||
// load the reader URL for the last page to update reading progrss to 100%
|
}, 0) / dimensions.length;
|
||||||
$.get(lastURL);
|
|
||||||
$('#next-btn').removeAttr('hidden');
|
console.log(avgRatio);
|
||||||
return;
|
this.longPages = avgRatio > 2;
|
||||||
|
this.loading = false;
|
||||||
|
this.mode = localStorage.getItem('mode') || 'continuous';
|
||||||
|
|
||||||
|
// Here we save a copy of this.mode, and use the copy as
|
||||||
|
// the model-select value. This is because `updateMode`
|
||||||
|
// might change this.mode and make it `height` or `width`,
|
||||||
|
// which are not available in mode-select
|
||||||
|
const mode = this.mode;
|
||||||
|
this.updateMode(this.mode, page, nextTick);
|
||||||
|
$('#mode-select').val(mode);
|
||||||
|
|
||||||
|
const savedMargin = localStorage.getItem('margin');
|
||||||
|
if (savedMargin) {
|
||||||
|
this.margin = savedMargin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preload Images
|
||||||
|
this.preloadLookahead = +(localStorage.getItem('preloadLookahead') ?? 3);
|
||||||
|
const limit = Math.min(page + this.preloadLookahead, this.items.length);
|
||||||
|
for (let idx = page + 1; idx <= limit; idx++) {
|
||||||
|
this.preloadImage(this.items[idx - 1].url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedFitType = localStorage.getItem('fitType');
|
||||||
|
if (savedFitType) {
|
||||||
|
this.fitType = savedFitType;
|
||||||
|
$('#fit-select').val(savedFitType);
|
||||||
|
}
|
||||||
|
const savedFlipAnimation = localStorage.getItem('enableFlipAnimation');
|
||||||
|
this.enableFlipAnimation = savedFlipAnimation === null || savedFlipAnimation === 'true';
|
||||||
|
|
||||||
|
const savedRightToLeft = localStorage.getItem('enableRightToLeft');
|
||||||
|
if (savedRightToLeft === null) {
|
||||||
|
this.enableRightToLeft = false;
|
||||||
|
} else {
|
||||||
|
this.enableRightToLeft = (savedRightToLeft === 'true');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
const errMsg = `Failed to get the page dimensions. ${e}`;
|
||||||
|
console.error(e);
|
||||||
|
this.alertClass = 'uk-alert-danger';
|
||||||
|
this.msg = errMsg;
|
||||||
|
})
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Preload an image, which is expected to be cached
|
||||||
|
*/
|
||||||
|
preloadImage(url) {
|
||||||
|
(new Image()).src = url;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Handles the `change` event for the page selector
|
||||||
|
*/
|
||||||
|
pageChanged() {
|
||||||
|
const p = parseInt($('#page-select').val());
|
||||||
|
this.toPage(p);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Handles the `change` event for the mode selector
|
||||||
|
*
|
||||||
|
* @param {function} nextTick - Alpine $nextTick magic property
|
||||||
|
*/
|
||||||
|
modeChanged(nextTick) {
|
||||||
|
const mode = $('#mode-select').val();
|
||||||
|
const curIdx = parseInt($('#page-select').val());
|
||||||
|
|
||||||
|
this.updateMode(mode, curIdx, nextTick);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Handles the window `resize` event
|
||||||
|
*/
|
||||||
|
resized() {
|
||||||
|
if (this.mode === 'continuous') return;
|
||||||
|
|
||||||
|
const wideScreen = $(window).width() > $(window).height();
|
||||||
|
this.mode = wideScreen ? 'height' : 'width';
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Handles the window `keydown` event
|
||||||
|
*
|
||||||
|
* @param {Event} event - The triggering event
|
||||||
|
*/
|
||||||
|
keyHandler(event) {
|
||||||
|
if (this.mode === 'continuous') return;
|
||||||
|
|
||||||
|
if (event.key === 'ArrowLeft' || event.key === 'k')
|
||||||
|
this.flipPage(false ^ this.enableRightToLeft);
|
||||||
|
if (event.key === 'ArrowRight' || event.key === 'j')
|
||||||
|
this.flipPage(true ^ this.enableRightToLeft);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Flips to the next or the previous page
|
||||||
|
*
|
||||||
|
* @param {bool} isNext - Whether we are going to the next page
|
||||||
|
*/
|
||||||
|
flipPage(isNext) {
|
||||||
|
const idx = parseInt(this.curItem.id);
|
||||||
|
const newIdx = idx + (isNext ? 1 : -1);
|
||||||
|
|
||||||
|
if (newIdx <= 0) return;
|
||||||
|
if (newIdx > this.items.length) {
|
||||||
|
this.showControl(idx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newIdx + this.preloadLookahead < this.items.length + 1) {
|
||||||
|
this.preloadImage(this.items[newIdx + this.preloadLookahead - 1].url);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.toPage(newIdx);
|
||||||
|
|
||||||
|
if (this.enableFlipAnimation) {
|
||||||
|
if (isNext ^ this.enableRightToLeft)
|
||||||
|
this.flipAnimation = 'right';
|
||||||
|
else
|
||||||
|
this.flipAnimation = 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.flipAnimation = null;
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
this.replaceHistory(newIdx);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Jumps to a specific page
|
||||||
|
*
|
||||||
|
* @param {number} idx - One-based index of the page
|
||||||
|
*/
|
||||||
|
toPage(idx) {
|
||||||
|
if (this.mode === 'continuous') {
|
||||||
|
$(`#${idx}`).get(0).scrollIntoView(true);
|
||||||
|
} else {
|
||||||
|
if (idx >= 1 && idx <= this.items.length) {
|
||||||
|
this.curItem = this.items[idx - 1];
|
||||||
}
|
}
|
||||||
$('#hidden').load(encodeURI(nextURL) + ' .uk-container', function(res, status, xhr) {
|
}
|
||||||
if (status === 'error') console.log(xhr.statusText);
|
this.replaceHistory(idx);
|
||||||
if (status === 'success') {
|
UIkit.modal($('#modal-sections')).hide();
|
||||||
console.log(nextURL + ' loaded');
|
},
|
||||||
// new page loaded to #hidden, we now append it
|
/**
|
||||||
$('.uk-section > .uk-container').append($('#hidden .uk-container').children());
|
* Replace the address bar history and save the reading progress if necessary
|
||||||
$('#hidden').empty();
|
*
|
||||||
bind();
|
* @param {number} idx - One-based index of the page
|
||||||
|
*/
|
||||||
|
replaceHistory(idx) {
|
||||||
|
const ary = window.location.pathname.split('/');
|
||||||
|
ary[ary.length - 1] = idx;
|
||||||
|
ary.shift(); // remove leading `/`
|
||||||
|
ary.unshift(window.location.origin);
|
||||||
|
const url = ary.join('/');
|
||||||
|
this.saveProgress(idx);
|
||||||
|
history.replaceState(null, "", url);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Updates the backend reading progress if:
|
||||||
|
* 1) the current page is more than five pages away from the last
|
||||||
|
* saved page, or
|
||||||
|
* 2) the average height/width ratio of the pages is over 2, or
|
||||||
|
* 3) the current page is the first page, or
|
||||||
|
* 4) the current page is the last page
|
||||||
|
*
|
||||||
|
* @param {number} idx - One-based index of the page
|
||||||
|
* @param {function} cb - Callback
|
||||||
|
*/
|
||||||
|
saveProgress(idx, cb) {
|
||||||
|
idx = parseInt(idx);
|
||||||
|
if (Math.abs(idx - this.lastSavedPage) >= 5 ||
|
||||||
|
this.longPages ||
|
||||||
|
idx === 1 || idx === this.items.length
|
||||||
|
) {
|
||||||
|
this.lastSavedPage = idx;
|
||||||
|
console.log('saving progress', idx);
|
||||||
|
|
||||||
|
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
|
||||||
|
$.ajax({
|
||||||
|
method: 'PUT',
|
||||||
|
url: url,
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (data.error)
|
||||||
|
alert('danger', data.error);
|
||||||
|
if (cb) cb();
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Updates the reader mode
|
||||||
|
*
|
||||||
|
* @param {string} mode - Either `continuous` or `paged`
|
||||||
|
* @param {number} targetPage - The one-based index of the target page
|
||||||
|
* @param {function} nextTick - Alpine $nextTick magic property
|
||||||
|
*/
|
||||||
|
updateMode(mode, targetPage, nextTick) {
|
||||||
|
localStorage.setItem('mode', mode);
|
||||||
|
|
||||||
|
// The mode to be put into the `mode` prop. It can't be `screen`
|
||||||
|
let propMode = mode;
|
||||||
|
|
||||||
|
if (mode === 'paged') {
|
||||||
|
const wideScreen = $(window).width() > $(window).height();
|
||||||
|
propMode = wideScreen ? 'height' : 'width';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mode = propMode;
|
||||||
|
|
||||||
|
if (mode === 'continuous') {
|
||||||
|
nextTick(() => {
|
||||||
|
this.setupScroller();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
this.toPage(targetPage);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Handles clicked image
|
||||||
|
*
|
||||||
|
* @param {Event} event - The triggering event
|
||||||
|
*/
|
||||||
|
clickImage(event) {
|
||||||
|
const idx = event.currentTarget.id;
|
||||||
|
this.showControl(idx);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Shows the control modal
|
||||||
|
*
|
||||||
|
* @param {number} idx - selected page index
|
||||||
|
*/
|
||||||
|
showControl(idx) {
|
||||||
|
this.selectedIndex = idx;
|
||||||
|
UIkit.modal($('#modal-sections')).show();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Redirects to a URL
|
||||||
|
*
|
||||||
|
* @param {string} url - The target URL
|
||||||
|
*/
|
||||||
|
redirect(url) {
|
||||||
|
window.location.replace(url);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Set up the scroll handler that calls `replaceHistory` when an image
|
||||||
|
* enters the view port
|
||||||
|
*/
|
||||||
|
setupScroller() {
|
||||||
|
if (this.mode !== 'continuous') return;
|
||||||
|
$('img').each((idx, el) => {
|
||||||
|
$(el).on('inview', (event, inView) => {
|
||||||
|
if (inView) {
|
||||||
|
const current = $(event.currentTarget).attr('id');
|
||||||
|
|
||||||
|
this.curItem = this.items[current - 1];
|
||||||
|
this.replaceHistory(current);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
/**
|
||||||
|
* Marks progress as 100% and jumps to the next entry
|
||||||
|
*
|
||||||
|
* @param {string} nextUrl - URL of the next entry
|
||||||
|
*/
|
||||||
|
nextEntry(nextUrl) {
|
||||||
|
this.saveProgress(this.items.length, () => {
|
||||||
|
this.redirect(nextUrl);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Exits the reader, and sets the reading progress tp 100%
|
||||||
|
*
|
||||||
|
* @param {string} exitUrl - The Exit URL
|
||||||
|
*/
|
||||||
|
exitReader(exitUrl) {
|
||||||
|
this.saveProgress(this.items.length, () => {
|
||||||
|
this.redirect(exitUrl);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
bind();
|
/**
|
||||||
});
|
* Handles the `change` event for the entry selector
|
||||||
$('#page-select').change(function() {
|
*/
|
||||||
jumpTo(parseInt($('#page-select').val()));
|
entryChanged() {
|
||||||
});
|
const id = $('#entry-select').val();
|
||||||
|
this.redirect(`${base_url}reader/${tid}/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
function showControl(idx) {
|
marginChanged() {
|
||||||
const pageCount = $('#page-select > option').length;
|
localStorage.setItem('margin', this.margin);
|
||||||
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
|
this.toPage(this.selectedIndex);
|
||||||
$('#progress-label').text(progressText);
|
},
|
||||||
$('#page-select').val(idx);
|
|
||||||
UIkit.modal($('#modal-sections')).show();
|
fitChanged(){
|
||||||
}
|
this.fitType = $('#fit-select').val();
|
||||||
|
localStorage.setItem('fitType', this.fitType);
|
||||||
function jumpTo(page) {
|
},
|
||||||
var ary = window.location.pathname.split('/');
|
|
||||||
ary[ary.length - 1] = page;
|
preloadLookaheadChanged() {
|
||||||
ary.shift(); // remove leading `/`
|
localStorage.setItem('preloadLookahead', this.preloadLookahead);
|
||||||
ary.unshift(window.location.origin);
|
},
|
||||||
window.location.replace(ary.join('/'));
|
|
||||||
}
|
enableFlipAnimationChanged() {
|
||||||
|
localStorage.setItem('enableFlipAnimation', this.enableFlipAnimation);
|
||||||
function replaceHistory(url) {
|
},
|
||||||
history.replaceState(null, "", url);
|
|
||||||
console.log('reading ' + url);
|
enableRightToLeftChanged() {
|
||||||
}
|
localStorage.setItem('enableRightToLeft', this.enableRightToLeft);
|
||||||
|
},
|
||||||
function redirect(url) {
|
};
|
||||||
window.location.replace(url);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
-5
File diff suppressed because one or more lines are too long
@@ -0,0 +1,147 @@
|
|||||||
|
const component = () => {
|
||||||
|
return {
|
||||||
|
subscriptions: [],
|
||||||
|
plugins: [],
|
||||||
|
pid: undefined,
|
||||||
|
subscription: undefined, // selected subscription
|
||||||
|
loading: false,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
fetch(`${base_url}api/admin/plugin`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (!data.success) throw new Error(data.error);
|
||||||
|
this.plugins = data.plugins;
|
||||||
|
|
||||||
|
const pid = localStorage.getItem("plugin");
|
||||||
|
if (pid && this.plugins.map((p) => p.id).includes(pid))
|
||||||
|
this.pid = pid;
|
||||||
|
else if (this.plugins.length > 0)
|
||||||
|
this.pid = this.plugins[0].id;
|
||||||
|
|
||||||
|
this.list(pid);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
alert(
|
||||||
|
"danger",
|
||||||
|
`Failed to list the available plugins. Error: ${e}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
pluginChanged() {
|
||||||
|
localStorage.setItem("plugin", this.pid);
|
||||||
|
this.list(this.pid);
|
||||||
|
},
|
||||||
|
list(pid) {
|
||||||
|
if (!pid) return;
|
||||||
|
fetch(
|
||||||
|
`${base_url}api/admin/plugin/subscriptions?${new URLSearchParams(
|
||||||
|
{
|
||||||
|
plugin: pid,
|
||||||
|
}
|
||||||
|
)}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (!data.success) throw new Error(data.error);
|
||||||
|
this.subscriptions = data.subscriptions;
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
alert(
|
||||||
|
"danger",
|
||||||
|
`Failed to list subscriptions. Error: ${e}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
renderStrCell(str) {
|
||||||
|
const maxLength = 40;
|
||||||
|
if (str.length > maxLength)
|
||||||
|
return `<td><span>${str.substring(
|
||||||
|
0,
|
||||||
|
maxLength
|
||||||
|
)}...</span><div uk-dropdown>${str}</div></td>`;
|
||||||
|
return `<td>${str}</td>`;
|
||||||
|
},
|
||||||
|
renderDateCell(timestamp) {
|
||||||
|
return `<td>${moment
|
||||||
|
.duration(moment.unix(timestamp).diff(moment()))
|
||||||
|
.humanize(true)}</td>`;
|
||||||
|
},
|
||||||
|
selected(event, modal) {
|
||||||
|
const id = event.currentTarget.getAttribute("sid");
|
||||||
|
this.subscription = this.subscriptions.find((s) => s.id === id);
|
||||||
|
UIkit.modal(modal).show();
|
||||||
|
},
|
||||||
|
renderFilterRow(ft) {
|
||||||
|
const key = ft.key;
|
||||||
|
let type = ft.type;
|
||||||
|
switch (type) {
|
||||||
|
case "number-min":
|
||||||
|
type = "number (minimum value)";
|
||||||
|
break;
|
||||||
|
case "number-max":
|
||||||
|
type = "number (maximum value)";
|
||||||
|
break;
|
||||||
|
case "date-min":
|
||||||
|
type = "minimum date";
|
||||||
|
break;
|
||||||
|
case "date-max":
|
||||||
|
type = "maximum date";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let value = ft.value;
|
||||||
|
|
||||||
|
if (ft.type.startsWith("number") && isNaN(value)) value = "";
|
||||||
|
else if (ft.type.startsWith("date") && value)
|
||||||
|
value = moment(Number(value)).format("MMM D, YYYY");
|
||||||
|
|
||||||
|
return `<td>${key}</td><td>${type}</td><td>${value}</td>`;
|
||||||
|
},
|
||||||
|
actionHandler(event, type) {
|
||||||
|
const id = $(event.currentTarget).closest("tr").attr("sid");
|
||||||
|
if (type !== 'delete') return this.action(id, type);
|
||||||
|
UIkit.modal.confirm('Are you sure you want to delete the subscription? This cannot be undone.', {
|
||||||
|
labels: {
|
||||||
|
ok: 'Yes, delete it',
|
||||||
|
cancel: 'Cancel'
|
||||||
|
}
|
||||||
|
}).then(() => {
|
||||||
|
this.action(id, type);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
action(id, type) {
|
||||||
|
if (this.loading) return;
|
||||||
|
this.loading = true;
|
||||||
|
fetch(
|
||||||
|
`${base_url}api/admin/plugin/subscriptions${type === 'update' ? '/update' : ''}?${new URLSearchParams(
|
||||||
|
{
|
||||||
|
plugin: this.pid,
|
||||||
|
subscription: id,
|
||||||
|
}
|
||||||
|
)}`,
|
||||||
|
{
|
||||||
|
method: type === 'delete' ? "DELETE" : 'POST'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (!data.success) throw new Error(data.error);
|
||||||
|
if (type === 'update')
|
||||||
|
alert("success", `Checking updates for subscription ${id}. Check the log for the progress or come back to this page later.`);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
alert(
|
||||||
|
"danger",
|
||||||
|
`Failed to ${type} subscription. Error: ${e}`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.loading = false;
|
||||||
|
this.list(this.pid);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
const component = () => {
|
||||||
|
return {
|
||||||
|
available: undefined,
|
||||||
|
subscriptions: [],
|
||||||
|
|
||||||
|
init() {
|
||||||
|
$.getJSON(`${base_url}api/admin/mangadex/expires`)
|
||||||
|
.done((data) => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', 'Failed to check MangaDex integration status. Error: ' + data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.available = Boolean(data.expires && data.expires > Math.floor(Date.now() / 1000));
|
||||||
|
|
||||||
|
if (this.available) this.getSubscriptions();
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
getSubscriptions() {
|
||||||
|
$.getJSON(`${base_url}api/admin/mangadex/subscriptions`)
|
||||||
|
.done(data => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', 'Failed to get subscriptions. Error: ' + data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.subscriptions = data.subscriptions;
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to get subscriptions. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
rm(event) {
|
||||||
|
const id = event.currentTarget.parentNode.getAttribute('data-id');
|
||||||
|
$.ajax({
|
||||||
|
type: 'DELETE',
|
||||||
|
url: `${base_url}api/admin/mangadex/subscriptions/${id}`,
|
||||||
|
contentType: 'application/json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', `Failed to delete subscription. Error: ${data.error}`);
|
||||||
|
}
|
||||||
|
this.getSubscriptions();
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to delete subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
check(event) {
|
||||||
|
const id = event.currentTarget.parentNode.getAttribute('data-id');
|
||||||
|
$.ajax({
|
||||||
|
type: 'POST',
|
||||||
|
url: `${base_url}api/admin/mangadex/subscriptions/check/${id}`,
|
||||||
|
contentType: 'application/json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', `Failed to check subscription. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert('success', 'Mango is now checking the subscription for updates. This might take a while, but you can safely leave the page.');
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to check subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
formatRange(min, max) {
|
||||||
|
if (!isNaN(min) && isNaN(max)) return `≥ ${min}`;
|
||||||
|
if (isNaN(min) && !isNaN(max)) return `≤ ${max}`;
|
||||||
|
if (isNaN(min) && isNaN(max)) return 'All';
|
||||||
|
|
||||||
|
if (min === max) return `= ${min}`;
|
||||||
|
return `${min} - ${max}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
// https://flaviocopes.com/javascript-detect-dark-mode/
|
|
||||||
const preferDarkMode = () => {
|
|
||||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validThemeSetting = (theme) => {
|
|
||||||
return ['dark', 'light', 'system'].indexOf(theme) >= 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// dark / light / system
|
|
||||||
const loadThemeSetting = () => {
|
|
||||||
let str = localStorage.getItem('theme');
|
|
||||||
if (!str || !validThemeSetting(str)) str = 'light';
|
|
||||||
return str;
|
|
||||||
};
|
|
||||||
|
|
||||||
// dark / light
|
|
||||||
const loadTheme = () => {
|
|
||||||
let setting = loadThemeSetting();
|
|
||||||
if (setting === 'system') {
|
|
||||||
setting = preferDarkMode() ? 'dark' : 'light';
|
|
||||||
}
|
|
||||||
return setting;
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveThemeSetting = setting => {
|
|
||||||
if (!validThemeSetting(setting)) setting = 'light';
|
|
||||||
localStorage.setItem('theme', setting);
|
|
||||||
};
|
|
||||||
|
|
||||||
// when toggled, Auto will be changed to light or dark
|
|
||||||
const toggleTheme = () => {
|
|
||||||
const theme = loadTheme();
|
|
||||||
const newTheme = theme === 'dark' ? 'light' : 'dark';
|
|
||||||
saveThemeSetting(newTheme);
|
|
||||||
setTheme(newTheme);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setTheme = (theme) => {
|
|
||||||
if (!theme) theme = loadTheme();
|
|
||||||
if (theme === 'dark') {
|
|
||||||
$('html').css('background', 'rgb(20, 20, 20)');
|
|
||||||
$('body').addClass('uk-light');
|
|
||||||
$('.uk-card').addClass('uk-card-secondary');
|
|
||||||
$('.uk-card').removeClass('uk-card-default');
|
|
||||||
$('.ui-widget-content').addClass('dark');
|
|
||||||
} else {
|
|
||||||
$('html').css('background', '');
|
|
||||||
$('body').removeClass('uk-light');
|
|
||||||
$('.uk-card').removeClass('uk-card-secondary');
|
|
||||||
$('.uk-card').addClass('uk-card-default');
|
|
||||||
$('.ui-widget-content').removeClass('dark');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// do it before document is ready to prevent the initial flash of white on
|
|
||||||
// most pages
|
|
||||||
setTheme();
|
|
||||||
|
|
||||||
$(() => {
|
|
||||||
// hack for the reader page
|
|
||||||
setTheme();
|
|
||||||
|
|
||||||
// on system dark mode setting change
|
|
||||||
if (window.matchMedia) {
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)')
|
|
||||||
.addEventListener('change', event => {
|
|
||||||
if (loadThemeSetting() === 'system')
|
|
||||||
setTheme(event.matches ? 'dark' : 'light');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
+227
-19
@@ -55,48 +55,60 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
|
|||||||
|
|
||||||
$('#modal-edit-btn').attr('onclick', `edit("${entryID}")`);
|
$('#modal-edit-btn').attr('onclick', `edit("${entryID}")`);
|
||||||
|
|
||||||
$('#modal-download-btn').attr('href', `/opds/download/${titleID}/${entryID}`);
|
$('#modal-download-btn').attr('href', `${base_url}api/download/${titleID}/${entryID}`);
|
||||||
|
|
||||||
UIkit.modal($('#modal')).show();
|
UIkit.modal($('#modal')).show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UIkit.util.on(document, 'hidden', '#modal', () => {
|
||||||
|
$('#read-btn').off('click');
|
||||||
|
$('#unread-btn').off('click');
|
||||||
|
});
|
||||||
|
|
||||||
const updateProgress = (tid, eid, page) => {
|
const updateProgress = (tid, eid, page) => {
|
||||||
let url = `${base_url}api/progress/${tid}/${page}`
|
let url = `${base_url}api/progress/${tid}/${page}`
|
||||||
const query = $.param({
|
const query = $.param({
|
||||||
entry: eid
|
eid: eid
|
||||||
});
|
});
|
||||||
if (eid)
|
if (eid)
|
||||||
url += `?${query}`;
|
url += `?${query}`;
|
||||||
$.post(url, (data) => {
|
|
||||||
if (data.success) {
|
$.ajax({
|
||||||
location.reload();
|
method: 'PUT',
|
||||||
} else {
|
url: url,
|
||||||
error = data.error;
|
dataType: 'json'
|
||||||
alert('danger', error);
|
})
|
||||||
}
|
.done(data => {
|
||||||
});
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
error = data.error;
|
||||||
|
alert('danger', error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const renameSubmit = (name, eid) => {
|
const renameSubmit = (name, eid) => {
|
||||||
const upload = $('.upload-field');
|
const upload = $('.upload-field');
|
||||||
const titleId = upload.attr('data-title-id');
|
const titleId = upload.attr('data-title-id');
|
||||||
|
|
||||||
console.log(name);
|
|
||||||
|
|
||||||
if (name.length === 0) {
|
if (name.length === 0) {
|
||||||
alert('danger', 'The display name should not be empty');
|
alert('danger', 'The display name should not be empty');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = $.param({
|
const query = $.param({
|
||||||
entry: eid
|
eid: eid
|
||||||
});
|
});
|
||||||
let url = `${base_url}api/admin/display_name/${titleId}/${name}`;
|
let url = `${base_url}api/admin/display_name/${titleId}/${name}`;
|
||||||
if (eid)
|
if (eid)
|
||||||
url += `?${query}`;
|
url += `?${query}`;
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'POST',
|
type: 'PUT',
|
||||||
url: url,
|
url: url,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
dataType: 'json'
|
dataType: 'json'
|
||||||
@@ -113,15 +125,47 @@ const renameSubmit = (name, eid) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renameSortNameSubmit = (name, eid) => {
|
||||||
|
const upload = $('.upload-field');
|
||||||
|
const titleId = upload.attr('data-title-id');
|
||||||
|
|
||||||
|
const params = {};
|
||||||
|
if (eid) params.eid = eid;
|
||||||
|
if (name) params.name = name;
|
||||||
|
const query = $.param(params);
|
||||||
|
let url = `${base_url}api/admin/sort_title/${titleId}?${query}`;
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
type: 'PUT',
|
||||||
|
url,
|
||||||
|
contentType: 'application/json',
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', `Failed to update sort title. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
location.reload();
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to update sort title. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const edit = (eid) => {
|
const edit = (eid) => {
|
||||||
const cover = $('#edit-modal #cover');
|
const cover = $('#edit-modal #cover');
|
||||||
let url = cover.attr('data-title-cover');
|
let url = cover.attr('data-title-cover');
|
||||||
let displayName = $('h2.uk-title > span').text();
|
let displayName = $('h2.uk-title > span').text();
|
||||||
|
let fileTitle = $('h2.uk-title').attr('data-file-title');
|
||||||
|
let sortTitle = $('h2.uk-title').attr('data-sort-title');
|
||||||
|
|
||||||
if (eid) {
|
if (eid) {
|
||||||
const item = $(`#${eid}`);
|
const item = $(`#${eid}`);
|
||||||
url = item.find('img').attr('data-src');
|
url = item.find('img').attr('data-src');
|
||||||
displayName = item.find('.uk-card-title').attr('data-title');
|
displayName = item.find('.uk-card-title').attr('data-title');
|
||||||
|
fileTitle = item.find('.uk-card-title').attr('data-file-title');
|
||||||
|
sortTitle = item.find('.uk-card-title').attr('data-sort-title');
|
||||||
$('#title-progress-control').attr('hidden', '');
|
$('#title-progress-control').attr('hidden', '');
|
||||||
} else {
|
} else {
|
||||||
$('#title-progress-control').removeAttr('hidden');
|
$('#title-progress-control').removeAttr('hidden');
|
||||||
@@ -131,13 +175,26 @@ const edit = (eid) => {
|
|||||||
|
|
||||||
const displayNameField = $('#display-name-field');
|
const displayNameField = $('#display-name-field');
|
||||||
displayNameField.attr('value', displayName);
|
displayNameField.attr('value', displayName);
|
||||||
|
displayNameField.attr('placeholder', fileTitle);
|
||||||
displayNameField.keyup(event => {
|
displayNameField.keyup(event => {
|
||||||
if (event.keyCode === 13) {
|
if (event.keyCode === 13) {
|
||||||
renameSubmit(displayNameField.val(), eid);
|
renameSubmit(displayNameField.val() || fileTitle, eid);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
displayNameField.siblings('a.uk-form-icon').click(() => {
|
displayNameField.siblings('a.uk-form-icon').click(() => {
|
||||||
renameSubmit(displayNameField.val(), eid);
|
renameSubmit(displayNameField.val() || fileTitle, eid);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortTitleField = $('#sort-title-field');
|
||||||
|
sortTitleField.val(sortTitle);
|
||||||
|
sortTitleField.attr('placeholder', fileTitle);
|
||||||
|
sortTitleField.keyup(event => {
|
||||||
|
if (event.keyCode === 13) {
|
||||||
|
renameSortNameSubmit(sortTitleField.val(), eid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sortTitleField.siblings('a.uk-form-icon').click(() => {
|
||||||
|
renameSortNameSubmit(sortTitleField.val(), eid);
|
||||||
});
|
});
|
||||||
|
|
||||||
setupUpload(eid);
|
setupUpload(eid);
|
||||||
@@ -145,18 +202,27 @@ const edit = (eid) => {
|
|||||||
UIkit.modal($('#edit-modal')).show();
|
UIkit.modal($('#edit-modal')).show();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
UIkit.util.on(document, 'hidden', '#edit-modal', () => {
|
||||||
|
const displayNameField = $('#display-name-field');
|
||||||
|
displayNameField.off('keyup');
|
||||||
|
displayNameField.off('click');
|
||||||
|
|
||||||
|
const sortTitleField = $('#sort-title-field');
|
||||||
|
sortTitleField.off('keyup');
|
||||||
|
sortTitleField.off('click');
|
||||||
|
});
|
||||||
|
|
||||||
const setupUpload = (eid) => {
|
const setupUpload = (eid) => {
|
||||||
const upload = $('.upload-field');
|
const upload = $('.upload-field');
|
||||||
const bar = $('#upload-progress').get(0);
|
const bar = $('#upload-progress').get(0);
|
||||||
const titleId = upload.attr('data-title-id');
|
const titleId = upload.attr('data-title-id');
|
||||||
const queryObj = {
|
const queryObj = {
|
||||||
title: titleId
|
tid: titleId
|
||||||
};
|
};
|
||||||
if (eid)
|
if (eid)
|
||||||
queryObj['entry'] = eid;
|
queryObj['eid'] = eid;
|
||||||
const query = $.param(queryObj);
|
const query = $.param(queryObj);
|
||||||
const url = `${base_url}api/admin/upload/cover?${query}`;
|
const url = `${base_url}api/admin/upload/cover?${query}`;
|
||||||
console.log(url);
|
|
||||||
UIkit.upload('.upload-field', {
|
UIkit.upload('.upload-field', {
|
||||||
url: url,
|
url: url,
|
||||||
name: 'file',
|
name: 'file',
|
||||||
@@ -182,3 +248,145 @@ const setupUpload = (eid) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deselectAll = () => {
|
||||||
|
$('.item .uk-card').each((i, e) => {
|
||||||
|
const data = e.__x.$data;
|
||||||
|
data['selected'] = false;
|
||||||
|
});
|
||||||
|
$('#select-bar')[0].__x.$data['count'] = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAll = () => {
|
||||||
|
let count = 0;
|
||||||
|
$('.item .uk-card').each((i, e) => {
|
||||||
|
const data = e.__x.$data;
|
||||||
|
if (!data['disabled']) {
|
||||||
|
data['selected'] = true;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$('#select-bar')[0].__x.$data['count'] = count;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedIDs = () => {
|
||||||
|
const ary = [];
|
||||||
|
$('.item .uk-card').each((i, e) => {
|
||||||
|
const data = e.__x.$data;
|
||||||
|
if (!data['disabled'] && data['selected']) {
|
||||||
|
const item = $(e).closest('.item');
|
||||||
|
ary.push($(item).attr('id'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return ary;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bulkProgress = (action, el) => {
|
||||||
|
const tid = $(el).attr('data-id');
|
||||||
|
const ids = selectedIDs();
|
||||||
|
const url = `${base_url}api/bulk_progress/${action}/${tid}`;
|
||||||
|
$.ajax({
|
||||||
|
type: 'PUT',
|
||||||
|
url: url,
|
||||||
|
contentType: "application/json",
|
||||||
|
dataType: 'json',
|
||||||
|
data: JSON.stringify({
|
||||||
|
ids: ids
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', `Failed to mark entries as ${action}. Error: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
location.reload();
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to mark entries as ${action}. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
.always(() => {
|
||||||
|
deselectAll();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const tagsComponent = () => {
|
||||||
|
return {
|
||||||
|
isAdmin: false,
|
||||||
|
tags: [],
|
||||||
|
tid: $('.upload-field').attr('data-title-id'),
|
||||||
|
loading: true,
|
||||||
|
|
||||||
|
load(admin) {
|
||||||
|
this.isAdmin = admin;
|
||||||
|
|
||||||
|
$('.tag-select').select2({
|
||||||
|
tags: true,
|
||||||
|
placeholder: this.isAdmin ? 'Tag the title' : 'No tags found',
|
||||||
|
disabled: !this.isAdmin,
|
||||||
|
templateSelection(state) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.setAttribute('href', `${base_url}tags/${encodeURIComponent(state.text)}`);
|
||||||
|
a.setAttribute('class', 'uk-link-reset');
|
||||||
|
a.onclick = event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
a.innerText = state.text;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.request(`${base_url}api/tags`, 'GET', (data) => {
|
||||||
|
const allTags = data.tags;
|
||||||
|
const url = `${base_url}api/tags/${this.tid}`;
|
||||||
|
this.request(url, 'GET', data => {
|
||||||
|
this.tags = data.tags;
|
||||||
|
allTags.forEach(t => {
|
||||||
|
const op = new Option(t, t, false, this.tags.indexOf(t) >= 0);
|
||||||
|
$('.tag-select').append(op);
|
||||||
|
});
|
||||||
|
$('.tag-select').on('select2:select', e => {
|
||||||
|
this.onAdd(e);
|
||||||
|
});
|
||||||
|
$('.tag-select').on('select2:unselect', e => {
|
||||||
|
this.onDelete(e);
|
||||||
|
});
|
||||||
|
$('.tag-select').on('change', () => {
|
||||||
|
this.onChange();
|
||||||
|
});
|
||||||
|
$('.tag-select').trigger('change');
|
||||||
|
this.loading = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onChange() {
|
||||||
|
this.tags = $('.tag-select').select2('data').map(o => o.text);
|
||||||
|
},
|
||||||
|
onAdd(event) {
|
||||||
|
const tag = event.params.data.text;
|
||||||
|
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
|
||||||
|
this.request(url, 'PUT');
|
||||||
|
},
|
||||||
|
onDelete(event) {
|
||||||
|
const tag = event.params.data.text;
|
||||||
|
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
|
||||||
|
this.request(url, 'DELETE');
|
||||||
|
},
|
||||||
|
request(url, method, cb) {
|
||||||
|
$.ajax({
|
||||||
|
url: url,
|
||||||
|
method: method,
|
||||||
|
dataType: 'json'
|
||||||
|
})
|
||||||
|
.done(data => {
|
||||||
|
if (data.success) {
|
||||||
|
if (cb) cb(data);
|
||||||
|
} else {
|
||||||
|
alert('danger', data.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
+16
-11
@@ -1,11 +1,16 @@
|
|||||||
function remove(username) {
|
const remove = (username) => {
|
||||||
$.post(base_url + 'api/admin/user/delete/' + username, function(data) {
|
$.ajax({
|
||||||
if (data.success) {
|
url: `${base_url}api/admin/user/delete/${username}`,
|
||||||
location.reload();
|
type: 'DELETE',
|
||||||
}
|
dataType: 'json'
|
||||||
else {
|
})
|
||||||
error = data.error;
|
.done(data => {
|
||||||
alert('danger', error);
|
if (data.success)
|
||||||
}
|
location.reload();
|
||||||
});
|
else
|
||||||
}
|
alert('danger', data.error);
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to delete the user. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "Mango",
|
||||||
|
"description": "Mango: A self-hosted manga server and web reader",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/img/icons/icon_x96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/img/icons/icon_x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/img/icons/icon_x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"display": "fullscreen",
|
||||||
|
"start_url": "/"
|
||||||
|
}
|
||||||
+55
-27
@@ -1,54 +1,82 @@
|
|||||||
version: 1.0
|
version: 2.0
|
||||||
shards:
|
shards:
|
||||||
ameba:
|
ameba:
|
||||||
github: crystal-ameba/ameba
|
git: https://github.com/crystal-ameba/ameba.git
|
||||||
version: 0.12.1
|
version: 0.14.3
|
||||||
|
|
||||||
archive:
|
archive:
|
||||||
github: hkalexling/archive.cr
|
git: https://github.com/hkalexling/archive.cr.git
|
||||||
version: 0.4.0
|
version: 0.5.0
|
||||||
|
|
||||||
baked_file_system:
|
baked_file_system:
|
||||||
github: schovi/baked_file_system
|
git: https://github.com/schovi/baked_file_system.git
|
||||||
version: 0.9.8
|
version: 0.10.0
|
||||||
|
|
||||||
clim:
|
clim:
|
||||||
github: at-grandpa/clim
|
git: https://github.com/at-grandpa/clim.git
|
||||||
version: 0.12.0
|
version: 0.17.1
|
||||||
|
|
||||||
db:
|
db:
|
||||||
github: crystal-lang/crystal-db
|
git: https://github.com/crystal-lang/crystal-db.git
|
||||||
version: 0.9.0
|
version: 0.10.1
|
||||||
|
|
||||||
duktape:
|
duktape:
|
||||||
github: jessedoyle/duktape.cr
|
git: https://github.com/jessedoyle/duktape.cr.git
|
||||||
version: 0.20.0
|
version: 1.0.0
|
||||||
|
|
||||||
exception_page:
|
exception_page:
|
||||||
github: crystal-loot/exception_page
|
git: https://github.com/crystal-loot/exception_page.git
|
||||||
version: 0.1.4
|
version: 0.1.5
|
||||||
|
|
||||||
|
http_proxy:
|
||||||
|
git: https://github.com/mamantoha/http_proxy.git
|
||||||
|
version: 0.8.0
|
||||||
|
|
||||||
|
image_size:
|
||||||
|
git: https://github.com/hkalexling/image_size.cr.git
|
||||||
|
version: 0.5.0
|
||||||
|
|
||||||
kemal:
|
kemal:
|
||||||
github: kemalcr/kemal
|
git: https://github.com/kemalcr/kemal.git
|
||||||
version: 0.26.1
|
version: 1.0.0
|
||||||
|
|
||||||
kemal-session:
|
kemal-session:
|
||||||
github: kemalcr/kemal-session
|
git: https://github.com/kemalcr/kemal-session.git
|
||||||
version: 0.12.1
|
version: 1.0.0
|
||||||
|
|
||||||
kilt:
|
kilt:
|
||||||
github: jeromegn/kilt
|
git: https://github.com/jeromegn/kilt.git
|
||||||
version: 0.4.0
|
version: 0.4.1
|
||||||
|
|
||||||
|
koa:
|
||||||
|
git: https://github.com/hkalexling/koa.git
|
||||||
|
version: 0.9.0
|
||||||
|
|
||||||
|
mg:
|
||||||
|
git: https://github.com/hkalexling/mg.git
|
||||||
|
version: 0.5.0+git.commit.697e46e27cde8c3969346e228e372db2455a6264
|
||||||
|
|
||||||
myhtml:
|
myhtml:
|
||||||
github: kostya/myhtml
|
git: https://github.com/kostya/myhtml.git
|
||||||
version: 1.5.1
|
version: 1.5.8
|
||||||
|
|
||||||
|
open_api:
|
||||||
|
git: https://github.com/hkalexling/open_api.cr.git
|
||||||
|
version: 1.2.1+git.commit.1d3c55dd5534c6b0af18964d031858a08515553a
|
||||||
|
|
||||||
radix:
|
radix:
|
||||||
github: luislavena/radix
|
git: https://github.com/luislavena/radix.git
|
||||||
version: 0.3.9
|
version: 0.4.1
|
||||||
|
|
||||||
|
sanitize:
|
||||||
|
git: https://github.com/hkalexling/sanitize.git
|
||||||
|
version: 0.1.0+git.commit.e09520e972d0d9b70b71bb003e6831f7c2c59dce
|
||||||
|
|
||||||
sqlite3:
|
sqlite3:
|
||||||
github: crystal-lang/crystal-sqlite3
|
git: https://github.com/crystal-lang/crystal-sqlite3.git
|
||||||
version: 0.16.0
|
version: 0.18.0
|
||||||
|
|
||||||
|
tallboy:
|
||||||
|
git: https://github.com/epoch/tallboy.git
|
||||||
|
version: 0.9.3+git.commit.9be1510bb0391c95e92f1b288f3afb429a73caa6
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: mango
|
name: mango
|
||||||
version: 0.9.0
|
version: 0.27.0
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Alex Ling <hkalexling@gmail.com>
|
- Alex Ling <hkalexling@gmail.com>
|
||||||
@@ -8,7 +8,7 @@ targets:
|
|||||||
mango:
|
mango:
|
||||||
main: src/mango.cr
|
main: src/mango.cr
|
||||||
|
|
||||||
crystal: 0.34.0
|
crystal: 1.0.0
|
||||||
|
|
||||||
license: MIT
|
license: MIT
|
||||||
|
|
||||||
@@ -29,6 +29,18 @@ dependencies:
|
|||||||
github: at-grandpa/clim
|
github: at-grandpa/clim
|
||||||
duktape:
|
duktape:
|
||||||
github: jessedoyle/duktape.cr
|
github: jessedoyle/duktape.cr
|
||||||
version: ~> 0.20.0
|
|
||||||
myhtml:
|
myhtml:
|
||||||
github: kostya/myhtml
|
github: kostya/myhtml
|
||||||
|
http_proxy:
|
||||||
|
github: mamantoha/http_proxy
|
||||||
|
image_size:
|
||||||
|
github: hkalexling/image_size.cr
|
||||||
|
koa:
|
||||||
|
github: hkalexling/koa
|
||||||
|
tallboy:
|
||||||
|
github: epoch/tallboy
|
||||||
|
branch: master
|
||||||
|
mg:
|
||||||
|
github: hkalexling/mg
|
||||||
|
sanitize:
|
||||||
|
github: hkalexling/sanitize
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"id": "test",
|
||||||
|
"title": "Test Plugin",
|
||||||
|
"placeholder": "placeholder",
|
||||||
|
"wait_seconds": 1
|
||||||
|
}
|
||||||
+19
-2
@@ -1,14 +1,31 @@
|
|||||||
require "./spec_helper"
|
require "./spec_helper"
|
||||||
|
|
||||||
describe Config do
|
describe Config do
|
||||||
it "creates config if it does not exist" do
|
it "creates default config if it does not exist" do
|
||||||
with_default_config do |_, path|
|
with_default_config do |config, path|
|
||||||
File.exists?(path).should be_true
|
File.exists?(path).should be_true
|
||||||
|
config.port.should eq 9000
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it "correctly loads config" do
|
it "correctly loads config" do
|
||||||
config = Config.load "spec/asset/test-config.yml"
|
config = Config.load "spec/asset/test-config.yml"
|
||||||
config.port.should eq 3000
|
config.port.should eq 3000
|
||||||
|
config.base_url.should eq "/"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "correctly reads config defaults from ENV" do
|
||||||
|
ENV["LOG_LEVEL"] = "debug"
|
||||||
|
config = Config.load "spec/asset/test-config.yml"
|
||||||
|
config.log_level.should eq "debug"
|
||||||
|
config.base_url.should eq "/"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "correctly handles ENV truthiness" do
|
||||||
|
ENV["CACHE_ENABLED"] = "false"
|
||||||
|
config = Config.load "spec/asset/test-config.yml"
|
||||||
|
config.cache_enabled.should be_false
|
||||||
|
config.cache_log_enabled.should be_true
|
||||||
|
config.disable_login.should be_false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
require "./spec_helper"
|
||||||
|
|
||||||
|
describe Plugin do
|
||||||
|
describe "helper functions" do
|
||||||
|
it "mango.text" do
|
||||||
|
with_plugin do |plugin|
|
||||||
|
res = plugin.eval <<-JS
|
||||||
|
mango.text('<a href="https://github.com">Click Me<a>');
|
||||||
|
JS
|
||||||
|
res.should eq "Click Me"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mango.text returns empty string when no text" do
|
||||||
|
with_plugin do |plugin|
|
||||||
|
res = plugin.eval <<-JS
|
||||||
|
mango.text('<img src="https://github.com" />');
|
||||||
|
JS
|
||||||
|
res.should eq ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mango.css" do
|
||||||
|
with_plugin do |plugin|
|
||||||
|
res = plugin.eval <<-JS
|
||||||
|
mango.css('<ul><li class="test">A</li><li class="test">B</li><li>C</li></ul>', 'li.test');
|
||||||
|
|
||||||
|
JS
|
||||||
|
res.should eq ["<li class=\"test\">A</li>", "<li class=\"test\">B</li>"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mango.css returns empty array when no match" do
|
||||||
|
with_plugin do |plugin|
|
||||||
|
res = plugin.eval <<-JS
|
||||||
|
mango.css('<ul><li class="test">A</li><li class="test">B</li><li>C</li></ul>', 'li.noclass');
|
||||||
|
JS
|
||||||
|
res.should eq [] of String
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mango.attribute" do
|
||||||
|
with_plugin do |plugin|
|
||||||
|
res = plugin.eval <<-JS
|
||||||
|
mango.attribute('<a href="https://github.com">Click Me<a>', 'href');
|
||||||
|
JS
|
||||||
|
res.should eq "https://github.com"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mango.attribute returns undefined when no match" do
|
||||||
|
with_plugin do |plugin|
|
||||||
|
res = plugin.eval <<-JS
|
||||||
|
mango.attribute('<div />', 'href') === undefined;
|
||||||
|
JS
|
||||||
|
res.should be_true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# https://github.com/hkalexling/Mango/issues/320
|
||||||
|
it "mango.attribute handles tags in attribute values" do
|
||||||
|
with_plugin do |plugin|
|
||||||
|
res = plugin.eval <<-JS
|
||||||
|
mango.attribute('<div data-a="<img />" data-b="test" />', 'data-b');
|
||||||
|
JS
|
||||||
|
res.should eq "test"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+8
-8
@@ -40,11 +40,6 @@ describe Rule do
|
|||||||
rule.render({"a" => "a", "b" => "b"}).should eq "a"
|
rule.render({"a" => "a", "b" => "b"}).should eq "a"
|
||||||
end
|
end
|
||||||
|
|
||||||
it "allows `|` outside of patterns" do
|
|
||||||
rule = Rule.new "hello|world"
|
|
||||||
rule.render({} of String => String).should eq "hello|world"
|
|
||||||
end
|
|
||||||
|
|
||||||
it "raises on escaped characters" do
|
it "raises on escaped characters" do
|
||||||
expect_raises Exception do
|
expect_raises Exception do
|
||||||
Rule.new "hello/world"
|
Rule.new "hello/world"
|
||||||
@@ -69,8 +64,13 @@ describe Rule do
|
|||||||
rule.render({} of String => String).should eq "testing"
|
rule.render({} of String => String).should eq "testing"
|
||||||
end
|
end
|
||||||
|
|
||||||
it "escapes slash" do
|
it "escapes illegal characters" do
|
||||||
rule = Rule.new "{id}"
|
rule = Rule.new "{a}"
|
||||||
rule.render({"id" => "/hello/world"}).should eq "_hello_world"
|
rule.render({"a" => "/?<>:*|\"^"}).should eq "_________"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "strips trailing spaces and dots" do
|
||||||
|
rule = Rule.new "hello. world. .."
|
||||||
|
rule.render({} of String => String).should eq "hello. world"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ require "spec"
|
|||||||
require "../src/queue"
|
require "../src/queue"
|
||||||
require "../src/server"
|
require "../src/server"
|
||||||
require "../src/config"
|
require "../src/config"
|
||||||
|
require "../src/main_fiber"
|
||||||
|
require "../src/plugin/plugin"
|
||||||
|
|
||||||
class State
|
class State
|
||||||
@@hash = {} of String => String
|
@@hash = {} of String => String
|
||||||
@@ -53,3 +55,10 @@ def with_storage
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def with_plugin
|
||||||
|
with_default_config do
|
||||||
|
plugin = Plugin.new "test", "spec/asset/plugins"
|
||||||
|
yield plugin
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ describe Storage do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it "deletes user" do
|
it "deletes user" do
|
||||||
with_storage do |storage|
|
with_storage &.delete_user "admin"
|
||||||
storage.delete_user "admin"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it "creates new user" do
|
it "creates new user" do
|
||||||
|
|||||||
+30
-3
@@ -21,7 +21,7 @@ describe "compare_numerically" do
|
|||||||
it "sorts like the stack exchange post" do
|
it "sorts like the stack exchange post" do
|
||||||
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
|
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
|
||||||
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
|
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
|
||||||
ary.reverse.sort { |a, b|
|
ary.reverse.sort! { |a, b|
|
||||||
compare_numerically a, b
|
compare_numerically a, b
|
||||||
}.should eq ary
|
}.should eq ary
|
||||||
end
|
end
|
||||||
@@ -29,18 +29,45 @@ describe "compare_numerically" do
|
|||||||
# https://github.com/hkalexling/Mango/issues/22
|
# https://github.com/hkalexling/Mango/issues/22
|
||||||
it "handles numbers larger than Int32" do
|
it "handles numbers larger than Int32" do
|
||||||
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
|
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
|
||||||
ary.reverse.sort { |a, b|
|
ary.reverse.sort! { |a, b|
|
||||||
compare_numerically a, b
|
compare_numerically a, b
|
||||||
}.should eq ary
|
}.should eq ary
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "is_supported_file" do
|
||||||
|
it "returns true when the filename has a supported extension" do
|
||||||
|
filename = "manga.cbz"
|
||||||
|
is_supported_file(filename).should eq true
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns true when the filename does not have a supported extension" do
|
||||||
|
filename = "info.json"
|
||||||
|
is_supported_file(filename).should eq false
|
||||||
|
end
|
||||||
|
|
||||||
|
it "is case insensitive" do
|
||||||
|
filename = "manga.ZiP"
|
||||||
|
is_supported_file(filename).should eq true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "chapter_sort" do
|
describe "chapter_sort" do
|
||||||
it "sorts correctly" do
|
it "sorts correctly" do
|
||||||
ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"]
|
ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"]
|
||||||
sorter = ChapterSorter.new ary
|
sorter = ChapterSorter.new ary
|
||||||
ary.reverse.sort do |a, b|
|
ary.reverse.sort! do |a, b|
|
||||||
sorter.compare a, b
|
sorter.compare a, b
|
||||||
end.should eq ary
|
end.should eq ary
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "sanitize_filename" do
|
||||||
|
it "returns a random string for empty sanitized string" do
|
||||||
|
sanitize_filename("..").should_not eq sanitize_filename("..")
|
||||||
|
end
|
||||||
|
it "sanitizes correctly" do
|
||||||
|
sanitize_filename(".. \n\v.\rマンゴー/|*()<[1/2] 3.14 hello world ")
|
||||||
|
.should eq "マンゴー_()[1_2] 3.14 hello world"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|||||||
+11
-11
@@ -1,13 +1,13 @@
|
|||||||
require "zip"
|
require "compress/zip"
|
||||||
require "archive"
|
require "archive"
|
||||||
|
|
||||||
# A unified class to handle all supported archive formats. It uses the ::Zip
|
# A unified class to handle all supported archive formats. It uses the
|
||||||
# module in crystal standard library if the target file is a zip archive.
|
# Compress::Zip module in crystal standard library if the target file is
|
||||||
# Otherwise it uses `archive.cr`.
|
# a zip archive. Otherwise it uses `archive.cr`.
|
||||||
class ArchiveFile
|
class ArchiveFile
|
||||||
def initialize(@filename : String)
|
def initialize(@filename : String)
|
||||||
if [".cbz", ".zip"].includes? File.extname filename
|
if [".cbz", ".zip"].includes? File.extname filename
|
||||||
@archive_file = Zip::File.new filename
|
@archive_file = Compress::Zip::File.new filename
|
||||||
else
|
else
|
||||||
@archive_file = Archive::File.new filename
|
@archive_file = Archive::File.new filename
|
||||||
end
|
end
|
||||||
@@ -20,16 +20,16 @@ class ArchiveFile
|
|||||||
end
|
end
|
||||||
|
|
||||||
def close
|
def close
|
||||||
if @archive_file.is_a? Zip::File
|
if @archive_file.is_a? Compress::Zip::File
|
||||||
@archive_file.as(Zip::File).close
|
@archive_file.as(Compress::Zip::File).close
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Lists all file entries
|
# Lists all file entries
|
||||||
def entries
|
def entries
|
||||||
ary = [] of Zip::File::Entry | Archive::Entry
|
ary = [] of Compress::Zip::File::Entry | Archive::Entry
|
||||||
@archive_file.entries.map do |e|
|
@archive_file.entries.map do |e|
|
||||||
if (e.is_a? Zip::File::Entry && e.file?) ||
|
if (e.is_a? Compress::Zip::File::Entry && e.file?) ||
|
||||||
(e.is_a? Archive::Entry && e.info.file?)
|
(e.is_a? Archive::Entry && e.info.file?)
|
||||||
ary.push e
|
ary.push e
|
||||||
end
|
end
|
||||||
@@ -37,8 +37,8 @@ class ArchiveFile
|
|||||||
ary
|
ary
|
||||||
end
|
end
|
||||||
|
|
||||||
def read_entry(e : Zip::File::Entry | Archive::Entry) : Bytes?
|
def read_entry(e : Compress::Zip::File::Entry | Archive::Entry) : Bytes?
|
||||||
if e.is_a? Zip::File::Entry
|
if e.is_a? Compress::Zip::File::Entry
|
||||||
data = nil
|
data = nil
|
||||||
e.open do |io|
|
e.open do |io|
|
||||||
slice = Bytes.new e.uncompressed_size
|
slice = Bytes.new e.uncompressed_size
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
Arabic,sa
|
|
||||||
Bengali,bd
|
|
||||||
Bulgarian,bg
|
|
||||||
Burmese,mm
|
|
||||||
Catalan,ct
|
|
||||||
Chinese (Simp),cn
|
|
||||||
Chinese (Trad),hk
|
|
||||||
Czech,cz
|
|
||||||
Danish,dk
|
|
||||||
Dutch,nl
|
|
||||||
English,gb
|
|
||||||
Filipino,ph
|
|
||||||
Finnish,fi
|
|
||||||
French,fr
|
|
||||||
German,de
|
|
||||||
Greek,gr
|
|
||||||
Hebrew,il
|
|
||||||
Hindi,in
|
|
||||||
Hungarian,hu
|
|
||||||
Indonesian,id
|
|
||||||
Italian,it
|
|
||||||
Japanese,jp
|
|
||||||
Korean,kr
|
|
||||||
Lithuanian,lt
|
|
||||||
Malay,my
|
|
||||||
Mongolian,mn
|
|
||||||
Other,
|
|
||||||
Persian,ir
|
|
||||||
Polish,pl
|
|
||||||
Portuguese (Br),br
|
|
||||||
Portuguese (Pt),pt
|
|
||||||
Romanian,ro
|
|
||||||
Russian,ru
|
|
||||||
Serbo-Croatian,rs
|
|
||||||
Spanish (Es),es
|
|
||||||
Spanish (LATAM),mx
|
|
||||||
Swedish,se
|
|
||||||
Thai,th
|
|
||||||
Turkish,tr
|
|
||||||
Ukrainian,ua
|
|
||||||
Vietnames,vn
|
|
||||||
|
+53
-42
@@ -1,36 +1,51 @@
|
|||||||
require "yaml"
|
require "yaml"
|
||||||
|
|
||||||
class Config
|
class Config
|
||||||
|
private OPTIONS = {
|
||||||
|
"host" => "0.0.0.0",
|
||||||
|
"port" => 9000,
|
||||||
|
"base_url" => "/",
|
||||||
|
"session_secret" => "mango-session-secret",
|
||||||
|
"library_path" => "~/mango/library",
|
||||||
|
"library_cache_path" => "~/mango/library.yml.gz",
|
||||||
|
"db_path" => "~/mango.db",
|
||||||
|
"queue_db_path" => "~/mango/queue.db",
|
||||||
|
"scan_interval_minutes" => 5,
|
||||||
|
"thumbnail_generation_interval_hours" => 24,
|
||||||
|
"log_level" => "info",
|
||||||
|
"upload_path" => "~/mango/uploads",
|
||||||
|
"plugin_path" => "~/mango/plugins",
|
||||||
|
"download_timeout_seconds" => 30,
|
||||||
|
"cache_enabled" => true,
|
||||||
|
"cache_size_mbs" => 50,
|
||||||
|
"cache_log_enabled" => true,
|
||||||
|
"disable_login" => false,
|
||||||
|
"default_username" => "",
|
||||||
|
"auth_proxy_header_name" => "",
|
||||||
|
"plugin_update_interval_hours" => 24,
|
||||||
|
}
|
||||||
|
|
||||||
include YAML::Serializable
|
include YAML::Serializable
|
||||||
|
|
||||||
@[YAML::Field(ignore: true)]
|
@[YAML::Field(ignore: true)]
|
||||||
property path : String = ""
|
property path : String = ""
|
||||||
property port : Int32 = 9000
|
|
||||||
property base_url : String = "/"
|
|
||||||
property session_secret : String = "mango-session-secret"
|
|
||||||
property library_path : String = File.expand_path "~/mango/library",
|
|
||||||
home: true
|
|
||||||
property db_path : String = File.expand_path "~/mango/mango.db", home: true
|
|
||||||
@[YAML::Field(key: "scan_interval_minutes")]
|
|
||||||
property scan_interval : Int32 = 5
|
|
||||||
property log_level : String = "info"
|
|
||||||
property upload_path : String = File.expand_path "~/mango/uploads",
|
|
||||||
home: true
|
|
||||||
property plugin_path : String = File.expand_path "~/mango/plugins",
|
|
||||||
home: true
|
|
||||||
property mangadex = Hash(String, String | Int32).new
|
|
||||||
|
|
||||||
@[YAML::Field(ignore: true)]
|
# Go through the options constant above and define them as properties.
|
||||||
@mangadex_defaults = {
|
# Allow setting the default values through environment variables.
|
||||||
"base_url" => "https://mangadex.org",
|
# Overall precedence: config file > environment variable > default value
|
||||||
"api_url" => "https://mangadex.org/api",
|
{% begin %}
|
||||||
"download_wait_seconds" => 5,
|
{% for k, v in OPTIONS %}
|
||||||
"download_retries" => 4,
|
{% if v.is_a? StringLiteral %}
|
||||||
"download_queue_db_path" => File.expand_path("~/mango/queue.db",
|
property {{k.id}} : String = ENV[{{k.upcase}}]? || {{ v }}
|
||||||
home: true),
|
{% elsif v.is_a? NumberLiteral %}
|
||||||
"chapter_rename_rule" => "[Vol.{volume} ][Ch.{chapter} ]{title|id}",
|
property {{k.id}} : Int32 = (ENV[{{k.upcase}}]? || {{ v.id }}).to_i
|
||||||
"manga_rename_rule" => "{title}",
|
{% elsif v.is_a? BoolLiteral %}
|
||||||
}
|
property {{k.id}} : Bool = env_is_true? {{ k.upcase }}, {{ v.id }}
|
||||||
|
{% else %}
|
||||||
|
raise "Unknown type in config option: {{ v.class_name.id }}"
|
||||||
|
{% end %}
|
||||||
|
{% end %}
|
||||||
|
{% end %}
|
||||||
|
|
||||||
@@singlet : Config?
|
@@singlet : Config?
|
||||||
|
|
||||||
@@ -43,24 +58,20 @@ class Config
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.load(path : String?)
|
def self.load(path : String?)
|
||||||
path = "~/.config/mango/config.yml" if path.nil?
|
path = (ENV["CONFIG_PATH"]? || "~/.config/mango/config.yml") if path.nil?
|
||||||
cfg_path = File.expand_path path, home: true
|
cfg_path = File.expand_path path, home: true
|
||||||
if File.exists? cfg_path
|
if File.exists? cfg_path
|
||||||
config = self.from_yaml File.read cfg_path
|
config = self.from_yaml File.read cfg_path
|
||||||
config.preprocess
|
|
||||||
config.path = path
|
config.path = path
|
||||||
config.fill_defaults
|
config.expand_paths
|
||||||
|
config.preprocess
|
||||||
return config
|
return config
|
||||||
end
|
end
|
||||||
puts "The config file #{cfg_path} does not exist." \
|
puts "The config file #{cfg_path} does not exist. " \
|
||||||
" Do you want mango to dump the default config there? [Y/n]"
|
"Dumping the default config there."
|
||||||
input = gets
|
|
||||||
if input && input.downcase == "n"
|
|
||||||
abort "Aborting..."
|
|
||||||
end
|
|
||||||
default = self.allocate
|
default = self.allocate
|
||||||
default.path = path
|
default.path = path
|
||||||
default.fill_defaults
|
default.expand_paths
|
||||||
cfg_dir = File.dirname cfg_path
|
cfg_dir = File.dirname cfg_path
|
||||||
unless Dir.exists? cfg_dir
|
unless Dir.exists? cfg_dir
|
||||||
Dir.mkdir_p cfg_dir
|
Dir.mkdir_p cfg_dir
|
||||||
@@ -70,13 +81,9 @@ class Config
|
|||||||
default
|
default
|
||||||
end
|
end
|
||||||
|
|
||||||
def fill_defaults
|
def expand_paths
|
||||||
{% for hash_name in ["mangadex"] %}
|
{% for p in %w(library library_cache db queue_db upload plugin) %}
|
||||||
@{{hash_name.id}}_defaults.map do |k, v|
|
@{{p.id}}_path = File.expand_path @{{p.id}}_path, home: true
|
||||||
if @{{hash_name.id}}[k]?.nil?
|
|
||||||
@{{hash_name.id}}[k] = v
|
|
||||||
end
|
|
||||||
end
|
|
||||||
{% end %}
|
{% end %}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -87,5 +94,9 @@ class Config
|
|||||||
unless base_url.ends_with? "/"
|
unless base_url.ends_with? "/"
|
||||||
@base_url += "/"
|
@base_url += "/"
|
||||||
end
|
end
|
||||||
|
if disable_login && default_username.empty?
|
||||||
|
raise "Login is disabled, but default username is not set. " \
|
||||||
|
"Please set a default username"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,41 +6,54 @@ class AuthHandler < Kemal::Handler
|
|||||||
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
|
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
|
||||||
|
|
||||||
BASIC = "Basic"
|
BASIC = "Basic"
|
||||||
|
BEARER = "Bearer"
|
||||||
AUTH = "Authorization"
|
AUTH = "Authorization"
|
||||||
AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \
|
AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \
|
||||||
"You have to login with proper credentials"
|
"You have to login with proper credentials"
|
||||||
HEADER_LOGIN_REQUIRED = "Basic realm=\"Login Required\""
|
HEADER_LOGIN_REQUIRED = "Basic realm=\"Login Required\""
|
||||||
|
|
||||||
def initialize(@storage : Storage)
|
|
||||||
end
|
|
||||||
|
|
||||||
def require_basic_auth(env)
|
def require_basic_auth(env)
|
||||||
env.response.status_code = 401
|
env.response.status_code = 401
|
||||||
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
|
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
|
||||||
env.response.print AUTH_MESSAGE
|
env.response.print AUTH_MESSAGE
|
||||||
call_next env
|
end
|
||||||
|
|
||||||
|
def require_auth(env)
|
||||||
|
if request_path_startswith env, ["/api"]
|
||||||
|
# Do not redirect API requests
|
||||||
|
env.response.status_code = 401
|
||||||
|
send_text env, "Unauthorized"
|
||||||
|
else
|
||||||
|
env.session.string "callback", env.request.path
|
||||||
|
redirect env, "/login"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_token(env)
|
def validate_token(env)
|
||||||
token = env.session.string? "token"
|
token = env.session.string? "token"
|
||||||
!token.nil? && @storage.verify_token token
|
!token.nil? && Storage.default.verify_token token
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_token_admin(env)
|
def validate_token_admin(env)
|
||||||
token = env.session.string? "token"
|
token = env.session.string? "token"
|
||||||
!token.nil? && @storage.verify_admin token
|
!token.nil? && Storage.default.verify_admin token
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_auth_header(env)
|
def validate_auth_header(env)
|
||||||
if env.request.headers[AUTH]?
|
if env.request.headers[AUTH]?
|
||||||
if value = env.request.headers[AUTH]
|
if value = env.request.headers[AUTH]
|
||||||
if value.size > 0 && value.starts_with?(BASIC)
|
if value.starts_with? BASIC
|
||||||
token = verify_user value
|
token = verify_user value
|
||||||
return false if token.nil?
|
return false if token.nil?
|
||||||
|
|
||||||
env.session.string "token", token
|
env.session.string "token", token
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
if value.starts_with? BEARER
|
||||||
|
session_id = value.split(" ")[1]
|
||||||
|
token = Kemal::Session.get(session_id).try &.string? "token"
|
||||||
|
return !token.nil? && Storage.default.verify_token token
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
false
|
false
|
||||||
@@ -49,44 +62,58 @@ class AuthHandler < Kemal::Handler
|
|||||||
def verify_user(value)
|
def verify_user(value)
|
||||||
username, password = Base64.decode_string(value[BASIC.size + 1..-1])
|
username, password = Base64.decode_string(value[BASIC.size + 1..-1])
|
||||||
.split(":")
|
.split(":")
|
||||||
@storage.verify_user username, password
|
Storage.default.verify_user username, password
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_opds_auth(env)
|
def call(env)
|
||||||
if validate_token(env) || validate_auth_header(env)
|
# OPTIONS requests do not require authentication
|
||||||
call_next env
|
if env.request.method === "OPTIONS"
|
||||||
else
|
return call_next(env)
|
||||||
env.response.status_code = 401
|
|
||||||
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
|
|
||||||
env.response.print AUTH_MESSAGE
|
|
||||||
end
|
end
|
||||||
end
|
# Skip all authentication if requesting /login, /logout, /api/login,
|
||||||
|
# or a static file
|
||||||
def handle_auth(env)
|
if request_path_startswith(env, ["/login", "/logout", "/api/login"]) ||
|
||||||
if request_path_startswith(env, ["/login", "/logout"]) ||
|
|
||||||
requesting_static_file env
|
requesting_static_file env
|
||||||
return call_next(env)
|
return call_next(env)
|
||||||
end
|
end
|
||||||
|
|
||||||
unless validate_token env
|
# Check user is logged in
|
||||||
env.session.string "callback", env.request.path
|
if validate_token(env) || validate_auth_header(env)
|
||||||
return redirect env, "/login"
|
# Skip if the request has a valid token (either from cookies or header)
|
||||||
|
elsif Config.current.disable_login
|
||||||
|
# Check default username if login is disabled
|
||||||
|
unless Storage.default.username_exists Config.current.default_username
|
||||||
|
Logger.warn "Default username #{Config.current.default_username} " \
|
||||||
|
"does not exist"
|
||||||
|
return require_auth env
|
||||||
|
end
|
||||||
|
elsif !Config.current.auth_proxy_header_name.empty?
|
||||||
|
# Check auth proxy if present
|
||||||
|
username = env.request.headers[Config.current.auth_proxy_header_name]?
|
||||||
|
unless username && Storage.default.username_exists username
|
||||||
|
Logger.warn "Header #{Config.current.auth_proxy_header_name} unset " \
|
||||||
|
"or is not a valid username"
|
||||||
|
return require_auth env
|
||||||
|
end
|
||||||
|
elsif request_path_startswith env, ["/opds"]
|
||||||
|
# Check auth header if requesting an opds page
|
||||||
|
unless validate_auth_header env
|
||||||
|
return require_basic_auth env
|
||||||
|
end
|
||||||
|
else
|
||||||
|
return require_auth env
|
||||||
end
|
end
|
||||||
|
|
||||||
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
|
# Check admin access when requesting an admin page
|
||||||
unless validate_token_admin env
|
if request_path_startswith env, %w(/admin /api/admin /download)
|
||||||
|
unless is_admin? env
|
||||||
env.response.status_code = 403
|
env.response.status_code = 403
|
||||||
|
return send_error_page "HTTP 403: You are not authorized to visit " \
|
||||||
|
"#{env.request.path}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Let the request go through if it passes the above checks
|
||||||
call_next env
|
call_next env
|
||||||
end
|
end
|
||||||
|
|
||||||
def call(env)
|
|
||||||
if request_path_startswith env, ["/opds"]
|
|
||||||
handle_opds_auth env
|
|
||||||
else
|
|
||||||
handle_auth env
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
class CORSHandler < Kemal::Handler
|
||||||
|
def call(env)
|
||||||
|
if request_path_startswith env, ["/api"]
|
||||||
|
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
|
end
|
||||||
|
call_next env
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -23,7 +23,7 @@ class StaticHandler < Kemal::Handler
|
|||||||
|
|
||||||
slice = Bytes.new file.size
|
slice = Bytes.new file.size
|
||||||
file.read slice
|
file.read slice
|
||||||
return send_file env, slice, file.mime_type
|
return send_file env, slice, MIME.from_filename file.path
|
||||||
end
|
end
|
||||||
call_next env
|
call_next env
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
require "yaml"
|
||||||
|
|
||||||
|
require "./entry"
|
||||||
|
|
||||||
|
class ArchiveEntry < Entry
|
||||||
|
include YAML::Serializable
|
||||||
|
|
||||||
|
getter zip_path : String
|
||||||
|
|
||||||
|
def initialize(@zip_path, @book)
|
||||||
|
storage = Storage.default
|
||||||
|
@path = @zip_path
|
||||||
|
@encoded_path = URI.encode @zip_path
|
||||||
|
@title = File.basename @zip_path, File.extname @zip_path
|
||||||
|
@encoded_title = URI.encode @title
|
||||||
|
@size = (File.size @zip_path).humanize_bytes
|
||||||
|
id = storage.get_entry_id @zip_path, File.signature(@zip_path)
|
||||||
|
if id.nil?
|
||||||
|
id = random_str
|
||||||
|
storage.insert_entry_id({
|
||||||
|
path: @zip_path,
|
||||||
|
id: id,
|
||||||
|
signature: File.signature(@zip_path).to_s,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
@id = id
|
||||||
|
@mtime = File.info(@zip_path).modification_time
|
||||||
|
|
||||||
|
unless File.readable? @zip_path
|
||||||
|
@err_msg = "File #{@zip_path} is not readable."
|
||||||
|
Logger.warn "#{@err_msg} Please make sure the " \
|
||||||
|
"file permission is configured correctly."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
archive_exception = validate_archive @zip_path
|
||||||
|
unless archive_exception.nil?
|
||||||
|
@err_msg = "Archive error: #{archive_exception}"
|
||||||
|
Logger.warn "Unable to extract archive #{@zip_path}. " \
|
||||||
|
"Ignoring it. #{@err_msg}"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
file = ArchiveFile.new @zip_path
|
||||||
|
@pages = file.entries.count do |e|
|
||||||
|
SUPPORTED_IMG_TYPES.includes? \
|
||||||
|
MIME.from_filename? e.filename
|
||||||
|
end
|
||||||
|
file.close
|
||||||
|
end
|
||||||
|
|
||||||
|
private def sorted_archive_entries
|
||||||
|
ArchiveFile.open @zip_path do |file|
|
||||||
|
entries = file.entries
|
||||||
|
.select { |e|
|
||||||
|
SUPPORTED_IMG_TYPES.includes? \
|
||||||
|
MIME.from_filename? e.filename
|
||||||
|
}
|
||||||
|
.sort! { |a, b|
|
||||||
|
compare_numerically a.filename, b.filename
|
||||||
|
}
|
||||||
|
yield file, entries
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_page(page_num)
|
||||||
|
raise "Unreadble archive. #{@err_msg}" if @err_msg
|
||||||
|
img = nil
|
||||||
|
begin
|
||||||
|
sorted_archive_entries do |file, entries|
|
||||||
|
page = entries[page_num - 1]
|
||||||
|
data = file.read_entry page
|
||||||
|
if data
|
||||||
|
img = Image.new data, MIME.from_filename(page.filename),
|
||||||
|
page.filename, data.size
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rescue e
|
||||||
|
Logger.warn "Unable to read page #{page_num} of #{@zip_path}. Error: #{e}"
|
||||||
|
end
|
||||||
|
img
|
||||||
|
end
|
||||||
|
|
||||||
|
def page_dimensions
|
||||||
|
sizes = [] of Hash(String, Int32)
|
||||||
|
sorted_archive_entries do |file, entries|
|
||||||
|
entries.each_with_index do |e, i|
|
||||||
|
begin
|
||||||
|
data = file.read_entry(e).not_nil!
|
||||||
|
size = ImageSize.get data
|
||||||
|
sizes << {
|
||||||
|
"width" => size.width,
|
||||||
|
"height" => size.height,
|
||||||
|
}
|
||||||
|
rescue e
|
||||||
|
Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}"
|
||||||
|
sizes << {"width" => 1000_i32, "height" => 1000_i32}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
sizes
|
||||||
|
end
|
||||||
|
|
||||||
|
def examine : Bool
|
||||||
|
File.exists? @zip_path
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.is_valid?(path : String) : Bool
|
||||||
|
is_supported_file path
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
require "digest"
|
||||||
|
|
||||||
|
require "./entry"
|
||||||
|
require "./title"
|
||||||
|
require "./types"
|
||||||
|
|
||||||
|
# Base class for an entry in the LRU cache.
|
||||||
|
# There are two ways to use it:
|
||||||
|
# 1. Use it as it is by instantiating with the appropriate `SaveT` and
|
||||||
|
# `ReturnT`. Note that in this case, `SaveT` and `ReturnT` must be the
|
||||||
|
# same type. That is, the input value will be stored as it is without
|
||||||
|
# any transformation.
|
||||||
|
# 2. You can also subclass it and provide custom implementations for
|
||||||
|
# `to_save_t` and `to_return_t`. This allows you to transform and store
|
||||||
|
# the input value to a different type. See `SortedEntriesCacheEntry` as
|
||||||
|
# an example.
|
||||||
|
private class CacheEntry(SaveT, ReturnT)
|
||||||
|
getter key : String, atime : Time
|
||||||
|
|
||||||
|
@value : SaveT
|
||||||
|
|
||||||
|
def initialize(@key : String, value : ReturnT)
|
||||||
|
@atime = @ctime = Time.utc
|
||||||
|
@value = self.class.to_save_t value
|
||||||
|
end
|
||||||
|
|
||||||
|
def value
|
||||||
|
@atime = Time.utc
|
||||||
|
self.class.to_return_t @value
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.to_save_t(value : ReturnT)
|
||||||
|
value
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.to_return_t(value : SaveT)
|
||||||
|
value
|
||||||
|
end
|
||||||
|
|
||||||
|
def instance_size
|
||||||
|
instance_sizeof(CacheEntry(SaveT, ReturnT)) + # sizeof itself
|
||||||
|
instance_sizeof(String) + @key.bytesize + # allocated memory for @key
|
||||||
|
@value.instance_size
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class SortedEntriesCacheEntry < CacheEntry(Array(String), Array(Entry))
|
||||||
|
def self.to_save_t(value : Array(Entry))
|
||||||
|
value.map &.id
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.to_return_t(value : Array(String))
|
||||||
|
ids_to_entries value
|
||||||
|
end
|
||||||
|
|
||||||
|
private def self.ids_to_entries(ids : Array(String))
|
||||||
|
e_map = Library.default.deep_entries.to_h { |entry| {entry.id, entry} }
|
||||||
|
entries = [] of Entry
|
||||||
|
begin
|
||||||
|
ids.each do |id|
|
||||||
|
entries << e_map[id]
|
||||||
|
end
|
||||||
|
return entries if ids.size == entries.size
|
||||||
|
rescue
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def instance_size
|
||||||
|
instance_sizeof(SortedEntriesCacheEntry) + # sizeof itself
|
||||||
|
instance_sizeof(String) + @key.bytesize + # allocated memory for @key
|
||||||
|
@value.size * (instance_sizeof(String) + sizeof(String)) +
|
||||||
|
@value.sum(&.bytesize) # elements in Array(String)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.gen_key(book_id : String, username : String,
|
||||||
|
entries : Array(Entry), opt : SortOptions?)
|
||||||
|
entries_sig = Digest::SHA1.hexdigest (entries.map &.id).to_s
|
||||||
|
user_context = opt && opt.method == SortMethod::Progress ? username : ""
|
||||||
|
sig = Digest::SHA1.hexdigest(book_id + entries_sig + user_context +
|
||||||
|
(opt ? opt.to_tuple.to_s : "nil"))
|
||||||
|
"#{sig}:sorted_entries"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class SortedTitlesCacheEntry < CacheEntry(Array(String), Array(Title))
|
||||||
|
def self.to_save_t(value : Array(Title))
|
||||||
|
value.map &.id
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.to_return_t(value : Array(String))
|
||||||
|
value.map { |title_id| Library.default.title_hash[title_id].not_nil! }
|
||||||
|
end
|
||||||
|
|
||||||
|
def instance_size
|
||||||
|
instance_sizeof(SortedTitlesCacheEntry) + # sizeof itself
|
||||||
|
instance_sizeof(String) + @key.bytesize + # allocated memory for @key
|
||||||
|
@value.size * (instance_sizeof(String) + sizeof(String)) +
|
||||||
|
@value.sum(&.bytesize) # elements in Array(String)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.gen_key(username : String, titles : Array(Title), opt : SortOptions?)
|
||||||
|
titles_sig = Digest::SHA1.hexdigest (titles.map &.id).to_s
|
||||||
|
user_context = opt && opt.method == SortMethod::Progress ? username : ""
|
||||||
|
sig = Digest::SHA1.hexdigest(titles_sig + user_context +
|
||||||
|
(opt ? opt.to_tuple.to_s : "nil"))
|
||||||
|
"#{sig}:sorted_titles"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class String
|
||||||
|
def instance_size
|
||||||
|
instance_sizeof(String) + bytesize
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
struct Tuple(*T)
|
||||||
|
def instance_size
|
||||||
|
sizeof(T) + # total size of non-reference types
|
||||||
|
self.sum do |e|
|
||||||
|
next 0 unless e.is_a? Reference
|
||||||
|
if e.responds_to? :instance_size
|
||||||
|
e.instance_size
|
||||||
|
else
|
||||||
|
instance_sizeof(typeof(e))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
alias CacheableType = Array(Entry) | Array(Title) | String |
|
||||||
|
Tuple(String, Int32)
|
||||||
|
alias CacheEntryType = SortedEntriesCacheEntry |
|
||||||
|
SortedTitlesCacheEntry |
|
||||||
|
CacheEntry(String, String) |
|
||||||
|
CacheEntry(Tuple(String, Int32), Tuple(String, Int32))
|
||||||
|
|
||||||
|
def generate_cache_entry(key : String, value : CacheableType)
|
||||||
|
if value.is_a? Array(Entry)
|
||||||
|
SortedEntriesCacheEntry.new key, value
|
||||||
|
elsif value.is_a? Array(Title)
|
||||||
|
SortedTitlesCacheEntry.new key, value
|
||||||
|
else
|
||||||
|
CacheEntry(typeof(value), typeof(value)).new key, value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# LRU Cache
|
||||||
|
class LRUCache
|
||||||
|
@@limit : Int128 = Int128.new 0
|
||||||
|
@@should_log = true
|
||||||
|
# key => entry
|
||||||
|
@@cache = {} of String => CacheEntryType
|
||||||
|
|
||||||
|
def self.enabled
|
||||||
|
Config.current.cache_enabled
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.init
|
||||||
|
cache_size = Config.current.cache_size_mbs
|
||||||
|
@@limit = Int128.new cache_size * 1024 * 1024 if enabled
|
||||||
|
@@should_log = Config.current.cache_log_enabled
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.get(key : String)
|
||||||
|
return unless enabled
|
||||||
|
entry = @@cache[key]?
|
||||||
|
if @@should_log
|
||||||
|
Logger.debug "LRUCache #{entry.nil? ? "miss" : "hit"} #{key}"
|
||||||
|
end
|
||||||
|
return entry.value unless entry.nil?
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.set(cache_entry : CacheEntryType)
|
||||||
|
return unless enabled
|
||||||
|
key = cache_entry.key
|
||||||
|
@@cache[key] = cache_entry
|
||||||
|
Logger.debug "LRUCache cached #{key}" if @@should_log
|
||||||
|
remove_least_recent_access
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.invalidate(key : String)
|
||||||
|
return unless enabled
|
||||||
|
@@cache.delete key
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.print
|
||||||
|
return unless @@should_log
|
||||||
|
sum = @@cache.sum { |_, entry| entry.instance_size }
|
||||||
|
Logger.debug "---- LRU Cache ----"
|
||||||
|
Logger.debug "Size: #{sum} Bytes"
|
||||||
|
Logger.debug "List:"
|
||||||
|
@@cache.each do |k, v|
|
||||||
|
Logger.debug "#{k} | #{v.atime} | #{v.instance_size}"
|
||||||
|
end
|
||||||
|
Logger.debug "-------------------"
|
||||||
|
end
|
||||||
|
|
||||||
|
private def self.is_cache_full
|
||||||
|
sum = @@cache.sum { |_, entry| entry.instance_size }
|
||||||
|
sum > @@limit
|
||||||
|
end
|
||||||
|
|
||||||
|
private def self.remove_least_recent_access
|
||||||
|
if @@should_log && is_cache_full
|
||||||
|
Logger.debug "Removing entries from LRUCache"
|
||||||
|
end
|
||||||
|
while is_cache_full && @@cache.size > 0
|
||||||
|
min_tuple = @@cache.min_by { |_, entry| entry.atime }
|
||||||
|
min_key = min_tuple[0]
|
||||||
|
min_entry = min_tuple[1]
|
||||||
|
|
||||||
|
Logger.debug " \
|
||||||
|
Target: #{min_key}, \
|
||||||
|
Last Access Time: #{min_entry.atime}" if @@should_log
|
||||||
|
invalidate min_key
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
require "yaml"
|
||||||
|
|
||||||
|
require "./entry"
|
||||||
|
|
||||||
|
class DirEntry < Entry
|
||||||
|
include YAML::Serializable
|
||||||
|
|
||||||
|
getter dir_path : String
|
||||||
|
|
||||||
|
@[YAML::Field(ignore: true)]
|
||||||
|
@sorted_files : Array(String)?
|
||||||
|
|
||||||
|
@signature : String
|
||||||
|
|
||||||
|
def initialize(@dir_path, @book)
|
||||||
|
storage = Storage.default
|
||||||
|
@path = @dir_path
|
||||||
|
@encoded_path = URI.encode @dir_path
|
||||||
|
@title = File.basename @dir_path
|
||||||
|
@encoded_title = URI.encode @title
|
||||||
|
|
||||||
|
unless File.readable? @dir_path
|
||||||
|
@err_msg = "Directory #{@dir_path} is not readable."
|
||||||
|
Logger.warn "#{@err_msg} Please make sure the " \
|
||||||
|
"file permission is configured correctly."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
unless DirEntry.is_valid? @dir_path
|
||||||
|
@err_msg = "Directory #{@dir_path} is not valid directory entry."
|
||||||
|
Logger.warn "#{@err_msg} Please make sure the " \
|
||||||
|
"directory has valid images."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
size_sum = 0
|
||||||
|
sorted_files.each do |file_path|
|
||||||
|
size_sum += File.size file_path
|
||||||
|
end
|
||||||
|
@size = size_sum.humanize_bytes
|
||||||
|
|
||||||
|
@signature = Dir.directory_entry_signature @dir_path
|
||||||
|
id = storage.get_entry_id @dir_path, @signature
|
||||||
|
if id.nil?
|
||||||
|
id = random_str
|
||||||
|
storage.insert_entry_id({
|
||||||
|
path: @dir_path,
|
||||||
|
id: id,
|
||||||
|
signature: @signature,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
@id = id
|
||||||
|
|
||||||
|
@mtime = sorted_files.map do |file_path|
|
||||||
|
File.info(file_path).modification_time
|
||||||
|
end.max
|
||||||
|
@pages = sorted_files.size
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_page(page_num)
|
||||||
|
img = nil
|
||||||
|
begin
|
||||||
|
files = sorted_files
|
||||||
|
file_path = files[page_num - 1]
|
||||||
|
data = File.read(file_path).to_slice
|
||||||
|
if data
|
||||||
|
img = Image.new data, MIME.from_filename(file_path),
|
||||||
|
File.basename(file_path), data.size
|
||||||
|
end
|
||||||
|
rescue e
|
||||||
|
Logger.warn "Unable to read page #{page_num} of #{@dir_path}. Error: #{e}"
|
||||||
|
end
|
||||||
|
img
|
||||||
|
end
|
||||||
|
|
||||||
|
def page_dimensions
|
||||||
|
sizes = [] of Hash(String, Int32)
|
||||||
|
sorted_files.each_with_index do |path, i|
|
||||||
|
data = File.read(path).to_slice
|
||||||
|
begin
|
||||||
|
data.not_nil!
|
||||||
|
size = ImageSize.get data
|
||||||
|
sizes << {
|
||||||
|
"width" => size.width,
|
||||||
|
"height" => size.height,
|
||||||
|
}
|
||||||
|
rescue e
|
||||||
|
Logger.warn "Failed to read page #{i} of entry #{@dir_path}. #{e}"
|
||||||
|
sizes << {"width" => 1000_i32, "height" => 1000_i32}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
sizes
|
||||||
|
end
|
||||||
|
|
||||||
|
def examine : Bool
|
||||||
|
existence = File.exists? @dir_path
|
||||||
|
return false unless existence
|
||||||
|
files = DirEntry.image_files @dir_path
|
||||||
|
signature = Dir.directory_entry_signature @dir_path
|
||||||
|
existence = files.size > 0 && @signature == signature
|
||||||
|
@sorted_files = nil unless existence
|
||||||
|
|
||||||
|
# For more efficient, update a directory entry with new property
|
||||||
|
# and return true like Title.examine
|
||||||
|
existence
|
||||||
|
end
|
||||||
|
|
||||||
|
def sorted_files
|
||||||
|
cached_sorted_files = @sorted_files
|
||||||
|
return cached_sorted_files if cached_sorted_files
|
||||||
|
@sorted_files = DirEntry.sorted_image_files @dir_path
|
||||||
|
@sorted_files.not_nil!
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.image_files(dir_path)
|
||||||
|
Dir.entries(dir_path)
|
||||||
|
.reject(&.starts_with? ".")
|
||||||
|
.map { |fn| File.join dir_path, fn }
|
||||||
|
.select { |fn| is_supported_image_file fn }
|
||||||
|
.reject { |fn| File.directory? fn }
|
||||||
|
.select { |fn| File.readable? fn }
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.sorted_image_files(dir_path)
|
||||||
|
self.image_files(dir_path)
|
||||||
|
.sort { |a, b| compare_numerically a, b }
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.is_valid?(path : String) : Bool
|
||||||
|
image_files(path).size > 0
|
||||||
|
end
|
||||||
|
end
|
||||||
+170
-94
@@ -1,60 +1,98 @@
|
|||||||
class Entry
|
require "image_size"
|
||||||
property zip_path : String, book : Title, title : String,
|
|
||||||
size : String, pages : Int32, id : String, encoded_path : String,
|
|
||||||
encoded_title : String, mtime : Time, err_msg : String?
|
|
||||||
|
|
||||||
def initialize(@zip_path, @book, storage)
|
private def node_has_key(node : YAML::Nodes::Mapping, key : String)
|
||||||
@encoded_path = URI.encode @zip_path
|
node.nodes
|
||||||
@title = File.basename @zip_path, File.extname @zip_path
|
.map_with_index { |n, i| {n, i} }
|
||||||
@encoded_title = URI.encode @title
|
.select(&.[1].even?)
|
||||||
@size = (File.size @zip_path).humanize_bytes
|
.map(&.[0])
|
||||||
id = storage.get_id @zip_path, false
|
.select(YAML::Nodes::Scalar)
|
||||||
if id.nil?
|
.map(&.as(YAML::Nodes::Scalar).value)
|
||||||
id = random_str
|
.includes? key
|
||||||
storage.insert_id({
|
end
|
||||||
path: @zip_path,
|
|
||||||
id: id,
|
|
||||||
is_title: false,
|
|
||||||
})
|
|
||||||
end
|
|
||||||
@id = id
|
|
||||||
@mtime = File.info(@zip_path).modification_time
|
|
||||||
|
|
||||||
unless File.readable? @zip_path
|
abstract class Entry
|
||||||
@err_msg = "File #{@zip_path} is not readable."
|
getter id : String, book : Title, title : String, path : String,
|
||||||
Logger.warn "#{@err_msg} Please make sure the " \
|
size : String, pages : Int32, mtime : Time,
|
||||||
"file permission is configured correctly."
|
encoded_path : String, encoded_title : String, err_msg : String?
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
archive_exception = validate_archive @zip_path
|
def initialize(
|
||||||
unless archive_exception.nil?
|
@id, @title, @book, @path,
|
||||||
@err_msg = "Archive error: #{archive_exception}"
|
@size, @pages, @mtime,
|
||||||
Logger.warn "Unable to extract archive #{@zip_path}. " \
|
@encoded_path, @encoded_title, @err_msg
|
||||||
"Ignoring it. #{@err_msg}"
|
)
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
file = ArchiveFile.new @zip_path
|
|
||||||
@pages = file.entries.count do |e|
|
|
||||||
SUPPORTED_IMG_TYPES.includes? \
|
|
||||||
MIME.from_filename? e.filename
|
|
||||||
end
|
|
||||||
file.close
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
def self.new(ctx : YAML::ParseContext, node : YAML::Nodes::Node)
|
||||||
json.object do
|
unless node.is_a? YAML::Nodes::Mapping
|
||||||
{% for str in ["zip_path", "title", "size", "id",
|
raise "Unexpected node type in YAML"
|
||||||
"encoded_path", "encoded_title"] %}
|
|
||||||
json.field {{str}}, @{{str.id}}
|
|
||||||
{% end %}
|
|
||||||
json.field "title_id", @book.id
|
|
||||||
json.field "display_name", @book.display_name @title
|
|
||||||
json.field "cover_url", cover_url
|
|
||||||
json.field "pages" { json.number @pages }
|
|
||||||
json.field "mtime" { json.number @mtime.to_unix }
|
|
||||||
end
|
end
|
||||||
|
# Doing YAML::Any.new(ctx, node) here causes a weird error, so
|
||||||
|
# instead we are using a more hacky approach (see `node_has_key`).
|
||||||
|
# TODO: Use a more elegant approach
|
||||||
|
if node_has_key node, "zip_path"
|
||||||
|
ArchiveEntry.new ctx, node
|
||||||
|
elsif node_has_key node, "dir_path"
|
||||||
|
DirEntry.new ctx, node
|
||||||
|
else
|
||||||
|
raise "Unknown entry found in YAML cache. Try deleting the " \
|
||||||
|
"`library.yml.gz` file"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_json(*, slim = false)
|
||||||
|
JSON.build do |json|
|
||||||
|
json.object do
|
||||||
|
{% for str in %w(path title size id) %}
|
||||||
|
json.field {{str}}, {{str.id}}
|
||||||
|
{% end %}
|
||||||
|
if err_msg
|
||||||
|
json.field "err_msg", err_msg
|
||||||
|
end
|
||||||
|
json.field "zip_path", path # for API backward compatability
|
||||||
|
json.field "path", path
|
||||||
|
json.field "title_id", @book.id
|
||||||
|
json.field "title_title", @book.title
|
||||||
|
json.field "sort_title", sort_title
|
||||||
|
json.field "pages" { json.number @pages }
|
||||||
|
unless slim
|
||||||
|
json.field "display_name", @book.display_name @title
|
||||||
|
json.field "cover_url", cover_url
|
||||||
|
json.field "mtime" { json.number @mtime.to_unix }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@[YAML::Field(ignore: true)]
|
||||||
|
@sort_title : String?
|
||||||
|
|
||||||
|
def sort_title
|
||||||
|
sort_title_cached = @sort_title
|
||||||
|
return sort_title_cached if sort_title_cached
|
||||||
|
sort_title = @book.entry_sort_title_db id
|
||||||
|
if sort_title
|
||||||
|
@sort_title = sort_title
|
||||||
|
return sort_title
|
||||||
|
end
|
||||||
|
@sort_title = @title
|
||||||
|
@title
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_sort_title(sort_title : String | Nil, username : String)
|
||||||
|
Storage.default.set_entry_sort_title id, sort_title
|
||||||
|
if sort_title == "" || sort_title.nil?
|
||||||
|
@sort_title = nil
|
||||||
|
else
|
||||||
|
@sort_title = sort_title
|
||||||
|
end
|
||||||
|
|
||||||
|
@book.entry_sort_title_cache = nil
|
||||||
|
@book.remove_sorted_entries_cache [SortMethod::Auto, SortMethod::Title],
|
||||||
|
username
|
||||||
|
end
|
||||||
|
|
||||||
|
def sort_title_db
|
||||||
|
@book.entry_sort_title_db @id
|
||||||
end
|
end
|
||||||
|
|
||||||
def display_name
|
def display_name
|
||||||
@@ -66,10 +104,18 @@ class Entry
|
|||||||
end
|
end
|
||||||
|
|
||||||
def cover_url
|
def cover_url
|
||||||
return "#{Config.current.base_url}img/icon.png" if @err_msg
|
return "#{Config.current.base_url}img/icons/icon_x192.png" if @err_msg
|
||||||
url = "#{Config.current.base_url}api/page/#{@book.id}/#{@id}/1"
|
|
||||||
TitleInfo.new @book.dir do |info|
|
unless @book.entry_cover_url_cache
|
||||||
info_url = info.entry_cover_url[@title]?
|
TitleInfo.new @book.dir do |info|
|
||||||
|
@book.entry_cover_url_cache = info.entry_cover_url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
entry_cover_url = @book.entry_cover_url_cache
|
||||||
|
|
||||||
|
url = "#{Config.current.base_url}api/cover/#{@book.id}/#{@id}"
|
||||||
|
if entry_cover_url
|
||||||
|
info_url = entry_cover_url[@title]?
|
||||||
unless info_url.nil? || info_url.empty?
|
unless info_url.nil? || info_url.empty?
|
||||||
url = File.join Config.current.base_url, info_url
|
url = File.join Config.current.base_url, info_url
|
||||||
end
|
end
|
||||||
@@ -77,28 +123,6 @@ class Entry
|
|||||||
url
|
url
|
||||||
end
|
end
|
||||||
|
|
||||||
def read_page(page_num)
|
|
||||||
raise "Unreadble archive. #{@err_msg}" if @err_msg
|
|
||||||
img = nil
|
|
||||||
ArchiveFile.open @zip_path do |file|
|
|
||||||
page = file.entries
|
|
||||||
.select { |e|
|
|
||||||
SUPPORTED_IMG_TYPES.includes? \
|
|
||||||
MIME.from_filename? e.filename
|
|
||||||
}
|
|
||||||
.sort { |a, b|
|
|
||||||
compare_numerically a.filename, b.filename
|
|
||||||
}
|
|
||||||
.[page_num - 1]
|
|
||||||
data = file.read_entry page
|
|
||||||
if data
|
|
||||||
img = Image.new data, MIME.from_filename(page.filename), page.filename,
|
|
||||||
data.size
|
|
||||||
end
|
|
||||||
end
|
|
||||||
img
|
|
||||||
end
|
|
||||||
|
|
||||||
def next_entry(username)
|
def next_entry(username)
|
||||||
entries = @book.sorted_entries username
|
entries = @book.sorted_entries username
|
||||||
idx = entries.index self
|
idx = entries.index self
|
||||||
@@ -106,29 +130,22 @@ class Entry
|
|||||||
entries[idx + 1]
|
entries[idx + 1]
|
||||||
end
|
end
|
||||||
|
|
||||||
def previous_entry
|
def previous_entry(username)
|
||||||
idx = @book.entries.index self
|
entries = @book.sorted_entries username
|
||||||
|
idx = entries.index self
|
||||||
return nil if idx.nil? || idx == 0
|
return nil if idx.nil? || idx == 0
|
||||||
@book.entries[idx - 1]
|
entries[idx - 1]
|
||||||
end
|
|
||||||
|
|
||||||
def date_added
|
|
||||||
date_added = nil
|
|
||||||
TitleInfo.new @book.dir do |info|
|
|
||||||
info_da = info.date_added[@title]?
|
|
||||||
if info_da.nil?
|
|
||||||
date_added = info.date_added[@title] = ctime @zip_path
|
|
||||||
info.save
|
|
||||||
else
|
|
||||||
date_added = info_da
|
|
||||||
end
|
|
||||||
end
|
|
||||||
date_added.not_nil! # is it ok to set not_nil! here?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# For backward backward compatibility with v0.1.0, we save entry titles
|
# For backward backward compatibility with v0.1.0, we save entry titles
|
||||||
# instead of IDs in info.json
|
# instead of IDs in info.json
|
||||||
def save_progress(username, page)
|
def save_progress(username, page)
|
||||||
|
LRUCache.invalidate "#{@book.id}:#{username}:progress_sum"
|
||||||
|
@book.parents.each do |parent|
|
||||||
|
LRUCache.invalidate "#{parent.id}:#{username}:progress_sum"
|
||||||
|
end
|
||||||
|
@book.remove_sorted_caches [SortMethod::Progress], username
|
||||||
|
|
||||||
TitleInfo.new @book.dir do |info|
|
TitleInfo.new @book.dir do |info|
|
||||||
if info.progress[username]?.nil?
|
if info.progress[username]?.nil?
|
||||||
info.progress[username] = {@title => page}
|
info.progress[username] = {@title => page}
|
||||||
@@ -179,4 +196,63 @@ class Entry
|
|||||||
def started?(username)
|
def started?(username)
|
||||||
load_progress(username) > 0
|
load_progress(username) > 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def generate_thumbnail : Image?
|
||||||
|
return if @err_msg
|
||||||
|
|
||||||
|
img = read_page(1).not_nil!
|
||||||
|
begin
|
||||||
|
size = ImageSize.get img.data
|
||||||
|
if size.height > size.width
|
||||||
|
thumbnail = ImageSize.resize img.data, width: 200
|
||||||
|
else
|
||||||
|
thumbnail = ImageSize.resize img.data, height: 300
|
||||||
|
end
|
||||||
|
img.data = thumbnail
|
||||||
|
img.size = thumbnail.size
|
||||||
|
unless img.mime == "image/webp"
|
||||||
|
# image_size.cr resizes non-webp images to jpg
|
||||||
|
img.mime = "image/jpeg"
|
||||||
|
end
|
||||||
|
Storage.default.save_thumbnail @id, img
|
||||||
|
rescue e
|
||||||
|
Logger.warn "Failed to generate thumbnail for file #{path}. #{e}"
|
||||||
|
end
|
||||||
|
|
||||||
|
img
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_thumbnail : Image?
|
||||||
|
Storage.default.get_thumbnail @id
|
||||||
|
end
|
||||||
|
|
||||||
|
def date_added : Time
|
||||||
|
date_added = Time::UNIX_EPOCH
|
||||||
|
TitleInfo.new @book.dir do |info|
|
||||||
|
info_da = info.date_added[@title]?
|
||||||
|
if info_da.nil?
|
||||||
|
date_added = info.date_added[@title] = ctime path
|
||||||
|
info.save
|
||||||
|
else
|
||||||
|
date_added = info_da
|
||||||
|
end
|
||||||
|
end
|
||||||
|
date_added
|
||||||
|
end
|
||||||
|
|
||||||
|
# Hack to have abstract class methods
|
||||||
|
# https://github.com/crystal-lang/crystal/issues/5956
|
||||||
|
private module ClassMethods
|
||||||
|
abstract def is_valid?(path : String) : Bool
|
||||||
|
end
|
||||||
|
|
||||||
|
macro inherited
|
||||||
|
extend ClassMethods
|
||||||
|
end
|
||||||
|
|
||||||
|
abstract def read_page(page_num)
|
||||||
|
|
||||||
|
abstract def page_dimensions
|
||||||
|
|
||||||
|
abstract def examine : Bool?
|
||||||
end
|
end
|
||||||
|
|||||||
+241
-60
@@ -1,27 +1,119 @@
|
|||||||
class Library
|
class Library
|
||||||
property dir : String, title_ids : Array(String), scan_interval : Int32,
|
struct ThumbnailContext
|
||||||
|
property current : Int32, total : Int32
|
||||||
|
|
||||||
|
def initialize
|
||||||
|
@current = 0
|
||||||
|
@total = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def progress
|
||||||
|
if total == 0
|
||||||
|
0
|
||||||
|
else
|
||||||
|
current / total
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def reset
|
||||||
|
@current = 0
|
||||||
|
@total = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def increment
|
||||||
|
@current += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
include YAML::Serializable
|
||||||
|
|
||||||
|
getter dir : String, title_ids : Array(String),
|
||||||
title_hash : Hash(String, Title)
|
title_hash : Hash(String, Title)
|
||||||
|
|
||||||
|
@[YAML::Field(ignore: true)]
|
||||||
|
getter thumbnail_ctx = ThumbnailContext.new
|
||||||
|
|
||||||
use_default
|
use_default
|
||||||
|
|
||||||
def initialize
|
def save_instance
|
||||||
register_mime_types
|
path = Config.current.library_cache_path
|
||||||
|
Logger.debug "Caching library to #{path}"
|
||||||
|
|
||||||
|
writer = Compress::Gzip::Writer.new path,
|
||||||
|
Compress::Gzip::BEST_COMPRESSION
|
||||||
|
writer.write self.to_yaml.to_slice
|
||||||
|
writer.close
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.load_instance
|
||||||
|
path = Config.current.library_cache_path
|
||||||
|
return unless File.exists? path
|
||||||
|
|
||||||
|
Logger.debug "Loading cached library from #{path}"
|
||||||
|
|
||||||
|
begin
|
||||||
|
Compress::Gzip::Reader.open path do |content|
|
||||||
|
loaded = Library.from_yaml content
|
||||||
|
# We will have to do a full restart in these cases. Otherwise having
|
||||||
|
# two instances of the library will cause some weirdness.
|
||||||
|
if loaded.dir != Config.current.library_path
|
||||||
|
Logger.fatal "Cached library dir #{loaded.dir} does not match " \
|
||||||
|
"current library dir #{Config.current.library_path}. " \
|
||||||
|
"Deleting cache"
|
||||||
|
delete_cache_and_exit path
|
||||||
|
end
|
||||||
|
if loaded.title_ids.size > 0 &&
|
||||||
|
Storage.default.count_titles == 0
|
||||||
|
Logger.fatal "The library cache is inconsistent with the DB. " \
|
||||||
|
"Deleting cache"
|
||||||
|
delete_cache_and_exit path
|
||||||
|
end
|
||||||
|
@@default = loaded
|
||||||
|
Logger.debug "Library cache loaded"
|
||||||
|
end
|
||||||
|
Library.default.register_jobs
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize
|
||||||
@dir = Config.current.library_path
|
@dir = Config.current.library_path
|
||||||
@scan_interval = Config.current.scan_interval
|
|
||||||
# explicitly initialize @titles to bypass the compiler check. it will
|
# explicitly initialize @titles to bypass the compiler check. it will
|
||||||
# be filled with actual Titles in the `scan` call below
|
# be filled with actual Titles in the `scan` call below
|
||||||
@title_ids = [] of String
|
@title_ids = [] of String
|
||||||
@title_hash = {} of String => Title
|
@title_hash = {} of String => Title
|
||||||
|
|
||||||
return scan if @scan_interval < 1
|
register_jobs
|
||||||
spawn do
|
end
|
||||||
loop do
|
|
||||||
start = Time.local
|
protected def register_jobs
|
||||||
scan
|
register_mime_types
|
||||||
ms = (Time.local - start).total_milliseconds
|
|
||||||
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
|
scan_interval = Config.current.scan_interval_minutes
|
||||||
sleep @scan_interval * 60
|
if scan_interval < 1
|
||||||
|
scan
|
||||||
|
else
|
||||||
|
spawn do
|
||||||
|
loop do
|
||||||
|
start = Time.local
|
||||||
|
scan
|
||||||
|
ms = (Time.local - start).total_milliseconds
|
||||||
|
Logger.debug "Library initialized in #{ms}ms"
|
||||||
|
sleep scan_interval.minutes
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
thumbnail_interval = Config.current.thumbnail_generation_interval_hours
|
||||||
|
unless thumbnail_interval < 1
|
||||||
|
spawn do
|
||||||
|
loop do
|
||||||
|
# Wait for scan to complete (in most cases)
|
||||||
|
sleep 1.minutes
|
||||||
|
generate_thumbnails
|
||||||
|
sleep thumbnail_interval.hours
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -30,15 +122,51 @@ class Library
|
|||||||
@title_ids.map { |tid| self.get_title!(tid) }
|
@title_ids.map { |tid| self.get_title!(tid) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def deep_titles
|
def sorted_titles(username, opt : SortOptions? = nil)
|
||||||
titles + titles.map { |t| t.deep_titles }.flatten
|
if opt.nil?
|
||||||
|
opt = SortOptions.from_info_json @dir, username
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper function from src/util/util.cr
|
||||||
|
sort_titles titles, opt.not_nil!, username
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
def deep_titles
|
||||||
json.object do
|
titles + titles.flat_map &.deep_titles
|
||||||
json.field "dir", @dir
|
end
|
||||||
json.field "titles" do
|
|
||||||
json.raw self.titles.to_json
|
def deep_entries
|
||||||
|
titles.flat_map &.deep_entries
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_json(*, slim = false, depth = -1, sort_context = nil,
|
||||||
|
percentage = false)
|
||||||
|
_titles = if sort_context
|
||||||
|
sorted_titles sort_context[:username],
|
||||||
|
sort_context[:opt]
|
||||||
|
else
|
||||||
|
self.titles
|
||||||
|
end
|
||||||
|
JSON.build do |json|
|
||||||
|
json.object do
|
||||||
|
json.field "dir", @dir
|
||||||
|
json.field "titles" do
|
||||||
|
json.array do
|
||||||
|
_titles.each do |title|
|
||||||
|
json.raw title.build_json(slim: slim, depth: depth,
|
||||||
|
sort_context: sort_context, percentage: percentage)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if percentage && sort_context
|
||||||
|
json.field "title_percentages" do
|
||||||
|
json.array do
|
||||||
|
_titles.each do |title|
|
||||||
|
json.number title.load_percentage sort_context[:username]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -52,22 +180,47 @@ class Library
|
|||||||
end
|
end
|
||||||
|
|
||||||
def scan
|
def scan
|
||||||
|
start = Time.local
|
||||||
unless Dir.exists? @dir
|
unless Dir.exists? @dir
|
||||||
Logger.info "The library directory #{@dir} does not exist. " \
|
Logger.info "The library directory #{@dir} does not exist. " \
|
||||||
"Attempting to create it"
|
"Attempting to create it"
|
||||||
Dir.mkdir_p @dir
|
Dir.mkdir_p @dir
|
||||||
end
|
end
|
||||||
@title_ids.clear
|
|
||||||
|
|
||||||
storage = Storage.new auto_close: false
|
storage = Storage.new auto_close: false
|
||||||
|
|
||||||
(Dir.entries @dir)
|
examine_context : ExamineContext = {
|
||||||
|
cached_contents_signature: {} of String => String,
|
||||||
|
deleted_title_ids: [] of String,
|
||||||
|
deleted_entry_ids: [] of String,
|
||||||
|
}
|
||||||
|
|
||||||
|
library_paths = (Dir.entries @dir)
|
||||||
.select { |fn| !fn.starts_with? "." }
|
.select { |fn| !fn.starts_with? "." }
|
||||||
.map { |fn| File.join @dir, fn }
|
.map { |fn| File.join @dir, fn }
|
||||||
|
@title_ids.select! do |title_id|
|
||||||
|
title = @title_hash[title_id]
|
||||||
|
next false unless library_paths.includes? title.dir
|
||||||
|
existence = title.examine examine_context
|
||||||
|
unless existence
|
||||||
|
examine_context["deleted_title_ids"].concat [title_id] +
|
||||||
|
title.deep_titles.map &.id
|
||||||
|
examine_context["deleted_entry_ids"].concat title.deep_entries.map &.id
|
||||||
|
end
|
||||||
|
existence
|
||||||
|
end
|
||||||
|
remained_title_dirs = @title_ids.map { |id| title_hash[id].dir }
|
||||||
|
examine_context["deleted_title_ids"].each do |title_id|
|
||||||
|
@title_hash.delete title_id
|
||||||
|
end
|
||||||
|
|
||||||
|
cache = examine_context["cached_contents_signature"]
|
||||||
|
library_paths
|
||||||
|
.select { |path| !(remained_title_dirs.includes? path) }
|
||||||
.select { |path| File.directory? path }
|
.select { |path| File.directory? path }
|
||||||
.map { |path| Title.new path, "", storage, self }
|
.map { |path| Title.new path, "", cache }
|
||||||
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
.select { |title| !(title.entries.empty? && title.titles.empty?) }
|
||||||
.sort { |a, b| a.title <=> b.title }
|
.sort! { |a, b| a.sort_title <=> b.sort_title }
|
||||||
.each do |title|
|
.each do |title|
|
||||||
@title_hash[title.id] = title
|
@title_hash[title.id] = title
|
||||||
@title_ids << title.id
|
@title_ids << title.id
|
||||||
@@ -76,19 +229,27 @@ class Library
|
|||||||
storage.bulk_insert_ids
|
storage.bulk_insert_ids
|
||||||
storage.close
|
storage.close
|
||||||
|
|
||||||
Logger.debug "Scan completed"
|
ms = (Time.local - start).total_milliseconds
|
||||||
|
Logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
|
||||||
|
|
||||||
|
Storage.default.mark_unavailable examine_context["deleted_entry_ids"],
|
||||||
|
examine_context["deleted_title_ids"]
|
||||||
|
|
||||||
|
spawn do
|
||||||
|
save_instance
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_continue_reading_entries(username)
|
def get_continue_reading_entries(username)
|
||||||
cr_entries = deep_titles
|
cr_entries = deep_titles
|
||||||
.map { |t| t.get_last_read_entry username }
|
.map(&.get_last_read_entry username)
|
||||||
# Select elements with type `Entry` from the array and ignore all `Nil`s
|
# Select elements with type `Entry` from the array and ignore all `Nil`s
|
||||||
.select(Entry)[0..11]
|
.select(Entry)[0...ENTRIES_IN_HOME_SECTIONS]
|
||||||
.map { |e|
|
.map { |e|
|
||||||
# Get the last read time of the entry. If it hasn't been started, get
|
# Get the last read time of the entry. If it hasn't been started, get
|
||||||
# the last read time of the previous entry
|
# the last read time of the previous entry
|
||||||
last_read = e.load_last_read username
|
last_read = e.load_last_read username
|
||||||
pe = e.previous_entry
|
pe = e.previous_entry username
|
||||||
if last_read.nil? && pe
|
if last_read.nil? && pe
|
||||||
last_read = pe.load_last_read username
|
last_read = pe.load_last_read username
|
||||||
end
|
end
|
||||||
@@ -117,14 +278,14 @@ class Library
|
|||||||
recently_added = [] of RA
|
recently_added = [] of RA
|
||||||
last_date_added = nil
|
last_date_added = nil
|
||||||
|
|
||||||
titles.map { |t| t.deep_entries_with_date_added }.flatten
|
titles.flat_map(&.deep_entries_with_date_added)
|
||||||
.select { |e| e[:date_added] > 1.month.ago }
|
.select(&.[:date_added].> 1.month.ago)
|
||||||
.sort { |a, b| b[:date_added] <=> a[:date_added] }
|
.sort! { |a, b| b[:date_added] <=> a[:date_added] }
|
||||||
.each do |e|
|
.each do |e|
|
||||||
break if recently_added.size > 12
|
break if recently_added.size > 12
|
||||||
last = recently_added.last?
|
last = recently_added.last?
|
||||||
if last && e[:entry].book.id == last[:entry].book.id &&
|
if last && e[:entry].book.id == last[:entry].book.id &&
|
||||||
(e[:date_added] - last_date_added.not_nil!).duration < 1.day
|
(e[:date_added] - last_date_added.not_nil!).abs < 1.day
|
||||||
# A NamedTuple is immutable, so we have to cast it to a Hash first
|
# A NamedTuple is immutable, so we have to cast it to a Hash first
|
||||||
last_hash = last.to_h
|
last_hash = last.to_h
|
||||||
count = last_hash[:grouped_count].as(Int32)
|
count = last_hash[:grouped_count].as(Int32)
|
||||||
@@ -143,41 +304,61 @@ class Library
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
recently_added[0..11]
|
recently_added[0...ENTRIES_IN_HOME_SECTIONS]
|
||||||
end
|
end
|
||||||
|
|
||||||
def sorted_titles(username, opt : SortOptions? = nil)
|
def get_start_reading_titles(username)
|
||||||
if opt.nil?
|
# Here we are not using `deep_titles` as it may cause unexpected behaviors
|
||||||
opt = SortOptions.from_info_json @dir, username
|
# For example, consider the following nested titles:
|
||||||
else
|
# - One Puch Man
|
||||||
TitleInfo.new @dir do |info|
|
# - Vol. 1
|
||||||
info.sort_by[username] = opt.to_tuple
|
# - Vol. 2
|
||||||
info.save
|
# If we use `deep_titles`, the start reading section might include `Vol. 2`
|
||||||
|
# when the user hasn't started `Vol. 1` yet
|
||||||
|
titles
|
||||||
|
.select(&.load_percentage(username).== 0)
|
||||||
|
.sample(ENTRIES_IN_HOME_SECTIONS)
|
||||||
|
.shuffle!
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_thumbnails
|
||||||
|
if thumbnail_ctx.current > 0
|
||||||
|
Logger.debug "Thumbnail generation in progress"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
Logger.info "Starting thumbnail generation"
|
||||||
|
entries = deep_titles.flat_map(&.deep_entries).reject &.err_msg
|
||||||
|
thumbnail_ctx.total = entries.size
|
||||||
|
thumbnail_ctx.current = 0
|
||||||
|
|
||||||
|
# Report generation progress regularly
|
||||||
|
spawn do
|
||||||
|
loop do
|
||||||
|
unless thumbnail_ctx.current == 0
|
||||||
|
Logger.debug "Thumbnail generation progress: " \
|
||||||
|
"#{(thumbnail_ctx.progress * 100).round 1}%"
|
||||||
|
end
|
||||||
|
# Generation is completed. We reset the count to 0 to allow subsequent
|
||||||
|
# calls to the function, and break from the loop to stop the progress
|
||||||
|
# report fiber
|
||||||
|
if thumbnail_ctx.progress.to_i == 1
|
||||||
|
thumbnail_ctx.reset
|
||||||
|
break
|
||||||
|
end
|
||||||
|
sleep 10.seconds
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# This is a hack to bypass a compiler bug
|
entries.each do |e|
|
||||||
ary = titles
|
unless e.get_thumbnail
|
||||||
|
e.generate_thumbnail
|
||||||
case opt.not_nil!.method
|
# Sleep after each generation to minimize the impact on disk IO
|
||||||
when .time_modified?
|
# and CPU
|
||||||
ary.sort! { |a, b| (a.mtime <=> b.mtime).or \
|
sleep 1.seconds
|
||||||
compare_numerically a.title, b.title }
|
|
||||||
when .progress?
|
|
||||||
ary.sort! do |a, b|
|
|
||||||
(a.load_percentage(username) <=> b.load_percentage(username)).or \
|
|
||||||
compare_numerically a.title, b.title
|
|
||||||
end
|
end
|
||||||
else
|
thumbnail_ctx.increment
|
||||||
unless opt.method.auto?
|
|
||||||
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
|
||||||
"Auto instead"
|
|
||||||
end
|
|
||||||
ary.sort! { |a, b| compare_numerically a.title, b.title }
|
|
||||||
end
|
end
|
||||||
|
Logger.info "Thumbnail generation finished"
|
||||||
ary.reverse! unless opt.not_nil!.ascend
|
|
||||||
|
|
||||||
ary
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+447
-81
@@ -1,22 +1,43 @@
|
|||||||
|
require "digest"
|
||||||
require "../archive"
|
require "../archive"
|
||||||
|
|
||||||
class Title
|
class Title
|
||||||
property dir : String, parent_id : String, title_ids : Array(String),
|
include YAML::Serializable
|
||||||
entries : Array(Entry), title : String, id : String,
|
|
||||||
encoded_title : String, mtime : Time
|
|
||||||
|
|
||||||
def initialize(@dir : String, @parent_id, storage,
|
getter dir : String, parent_id : String, title_ids : Array(String),
|
||||||
@library : Library)
|
entries : Array(Entry), title : String, id : String,
|
||||||
id = storage.get_id @dir, true
|
encoded_title : String, mtime : Time, signature : UInt64,
|
||||||
|
entry_cover_url_cache : Hash(String, String)?
|
||||||
|
setter entry_cover_url_cache : Hash(String, String)?,
|
||||||
|
entry_sort_title_cache : Hash(String, String | Nil)?
|
||||||
|
|
||||||
|
@[YAML::Field(ignore: true)]
|
||||||
|
@sort_title : String?
|
||||||
|
@[YAML::Field(ignore: true)]
|
||||||
|
@entry_sort_title_cache : Hash(String, String | Nil)?
|
||||||
|
@[YAML::Field(ignore: true)]
|
||||||
|
@entry_display_name_cache : Hash(String, String)?
|
||||||
|
@[YAML::Field(ignore: true)]
|
||||||
|
@entry_cover_url_cache : Hash(String, String)?
|
||||||
|
@[YAML::Field(ignore: true)]
|
||||||
|
@cached_display_name : String?
|
||||||
|
@[YAML::Field(ignore: true)]
|
||||||
|
@cached_cover_url : String?
|
||||||
|
|
||||||
|
def initialize(@dir : String, @parent_id, cache = {} of String => String)
|
||||||
|
storage = Storage.default
|
||||||
|
@signature = Dir.signature dir
|
||||||
|
id = storage.get_title_id dir, signature
|
||||||
if id.nil?
|
if id.nil?
|
||||||
id = random_str
|
id = random_str
|
||||||
storage.insert_id({
|
storage.insert_title_id({
|
||||||
path: @dir,
|
path: dir,
|
||||||
id: id,
|
id: id,
|
||||||
is_title: true,
|
signature: signature.to_s,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
@id = id
|
@id = id
|
||||||
|
@contents_signature = Dir.contents_signature dir, cache
|
||||||
@title = File.basename dir
|
@title = File.basename dir
|
||||||
@encoded_title = URI.encode @title
|
@encoded_title = URI.encode @title
|
||||||
@title_ids = [] of String
|
@title_ids = [] of String
|
||||||
@@ -27,53 +48,250 @@ class Title
|
|||||||
next if fn.starts_with? "."
|
next if fn.starts_with? "."
|
||||||
path = File.join dir, fn
|
path = File.join dir, fn
|
||||||
if File.directory? path
|
if File.directory? path
|
||||||
title = Title.new path, @id, storage, library
|
title = Title.new path, @id, cache
|
||||||
next if title.entries.size == 0 && title.titles.size == 0
|
unless title.entries.size == 0 && title.titles.size == 0
|
||||||
@library.title_hash[title.id] = title
|
Library.default.title_hash[title.id] = title
|
||||||
@title_ids << title.id
|
@title_ids << title.id
|
||||||
|
end
|
||||||
|
if DirEntry.is_valid? path
|
||||||
|
entry = DirEntry.new path, self
|
||||||
|
@entries << entry if entry.pages > 0 || entry.err_msg
|
||||||
|
end
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
if [".zip", ".cbz", ".rar", ".cbr"].includes? File.extname path
|
if is_supported_file path
|
||||||
entry = Entry.new path, self, storage
|
entry = ArchiveEntry.new path, self
|
||||||
@entries << entry if entry.pages > 0 || entry.err_msg
|
@entries << entry if entry.pages > 0 || entry.err_msg
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
mtimes = [@mtime]
|
mtimes = [@mtime]
|
||||||
mtimes += @title_ids.map { |e| @library.title_hash[e].mtime }
|
mtimes += @title_ids.map { |e| Library.default.title_hash[e].mtime }
|
||||||
mtimes += @entries.map { |e| e.mtime }
|
mtimes += @entries.map &.mtime
|
||||||
@mtime = mtimes.max
|
@mtime = mtimes.max
|
||||||
|
|
||||||
@title_ids.sort! do |a, b|
|
@title_ids.sort! do |a, b|
|
||||||
compare_numerically @library.title_hash[a].title,
|
compare_numerically Library.default.title_hash[a].title,
|
||||||
@library.title_hash[b].title
|
Library.default.title_hash[b].title
|
||||||
end
|
end
|
||||||
sorter = ChapterSorter.new @entries.map { |e| e.title }
|
sorter = ChapterSorter.new @entries.map &.title
|
||||||
@entries.sort! do |a, b|
|
@entries.sort! do |a, b|
|
||||||
sorter.compare a.title, b.title
|
sorter.compare a.sort_title, b.sort_title
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
# Utility method used in library rescanning.
|
||||||
json.object do
|
# - When the title does not exist on the file system anymore, return false
|
||||||
{% for str in ["dir", "title", "id", "encoded_title"] %}
|
# and let it be deleted from the library instance
|
||||||
|
# - When the title exists, but its contents signature is now different from
|
||||||
|
# the cache, it means some of its content (nested titles or entries)
|
||||||
|
# has been added, deleted, or renamed. In this case we update its
|
||||||
|
# contents signature and instance variables
|
||||||
|
# - When the title exists and its contents signature is still the same, we
|
||||||
|
# return true so it can be reused without rescanning
|
||||||
|
def examine(context : ExamineContext) : Bool
|
||||||
|
return false unless Dir.exists? @dir
|
||||||
|
contents_signature = Dir.contents_signature @dir,
|
||||||
|
context["cached_contents_signature"]
|
||||||
|
return true if @contents_signature == contents_signature
|
||||||
|
|
||||||
|
@contents_signature = contents_signature
|
||||||
|
@signature = Dir.signature @dir
|
||||||
|
storage = Storage.default
|
||||||
|
id = storage.get_title_id dir, signature
|
||||||
|
if id.nil?
|
||||||
|
id = random_str
|
||||||
|
storage.insert_title_id({
|
||||||
|
path: dir,
|
||||||
|
id: id,
|
||||||
|
signature: signature.to_s,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
@id = id
|
||||||
|
@mtime = File.info(@dir).modification_time
|
||||||
|
|
||||||
|
previous_titles_size = @title_ids.size
|
||||||
|
@title_ids.select! do |title_id|
|
||||||
|
title = Library.default.get_title title_id
|
||||||
|
unless title # for if data consistency broken
|
||||||
|
context["deleted_title_ids"].concat [title_id]
|
||||||
|
next false
|
||||||
|
end
|
||||||
|
existence = title.examine context
|
||||||
|
unless existence
|
||||||
|
context["deleted_title_ids"].concat [title_id] +
|
||||||
|
title.deep_titles.map &.id
|
||||||
|
context["deleted_entry_ids"].concat title.deep_entries.map &.id
|
||||||
|
end
|
||||||
|
existence
|
||||||
|
end
|
||||||
|
remained_title_dirs = @title_ids.map do |title_id|
|
||||||
|
title = Library.default.get_title! title_id
|
||||||
|
title.dir
|
||||||
|
end
|
||||||
|
|
||||||
|
previous_entries_size = @entries.size
|
||||||
|
@entries.select! do |entry|
|
||||||
|
existence = entry.examine
|
||||||
|
Fiber.yield
|
||||||
|
context["deleted_entry_ids"] << entry.id unless existence
|
||||||
|
existence
|
||||||
|
end
|
||||||
|
remained_entry_paths = @entries.map &.path
|
||||||
|
|
||||||
|
is_titles_added = false
|
||||||
|
is_entries_added = false
|
||||||
|
Dir.entries(dir).each do |fn|
|
||||||
|
next if fn.starts_with? "."
|
||||||
|
path = File.join dir, fn
|
||||||
|
if File.directory? path
|
||||||
|
unless remained_entry_paths.includes? path
|
||||||
|
if DirEntry.is_valid? path
|
||||||
|
entry = DirEntry.new path, self
|
||||||
|
if entry.pages > 0 || entry.err_msg
|
||||||
|
@entries << entry
|
||||||
|
is_entries_added = true
|
||||||
|
context["deleted_entry_ids"].select! do |deleted_entry_id|
|
||||||
|
entry.id != deleted_entry_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
next if remained_title_dirs.includes? path
|
||||||
|
title = Title.new path, @id, context["cached_contents_signature"]
|
||||||
|
unless title.entries.size == 0 && title.titles.size == 0
|
||||||
|
Library.default.title_hash[title.id] = title
|
||||||
|
@title_ids << title.id
|
||||||
|
is_titles_added = true
|
||||||
|
|
||||||
|
# We think they are removed, but they are here!
|
||||||
|
# Cancel reserved jobs
|
||||||
|
revival_title_ids = [title.id] + title.deep_titles.map &.id
|
||||||
|
context["deleted_title_ids"].select! do |deleted_title_id|
|
||||||
|
!(revival_title_ids.includes? deleted_title_id)
|
||||||
|
end
|
||||||
|
revival_entry_ids = title.deep_entries.map &.id
|
||||||
|
context["deleted_entry_ids"].select! do |deleted_entry_id|
|
||||||
|
!(revival_entry_ids.includes? deleted_entry_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
next
|
||||||
|
end
|
||||||
|
if is_supported_file path
|
||||||
|
next if remained_entry_paths.includes? path
|
||||||
|
entry = ArchiveEntry.new path, self
|
||||||
|
if entry.pages > 0 || entry.err_msg
|
||||||
|
@entries << entry
|
||||||
|
is_entries_added = true
|
||||||
|
context["deleted_entry_ids"].select! do |deleted_entry_id|
|
||||||
|
entry.id != deleted_entry_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
mtimes = [@mtime]
|
||||||
|
mtimes += @title_ids.map { |e| Library.default.title_hash[e].mtime }
|
||||||
|
mtimes += @entries.map &.mtime
|
||||||
|
@mtime = mtimes.max
|
||||||
|
|
||||||
|
if is_titles_added || previous_titles_size != @title_ids.size
|
||||||
|
@title_ids.sort! do |a, b|
|
||||||
|
compare_numerically Library.default.title_hash[a].title,
|
||||||
|
Library.default.title_hash[b].title
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if is_entries_added || previous_entries_size != @entries.size
|
||||||
|
sorter = ChapterSorter.new @entries.map &.sort_title
|
||||||
|
@entries.sort! do |a, b|
|
||||||
|
sorter.compare a.sort_title, b.sort_title
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if @title_ids.size > 0 || @entries.size > 0
|
||||||
|
true
|
||||||
|
else
|
||||||
|
context["deleted_title_ids"].concat [@id]
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
alias SortContext = NamedTuple(username: String, opt: SortOptions)
|
||||||
|
|
||||||
|
def build_json(*, slim = false, depth = -1,
|
||||||
|
sort_context : SortContext? = nil,
|
||||||
|
percentage = false)
|
||||||
|
_titles = if sort_context
|
||||||
|
sorted_titles sort_context[:username],
|
||||||
|
sort_context[:opt]
|
||||||
|
else
|
||||||
|
self.titles
|
||||||
|
end
|
||||||
|
_entries = if sort_context
|
||||||
|
sorted_entries sort_context[:username],
|
||||||
|
sort_context[:opt]
|
||||||
|
else
|
||||||
|
@entries
|
||||||
|
end
|
||||||
|
|
||||||
|
JSON.build do |json|
|
||||||
|
json.object do
|
||||||
|
{% for str in ["dir", "title", "id"] %}
|
||||||
json.field {{str}}, @{{str.id}}
|
json.field {{str}}, @{{str.id}}
|
||||||
{% end %}
|
{% end %}
|
||||||
json.field "display_name", display_name
|
json.field "signature" { json.number @signature }
|
||||||
json.field "cover_url", cover_url
|
json.field "sort_title", sort_title
|
||||||
json.field "mtime" { json.number @mtime.to_unix }
|
unless slim
|
||||||
json.field "titles" do
|
json.field "display_name", display_name
|
||||||
json.raw self.titles.to_json
|
json.field "cover_url", cover_url
|
||||||
end
|
json.field "mtime" { json.number @mtime.to_unix }
|
||||||
json.field "entries" do
|
end
|
||||||
json.raw @entries.to_json
|
unless depth == 0
|
||||||
end
|
json.field "titles" do
|
||||||
json.field "parents" do
|
json.array do
|
||||||
json.array do
|
_titles.each do |title|
|
||||||
self.parents.each do |title|
|
json.raw title.build_json(slim: slim,
|
||||||
json.object do
|
depth: depth > 0 ? depth - 1 : depth,
|
||||||
json.field "title", title.title
|
sort_context: sort_context, percentage: percentage)
|
||||||
json.field "id", title.id
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
json.field "entries" do
|
||||||
|
json.array do
|
||||||
|
_entries.each do |entry|
|
||||||
|
json.raw entry.build_json(slim: slim)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if percentage && sort_context
|
||||||
|
json.field "title_percentages" do
|
||||||
|
json.array do
|
||||||
|
_titles.each do |t|
|
||||||
|
json.number t.load_percentage sort_context[:username]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
json.field "entry_percentages" do
|
||||||
|
json.array do
|
||||||
|
load_percentage_for_all_entries(
|
||||||
|
sort_context[:username],
|
||||||
|
sort_context[:opt]
|
||||||
|
).each do |p|
|
||||||
|
json.number p.nan? ? 0 : p
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
json.field "parents" do
|
||||||
|
json.array do
|
||||||
|
self.parents.each do |title|
|
||||||
|
json.object do
|
||||||
|
json.field "title", title.title
|
||||||
|
json.field "id", title.id
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -82,45 +300,122 @@ class Title
|
|||||||
end
|
end
|
||||||
|
|
||||||
def titles
|
def titles
|
||||||
@title_ids.map { |tid| @library.get_title! tid }
|
@title_ids.map { |tid| Library.default.get_title! tid }
|
||||||
|
end
|
||||||
|
|
||||||
|
def sorted_titles(username, opt : SortOptions? = nil)
|
||||||
|
if opt.nil?
|
||||||
|
opt = SortOptions.from_info_json @dir, username
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper function from src/util/util.cr
|
||||||
|
sort_titles titles, opt.not_nil!, username
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get all entries, including entries in nested titles
|
# Get all entries, including entries in nested titles
|
||||||
def deep_entries
|
def deep_entries
|
||||||
return @entries if title_ids.empty?
|
return @entries if title_ids.empty?
|
||||||
@entries + titles.map { |t| t.deep_entries }.flatten
|
@entries + titles.flat_map &.deep_entries
|
||||||
end
|
end
|
||||||
|
|
||||||
def deep_titles
|
def deep_titles
|
||||||
return [] of Title if titles.empty?
|
return [] of Title if titles.empty?
|
||||||
titles + titles.map { |t| t.deep_titles }.flatten
|
titles + titles.flat_map &.deep_titles
|
||||||
end
|
end
|
||||||
|
|
||||||
def parents
|
def parents
|
||||||
ary = [] of Title
|
ary = [] of Title
|
||||||
tid = @parent_id
|
tid = @parent_id
|
||||||
while !tid.empty?
|
while !tid.empty?
|
||||||
title = @library.get_title! tid
|
title = Library.default.get_title! tid
|
||||||
ary << title
|
ary << title
|
||||||
tid = title.parent_id
|
tid = title.parent_id
|
||||||
end
|
end
|
||||||
ary.reverse
|
ary.reverse
|
||||||
end
|
end
|
||||||
|
|
||||||
def size
|
# Returns a string the describes the content of the title
|
||||||
@entries.size + @title_ids.size
|
# e.g., - 3 titles and 1 entry
|
||||||
|
# - 4 entries
|
||||||
|
# - 1 title
|
||||||
|
def content_label
|
||||||
|
ary = [] of String
|
||||||
|
tsize = titles.size
|
||||||
|
esize = entries.size
|
||||||
|
|
||||||
|
ary << "#{tsize} #{tsize > 1 ? "titles" : "title"}" if tsize > 0
|
||||||
|
ary << "#{esize} #{esize > 1 ? "entries" : "entry"}" if esize > 0
|
||||||
|
ary.join " and "
|
||||||
|
end
|
||||||
|
|
||||||
|
def sort_title
|
||||||
|
sort_title_cached = @sort_title
|
||||||
|
return sort_title_cached if sort_title_cached
|
||||||
|
sort_title = Storage.default.get_title_sort_title id
|
||||||
|
if sort_title
|
||||||
|
@sort_title = sort_title
|
||||||
|
return sort_title
|
||||||
|
end
|
||||||
|
@sort_title = @title
|
||||||
|
@title
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_sort_title(sort_title : String | Nil, username : String)
|
||||||
|
Storage.default.set_title_sort_title id, sort_title
|
||||||
|
if sort_title == "" || sort_title.nil?
|
||||||
|
@sort_title = nil
|
||||||
|
else
|
||||||
|
@sort_title = sort_title
|
||||||
|
end
|
||||||
|
|
||||||
|
if parents.size > 0
|
||||||
|
target = parents[-1].titles
|
||||||
|
else
|
||||||
|
target = Library.default.titles
|
||||||
|
end
|
||||||
|
remove_sorted_titles_cache target,
|
||||||
|
[SortMethod::Auto, SortMethod::Title], username
|
||||||
|
end
|
||||||
|
|
||||||
|
def sort_title_db
|
||||||
|
Storage.default.get_title_sort_title id
|
||||||
|
end
|
||||||
|
|
||||||
|
def entry_sort_title_db(entry_id)
|
||||||
|
unless @entry_sort_title_cache
|
||||||
|
@entry_sort_title_cache =
|
||||||
|
Storage.default.get_entries_sort_title @entries.map &.id
|
||||||
|
end
|
||||||
|
|
||||||
|
@entry_sort_title_cache.not_nil![entry_id]?
|
||||||
|
end
|
||||||
|
|
||||||
|
def tags
|
||||||
|
Storage.default.get_title_tags @id
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_tag(tag)
|
||||||
|
Storage.default.add_tag @id, tag
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_tag(tag)
|
||||||
|
Storage.default.delete_tag @id, tag
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_entry(eid)
|
def get_entry(eid)
|
||||||
@entries.find { |e| e.id == eid }
|
@entries.find &.id.== eid
|
||||||
end
|
end
|
||||||
|
|
||||||
def display_name
|
def display_name
|
||||||
|
cached_display_name = @cached_display_name
|
||||||
|
return cached_display_name unless cached_display_name.nil?
|
||||||
|
|
||||||
dn = @title
|
dn = @title
|
||||||
TitleInfo.new @dir do |info|
|
TitleInfo.new @dir do |info|
|
||||||
info_dn = info.display_name
|
info_dn = info.display_name
|
||||||
dn = info_dn unless info_dn.empty?
|
dn = info_dn unless info_dn.empty?
|
||||||
end
|
end
|
||||||
|
@cached_display_name = dn
|
||||||
dn
|
dn
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -129,17 +424,22 @@ class Title
|
|||||||
end
|
end
|
||||||
|
|
||||||
def display_name(entry_name)
|
def display_name(entry_name)
|
||||||
dn = entry_name
|
unless @entry_display_name_cache
|
||||||
TitleInfo.new @dir do |info|
|
TitleInfo.new @dir do |info|
|
||||||
info_dn = info.entry_display_name[entry_name]?
|
@entry_display_name_cache = info.entry_display_name
|
||||||
unless info_dn.nil? || info_dn.empty?
|
|
||||||
dn = info_dn
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
dn = entry_name
|
||||||
|
info_dn = @entry_display_name_cache.not_nil![entry_name]?
|
||||||
|
unless info_dn.nil? || info_dn.empty?
|
||||||
|
dn = info_dn
|
||||||
|
end
|
||||||
dn
|
dn
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_display_name(dn)
|
def set_display_name(dn)
|
||||||
|
@cached_display_name = dn
|
||||||
TitleInfo.new @dir do |info|
|
TitleInfo.new @dir do |info|
|
||||||
info.display_name = dn
|
info.display_name = dn
|
||||||
info.save
|
info.save
|
||||||
@@ -149,12 +449,16 @@ class Title
|
|||||||
def set_display_name(entry_name : String, dn)
|
def set_display_name(entry_name : String, dn)
|
||||||
TitleInfo.new @dir do |info|
|
TitleInfo.new @dir do |info|
|
||||||
info.entry_display_name[entry_name] = dn
|
info.entry_display_name[entry_name] = dn
|
||||||
|
@entry_display_name_cache = info.entry_display_name
|
||||||
info.save
|
info.save
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def cover_url
|
def cover_url
|
||||||
url = "#{Config.current.base_url}img/icon.png"
|
cached_cover_url = @cached_cover_url
|
||||||
|
return cached_cover_url unless cached_cover_url.nil?
|
||||||
|
|
||||||
|
url = "#{Config.current.base_url}img/icons/icon_x192.png"
|
||||||
readable_entries = @entries.select &.err_msg.nil?
|
readable_entries = @entries.select &.err_msg.nil?
|
||||||
if readable_entries.size > 0
|
if readable_entries.size > 0
|
||||||
url = readable_entries[0].cover_url
|
url = readable_entries[0].cover_url
|
||||||
@@ -165,10 +469,12 @@ class Title
|
|||||||
url = File.join Config.current.base_url, info_url
|
url = File.join Config.current.base_url, info_url
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@cached_cover_url = url
|
||||||
url
|
url
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_cover_url(url : String)
|
def set_cover_url(url : String)
|
||||||
|
@cached_cover_url = url
|
||||||
TitleInfo.new @dir do |info|
|
TitleInfo.new @dir do |info|
|
||||||
info.cover_url = url
|
info.cover_url = url
|
||||||
info.save
|
info.save
|
||||||
@@ -178,6 +484,7 @@ class Title
|
|||||||
def set_cover_url(entry_name : String, url : String)
|
def set_cover_url(entry_name : String, url : String)
|
||||||
TitleInfo.new @dir do |info|
|
TitleInfo.new @dir do |info|
|
||||||
info.entry_cover_url[entry_name] = url
|
info.entry_cover_url[entry_name] = url
|
||||||
|
@entry_cover_url_cache = info.entry_cover_url
|
||||||
info.save
|
info.save
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -187,29 +494,30 @@ class Title
|
|||||||
@entries.each do |e|
|
@entries.each do |e|
|
||||||
e.save_progress username, e.pages
|
e.save_progress username, e.pages
|
||||||
end
|
end
|
||||||
titles.each do |t|
|
titles.each &.read_all username
|
||||||
t.read_all username
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Set the reading progress of all entries and nested libraries to 0%
|
# Set the reading progress of all entries and nested libraries to 0%
|
||||||
def unread_all(username)
|
def unread_all(username)
|
||||||
@entries.each do |e|
|
@entries.each &.save_progress(username, 0)
|
||||||
e.save_progress username, 0
|
titles.each &.unread_all username
|
||||||
end
|
|
||||||
titles.each do |t|
|
|
||||||
t.unread_all username
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def deep_read_page_count(username) : Int32
|
def deep_read_page_count(username) : Int32
|
||||||
load_progress_for_all_entries(username).sum +
|
key = "#{@id}:#{username}:progress_sum"
|
||||||
titles.map { |t| t.deep_read_page_count username }.flatten.sum
|
sig = Digest::SHA1.hexdigest (entries.map &.id).to_s
|
||||||
|
cached_sum = LRUCache.get key
|
||||||
|
return cached_sum[1] if cached_sum.is_a? Tuple(String, Int32) &&
|
||||||
|
cached_sum[0] == sig
|
||||||
|
sum = load_progress_for_all_entries(username, nil, true).sum +
|
||||||
|
titles.flat_map(&.deep_read_page_count username).sum
|
||||||
|
LRUCache.set generate_cache_entry key, {sig, sum}
|
||||||
|
sum
|
||||||
end
|
end
|
||||||
|
|
||||||
def deep_total_page_count : Int32
|
def deep_total_page_count : Int32
|
||||||
entries.map { |e| e.pages }.sum +
|
entries.sum(&.pages) +
|
||||||
titles.map { |t| t.deep_total_page_count }.flatten.sum
|
titles.flat_map(&.deep_total_page_count).sum
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_percentage(username)
|
def load_percentage(username)
|
||||||
@@ -258,44 +566,46 @@ class Title
|
|||||||
# use the default (auto, ascending)
|
# use the default (auto, ascending)
|
||||||
# When `opt` is not nil, it saves the options to info.json
|
# When `opt` is not nil, it saves the options to info.json
|
||||||
def sorted_entries(username, opt : SortOptions? = nil)
|
def sorted_entries(username, opt : SortOptions? = nil)
|
||||||
|
cache_key = SortedEntriesCacheEntry.gen_key @id, username, @entries, opt
|
||||||
|
cached_entries = LRUCache.get cache_key
|
||||||
|
return cached_entries if cached_entries.is_a? Array(Entry)
|
||||||
|
|
||||||
if opt.nil?
|
if opt.nil?
|
||||||
opt = SortOptions.from_info_json @dir, username
|
opt = SortOptions.from_info_json @dir, username
|
||||||
else
|
|
||||||
TitleInfo.new @dir do |info|
|
|
||||||
info.sort_by[username] = opt.to_tuple
|
|
||||||
info.save
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
case opt.not_nil!.method
|
case opt.not_nil!.method
|
||||||
when .title?
|
when .title?
|
||||||
ary = @entries.sort { |a, b| compare_numerically a.title, b.title }
|
ary = @entries.sort do |a, b|
|
||||||
|
compare_numerically a.sort_title, b.sort_title
|
||||||
|
end
|
||||||
when .time_modified?
|
when .time_modified?
|
||||||
ary = @entries.sort { |a, b| (a.mtime <=> b.mtime).or \
|
ary = @entries.sort { |a, b| (a.mtime <=> b.mtime).or \
|
||||||
compare_numerically a.title, b.title }
|
compare_numerically a.sort_title, b.sort_title }
|
||||||
when .time_added?
|
when .time_added?
|
||||||
ary = @entries.sort { |a, b| (a.date_added <=> b.date_added).or \
|
ary = @entries.sort { |a, b| (a.date_added <=> b.date_added).or \
|
||||||
compare_numerically a.title, b.title }
|
compare_numerically a.sort_title, b.sort_title }
|
||||||
when .progress?
|
when .progress?
|
||||||
percentage_ary = load_percentage_for_all_entries username, opt, true
|
percentage_ary = load_percentage_for_all_entries username, opt, true
|
||||||
ary = @entries.zip(percentage_ary)
|
ary = @entries.zip(percentage_ary)
|
||||||
.sort { |a_tp, b_tp| (a_tp[1] <=> b_tp[1]).or \
|
.sort { |a_tp, b_tp| (a_tp[1] <=> b_tp[1]).or \
|
||||||
compare_numerically a_tp[0].title, b_tp[0].title }
|
compare_numerically a_tp[0].sort_title, b_tp[0].sort_title }
|
||||||
.map { |tp| tp[0] }
|
.map &.[0]
|
||||||
else
|
else
|
||||||
unless opt.method.auto?
|
unless opt.method.auto?
|
||||||
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
||||||
"Auto instead"
|
"Auto instead"
|
||||||
end
|
end
|
||||||
sorter = ChapterSorter.new @entries.map { |e| e.title }
|
sorter = ChapterSorter.new @entries.map &.sort_title
|
||||||
ary = @entries.sort do |a, b|
|
ary = @entries.sort do |a, b|
|
||||||
sorter.compare(a.title, b.title).or \
|
sorter.compare(a.sort_title, b.sort_title).or \
|
||||||
compare_numerically a.title, b.title
|
compare_numerically a.sort_title, b.sort_title
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
ary.reverse! unless opt.not_nil!.ascend
|
ary.reverse! unless opt.not_nil!.ascend
|
||||||
|
|
||||||
|
LRUCache.set generate_cache_entry cache_key, ary
|
||||||
ary
|
ary
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -322,6 +632,16 @@ class Title
|
|||||||
|
|
||||||
if last_read_entry && last_read_entry.finished? username
|
if last_read_entry && last_read_entry.finished? username
|
||||||
last_read_entry = last_read_entry.next_entry username
|
last_read_entry = last_read_entry.next_entry username
|
||||||
|
if last_read_entry.nil?
|
||||||
|
# The last entry is finished. Return the first unfinished entry
|
||||||
|
# (if any)
|
||||||
|
sorted_entries(username).each do |e|
|
||||||
|
unless e.finished? username
|
||||||
|
last_read_entry = e
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
last_read_entry
|
last_read_entry
|
||||||
@@ -336,7 +656,7 @@ class Title
|
|||||||
|
|
||||||
@entries.each do |e|
|
@entries.each do |e|
|
||||||
next if da.has_key? e.title
|
next if da.has_key? e.title
|
||||||
da[e.title] = ctime e.zip_path
|
da[e.title] = ctime e.path
|
||||||
end
|
end
|
||||||
|
|
||||||
TitleInfo.new @dir do |info|
|
TitleInfo.new @dir do |info|
|
||||||
@@ -353,6 +673,52 @@ class Title
|
|||||||
{entry: e, date_added: da_ary[i]}
|
{entry: e, date_added: da_ary[i]}
|
||||||
end
|
end
|
||||||
return zip if title_ids.empty?
|
return zip if title_ids.empty?
|
||||||
zip + titles.map { |t| t.deep_entries_with_date_added }.flatten
|
zip + titles.flat_map &.deep_entries_with_date_added
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_sorted_entries_cache(sort_methods : Array(SortMethod),
|
||||||
|
username : String)
|
||||||
|
[false, true].each do |ascend|
|
||||||
|
sort_methods.each do |sort_method|
|
||||||
|
sorted_entries_cache_key =
|
||||||
|
SortedEntriesCacheEntry.gen_key @id, username, @entries,
|
||||||
|
SortOptions.new(sort_method, ascend)
|
||||||
|
LRUCache.invalidate sorted_entries_cache_key
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_sorted_caches(sort_methods : Array(SortMethod), username : String)
|
||||||
|
remove_sorted_entries_cache sort_methods, username
|
||||||
|
parents.each do |parent|
|
||||||
|
remove_sorted_titles_cache parent.titles, sort_methods, username
|
||||||
|
end
|
||||||
|
remove_sorted_titles_cache Library.default.titles, sort_methods, username
|
||||||
|
end
|
||||||
|
|
||||||
|
def bulk_progress(action, ids : Array(String), username)
|
||||||
|
LRUCache.invalidate "#{@id}:#{username}:progress_sum"
|
||||||
|
parents.each do |parent|
|
||||||
|
LRUCache.invalidate "#{parent.id}:#{username}:progress_sum"
|
||||||
|
end
|
||||||
|
remove_sorted_caches [SortMethod::Progress], username
|
||||||
|
|
||||||
|
selected_entries = ids
|
||||||
|
.map { |id|
|
||||||
|
@entries.find &.id.==(id)
|
||||||
|
}
|
||||||
|
.select(Entry)
|
||||||
|
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
selected_entries.each do |e|
|
||||||
|
page = action == "read" ? e.pages : 0
|
||||||
|
if info.progress[username]?.nil?
|
||||||
|
info.progress[username] = {e.title => page}
|
||||||
|
else
|
||||||
|
info.progress[username][e.title] = page
|
||||||
|
end
|
||||||
|
end
|
||||||
|
info.save
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+37
-2
@@ -1,5 +1,3 @@
|
|||||||
SUPPORTED_IMG_TYPES = ["image/jpeg", "image/png", "image/webp"]
|
|
||||||
|
|
||||||
enum SortMethod
|
enum SortMethod
|
||||||
Auto
|
Auto
|
||||||
Title
|
Title
|
||||||
@@ -47,6 +45,13 @@ class SortOptions
|
|||||||
def to_tuple
|
def to_tuple
|
||||||
{@method.to_s.underscore, ascend}
|
{@method.to_s.underscore, ascend}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def to_json
|
||||||
|
{
|
||||||
|
"method" => method.to_s.underscore,
|
||||||
|
"ascend" => ascend,
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
struct Image
|
struct Image
|
||||||
@@ -57,6 +62,16 @@ struct Image
|
|||||||
|
|
||||||
def initialize(@data, @mime, @filename, @size)
|
def initialize(@data, @mime, @filename, @size)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.from_db(res : DB::ResultSet)
|
||||||
|
img = Image.allocate
|
||||||
|
res.read String
|
||||||
|
img.data = res.read Bytes
|
||||||
|
img.filename = res.read String
|
||||||
|
img.mime = res.read String
|
||||||
|
img.size = res.read Int32
|
||||||
|
img
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
class TitleInfo
|
class TitleInfo
|
||||||
@@ -78,6 +93,18 @@ class TitleInfo
|
|||||||
@@mutex_hash = {} of String => Mutex
|
@@mutex_hash = {} of String => Mutex
|
||||||
|
|
||||||
def self.new(dir, &)
|
def self.new(dir, &)
|
||||||
|
key = "#{dir}:info.json"
|
||||||
|
info = LRUCache.get key
|
||||||
|
if info.is_a? String
|
||||||
|
begin
|
||||||
|
instance = TitleInfo.from_json info
|
||||||
|
instance.dir = dir
|
||||||
|
yield instance
|
||||||
|
return
|
||||||
|
rescue
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
if @@mutex_hash[dir]?
|
if @@mutex_hash[dir]?
|
||||||
mutex = @@mutex_hash[dir]
|
mutex = @@mutex_hash[dir]
|
||||||
else
|
else
|
||||||
@@ -91,6 +118,7 @@ class TitleInfo
|
|||||||
instance = TitleInfo.from_json File.read json_path
|
instance = TitleInfo.from_json File.read json_path
|
||||||
end
|
end
|
||||||
instance.dir = dir
|
instance.dir = dir
|
||||||
|
LRUCache.set generate_cache_entry key, instance.to_json
|
||||||
yield instance
|
yield instance
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -98,5 +126,12 @@ class TitleInfo
|
|||||||
def save
|
def save
|
||||||
json_path = File.join @dir, "info.json"
|
json_path = File.join @dir, "info.json"
|
||||||
File.write json_path, self.to_pretty_json
|
File.write json_path, self.to_pretty_json
|
||||||
|
key = "#{@dir}:info.json"
|
||||||
|
LRUCache.set generate_cache_entry key, self.to_json
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
alias ExamineContext = NamedTuple(
|
||||||
|
cached_contents_signature: Hash(String, String),
|
||||||
|
deleted_title_ids: Array(String),
|
||||||
|
deleted_entry_ids: Array(String))
|
||||||
|
|||||||
+33
-20
@@ -6,29 +6,17 @@ class Logger
|
|||||||
SEVERITY_IDS = [0, 4, 5, 2, 3]
|
SEVERITY_IDS = [0, 4, 5, 2, 3]
|
||||||
COLORS = [:light_cyan, :light_red, :red, :light_yellow, :light_magenta]
|
COLORS = [:light_cyan, :light_red, :red, :light_yellow, :light_magenta]
|
||||||
|
|
||||||
|
getter raw_log = Log.for ""
|
||||||
|
|
||||||
@@severity : Log::Severity = :info
|
@@severity : Log::Severity = :info
|
||||||
|
|
||||||
use_default
|
use_default
|
||||||
|
|
||||||
def initialize
|
def initialize
|
||||||
level = Config.current.log_level
|
@@severity = Logger.get_severity
|
||||||
{% begin %}
|
|
||||||
case level.downcase
|
|
||||||
when "off"
|
|
||||||
@@severity = :none
|
|
||||||
{% for lvl, i in LEVELS %}
|
|
||||||
when {{lvl}}
|
|
||||||
@@severity = Log::Severity.new SEVERITY_IDS[{{i}}]
|
|
||||||
{% end %}
|
|
||||||
else
|
|
||||||
raise "Unknown log level #{level}"
|
|
||||||
end
|
|
||||||
{% end %}
|
|
||||||
|
|
||||||
@log = Log.for("")
|
|
||||||
|
|
||||||
@backend = Log::IOBackend.new
|
@backend = Log::IOBackend.new
|
||||||
@backend.formatter = ->(entry : Log::Entry, io : IO) do
|
|
||||||
|
format_proc = ->(entry : Log::Entry, io : IO) do
|
||||||
color = :default
|
color = :default
|
||||||
{% begin %}
|
{% begin %}
|
||||||
case entry.severity.label.to_s().downcase
|
case entry.severity.label.to_s().downcase
|
||||||
@@ -45,12 +33,37 @@ class Logger
|
|||||||
io << entry.message
|
io << entry.message
|
||||||
end
|
end
|
||||||
|
|
||||||
Log.builder.bind "*", @@severity, @backend
|
@backend.formatter = Log::Formatter.new &format_proc
|
||||||
|
|
||||||
|
Log.setup do |c|
|
||||||
|
c.bind "*", @@severity, @backend
|
||||||
|
c.bind "db.*", :error, @backend
|
||||||
|
c.bind "duktape", :none, @backend
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.get_severity(level = "") : Log::Severity
|
||||||
|
if level.empty?
|
||||||
|
level = Config.current.log_level
|
||||||
|
end
|
||||||
|
{% begin %}
|
||||||
|
case level.downcase
|
||||||
|
when "off"
|
||||||
|
return Log::Severity::None
|
||||||
|
{% for lvl, i in LEVELS %}
|
||||||
|
when {{lvl}}
|
||||||
|
return Log::Severity.new SEVERITY_IDS[{{i}}]
|
||||||
|
{% end %}
|
||||||
|
else
|
||||||
|
raise "Unknown log level #{level}"
|
||||||
|
end
|
||||||
|
{% end %}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Ignores @@severity and always log msg
|
# Ignores @@severity and always log msg
|
||||||
def log(msg)
|
def log(msg)
|
||||||
@backend.write Log::Entry.new "", Log::Severity::None, msg, nil
|
@backend.write Log::Entry.new "", Log::Severity::None, msg,
|
||||||
|
Log::Metadata.empty, nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.log(msg)
|
def self.log(msg)
|
||||||
@@ -59,7 +72,7 @@ class Logger
|
|||||||
|
|
||||||
{% for lvl in LEVELS %}
|
{% for lvl in LEVELS %}
|
||||||
def {{lvl.id}}(msg)
|
def {{lvl.id}}(msg)
|
||||||
@log.{{lvl.id}} { msg }
|
raw_log.{{lvl.id}} { msg }
|
||||||
end
|
end
|
||||||
def self.{{lvl.id}}(msg)
|
def self.{{lvl.id}}(msg)
|
||||||
default.not_nil!.{{lvl.id}} msg
|
default.not_nil!.{{lvl.id}} msg
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# On ARM, connecting to the SQLite DB from a spawned fiber would crash
|
||||||
|
# https://github.com/crystal-lang/crystal-sqlite3/issues/30
|
||||||
|
# This is a temporary workaround that forces the relevant code to run in the
|
||||||
|
# main fiber
|
||||||
|
|
||||||
|
class MainFiber
|
||||||
|
@@channel = Channel(-> Nil).new
|
||||||
|
@@done = Channel(Bool).new
|
||||||
|
@@main_fiber = Fiber.current
|
||||||
|
|
||||||
|
def self.start_and_block
|
||||||
|
loop do
|
||||||
|
if proc = @@channel.receive
|
||||||
|
begin
|
||||||
|
proc.call
|
||||||
|
ensure
|
||||||
|
@@done.send true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
Fiber.yield
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.run(&block : -> Nil)
|
||||||
|
if @@main_fiber == Fiber.current
|
||||||
|
block.call
|
||||||
|
else
|
||||||
|
@@channel.send block
|
||||||
|
until @@done.receive
|
||||||
|
Fiber.yield
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
require "http/client"
|
|
||||||
require "json"
|
|
||||||
require "csv"
|
|
||||||
require "../rename"
|
|
||||||
|
|
||||||
macro string_properties(names)
|
|
||||||
{% for name in names %}
|
|
||||||
property {{name.id}} = ""
|
|
||||||
{% end %}
|
|
||||||
end
|
|
||||||
|
|
||||||
macro parse_strings_from_json(names)
|
|
||||||
{% for name in names %}
|
|
||||||
@{{name.id}} = obj[{{name}}].as_s
|
|
||||||
{% end %}
|
|
||||||
end
|
|
||||||
|
|
||||||
macro properties_to_hash(names)
|
|
||||||
{
|
|
||||||
{% for name in names %}
|
|
||||||
"{{name.id}}" => @{{name.id}}.to_s,
|
|
||||||
{% end %}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
module MangaDex
|
|
||||||
class Chapter
|
|
||||||
string_properties ["lang_code", "title", "volume", "chapter"]
|
|
||||||
property manga : Manga
|
|
||||||
property time = Time.local
|
|
||||||
property id : String
|
|
||||||
property full_title = ""
|
|
||||||
property language = ""
|
|
||||||
property pages = [] of {String, String} # filename, url
|
|
||||||
property groups = [] of {Int32, String} # group_id, group_name
|
|
||||||
|
|
||||||
def initialize(@id, json_obj : JSON::Any, @manga,
|
|
||||||
lang : Hash(String, String))
|
|
||||||
self.parse_json json_obj, lang
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_info_json
|
|
||||||
JSON.build do |json|
|
|
||||||
json.object do
|
|
||||||
{% for name in ["id", "title", "volume", "chapter",
|
|
||||||
"language", "full_title"] %}
|
|
||||||
json.field {{name}}, @{{name.id}}
|
|
||||||
{% end %}
|
|
||||||
json.field "time", @time.to_unix.to_s
|
|
||||||
json.field "manga_title", @manga.title
|
|
||||||
json.field "manga_id", @manga.id
|
|
||||||
json.field "groups" do
|
|
||||||
json.object do
|
|
||||||
@groups.each do |gid, gname|
|
|
||||||
json.field gname, gid
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def parse_json(obj, lang)
|
|
||||||
parse_strings_from_json ["lang_code", "title", "volume",
|
|
||||||
"chapter"]
|
|
||||||
language = lang[@lang_code]?
|
|
||||||
@language = language if language
|
|
||||||
@time = Time.unix obj["timestamp"].as_i
|
|
||||||
suffixes = ["", "_2", "_3"]
|
|
||||||
suffixes.each do |s|
|
|
||||||
gid = obj["group_id#{s}"].as_i
|
|
||||||
next if gid == 0
|
|
||||||
gname = obj["group_name#{s}"].as_s
|
|
||||||
@groups << {gid, gname}
|
|
||||||
end
|
|
||||||
|
|
||||||
rename_rule = Rename::Rule.new \
|
|
||||||
Config.current.mangadex["chapter_rename_rule"].to_s
|
|
||||||
@full_title = rename rename_rule
|
|
||||||
rescue e
|
|
||||||
raise "failed to parse json: #{e}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def rename(rule : Rename::Rule)
|
|
||||||
hash = properties_to_hash ["id", "title", "volume", "chapter",
|
|
||||||
"lang_code", "language", "pages"]
|
|
||||||
hash["groups"] = @groups.map { |g| g[1] }.join ","
|
|
||||||
rule.render hash
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class Manga
|
|
||||||
string_properties ["cover_url", "description", "title", "author", "artist"]
|
|
||||||
property chapters = [] of Chapter
|
|
||||||
property id : String
|
|
||||||
|
|
||||||
def initialize(@id, json_obj : JSON::Any)
|
|
||||||
self.parse_json json_obj
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_info_json(with_chapters = true)
|
|
||||||
JSON.build do |json|
|
|
||||||
json.object do
|
|
||||||
{% for name in ["id", "title", "description", "author", "artist",
|
|
||||||
"cover_url"] %}
|
|
||||||
json.field {{name}}, @{{name.id}}
|
|
||||||
{% end %}
|
|
||||||
if with_chapters
|
|
||||||
json.field "chapters" do
|
|
||||||
json.array do
|
|
||||||
@chapters.each do |c|
|
|
||||||
json.raw c.to_info_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def parse_json(obj)
|
|
||||||
parse_strings_from_json ["cover_url", "description", "title", "author",
|
|
||||||
"artist"]
|
|
||||||
rescue e
|
|
||||||
raise "failed to parse json: #{e}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def rename(rule : Rename::Rule)
|
|
||||||
rule.render properties_to_hash ["id", "title", "author", "artist"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class API
|
|
||||||
use_default
|
|
||||||
|
|
||||||
def initialize
|
|
||||||
@base_url = Config.current.mangadex["api_url"].to_s ||
|
|
||||||
"https://mangadex.org/api/"
|
|
||||||
@lang = {} of String => String
|
|
||||||
CSV.each_row {{read_file "src/assets/lang_codes.csv"}} do |row|
|
|
||||||
@lang[row[1]] = row[0]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def get(url)
|
|
||||||
headers = HTTP::Headers{
|
|
||||||
"User-agent" => "Mangadex.cr",
|
|
||||||
}
|
|
||||||
res = HTTP::Client.get url, headers
|
|
||||||
raise "Failed to get #{url}. [#{res.status_code}] " \
|
|
||||||
"#{res.status_message}" if !res.success?
|
|
||||||
JSON.parse res.body
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_manga(id)
|
|
||||||
obj = self.get File.join @base_url, "manga/#{id}"
|
|
||||||
if obj["status"]? != "OK"
|
|
||||||
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
|
|
||||||
end
|
|
||||||
begin
|
|
||||||
manga = Manga.new id, obj["manga"]
|
|
||||||
obj["chapter"].as_h.map do |k, v|
|
|
||||||
chapter = Chapter.new k, v, manga, @lang
|
|
||||||
manga.chapters << chapter
|
|
||||||
end
|
|
||||||
manga
|
|
||||||
rescue
|
|
||||||
raise "Failed to parse JSON"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_chapter(chapter : Chapter)
|
|
||||||
obj = self.get File.join @base_url, "chapter/#{chapter.id}"
|
|
||||||
if obj["status"]? == "external"
|
|
||||||
raise "This chapter is hosted on an external site " \
|
|
||||||
"#{obj["external"]?}, and Mango does not support " \
|
|
||||||
"external chapters."
|
|
||||||
end
|
|
||||||
if obj["status"]? != "OK"
|
|
||||||
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
|
|
||||||
end
|
|
||||||
begin
|
|
||||||
server = obj["server"].as_s
|
|
||||||
hash = obj["hash"].as_s
|
|
||||||
chapter.pages = obj["page_array"].as_a.map do |fn|
|
|
||||||
{
|
|
||||||
fn.as_s,
|
|
||||||
"#{server}#{hash}/#{fn.as_s}",
|
|
||||||
}
|
|
||||||
end
|
|
||||||
rescue
|
|
||||||
raise "Failed to parse JSON"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_chapter(id : String)
|
|
||||||
obj = self.get File.join @base_url, "chapter/#{id}"
|
|
||||||
if obj["status"]? == "external"
|
|
||||||
raise "This chapter is hosted on an external site " \
|
|
||||||
"#{obj["external"]?}, and Mango does not support " \
|
|
||||||
"external chapters."
|
|
||||||
end
|
|
||||||
if obj["status"]? != "OK"
|
|
||||||
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
|
|
||||||
end
|
|
||||||
manga_id = ""
|
|
||||||
begin
|
|
||||||
manga_id = obj["manga_id"].as_i.to_s
|
|
||||||
rescue
|
|
||||||
raise "Failed to parse JSON"
|
|
||||||
end
|
|
||||||
manga = self.get_manga manga_id
|
|
||||||
chapter = manga.chapters.find { |c| c.id == id }.not_nil!
|
|
||||||
self.get_chapter chapter
|
|
||||||
chapter
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
require "./api"
|
|
||||||
require "zip"
|
|
||||||
|
|
||||||
module MangaDex
|
|
||||||
class PageJob
|
|
||||||
property success = false
|
|
||||||
property url : String
|
|
||||||
property filename : String
|
|
||||||
property writer : Zip::Writer
|
|
||||||
property tries_remaning : Int32
|
|
||||||
|
|
||||||
def initialize(@url, @filename, @writer, @tries_remaning)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class Downloader < Queue::Downloader
|
|
||||||
@wait_seconds : Int32 = Config.current.mangadex["download_wait_seconds"]
|
|
||||||
.to_i32
|
|
||||||
@retries : Int32 = Config.current.mangadex["download_retries"].to_i32
|
|
||||||
|
|
||||||
use_default
|
|
||||||
|
|
||||||
def initialize
|
|
||||||
@api = API.default
|
|
||||||
super
|
|
||||||
end
|
|
||||||
|
|
||||||
def pop : Queue::Job?
|
|
||||||
job = nil
|
|
||||||
DB.open "sqlite3://#{@queue.path}" do |db|
|
|
||||||
begin
|
|
||||||
db.query_one "select * from queue where id not like '%-%' " \
|
|
||||||
"and (status = 0 or status = 1) " \
|
|
||||||
"order by time limit 1" do |res|
|
|
||||||
job = Queue::Job.from_query_result res
|
|
||||||
end
|
|
||||||
rescue
|
|
||||||
end
|
|
||||||
end
|
|
||||||
job
|
|
||||||
end
|
|
||||||
|
|
||||||
private def download(job : Queue::Job)
|
|
||||||
@downloading = true
|
|
||||||
@queue.set_status Queue::JobStatus::Downloading, job
|
|
||||||
begin
|
|
||||||
chapter = @api.get_chapter(job.id)
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
@queue.set_status Queue::JobStatus::Error, job
|
|
||||||
unless e.message.nil?
|
|
||||||
@queue.add_message e.message.not_nil!, job
|
|
||||||
end
|
|
||||||
@downloading = false
|
|
||||||
return
|
|
||||||
end
|
|
||||||
@queue.set_pages chapter.pages.size, job
|
|
||||||
lib_dir = @library_path
|
|
||||||
rename_rule = Rename::Rule.new \
|
|
||||||
Config.current.mangadex["manga_rename_rule"].to_s
|
|
||||||
manga_dir = File.join lib_dir, chapter.manga.rename rename_rule
|
|
||||||
unless File.exists? manga_dir
|
|
||||||
Dir.mkdir_p manga_dir
|
|
||||||
end
|
|
||||||
zip_path = File.join manga_dir, "#{job.title}.cbz.part"
|
|
||||||
|
|
||||||
# Find the number of digits needed to store the number of pages
|
|
||||||
len = Math.log10(chapter.pages.size).to_i + 1
|
|
||||||
|
|
||||||
writer = Zip::Writer.new zip_path
|
|
||||||
# Create a buffered channel. It works as an FIFO queue
|
|
||||||
channel = Channel(PageJob).new chapter.pages.size
|
|
||||||
spawn do
|
|
||||||
chapter.pages.each_with_index do |tuple, i|
|
|
||||||
fn, url = tuple
|
|
||||||
ext = File.extname fn
|
|
||||||
fn = "#{i.to_s.rjust len, '0'}#{ext}"
|
|
||||||
page_job = PageJob.new url, fn, writer, @retries
|
|
||||||
Logger.debug "Downloading #{url}"
|
|
||||||
loop do
|
|
||||||
sleep @wait_seconds.seconds
|
|
||||||
download_page page_job
|
|
||||||
break if page_job.success ||
|
|
||||||
page_job.tries_remaning <= 0
|
|
||||||
page_job.tries_remaning -= 1
|
|
||||||
Logger.warn "Failed to download page #{url}. " \
|
|
||||||
"Retrying... Remaining retries: " \
|
|
||||||
"#{page_job.tries_remaning}"
|
|
||||||
end
|
|
||||||
|
|
||||||
channel.send page_job
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
spawn do
|
|
||||||
page_jobs = [] of PageJob
|
|
||||||
chapter.pages.size.times do
|
|
||||||
page_job = channel.receive
|
|
||||||
Logger.debug "[#{page_job.success ? "success" : "failed"}] " \
|
|
||||||
"#{page_job.url}"
|
|
||||||
page_jobs << page_job
|
|
||||||
if page_job.success
|
|
||||||
@queue.add_success job
|
|
||||||
else
|
|
||||||
@queue.add_fail job
|
|
||||||
msg = "Failed to download page #{page_job.url}"
|
|
||||||
@queue.add_message msg, job
|
|
||||||
Logger.error msg
|
|
||||||
end
|
|
||||||
end
|
|
||||||
fail_count = page_jobs.count { |j| !j.success }
|
|
||||||
Logger.debug "Download completed. " \
|
|
||||||
"#{fail_count}/#{page_jobs.size} failed"
|
|
||||||
writer.close
|
|
||||||
filename = File.join File.dirname(zip_path), File.basename(zip_path,
|
|
||||||
".part")
|
|
||||||
File.rename zip_path, filename
|
|
||||||
Logger.debug "cbz File created at #{filename}"
|
|
||||||
|
|
||||||
zip_exception = validate_archive filename
|
|
||||||
if !zip_exception.nil?
|
|
||||||
@queue.add_message "The downloaded archive is corrupted. " \
|
|
||||||
"Error: #{zip_exception}", job
|
|
||||||
@queue.set_status Queue::JobStatus::Error, job
|
|
||||||
elsif fail_count > 0
|
|
||||||
@queue.set_status Queue::JobStatus::MissingPages, job
|
|
||||||
else
|
|
||||||
@queue.set_status Queue::JobStatus::Completed, job
|
|
||||||
end
|
|
||||||
@downloading = false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private def download_page(job : PageJob)
|
|
||||||
Logger.debug "downloading #{job.url}"
|
|
||||||
headers = HTTP::Headers{
|
|
||||||
"User-agent" => "Mangadex.cr",
|
|
||||||
}
|
|
||||||
begin
|
|
||||||
HTTP::Client.get job.url, headers do |res|
|
|
||||||
unless res.success?
|
|
||||||
raise "Failed to download page #{job.url}. " \
|
|
||||||
"[#{res.status_code}] #{res.status_message}"
|
|
||||||
end
|
|
||||||
job.writer.add job.filename, res.body_io
|
|
||||||
end
|
|
||||||
job.success = true
|
|
||||||
rescue e
|
|
||||||
Logger.error e
|
|
||||||
job.success = false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
+52
-20
@@ -1,12 +1,29 @@
|
|||||||
require "./config"
|
require "./config"
|
||||||
require "./queue"
|
require "./queue"
|
||||||
require "./server"
|
require "./server"
|
||||||
require "./mangadex/*"
|
require "./main_fiber"
|
||||||
|
require "./plugin/*"
|
||||||
require "option_parser"
|
require "option_parser"
|
||||||
require "clim"
|
require "clim"
|
||||||
require "./plugin/*"
|
require "tallboy"
|
||||||
|
|
||||||
MANGO_VERSION = "0.9.0"
|
MANGO_VERSION = "0.27.0"
|
||||||
|
|
||||||
|
# From http://www.network-science.de/ascii/
|
||||||
|
BANNER = %{
|
||||||
|
|
||||||
|
_| _|
|
||||||
|
_|_| _|_| _|_|_| _|_|_| _|_|_| _|_|
|
||||||
|
_| _| _| _| _| _| _| _| _| _| _|
|
||||||
|
_| _| _| _| _| _| _| _| _| _|
|
||||||
|
_| _| _|_|_| _| _| _|_|_| _|_|
|
||||||
|
_|
|
||||||
|
_|_|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
DESCRIPTION = "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}"
|
||||||
|
|
||||||
macro common_option
|
macro common_option
|
||||||
option "-c PATH", "--config=PATH", type: String,
|
option "-c PATH", "--config=PATH", type: String,
|
||||||
@@ -22,20 +39,40 @@ end
|
|||||||
|
|
||||||
class CLI < Clim
|
class CLI < Clim
|
||||||
main do
|
main do
|
||||||
desc "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}"
|
desc DESCRIPTION
|
||||||
usage "mango [sub_command] [options]"
|
usage "mango [sub_command] [options]"
|
||||||
help short: "-h"
|
help short: "-h"
|
||||||
version "Version #{MANGO_VERSION}", short: "-v"
|
version "Version #{MANGO_VERSION}", short: "-v"
|
||||||
common_option
|
common_option
|
||||||
run do |opts|
|
run do |opts|
|
||||||
Config.load(opts.config).set_current
|
puts BANNER
|
||||||
MangaDex::Downloader.default
|
puts DESCRIPTION
|
||||||
Plugin::Downloader.default
|
puts
|
||||||
|
|
||||||
# empty ARGV so it won't be passed to Kemal
|
# empty ARGV so it won't be passed to Kemal
|
||||||
ARGV.clear
|
ARGV.clear
|
||||||
server = Server.new
|
|
||||||
server.start
|
Config.load(opts.config).set_current
|
||||||
|
|
||||||
|
# Initialize main components
|
||||||
|
LRUCache.init
|
||||||
|
Storage.default
|
||||||
|
Queue.default
|
||||||
|
Library.load_instance
|
||||||
|
Library.default
|
||||||
|
Plugin::Downloader.default
|
||||||
|
Plugin::Updater.default
|
||||||
|
|
||||||
|
spawn do
|
||||||
|
begin
|
||||||
|
Server.new.start
|
||||||
|
rescue e
|
||||||
|
Logger.fatal e
|
||||||
|
Process.exit 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
MainFiber.start_and_block
|
||||||
end
|
end
|
||||||
|
|
||||||
sub "admin" do
|
sub "admin" do
|
||||||
@@ -80,18 +117,13 @@ class CLI < Clim
|
|||||||
password.not_nil!, opts.admin
|
password.not_nil!, opts.admin
|
||||||
when "list"
|
when "list"
|
||||||
users = storage.list_users
|
users = storage.list_users
|
||||||
name_length = users.map(&.[0].size).max? || 0
|
table = Tallboy.table do
|
||||||
l_cell_width = ["username".size, name_length].max
|
header ["username", "admin access"]
|
||||||
r_cell_width = "admin access".size
|
users.each do |name, admin|
|
||||||
header = " #{"username".ljust l_cell_width} | admin access "
|
row [name, admin]
|
||||||
puts "-" * header.size
|
end
|
||||||
puts header
|
|
||||||
puts "-" * header.size
|
|
||||||
users.each do |name, admin|
|
|
||||||
puts " #{name.ljust l_cell_width} | " \
|
|
||||||
"#{admin.to_s.ljust r_cell_width} "
|
|
||||||
end
|
end
|
||||||
puts "-" * header.size
|
puts table
|
||||||
when nil
|
when nil
|
||||||
puts opts.help_string
|
puts opts.help_string
|
||||||
else
|
else
|
||||||
|
|||||||
+21
-16
@@ -8,24 +8,21 @@ class Plugin
|
|||||||
|
|
||||||
def pop : Queue::Job?
|
def pop : Queue::Job?
|
||||||
job = nil
|
job = nil
|
||||||
DB.open "sqlite3://#{@queue.path}" do |db|
|
MainFiber.run do
|
||||||
begin
|
DB.open "sqlite3://#{@queue.path}" do |db|
|
||||||
db.query_one "select * from queue where id like '%-%' " \
|
begin
|
||||||
"and (status = 0 or status = 1) " \
|
db.query_one "select * from queue where id like '%-%' " \
|
||||||
"order by time limit 1" do |res|
|
"and (status = 0 or status = 1) " \
|
||||||
job = Queue::Job.from_query_result res
|
"order by time limit 1" do |res|
|
||||||
|
job = Queue::Job.from_query_result res
|
||||||
|
end
|
||||||
|
rescue
|
||||||
end
|
end
|
||||||
rescue
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
job
|
job
|
||||||
end
|
end
|
||||||
|
|
||||||
private def process_filename(str)
|
|
||||||
return "_" if str == ".."
|
|
||||||
str.gsub "/", "_"
|
|
||||||
end
|
|
||||||
|
|
||||||
private def download(job : Queue::Job)
|
private def download(job : Queue::Job)
|
||||||
@downloading = true
|
@downloading = true
|
||||||
@queue.set_status Queue::JobStatus::Downloading, job
|
@queue.set_status Queue::JobStatus::Downloading, job
|
||||||
@@ -40,8 +37,8 @@ class Plugin
|
|||||||
|
|
||||||
pages = info["pages"].as_i
|
pages = info["pages"].as_i
|
||||||
|
|
||||||
manga_title = process_filename job.manga_title
|
manga_title = sanitize_filename job.manga_title
|
||||||
chapter_title = process_filename info["title"].as_s
|
chapter_title = sanitize_filename info["title"].as_s
|
||||||
|
|
||||||
@queue.set_pages pages, job
|
@queue.set_pages pages, job
|
||||||
lib_dir = @library_path
|
lib_dir = @library_path
|
||||||
@@ -51,7 +48,7 @@ class Plugin
|
|||||||
end
|
end
|
||||||
|
|
||||||
zip_path = File.join manga_dir, "#{chapter_title}.cbz.part"
|
zip_path = File.join manga_dir, "#{chapter_title}.cbz.part"
|
||||||
writer = Zip::Writer.new zip_path
|
writer = Compress::Zip::Writer.new zip_path
|
||||||
rescue e
|
rescue e
|
||||||
@queue.set_status Queue::JobStatus::Error, job
|
@queue.set_status Queue::JobStatus::Error, job
|
||||||
unless e.message.nil?
|
unless e.message.nil?
|
||||||
@@ -64,7 +61,9 @@ class Plugin
|
|||||||
fail_count = 0
|
fail_count = 0
|
||||||
|
|
||||||
while page = plugin.next_page
|
while page = plugin.next_page
|
||||||
fn = process_filename page["filename"].as_s
|
break unless @queue.exists? job
|
||||||
|
|
||||||
|
fn = sanitize_filename page["filename"].as_s
|
||||||
url = page["url"].as_s
|
url = page["url"].as_s
|
||||||
headers = HTTP::Headers.new
|
headers = HTTP::Headers.new
|
||||||
|
|
||||||
@@ -107,6 +106,12 @@ class Plugin
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
unless @queue.exists? job
|
||||||
|
Logger.debug "Download cancelled"
|
||||||
|
@downloading = false
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
Logger.debug "Download completed. #{fail_count}/#{pages} failed"
|
Logger.debug "Download completed. #{fail_count}/#{pages} failed"
|
||||||
writer.close
|
writer.close
|
||||||
filename = File.join File.dirname(zip_path), File.basename(zip_path,
|
filename = File.join File.dirname(zip_path), File.basename(zip_path,
|
||||||
|
|||||||
+225
-28
@@ -1,8 +1,9 @@
|
|||||||
require "duktape/runtime"
|
require "duktape/runtime"
|
||||||
require "myhtml"
|
require "myhtml"
|
||||||
require "http"
|
|
||||||
require "xml"
|
require "xml"
|
||||||
|
|
||||||
|
require "./subscriptions"
|
||||||
|
|
||||||
class Plugin
|
class Plugin
|
||||||
class Error < ::Exception
|
class Error < ::Exception
|
||||||
end
|
end
|
||||||
@@ -17,12 +18,19 @@ class Plugin
|
|||||||
end
|
end
|
||||||
|
|
||||||
struct Info
|
struct Info
|
||||||
|
include JSON::Serializable
|
||||||
|
|
||||||
{% for name in ["id", "title", "placeholder"] %}
|
{% for name in ["id", "title", "placeholder"] %}
|
||||||
getter {{name.id}} = ""
|
getter {{name.id}} = ""
|
||||||
{% end %}
|
{% end %}
|
||||||
getter wait_seconds : UInt64 = 0
|
getter wait_seconds = 0u64
|
||||||
|
getter version = 0u64
|
||||||
|
getter settings = {} of String => String?
|
||||||
getter dir : String
|
getter dir : String
|
||||||
|
|
||||||
|
@[JSON::Field(ignore: true)]
|
||||||
|
@json : JSON::Any
|
||||||
|
|
||||||
def initialize(@dir)
|
def initialize(@dir)
|
||||||
info_path = File.join @dir, "info.json"
|
info_path = File.join @dir, "info.json"
|
||||||
|
|
||||||
@@ -38,6 +46,16 @@ class Plugin
|
|||||||
@{{name.id}} = @json[{{name}}].as_s
|
@{{name.id}} = @json[{{name}}].as_s
|
||||||
{% end %}
|
{% end %}
|
||||||
@wait_seconds = @json["wait_seconds"].as_i.to_u64
|
@wait_seconds = @json["wait_seconds"].as_i.to_u64
|
||||||
|
@version = @json["api_version"]?.try(&.as_i.to_u64) || 1u64
|
||||||
|
|
||||||
|
if @version > 1 && (settings_hash = @json["settings"]?.try &.as_h?)
|
||||||
|
settings_hash.each do |k, v|
|
||||||
|
unless str_value = v.as_s?
|
||||||
|
raise "The settings object can only contain strings or null"
|
||||||
|
end
|
||||||
|
@settings[k] = str_value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
unless @id.alphanumeric_underscore?
|
unless @id.alphanumeric_underscore?
|
||||||
raise "Plugin ID can only contain alphanumeric characters and " \
|
raise "Plugin ID can only contain alphanumeric characters and " \
|
||||||
@@ -87,9 +105,10 @@ class Plugin
|
|||||||
getter js_path = ""
|
getter js_path = ""
|
||||||
getter storage_path = ""
|
getter storage_path = ""
|
||||||
|
|
||||||
def self.build_info_ary
|
def self.build_info_ary(dir : String? = nil)
|
||||||
@@info_ary.clear
|
@@info_ary.clear
|
||||||
dir = Config.current.plugin_path
|
dir ||= Config.current.plugin_path
|
||||||
|
|
||||||
Dir.mkdir_p dir unless Dir.exists? dir
|
Dir.mkdir_p dir unless Dir.exists? dir
|
||||||
|
|
||||||
Dir.each_child dir do |f|
|
Dir.each_child dir do |f|
|
||||||
@@ -115,10 +134,37 @@ class Plugin
|
|||||||
@info.not_nil!
|
@info.not_nil!
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize(id : String)
|
def subscribe(subscription : Subscription)
|
||||||
Plugin.build_info_ary
|
list = SubscriptionList.new info.dir
|
||||||
|
list << subscription
|
||||||
|
list.save
|
||||||
|
end
|
||||||
|
|
||||||
@info = @@info_ary.find { |i| i.id == id }
|
def list_subscriptions
|
||||||
|
SubscriptionList.new(info.dir).ary
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_subscriptions_raw
|
||||||
|
SubscriptionList.new(info.dir)
|
||||||
|
end
|
||||||
|
|
||||||
|
def unsubscribe(id : String)
|
||||||
|
list = SubscriptionList.new info.dir
|
||||||
|
list.reject! &.id.== id
|
||||||
|
list.save
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_subscription(id : String)
|
||||||
|
list = list_subscriptions_raw
|
||||||
|
sub = list.find &.id.== id
|
||||||
|
Plugin::Updater.default.check_subscription self, sub.not_nil!
|
||||||
|
list.save
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(id : String, dir : String? = nil)
|
||||||
|
Plugin.build_info_ary dir
|
||||||
|
|
||||||
|
@info = @@info_ary.find &.id.== id
|
||||||
if @info.nil?
|
if @info.nil?
|
||||||
raise Error.new "Plugin with ID #{id} not found"
|
raise Error.new "Plugin with ID #{id} not found"
|
||||||
end
|
end
|
||||||
@@ -139,6 +185,12 @@ class Plugin
|
|||||||
sbx.push_string path
|
sbx.push_string path
|
||||||
sbx.put_prop_string -2, "storage_path"
|
sbx.put_prop_string -2, "storage_path"
|
||||||
|
|
||||||
|
sbx.push_pointer info.dir.as(Void*)
|
||||||
|
path = sbx.require_pointer(-1).as String
|
||||||
|
sbx.pop
|
||||||
|
sbx.push_string path
|
||||||
|
sbx.put_prop_string -2, "info_dir"
|
||||||
|
|
||||||
def_helper_functions sbx
|
def_helper_functions sbx
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -153,23 +205,71 @@ class Plugin
|
|||||||
{% end %}
|
{% end %}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def assert_manga_type(obj : JSON::Any)
|
||||||
|
obj["id"].as_s && obj["title"].as_s
|
||||||
|
rescue e
|
||||||
|
raise Error.new "Missing required fields in the Manga type"
|
||||||
|
end
|
||||||
|
|
||||||
|
def assert_chapter_type(obj : JSON::Any)
|
||||||
|
obj["id"].as_s && obj["title"].as_s && obj["pages"].as_i &&
|
||||||
|
obj["manga_title"].as_s
|
||||||
|
rescue e
|
||||||
|
raise Error.new "Missing required fields in the Chapter type"
|
||||||
|
end
|
||||||
|
|
||||||
|
def assert_page_type(obj : JSON::Any)
|
||||||
|
obj["url"].as_s && obj["filename"].as_s
|
||||||
|
rescue e
|
||||||
|
raise Error.new "Missing required fields in the Page type"
|
||||||
|
end
|
||||||
|
|
||||||
|
def can_subscribe? : Bool
|
||||||
|
info.version > 1 && eval_exists?("newChapters")
|
||||||
|
end
|
||||||
|
|
||||||
|
def search_manga(query : String)
|
||||||
|
if info.version == 1
|
||||||
|
raise Error.new "Manga searching is only available for plugins " \
|
||||||
|
"targeting API v2 or above"
|
||||||
|
end
|
||||||
|
json = eval_json "searchManga('#{query}')"
|
||||||
|
begin
|
||||||
|
json.as_a.each do |obj|
|
||||||
|
assert_manga_type obj
|
||||||
|
end
|
||||||
|
rescue e
|
||||||
|
raise Error.new e.message
|
||||||
|
end
|
||||||
|
json
|
||||||
|
end
|
||||||
|
|
||||||
def list_chapters(query : String)
|
def list_chapters(query : String)
|
||||||
json = eval_json "listChapters('#{query}')"
|
json = eval_json "listChapters('#{query}')"
|
||||||
begin
|
begin
|
||||||
check_fields ["title", "chapters"]
|
if info.version > 1
|
||||||
|
# Since v2, listChapters returns an array
|
||||||
ary = json["chapters"].as_a
|
json.as_a.each do |obj|
|
||||||
ary.each do |obj|
|
assert_chapter_type obj
|
||||||
id = obj["id"]?
|
|
||||||
raise "Field `id` missing from `listChapters` outputs" if id.nil?
|
|
||||||
|
|
||||||
unless id.to_s.alphanumeric_underscore?
|
|
||||||
raise "The `id` field can only contain alphanumeric characters " \
|
|
||||||
"and underscores"
|
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
check_fields ["title", "chapters"]
|
||||||
|
|
||||||
title = obj["title"]?
|
ary = json["chapters"].as_a
|
||||||
raise "Field `title` missing from `listChapters` outputs" if title.nil?
|
ary.each do |obj|
|
||||||
|
id = obj["id"]?
|
||||||
|
raise "Field `id` missing from `listChapters` outputs" if id.nil?
|
||||||
|
|
||||||
|
unless id.to_s.alphanumeric_underscore?
|
||||||
|
raise "The `id` field can only contain alphanumeric characters " \
|
||||||
|
"and underscores"
|
||||||
|
end
|
||||||
|
|
||||||
|
title = obj["title"]?
|
||||||
|
if title.nil?
|
||||||
|
raise "Field `title` missing from `listChapters` outputs"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
rescue e
|
rescue e
|
||||||
raise Error.new e.message
|
raise Error.new e.message
|
||||||
@@ -180,10 +280,14 @@ class Plugin
|
|||||||
def select_chapter(id : String)
|
def select_chapter(id : String)
|
||||||
json = eval_json "selectChapter('#{id}')"
|
json = eval_json "selectChapter('#{id}')"
|
||||||
begin
|
begin
|
||||||
check_fields ["title", "pages"]
|
if info.version > 1
|
||||||
|
assert_chapter_type json
|
||||||
|
else
|
||||||
|
check_fields ["title", "pages"]
|
||||||
|
|
||||||
if json["title"].to_s.empty?
|
if json["title"].to_s.empty?
|
||||||
raise "The `title` field of the chapter can not be empty"
|
raise "The `title` field of the chapter can not be empty"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
rescue e
|
rescue e
|
||||||
raise Error.new e.message
|
raise Error.new e.message
|
||||||
@@ -195,14 +299,28 @@ class Plugin
|
|||||||
json = eval_json "nextPage()"
|
json = eval_json "nextPage()"
|
||||||
return if json.size == 0
|
return if json.size == 0
|
||||||
begin
|
begin
|
||||||
check_fields ["filename", "url"]
|
assert_page_type json
|
||||||
rescue e
|
rescue e
|
||||||
raise Error.new e.message
|
raise Error.new e.message
|
||||||
end
|
end
|
||||||
json
|
json
|
||||||
end
|
end
|
||||||
|
|
||||||
private def eval(str)
|
def new_chapters(manga_id : String, after : Int64)
|
||||||
|
# Converting standard timestamp to milliseconds so plugins can easily do
|
||||||
|
# `new Date(ms_timestamp)` in JS.
|
||||||
|
json = eval_json "newChapters('#{manga_id}', #{after * 1000})"
|
||||||
|
begin
|
||||||
|
json.as_a.each do |obj|
|
||||||
|
assert_chapter_type obj
|
||||||
|
end
|
||||||
|
rescue e
|
||||||
|
raise Error.new e.message
|
||||||
|
end
|
||||||
|
json
|
||||||
|
end
|
||||||
|
|
||||||
|
def eval(str)
|
||||||
@rt.eval str
|
@rt.eval str
|
||||||
rescue e : Duktape::SyntaxError
|
rescue e : Duktape::SyntaxError
|
||||||
raise SyntaxError.new e.message
|
raise SyntaxError.new e.message
|
||||||
@@ -214,6 +332,15 @@ class Plugin
|
|||||||
JSON.parse eval(str).as String
|
JSON.parse eval(str).as String
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private def eval_exists?(str) : Bool
|
||||||
|
@rt.eval str
|
||||||
|
true
|
||||||
|
rescue e : Duktape::ReferenceError
|
||||||
|
false
|
||||||
|
rescue e : Duktape::Error
|
||||||
|
raise Error.new e.message
|
||||||
|
end
|
||||||
|
|
||||||
private def def_helper_functions(sbx)
|
private def def_helper_functions(sbx)
|
||||||
sbx.push_object
|
sbx.push_object
|
||||||
|
|
||||||
@@ -258,6 +385,48 @@ class Plugin
|
|||||||
end
|
end
|
||||||
sbx.put_prop_string -2, "get"
|
sbx.put_prop_string -2, "get"
|
||||||
|
|
||||||
|
sbx.push_proc LibDUK::VARARGS do |ptr|
|
||||||
|
env = Duktape::Sandbox.new ptr
|
||||||
|
url = env.require_string 0
|
||||||
|
body = env.require_string 1
|
||||||
|
|
||||||
|
headers = HTTP::Headers.new
|
||||||
|
|
||||||
|
if env.get_top == 3
|
||||||
|
env.enum 2, LibDUK::Enum::OwnPropertiesOnly
|
||||||
|
while env.next -1, true
|
||||||
|
key = env.require_string -2
|
||||||
|
val = env.require_string -1
|
||||||
|
headers.add key, val
|
||||||
|
env.pop_2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
res = HTTP::Client.post url, headers, body
|
||||||
|
|
||||||
|
env.push_object
|
||||||
|
|
||||||
|
env.push_int res.status_code
|
||||||
|
env.put_prop_string -2, "status_code"
|
||||||
|
|
||||||
|
env.push_string res.body
|
||||||
|
env.put_prop_string -2, "body"
|
||||||
|
|
||||||
|
env.push_object
|
||||||
|
res.headers.each do |k, v|
|
||||||
|
if v.size == 1
|
||||||
|
env.push_string v[0]
|
||||||
|
else
|
||||||
|
env.push_string v.join ","
|
||||||
|
end
|
||||||
|
env.put_prop_string -2, k
|
||||||
|
end
|
||||||
|
env.put_prop_string -2, "headers"
|
||||||
|
|
||||||
|
env.call_success
|
||||||
|
end
|
||||||
|
sbx.put_prop_string -2, "post"
|
||||||
|
|
||||||
sbx.push_proc 2 do |ptr|
|
sbx.push_proc 2 do |ptr|
|
||||||
env = Duktape::Sandbox.new ptr
|
env = Duktape::Sandbox.new ptr
|
||||||
html = env.require_string 0
|
html = env.require_string 0
|
||||||
@@ -280,9 +449,15 @@ class Plugin
|
|||||||
env = Duktape::Sandbox.new ptr
|
env = Duktape::Sandbox.new ptr
|
||||||
html = env.require_string 0
|
html = env.require_string 0
|
||||||
|
|
||||||
str = XML.parse(html).inner_text
|
begin
|
||||||
|
parser = Myhtml::Parser.new html
|
||||||
|
str = parser.body!.children.first.inner_text
|
||||||
|
|
||||||
|
env.push_string str
|
||||||
|
rescue
|
||||||
|
env.push_string ""
|
||||||
|
end
|
||||||
|
|
||||||
env.push_string str
|
|
||||||
env.call_success
|
env.call_success
|
||||||
end
|
end
|
||||||
sbx.put_prop_string -2, "text"
|
sbx.put_prop_string -2, "text"
|
||||||
@@ -293,8 +468,9 @@ class Plugin
|
|||||||
name = env.require_string 1
|
name = env.require_string 1
|
||||||
|
|
||||||
begin
|
begin
|
||||||
attr = XML.parse(html).first_element_child.not_nil![name]
|
parser = Myhtml::Parser.new html
|
||||||
env.push_string attr
|
attr = parser.body!.children.first.attribute_by name
|
||||||
|
env.push_string attr.not_nil!
|
||||||
rescue
|
rescue
|
||||||
env.push_undefined
|
env.push_undefined
|
||||||
end
|
end
|
||||||
@@ -338,6 +514,27 @@ class Plugin
|
|||||||
end
|
end
|
||||||
sbx.put_prop_string -2, "storage"
|
sbx.put_prop_string -2, "storage"
|
||||||
|
|
||||||
|
if info.version > 1
|
||||||
|
sbx.push_proc 1 do |ptr|
|
||||||
|
env = Duktape::Sandbox.new ptr
|
||||||
|
key = env.require_string 0
|
||||||
|
|
||||||
|
env.get_global_string "info_dir"
|
||||||
|
info_dir = env.require_string -1
|
||||||
|
env.pop
|
||||||
|
info = Info.new info_dir
|
||||||
|
|
||||||
|
if value = info.settings[key]?
|
||||||
|
env.push_string value
|
||||||
|
else
|
||||||
|
env.push_undefined
|
||||||
|
end
|
||||||
|
|
||||||
|
env.call_success
|
||||||
|
end
|
||||||
|
sbx.put_prop_string -2, "settings"
|
||||||
|
end
|
||||||
|
|
||||||
sbx.put_prop_string -2, "mango"
|
sbx.put_prop_string -2, "mango"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
require "uuid"
|
||||||
|
require "big"
|
||||||
|
|
||||||
|
enum FilterType
|
||||||
|
String
|
||||||
|
NumMin
|
||||||
|
NumMax
|
||||||
|
DateMin
|
||||||
|
DateMax
|
||||||
|
Array
|
||||||
|
|
||||||
|
def self.from_string(str)
|
||||||
|
case str
|
||||||
|
when "string"
|
||||||
|
String
|
||||||
|
when "number-min"
|
||||||
|
NumMin
|
||||||
|
when "number-max"
|
||||||
|
NumMax
|
||||||
|
when "date-min"
|
||||||
|
DateMin
|
||||||
|
when "date-max"
|
||||||
|
DateMax
|
||||||
|
when "array"
|
||||||
|
Array
|
||||||
|
else
|
||||||
|
raise "Unknown filter type with string #{str}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
struct Filter
|
||||||
|
include JSON::Serializable
|
||||||
|
|
||||||
|
property key : String
|
||||||
|
property value : String | Int32 | Int64 | Float32 | Nil
|
||||||
|
property type : FilterType
|
||||||
|
|
||||||
|
def initialize(@key, @value, @type)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_json(str) : Filter
|
||||||
|
json = JSON.parse str
|
||||||
|
key = json["key"].as_s
|
||||||
|
type = FilterType.from_string json["type"].as_s
|
||||||
|
_value = json["value"]
|
||||||
|
value = _value.as_s? || _value.as_i? || _value.as_i64? ||
|
||||||
|
_value.as_f32? || nil
|
||||||
|
self.new key, value, type
|
||||||
|
end
|
||||||
|
|
||||||
|
def match_chapter(obj : JSON::Any) : Bool
|
||||||
|
return true if value.nil? || value.to_s.empty?
|
||||||
|
raw_value = obj[key]
|
||||||
|
case type
|
||||||
|
when FilterType::String
|
||||||
|
raw_value.as_s.downcase == value.to_s.downcase
|
||||||
|
when FilterType::NumMin, FilterType::DateMin
|
||||||
|
BigFloat.new(raw_value.as_s) >= BigFloat.new value.not_nil!.to_f32
|
||||||
|
when FilterType::NumMax, FilterType::DateMax
|
||||||
|
BigFloat.new(raw_value.as_s) <= BigFloat.new value.not_nil!.to_f32
|
||||||
|
when FilterType::Array
|
||||||
|
return true if value == "all"
|
||||||
|
raw_value.as_s.downcase.split(",")
|
||||||
|
.map(&.strip).includes? value.to_s.downcase.strip
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# We use class instead of struct so we can update `last_checked` from
|
||||||
|
# `SubscriptionList`
|
||||||
|
class Subscription
|
||||||
|
include JSON::Serializable
|
||||||
|
|
||||||
|
property id : String
|
||||||
|
property plugin_id : String
|
||||||
|
property manga_id : String
|
||||||
|
property manga_title : String
|
||||||
|
property name : String
|
||||||
|
property created_at : Int64
|
||||||
|
property last_checked : Int64
|
||||||
|
property filters = [] of Filter
|
||||||
|
|
||||||
|
def initialize(@plugin_id, @manga_id, @manga_title, @name)
|
||||||
|
@id = UUID.random.to_s
|
||||||
|
@created_at = Time.utc.to_unix
|
||||||
|
@last_checked = Time.utc.to_unix
|
||||||
|
end
|
||||||
|
|
||||||
|
def match_chapter(obj : JSON::Any) : Bool
|
||||||
|
filters.all? &.match_chapter(obj)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
struct SubscriptionList
|
||||||
|
@dir : String
|
||||||
|
@path : String
|
||||||
|
|
||||||
|
getter ary = [] of Subscription
|
||||||
|
|
||||||
|
forward_missing_to @ary
|
||||||
|
|
||||||
|
def initialize(@dir)
|
||||||
|
@path = Path[@dir, "subscriptions.json"].to_s
|
||||||
|
if File.exists? @path
|
||||||
|
@ary = Array(Subscription).from_json File.read @path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def save
|
||||||
|
File.write @path, @ary.to_pretty_json
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
class Plugin
|
||||||
|
class Updater
|
||||||
|
use_default
|
||||||
|
|
||||||
|
def initialize
|
||||||
|
interval = Config.current.plugin_update_interval_hours
|
||||||
|
return if interval <= 0
|
||||||
|
spawn do
|
||||||
|
loop do
|
||||||
|
Plugin.list.map(&.["id"]).each do |pid|
|
||||||
|
check_updates pid
|
||||||
|
end
|
||||||
|
sleep interval.hours
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_updates(plugin_id : String)
|
||||||
|
Logger.debug "Checking plugin #{plugin_id} for updates"
|
||||||
|
|
||||||
|
plugin = Plugin.new plugin_id
|
||||||
|
if plugin.info.version == 1
|
||||||
|
Logger.debug "Plugin #{plugin_id} is targeting API version 1. " \
|
||||||
|
"Skipping update check"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
subscriptions = plugin.list_subscriptions_raw
|
||||||
|
subscriptions.each do |sub|
|
||||||
|
check_subscription plugin, sub
|
||||||
|
end
|
||||||
|
subscriptions.save
|
||||||
|
rescue e
|
||||||
|
Logger.error "Error checking plugin #{plugin_id} for updates: " \
|
||||||
|
"#{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_subscription(plugin : Plugin, sub : Subscription)
|
||||||
|
Logger.debug "Checking subscription #{sub.name} for updates"
|
||||||
|
matches = plugin.new_chapters(sub.manga_id, sub.last_checked)
|
||||||
|
.as_a.select do |chapter|
|
||||||
|
sub.match_chapter chapter
|
||||||
|
end
|
||||||
|
if matches.empty?
|
||||||
|
Logger.debug "No new chapters found."
|
||||||
|
sub.last_checked = Time.utc.to_unix
|
||||||
|
return
|
||||||
|
end
|
||||||
|
Logger.debug "Found #{matches.size} new chapters. " \
|
||||||
|
"Pushing to download queue"
|
||||||
|
jobs = matches.map { |ch|
|
||||||
|
Queue::Job.new(
|
||||||
|
"#{plugin.info.id}-#{Base64.encode ch["id"].as_s}",
|
||||||
|
"", # manga_id
|
||||||
|
ch["title"].as_s,
|
||||||
|
sub.manga_title,
|
||||||
|
Queue::JobStatus::Pending,
|
||||||
|
Time.utc
|
||||||
|
)
|
||||||
|
}
|
||||||
|
inserted_count = Queue.default.push jobs
|
||||||
|
Logger.info "#{inserted_count}/#{matches.size} new chapters added " \
|
||||||
|
"to the download queue. Plugin ID #{plugin.info.id}, " \
|
||||||
|
"subscription name #{sub.name}"
|
||||||
|
if inserted_count != matches.size
|
||||||
|
Logger.error "Failed to add #{matches.size - inserted_count} " \
|
||||||
|
"chapters to download queue"
|
||||||
|
end
|
||||||
|
sub.last_checked = Time.utc.to_unix
|
||||||
|
rescue e
|
||||||
|
Logger.error "Error when checking updates for subscription " \
|
||||||
|
"#{sub.name}: #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+112
-63
@@ -70,7 +70,13 @@ class Queue
|
|||||||
ary = @id.split("-")
|
ary = @id.split("-")
|
||||||
if ary.size == 2
|
if ary.size == 2
|
||||||
@plugin_id = ary[0]
|
@plugin_id = ary[0]
|
||||||
@plugin_chapter_id = ary[1]
|
# This begin-rescue block is for backward compatibility. In earlier
|
||||||
|
# versions we didn't encode the chapter ID
|
||||||
|
@plugin_chapter_id = begin
|
||||||
|
Base64.decode_string ary[1]
|
||||||
|
rescue
|
||||||
|
ary[1]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -112,29 +118,31 @@ class Queue
|
|||||||
use_default
|
use_default
|
||||||
|
|
||||||
def initialize(db_path : String? = nil)
|
def initialize(db_path : String? = nil)
|
||||||
@path = db_path || Config.current.mangadex["download_queue_db_path"].to_s
|
@path = db_path || Config.current.queue_db_path.to_s
|
||||||
dir = File.dirname @path
|
dir = File.dirname @path
|
||||||
unless Dir.exists? dir
|
unless Dir.exists? dir
|
||||||
Logger.info "The queue DB directory #{dir} does not exist. " \
|
Logger.info "The queue DB directory #{dir} does not exist. " \
|
||||||
"Attepmting to create it"
|
"Attepmting to create it"
|
||||||
Dir.mkdir_p dir
|
Dir.mkdir_p dir
|
||||||
end
|
end
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
MainFiber.run do
|
||||||
begin
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
db.exec "create table if not exists queue " \
|
begin
|
||||||
"(id text, manga_id text, title text, manga_title " \
|
db.exec "create table if not exists queue " \
|
||||||
"text, status integer, status_message text, " \
|
"(id text, manga_id text, title text, manga_title " \
|
||||||
"pages integer, success_count integer, " \
|
"text, status integer, status_message text, " \
|
||||||
"fail_count integer, time integer)"
|
"pages integer, success_count integer, " \
|
||||||
db.exec "create unique index if not exists id_idx " \
|
"fail_count integer, time integer)"
|
||||||
"on queue (id)"
|
db.exec "create unique index if not exists id_idx " \
|
||||||
db.exec "create index if not exists manga_id_idx " \
|
"on queue (id)"
|
||||||
"on queue (manga_id)"
|
db.exec "create index if not exists manga_id_idx " \
|
||||||
db.exec "create index if not exists status_idx " \
|
"on queue (manga_id)"
|
||||||
"on queue (status)"
|
db.exec "create index if not exists status_idx " \
|
||||||
rescue e
|
"on queue (status)"
|
||||||
Logger.error "Error when checking tables in DB: #{e}"
|
rescue e
|
||||||
raise e
|
Logger.error "Error when checking tables in DB: #{e}"
|
||||||
|
raise e
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -143,23 +151,27 @@ class Queue
|
|||||||
# inserted. Any job already exists in the queue will be ignored.
|
# inserted. Any job already exists in the queue will be ignored.
|
||||||
def push(jobs : Array(Job))
|
def push(jobs : Array(Job))
|
||||||
start_count = self.count
|
start_count = self.count
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
MainFiber.run do
|
||||||
jobs.each do |job|
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
db.exec "insert or ignore into queue values " \
|
jobs.each do |job|
|
||||||
"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
db.exec "insert or ignore into queue values " \
|
||||||
job.id, job.manga_id, job.title, job.manga_title,
|
"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
job.status.to_i, job.status_message, job.pages,
|
job.id, job.manga_id, job.title, job.manga_title,
|
||||||
job.success_count, job.fail_count, job.time.to_unix_ms
|
job.status.to_i, job.status_message, job.pages,
|
||||||
|
job.success_count, job.fail_count, job.time.to_unix_ms
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
self.count - start_count
|
self.count - start_count
|
||||||
end
|
end
|
||||||
|
|
||||||
def reset(id : String)
|
def reset(id : String)
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
MainFiber.run do
|
||||||
db.exec "update queue set status = 0, status_message = '', " \
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
"pages = 0, success_count = 0, fail_count = 0 " \
|
db.exec "update queue set status = 0, status_message = '', " \
|
||||||
"where id = (?)", id
|
"pages = 0, success_count = 0, fail_count = 0 " \
|
||||||
|
"where id = (?)", id
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -169,16 +181,20 @@ class Queue
|
|||||||
|
|
||||||
# Reset all failed tasks (missing pages and error)
|
# Reset all failed tasks (missing pages and error)
|
||||||
def reset
|
def reset
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
MainFiber.run do
|
||||||
db.exec "update queue set status = 0, status_message = '', " \
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
"pages = 0, success_count = 0, fail_count = 0 " \
|
db.exec "update queue set status = 0, status_message = '', " \
|
||||||
"where status = 2 or status = 4"
|
"pages = 0, success_count = 0, fail_count = 0 " \
|
||||||
|
"where status = 2 or status = 4"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def delete(id : String)
|
def delete(id : String)
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
MainFiber.run do
|
||||||
db.exec "delete from queue where id = (?)", id
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
db.exec "delete from queue where id = (?)", id
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -186,72 +202,105 @@ class Queue
|
|||||||
self.delete job.id
|
self.delete job.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def exists?(id : String)
|
||||||
|
res = false
|
||||||
|
MainFiber.run do
|
||||||
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
res = db.query_one "select count(*) from queue where id = (?)", id,
|
||||||
|
as: Bool
|
||||||
|
end
|
||||||
|
end
|
||||||
|
res
|
||||||
|
end
|
||||||
|
|
||||||
|
def exists?(job : Job)
|
||||||
|
self.exists? job.id
|
||||||
|
end
|
||||||
|
|
||||||
def delete_status(status : JobStatus)
|
def delete_status(status : JobStatus)
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
MainFiber.run do
|
||||||
db.exec "delete from queue where status = (?)", status.to_i
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
db.exec "delete from queue where status = (?)", status.to_i
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def count_status(status : JobStatus)
|
def count_status(status : JobStatus)
|
||||||
num = 0
|
num = 0
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
MainFiber.run do
|
||||||
num = db.query_one "select count(*) from queue where " \
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
"status = (?)", status.to_i, as: Int32
|
num = db.query_one "select count(*) from queue where " \
|
||||||
|
"status = (?)", status.to_i, as: Int32
|
||||||
|
end
|
||||||
end
|
end
|
||||||
num
|
num
|
||||||
end
|
end
|
||||||
|
|
||||||
def count
|
def count
|
||||||
num = 0
|
num = 0
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
MainFiber.run do
|
||||||
num = db.query_one "select count(*) from queue", as: Int32
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
num = db.query_one "select count(*) from queue", as: Int32
|
||||||
|
end
|
||||||
end
|
end
|
||||||
num
|
num
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_status(status : JobStatus, job : Job)
|
def set_status(status : JobStatus, job : Job)
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
MainFiber.run do
|
||||||
db.exec "update queue set status = (?) where id = (?)",
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
status.to_i, job.id
|
db.exec "update queue set status = (?) where id = (?)",
|
||||||
|
status.to_i, job.id
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_all
|
def get_all
|
||||||
jobs = [] of Job
|
jobs = [] of Job
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
MainFiber.run do
|
||||||
jobs = db.query_all "select * from queue order by time" do |rs|
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
Job.from_query_result rs
|
jobs = db.query_all "select * from queue order by time" do |rs|
|
||||||
|
Job.from_query_result rs
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
jobs
|
jobs
|
||||||
end
|
end
|
||||||
|
|
||||||
def add_success(job : Job)
|
def add_success(job : Job)
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
MainFiber.run do
|
||||||
db.exec "update queue set success_count = success_count + 1 " \
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
"where id = (?)", job.id
|
db.exec "update queue set success_count = success_count + 1 " \
|
||||||
|
"where id = (?)", job.id
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def add_fail(job : Job)
|
def add_fail(job : Job)
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
MainFiber.run do
|
||||||
db.exec "update queue set fail_count = fail_count + 1 " \
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
"where id = (?)", job.id
|
db.exec "update queue set fail_count = fail_count + 1 " \
|
||||||
|
"where id = (?)", job.id
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_pages(pages : Int32, job : Job)
|
def set_pages(pages : Int32, job : Job)
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
MainFiber.run do
|
||||||
db.exec "update queue set pages = (?), success_count = 0, " \
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
"fail_count = 0 where id = (?)", pages, job.id
|
db.exec "update queue set pages = (?), success_count = 0, " \
|
||||||
|
"fail_count = 0 where id = (?)", pages, job.id
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def add_message(msg : String, job : Job)
|
def add_message(msg : String, job : Job)
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
MainFiber.run do
|
||||||
db.exec "update queue set status_message = " \
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
"status_message || (?) || (?) where id = (?)",
|
db.exec "update queue set status_message = " \
|
||||||
"\n", msg, job.id
|
"status_message || (?) || (?) where id = (?)",
|
||||||
|
"\n", msg, job.id
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -260,12 +309,12 @@ class Queue
|
|||||||
end
|
end
|
||||||
|
|
||||||
def pause
|
def pause
|
||||||
@downloaders.each { |d| d.stopped = true }
|
@downloaders.each &.stopped=(true)
|
||||||
@paused = true
|
@paused = true
|
||||||
end
|
end
|
||||||
|
|
||||||
def resume
|
def resume
|
||||||
@downloaders.each { |d| d.stopped = false }
|
@downloaders.each &.stopped=(false)
|
||||||
@paused = false
|
@paused = false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+10
-6
@@ -35,15 +35,15 @@ module Rename
|
|||||||
|
|
||||||
class Group < Base(Pattern | String)
|
class Group < Base(Pattern | String)
|
||||||
def render(hash : VHash)
|
def render(hash : VHash)
|
||||||
return "" if @ary.select(&.is_a? Pattern)
|
return "" if @ary.select(Pattern)
|
||||||
.any? &.as(Pattern).render(hash).empty?
|
.any? &.as(Pattern).render(hash).empty?
|
||||||
@ary.map do |e|
|
@ary.join do |e|
|
||||||
if e.is_a? Pattern
|
if e.is_a? Pattern
|
||||||
e.render hash
|
e.render hash
|
||||||
else
|
else
|
||||||
e
|
e
|
||||||
end
|
end
|
||||||
end.join
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -129,19 +129,23 @@ module Rename
|
|||||||
end
|
end
|
||||||
|
|
||||||
def render(hash : VHash)
|
def render(hash : VHash)
|
||||||
str = @ary.map do |e|
|
str = @ary.join do |e|
|
||||||
if e.is_a? String
|
if e.is_a? String
|
||||||
e
|
e
|
||||||
else
|
else
|
||||||
e.render hash
|
e.render hash
|
||||||
end
|
end
|
||||||
end.join.strip
|
end.strip
|
||||||
post_process str
|
post_process str
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Post-processes the generated file/folder name
|
||||||
|
# - Handles the rare case where the string is `..`
|
||||||
|
# - Removes trailing spaces and periods
|
||||||
|
# - Replace illegal characters with `_`
|
||||||
private def post_process(str)
|
private def post_process(str)
|
||||||
return "_" if str == ".."
|
return "_" if str == ".."
|
||||||
str.gsub "/", "_"
|
str.rstrip(" .").gsub /[\/?<>\\:*|"^]/, "_"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+21
-11
@@ -1,25 +1,28 @@
|
|||||||
require "./router"
|
require "sanitize"
|
||||||
|
|
||||||
class AdminRouter < Router
|
struct AdminRouter
|
||||||
def initialize
|
def initialize
|
||||||
get "/admin" do |env|
|
get "/admin" do |env|
|
||||||
|
storage = Storage.default
|
||||||
|
missing_count = storage.missing_titles.size +
|
||||||
|
storage.missing_entries.size
|
||||||
layout "admin"
|
layout "admin"
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/admin/user" do |env|
|
get "/admin/user" do |env|
|
||||||
users = @context.storage.list_users
|
users = Storage.default.list_users
|
||||||
username = get_username env
|
username = get_username env
|
||||||
layout "user"
|
layout "user"
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/admin/user/edit" do |env|
|
get "/admin/user/edit" do |env|
|
||||||
username = env.params.query["username"]?
|
sanitizer = Sanitize::Policy::Text.new
|
||||||
|
username = env.params.query["username"]?.try { |s| sanitizer.process s }
|
||||||
admin = env.params.query["admin"]?
|
admin = env.params.query["admin"]?
|
||||||
if admin
|
if admin
|
||||||
admin = admin == "true"
|
admin = admin == "true"
|
||||||
end
|
end
|
||||||
error = env.params.query["error"]?
|
error = env.params.query["error"]?.try { |s| sanitizer.process s }
|
||||||
current_user = get_username env
|
|
||||||
new_user = username.nil? && admin.nil?
|
new_user = username.nil? && admin.nil?
|
||||||
layout "user-edit"
|
layout "user-edit"
|
||||||
end
|
end
|
||||||
@@ -32,11 +35,11 @@ class AdminRouter < Router
|
|||||||
# would not contain `admin`
|
# would not contain `admin`
|
||||||
admin = !env.params.body["admin"]?.nil?
|
admin = !env.params.body["admin"]?.nil?
|
||||||
|
|
||||||
@context.storage.new_user username, password, admin
|
Storage.default.new_user username, password, admin
|
||||||
|
|
||||||
redirect env, "/admin/user"
|
redirect env, "/admin/user"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
redirect_url = URI.new \
|
redirect_url = URI.new \
|
||||||
path: "/admin/user/edit",
|
path: "/admin/user/edit",
|
||||||
query: hash_to_query({"error" => e.message})
|
query: hash_to_query({"error" => e.message})
|
||||||
@@ -51,12 +54,12 @@ class AdminRouter < Router
|
|||||||
admin = !env.params.body["admin"]?.nil?
|
admin = !env.params.body["admin"]?.nil?
|
||||||
original_username = env.params.url["original_username"]
|
original_username = env.params.url["original_username"]
|
||||||
|
|
||||||
@context.storage.update_user \
|
Storage.default.update_user \
|
||||||
original_username, username, password, admin
|
original_username, username, password, admin
|
||||||
|
|
||||||
redirect env, "/admin/user"
|
redirect env, "/admin/user"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
redirect_url = URI.new \
|
redirect_url = URI.new \
|
||||||
path: "/admin/user/edit",
|
path: "/admin/user/edit",
|
||||||
query: hash_to_query({"username" => original_username, \
|
query: hash_to_query({"username" => original_username, \
|
||||||
@@ -65,8 +68,15 @@ class AdminRouter < Router
|
|||||||
end
|
end
|
||||||
|
|
||||||
get "/admin/downloads" do |env|
|
get "/admin/downloads" do |env|
|
||||||
mangadex_base_url = Config.current.mangadex["base_url"]
|
|
||||||
layout "download-manager"
|
layout "download-manager"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
get "/admin/subscriptions" do |env|
|
||||||
|
layout "subscription-manager"
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/admin/missing" do |env|
|
||||||
|
layout "missing-items"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+1208
-81
File diff suppressed because it is too large
Load Diff
+73
-36
@@ -1,6 +1,4 @@
|
|||||||
require "./router"
|
struct MainRouter
|
||||||
|
|
||||||
class MainRouter < Router
|
|
||||||
def initialize
|
def initialize
|
||||||
get "/login" do |env|
|
get "/login" do |env|
|
||||||
base_url = Config.current.base_url
|
base_url = Config.current.base_url
|
||||||
@@ -11,7 +9,7 @@ class MainRouter < Router
|
|||||||
begin
|
begin
|
||||||
env.session.delete_string "token"
|
env.session.delete_string "token"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error "Error when attempting to log out: #{e}"
|
Logger.error "Error when attempting to log out: #{e}"
|
||||||
ensure
|
ensure
|
||||||
redirect env, "/login"
|
redirect env, "/login"
|
||||||
end
|
end
|
||||||
@@ -21,7 +19,7 @@ class MainRouter < Router
|
|||||||
begin
|
begin
|
||||||
username = env.params.body["username"]
|
username = env.params.body["username"]
|
||||||
password = env.params.body["password"]
|
password = env.params.body["password"]
|
||||||
token = @context.storage.verify_user(username, password).not_nil!
|
token = Storage.default.verify_user(username, password).not_nil!
|
||||||
|
|
||||||
env.session.string "token", token
|
env.session.string "token", token
|
||||||
|
|
||||||
@@ -32,7 +30,8 @@ class MainRouter < Router
|
|||||||
else
|
else
|
||||||
redirect env, "/"
|
redirect env, "/"
|
||||||
end
|
end
|
||||||
rescue
|
rescue e
|
||||||
|
Logger.error e
|
||||||
redirect env, "/login"
|
redirect env, "/login"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -41,58 +40,49 @@ class MainRouter < Router
|
|||||||
begin
|
begin
|
||||||
username = get_username env
|
username = get_username env
|
||||||
|
|
||||||
sort_opt = SortOptions.from_info_json @context.library.dir, username
|
sort_opt = SortOptions.from_info_json Library.default.dir, username
|
||||||
get_sort_opt
|
get_and_save_sort_opt Library.default.dir
|
||||||
|
|
||||||
titles = @context.library.sorted_titles username, sort_opt
|
titles = Library.default.sorted_titles username, sort_opt
|
||||||
percentage = titles.map &.load_percentage username
|
percentage = titles.map &.load_percentage username
|
||||||
|
|
||||||
layout "library"
|
layout "library"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/book/:title" do |env|
|
get "/book/:title" do |env|
|
||||||
begin
|
begin
|
||||||
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
title = (Library.default.get_title env.params.url["title"]).not_nil!
|
||||||
username = get_username env
|
username = get_username env
|
||||||
|
|
||||||
sort_opt = SortOptions.from_info_json title.dir, username
|
sort_opt = SortOptions.from_info_json title.dir, username
|
||||||
get_sort_opt
|
get_and_save_sort_opt title.dir
|
||||||
|
|
||||||
|
sorted_titles = title.sorted_titles username, sort_opt
|
||||||
entries = title.sorted_entries username, sort_opt
|
entries = title.sorted_entries username, sort_opt
|
||||||
|
|
||||||
percentage = title.load_percentage_for_all_entries username, sort_opt
|
percentage = title.load_percentage_for_all_entries username, sort_opt
|
||||||
title_percentage = title.titles.map &.load_percentage username
|
title_percentage = title.titles.map &.load_percentage username
|
||||||
|
title_percentage_map = {} of String => Float64
|
||||||
|
title_percentage.each_with_index do |tp, i|
|
||||||
|
t = title.titles[i]
|
||||||
|
title_percentage_map[t.id] = tp
|
||||||
|
end
|
||||||
|
|
||||||
layout "title"
|
layout "title"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/download" do |env|
|
|
||||||
mangadex_base_url = Config.current.mangadex["base_url"]
|
|
||||||
layout "download"
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/download/plugins" do |env|
|
get "/download/plugins" do |env|
|
||||||
begin
|
begin
|
||||||
id = env.params.query["plugin"]?
|
|
||||||
plugins = Plugin.list
|
|
||||||
plugin = nil
|
|
||||||
|
|
||||||
if id
|
|
||||||
plugin = Plugin.new id
|
|
||||||
elsif !plugins.empty?
|
|
||||||
plugin = Plugin.new plugins[0][:id]
|
|
||||||
end
|
|
||||||
|
|
||||||
layout "plugin-download"
|
layout "plugin-download"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -100,17 +90,64 @@ class MainRouter < Router
|
|||||||
get "/" do |env|
|
get "/" do |env|
|
||||||
begin
|
begin
|
||||||
username = get_username env
|
username = get_username env
|
||||||
continue_reading = @context
|
continue_reading = Library.default
|
||||||
.library.get_continue_reading_entries username
|
.get_continue_reading_entries username
|
||||||
recently_added = @context.library.get_recently_added_entries username
|
recently_added = Library.default.get_recently_added_entries username
|
||||||
titles = @context.library.titles
|
start_reading = Library.default.get_start_reading_titles username
|
||||||
new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
|
titles = Library.default.titles
|
||||||
|
new_user = !titles.any? &.load_percentage(username).> 0
|
||||||
empty_library = titles.size == 0
|
empty_library = titles.size == 0
|
||||||
layout "home"
|
layout "home"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
get "/tags/:tag" do |env|
|
||||||
|
begin
|
||||||
|
username = get_username env
|
||||||
|
tag = env.params.url["tag"]
|
||||||
|
|
||||||
|
sort_opt = SortOptions.new
|
||||||
|
get_sort_opt
|
||||||
|
|
||||||
|
title_ids = Storage.default.get_tag_titles tag
|
||||||
|
|
||||||
|
raise "Tag #{tag} not found" if title_ids.empty?
|
||||||
|
|
||||||
|
titles = title_ids.map { |id| Library.default.get_title id }
|
||||||
|
.select Title
|
||||||
|
|
||||||
|
titles = sort_titles titles, sort_opt, username
|
||||||
|
percentage = titles.map &.load_percentage username
|
||||||
|
|
||||||
|
layout "tag"
|
||||||
|
rescue e
|
||||||
|
Logger.error e
|
||||||
|
env.response.status_code = 404
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/tags" do |env|
|
||||||
|
tags = Storage.default.list_tags.map do |tag|
|
||||||
|
{
|
||||||
|
tag: tag,
|
||||||
|
encoded_tag: URI.encode_www_form(tag, space_to_plus: false),
|
||||||
|
count: Storage.default.get_tag_titles(tag).size,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
# Sort by :count reversly, and then sort by :tag
|
||||||
|
tags.sort! do |a, b|
|
||||||
|
(b[:count] <=> a[:count]).or(a[:tag] <=> b[:tag])
|
||||||
|
end
|
||||||
|
|
||||||
|
layout "tags"
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/api" do |env|
|
||||||
|
base_url = Config.current.base_url
|
||||||
|
render "src/views/api.html.ecr"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+4
-18
@@ -1,30 +1,16 @@
|
|||||||
require "./router"
|
struct OPDSRouter
|
||||||
|
|
||||||
class OPDSRouter < Router
|
|
||||||
def initialize
|
def initialize
|
||||||
get "/opds" do |env|
|
get "/opds" do |env|
|
||||||
titles = @context.library.titles
|
titles = Library.default.titles
|
||||||
render_xml "src/views/opds/index.xml.ecr"
|
render_xml "src/views/opds/index.xml.ecr"
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/opds/book/:title_id" do |env|
|
get "/opds/book/:title_id" do |env|
|
||||||
begin
|
begin
|
||||||
title = @context.library.get_title(env.params.url["title_id"]).not_nil!
|
title = Library.default.get_title(env.params.url["title_id"]).not_nil!
|
||||||
render_xml "src/views/opds/title.xml.ecr"
|
render_xml "src/views/opds/title.xml.ecr"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
env.response.status_code = 404
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
get "/opds/download/:title/:entry" do |env|
|
|
||||||
begin
|
|
||||||
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
|
||||||
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
|
||||||
|
|
||||||
send_attachment env, entry.zip_path
|
|
||||||
rescue e
|
|
||||||
@context.error e
|
|
||||||
env.response.status_code = 404
|
env.response.status_code = 404
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+27
-35
@@ -1,29 +1,23 @@
|
|||||||
require "./router"
|
struct ReaderRouter
|
||||||
|
|
||||||
class ReaderRouter < Router
|
|
||||||
def initialize
|
def initialize
|
||||||
get "/reader/:title/:entry" do |env|
|
get "/reader/:title/:entry" do |env|
|
||||||
begin
|
begin
|
||||||
username = get_username env
|
username = get_username env
|
||||||
|
|
||||||
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
title = (Library.default.get_title env.params.url["title"]).not_nil!
|
||||||
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
||||||
|
|
||||||
next layout "reader-error" if entry.err_msg
|
next layout "reader-error" if entry.err_msg
|
||||||
|
|
||||||
# load progress
|
# load progress
|
||||||
page = entry.load_progress username
|
page_idx = [1, entry.load_progress username].max
|
||||||
# we go back 2 * `IMGS_PER_PAGE` pages. the infinite scroll
|
|
||||||
# library perloads a few pages in advance, and the user
|
|
||||||
# might not have actually read them
|
|
||||||
page = [page - 2 * IMGS_PER_PAGE, 1].max
|
|
||||||
|
|
||||||
# start from page 1 if the user has finished reading the entry
|
# start from page 1 if the user has finished reading the entry
|
||||||
page = 1 if entry.finished? username
|
page_idx = 1 if entry.finished? username
|
||||||
|
|
||||||
redirect env, "/reader/#{title.id}/#{entry.id}/#{page}"
|
redirect env, "/reader/#{title.id}/#{entry.id}/#{page_idx}"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
env.response.status_code = 404
|
env.response.status_code = 404
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -32,36 +26,34 @@ class ReaderRouter < Router
|
|||||||
begin
|
begin
|
||||||
base_url = Config.current.base_url
|
base_url = Config.current.base_url
|
||||||
|
|
||||||
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
|
||||||
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
|
||||||
page = env.params.url["page"].to_i
|
|
||||||
raise "" if page > entry.pages || page <= 0
|
|
||||||
|
|
||||||
# save progress
|
|
||||||
username = get_username env
|
username = get_username env
|
||||||
entry.save_progress username, page
|
|
||||||
|
|
||||||
pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min)
|
title = (Library.default.get_title env.params.url["title"]).not_nil!
|
||||||
urls = pages.map { |idx|
|
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
||||||
"#{base_url}api/page/#{title.id}/#{entry.id}/#{idx}"
|
|
||||||
}
|
sort_opt = SortOptions.from_info_json title.dir, username
|
||||||
reader_urls = pages.map { |idx|
|
get_sort_opt
|
||||||
"#{base_url}reader/#{title.id}/#{entry.id}/#{idx}"
|
entries = title.sorted_entries username, sort_opt
|
||||||
}
|
|
||||||
next_page = page + IMGS_PER_PAGE
|
page_idx = env.params.url["page"].to_i
|
||||||
next_url = next_entry_url = nil
|
if page_idx > entry.pages || page_idx <= 0
|
||||||
exit_url = "#{base_url}book/#{title.id}"
|
raise "Page #{page_idx} not found."
|
||||||
next_entry = entry.next_entry username
|
|
||||||
unless next_page > entry.pages
|
|
||||||
next_url = "#{base_url}reader/#{title.id}/#{entry.id}/#{next_page}"
|
|
||||||
end
|
end
|
||||||
unless next_entry.nil?
|
|
||||||
next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}"
|
exit_url = "#{base_url}book/#{title.id}"
|
||||||
|
|
||||||
|
next_entry_url = entry.next_entry(username).try do |e|
|
||||||
|
"#{base_url}reader/#{title.id}/#{e.id}"
|
||||||
|
end
|
||||||
|
|
||||||
|
previous_entry_url = entry.previous_entry(username).try do |e|
|
||||||
|
"#{base_url}reader/#{title.id}/#{e.id}"
|
||||||
end
|
end
|
||||||
|
|
||||||
render "src/views/reader.html.ecr"
|
render "src/views/reader.html.ecr"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
Logger.error e
|
||||||
|
Logger.debug e.backtrace?
|
||||||
env.response.status_code = 404
|
env.response.status_code = 404
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
class Router
|
|
||||||
@context : Context = Context.default
|
|
||||||
end
|
|
||||||
+15
-29
@@ -5,34 +5,8 @@ require "./handlers/*"
|
|||||||
require "./util/*"
|
require "./util/*"
|
||||||
require "./routes/*"
|
require "./routes/*"
|
||||||
|
|
||||||
class Context
|
|
||||||
property library : Library
|
|
||||||
property storage : Storage
|
|
||||||
property queue : Queue
|
|
||||||
|
|
||||||
use_default
|
|
||||||
|
|
||||||
def initialize
|
|
||||||
@storage = Storage.default
|
|
||||||
@library = Library.default
|
|
||||||
@queue = Queue.default
|
|
||||||
end
|
|
||||||
|
|
||||||
{% for lvl in Logger::LEVELS %}
|
|
||||||
def {{lvl.id}}(msg)
|
|
||||||
Logger.{{lvl.id}} msg
|
|
||||||
end
|
|
||||||
{% end %}
|
|
||||||
end
|
|
||||||
|
|
||||||
class Server
|
class Server
|
||||||
@context : Context = Context.default
|
|
||||||
|
|
||||||
def initialize
|
def initialize
|
||||||
error 403 do |env|
|
|
||||||
message = "HTTP 403: You are not authorized to visit #{env.request.path}"
|
|
||||||
layout "message"
|
|
||||||
end
|
|
||||||
error 404 do |env|
|
error 404 do |env|
|
||||||
message = "HTTP 404: Mango cannot find the page #{env.request.path}"
|
message = "HTTP 404: Mango cannot find the page #{env.request.path}"
|
||||||
layout "message"
|
layout "message"
|
||||||
@@ -51,13 +25,24 @@ class Server
|
|||||||
APIRouter.new
|
APIRouter.new
|
||||||
OPDSRouter.new
|
OPDSRouter.new
|
||||||
|
|
||||||
|
{% for path in %w(/api/* /uploads/* /img/*) %}
|
||||||
|
options {{path}} do |env|
|
||||||
|
cors
|
||||||
|
halt env
|
||||||
|
end
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
static_headers do |response|
|
||||||
|
response.headers.add("Access-Control-Allow-Origin", "*")
|
||||||
|
end
|
||||||
|
|
||||||
Kemal.config.logging = false
|
Kemal.config.logging = false
|
||||||
add_handler LogHandler.new
|
add_handler LogHandler.new
|
||||||
add_handler AuthHandler.new @context.storage
|
add_handler AuthHandler.new
|
||||||
add_handler UploadHandler.new Config.current.upload_path
|
add_handler UploadHandler.new Config.current.upload_path
|
||||||
{% if flag?(:release) %}
|
{% if flag?(:release) %}
|
||||||
# when building for relase, embed the static files in binary
|
# when building for relase, embed the static files in binary
|
||||||
@context.debug "We are in release mode. Using embedded static files."
|
Logger.debug "We are in release mode. Using embedded static files."
|
||||||
serve_static false
|
serve_static false
|
||||||
add_handler StaticHandler.new
|
add_handler StaticHandler.new
|
||||||
{% end %}
|
{% end %}
|
||||||
@@ -71,10 +56,11 @@ class Server
|
|||||||
end
|
end
|
||||||
|
|
||||||
def start
|
def start
|
||||||
@context.debug "Starting Kemal server"
|
Logger.debug "Starting Kemal server"
|
||||||
{% if flag?(:release) %}
|
{% if flag?(:release) %}
|
||||||
Kemal.config.env = "production"
|
Kemal.config.env = "production"
|
||||||
{% end %}
|
{% end %}
|
||||||
|
Kemal.config.host_binding = Config.current.host
|
||||||
Kemal.config.port = Config.current.port
|
Kemal.config.port = Config.current.port
|
||||||
Kemal.run
|
Kemal.run
|
||||||
end
|
end
|
||||||
|
|||||||
+520
-102
@@ -3,6 +3,8 @@ require "crypto/bcrypt"
|
|||||||
require "uuid"
|
require "uuid"
|
||||||
require "base64"
|
require "base64"
|
||||||
require "./util/*"
|
require "./util/*"
|
||||||
|
require "mg"
|
||||||
|
require "../migration/*"
|
||||||
|
|
||||||
def hash_password(pw)
|
def hash_password(pw)
|
||||||
Crypto::Bcrypt::Password.create(pw).to_s
|
Crypto::Bcrypt::Password.create(pw).to_s
|
||||||
@@ -13,13 +15,16 @@ def verify_password(hash, pw)
|
|||||||
end
|
end
|
||||||
|
|
||||||
class Storage
|
class Storage
|
||||||
|
@@insert_entry_ids = [] of IDTuple
|
||||||
|
@@insert_title_ids = [] of IDTuple
|
||||||
|
|
||||||
@path : String
|
@path : String
|
||||||
@db : DB::Database?
|
@db : DB::Database?
|
||||||
@insert_ids = [] of IDTuple
|
|
||||||
|
|
||||||
alias IDTuple = NamedTuple(path: String,
|
alias IDTuple = NamedTuple(
|
||||||
|
path: String,
|
||||||
id: String,
|
id: String,
|
||||||
is_title: Bool)
|
signature: String?)
|
||||||
|
|
||||||
use_default
|
use_default
|
||||||
|
|
||||||
@@ -29,41 +34,24 @@ class Storage
|
|||||||
dir = File.dirname @path
|
dir = File.dirname @path
|
||||||
unless Dir.exists? dir
|
unless Dir.exists? dir
|
||||||
Logger.info "The DB directory #{dir} does not exist. " \
|
Logger.info "The DB directory #{dir} does not exist. " \
|
||||||
"Attepmting to create it"
|
"Attempting to create it"
|
||||||
Dir.mkdir_p dir
|
Dir.mkdir_p dir
|
||||||
end
|
end
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
MainFiber.run do
|
||||||
begin
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
# We create the `ids` table first. even if the uses has an
|
begin
|
||||||
# early version installed and has the `user` table only,
|
MG::Migration.new(db, log: Logger.default.raw_log).migrate
|
||||||
# we will still be able to create `ids`
|
rescue e
|
||||||
db.exec "create table ids" \
|
Logger.fatal "DB migration failed. #{e}"
|
||||||
"(path text, id text, is_title integer)"
|
|
||||||
db.exec "create unique index path_idx on ids (path)"
|
|
||||||
db.exec "create unique index id_idx on ids (id)"
|
|
||||||
|
|
||||||
db.exec "create table users" \
|
|
||||||
"(username text, password text, token text, admin integer)"
|
|
||||||
rescue e
|
|
||||||
unless e.message.not_nil!.ends_with? "already exists"
|
|
||||||
Logger.fatal "Error when checking tables in DB: #{e}"
|
|
||||||
raise e
|
raise e
|
||||||
end
|
end
|
||||||
|
|
||||||
# If the DB is initialized through CLI but no user is added, we need
|
|
||||||
# to create the admin user when first starting the app
|
|
||||||
user_count = db.query_one "select count(*) from users", as: Int32
|
user_count = db.query_one "select count(*) from users", as: Int32
|
||||||
init_admin if init_user && user_count == 0
|
init_admin if init_user && user_count == 0
|
||||||
else
|
|
||||||
Logger.debug "Creating DB file at #{@path}"
|
|
||||||
db.exec "create unique index username_idx on users (username)"
|
|
||||||
db.exec "create unique index token_idx on users (token)"
|
|
||||||
|
|
||||||
init_admin if init_user
|
|
||||||
end
|
end
|
||||||
end
|
unless @auto_close
|
||||||
unless @auto_close
|
@db = DB.open "sqlite3://#{@path}"
|
||||||
@db = DB.open "sqlite3://#{@path}"
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -79,45 +67,77 @@ class Storage
|
|||||||
private def get_db(&block : DB::Database ->)
|
private def get_db(&block : DB::Database ->)
|
||||||
if @db.nil?
|
if @db.nil?
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
|
db.exec "PRAGMA foreign_keys = 1"
|
||||||
yield db
|
yield db
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
|
@db.not_nil!.exec "PRAGMA foreign_keys = 1"
|
||||||
yield @db.not_nil!
|
yield @db.not_nil!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def verify_user(username, password)
|
def username_exists(username)
|
||||||
get_db do |db|
|
exists = false
|
||||||
begin
|
MainFiber.run do
|
||||||
hash, token = db.query_one "select password, token from " \
|
get_db do |db|
|
||||||
"users where username = (?)",
|
exists = db.query_one("select count(*) from users where " \
|
||||||
username, as: {String, String?}
|
"username = (?)", username, as: Int32) > 0
|
||||||
unless verify_password hash, password
|
|
||||||
Logger.debug "Password does not match the hash"
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
Logger.debug "User #{username} verified"
|
|
||||||
return token if token
|
|
||||||
token = random_str
|
|
||||||
Logger.debug "Updating token for #{username}"
|
|
||||||
db.exec "update users set token = (?) where username = (?)",
|
|
||||||
token, username
|
|
||||||
return token
|
|
||||||
rescue e
|
|
||||||
Logger.error "Error when verifying user #{username}: #{e}"
|
|
||||||
return nil
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
exists
|
||||||
|
end
|
||||||
|
|
||||||
|
def username_is_admin(username)
|
||||||
|
is_admin = false
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
is_admin = db.query_one("select admin from users where " \
|
||||||
|
"username = (?)", username, as: Int32) > 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
is_admin
|
||||||
|
end
|
||||||
|
|
||||||
|
def verify_user(username, password)
|
||||||
|
out_token = nil
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
begin
|
||||||
|
hash, token = db.query_one "select password, token from " \
|
||||||
|
"users where username = (?)",
|
||||||
|
username, as: {String, String?}
|
||||||
|
unless verify_password hash, password
|
||||||
|
Logger.debug "Password does not match the hash"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
Logger.debug "User #{username} verified"
|
||||||
|
if token
|
||||||
|
out_token = token
|
||||||
|
next
|
||||||
|
end
|
||||||
|
token = random_str
|
||||||
|
Logger.debug "Updating token for #{username}"
|
||||||
|
db.exec "update users set token = (?) where username = (?)",
|
||||||
|
token, username
|
||||||
|
out_token = token
|
||||||
|
rescue e
|
||||||
|
Logger.error "Error when verifying user #{username}: #{e}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
out_token
|
||||||
end
|
end
|
||||||
|
|
||||||
def verify_token(token)
|
def verify_token(token)
|
||||||
username = nil
|
username = nil
|
||||||
get_db do |db|
|
MainFiber.run do
|
||||||
begin
|
get_db do |db|
|
||||||
username = db.query_one "select username from users where " \
|
begin
|
||||||
"token = (?)", token, as: String
|
username = db.query_one "select username from users where " \
|
||||||
rescue e
|
"token = (?)", token, as: String
|
||||||
Logger.debug "Unable to verify token"
|
rescue e
|
||||||
|
Logger.debug "Unable to verify token"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
username
|
username
|
||||||
@@ -125,12 +145,14 @@ class Storage
|
|||||||
|
|
||||||
def verify_admin(token)
|
def verify_admin(token)
|
||||||
is_admin = false
|
is_admin = false
|
||||||
get_db do |db|
|
MainFiber.run do
|
||||||
begin
|
get_db do |db|
|
||||||
is_admin = db.query_one "select admin from users where " \
|
begin
|
||||||
"token = (?)", token, as: Bool
|
is_admin = db.query_one "select admin from users where " \
|
||||||
rescue e
|
"token = (?)", token, as: Bool
|
||||||
Logger.debug "Unable to verify user as admin"
|
rescue e
|
||||||
|
Logger.debug "Unable to verify user as admin"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
is_admin
|
is_admin
|
||||||
@@ -138,10 +160,12 @@ class Storage
|
|||||||
|
|
||||||
def list_users
|
def list_users
|
||||||
results = Array(Tuple(String, Bool)).new
|
results = Array(Tuple(String, Bool)).new
|
||||||
get_db do |db|
|
MainFiber.run do
|
||||||
db.query "select username, admin from users" do |rs|
|
get_db do |db|
|
||||||
rs.each do
|
db.query "select username, admin from users" do |rs|
|
||||||
results << {rs.read(String), rs.read(Bool)}
|
rs.each do
|
||||||
|
results << {rs.read(String), rs.read(Bool)}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -152,10 +176,12 @@ class Storage
|
|||||||
validate_username username
|
validate_username username
|
||||||
validate_password password
|
validate_password password
|
||||||
admin = (admin ? 1 : 0)
|
admin = (admin ? 1 : 0)
|
||||||
get_db do |db|
|
MainFiber.run do
|
||||||
hash = hash_password password
|
get_db do |db|
|
||||||
db.exec "insert into users values (?, ?, ?, ?)",
|
hash = hash_password password
|
||||||
username, hash, nil, admin
|
db.exec "insert into users values (?, ?, ?, ?)",
|
||||||
|
username, hash, nil, admin
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -163,63 +189,455 @@ class Storage
|
|||||||
admin = (admin ? 1 : 0)
|
admin = (admin ? 1 : 0)
|
||||||
validate_username username
|
validate_username username
|
||||||
validate_password password unless password.empty?
|
validate_password password unless password.empty?
|
||||||
get_db do |db|
|
MainFiber.run do
|
||||||
if password.empty?
|
get_db do |db|
|
||||||
db.exec "update users set username = (?), admin = (?) " \
|
if password.empty?
|
||||||
"where username = (?)",
|
db.exec "update users set username = (?), admin = (?) " \
|
||||||
username, admin, original_username
|
"where username = (?)",
|
||||||
else
|
username, admin, original_username
|
||||||
hash = hash_password password
|
else
|
||||||
db.exec "update users set username = (?), admin = (?)," \
|
hash = hash_password password
|
||||||
"password = (?) where username = (?)",
|
db.exec "update users set username = (?), admin = (?)," \
|
||||||
username, admin, hash, original_username
|
"password = (?) where username = (?)",
|
||||||
|
username, admin, hash, original_username
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def delete_user(username)
|
def delete_user(username)
|
||||||
get_db do |db|
|
MainFiber.run do
|
||||||
db.exec "delete from users where username = (?)", username
|
get_db do |db|
|
||||||
|
db.exec "delete from users where username = (?)", username
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def logout(token)
|
def logout(token)
|
||||||
get_db do |db|
|
MainFiber.run do
|
||||||
begin
|
get_db do |db|
|
||||||
db.exec "update users set token = (?) where token = (?)", nil, token
|
begin
|
||||||
rescue
|
db.exec "update users set token = (?) where token = (?)", nil, token
|
||||||
|
rescue
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_id(path, is_title)
|
def get_title_id(path, signature)
|
||||||
id = nil
|
id = nil
|
||||||
get_db do |db|
|
path = Path.new(path).relative_to(Config.current.library_path).to_s
|
||||||
id = db.query_one? "select id from ids where path = (?)", path,
|
MainFiber.run do
|
||||||
as: {String}
|
get_db do |db|
|
||||||
|
# First attempt to find the matching title in DB using BOTH path
|
||||||
|
# and signature
|
||||||
|
id = db.query_one? "select id from titles where path = (?) and " \
|
||||||
|
"signature = (?) and unavailable = 0",
|
||||||
|
path, signature.to_s, as: String
|
||||||
|
|
||||||
|
should_update = id.nil?
|
||||||
|
# If it fails, try to match using the path only. This could happen
|
||||||
|
# for example when a new entry is added to the title
|
||||||
|
id ||= db.query_one? "select id from titles where path = (?)", path,
|
||||||
|
as: String
|
||||||
|
|
||||||
|
# If it still fails, we will have to rely on the signature values.
|
||||||
|
# This could happen when the user moved or renamed the title, or
|
||||||
|
# a title containing the title
|
||||||
|
unless id
|
||||||
|
# If there are multiple rows with the same signature (this could
|
||||||
|
# happen simply by bad luck, or when the user copied a title),
|
||||||
|
# pick the row that has the most similar path to the give path
|
||||||
|
rows = [] of Tuple(String, String)
|
||||||
|
db.query "select id, path from titles where signature = (?)",
|
||||||
|
signature.to_s do |rs|
|
||||||
|
rs.each do
|
||||||
|
rows << {rs.read(String), rs.read(String)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
row = rows.max_by?(&.[1].components_similarity(path))
|
||||||
|
id = row[0] if row
|
||||||
|
end
|
||||||
|
|
||||||
|
# At this point, `id` would still be nil if there's no row matching
|
||||||
|
# either the path or the signature
|
||||||
|
|
||||||
|
# If we did identify a matching title, save the path and signature
|
||||||
|
# values back to the DB
|
||||||
|
if id && should_update
|
||||||
|
db.exec "update titles set path = (?), signature = (?), " \
|
||||||
|
"unavailable = 0 where id = (?)", path, signature.to_s, id
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
id
|
id
|
||||||
end
|
end
|
||||||
|
|
||||||
def insert_id(tp : IDTuple)
|
# See the comments in `#get_title_id` to see how this method works.
|
||||||
@insert_ids << tp
|
def get_entry_id(path, signature)
|
||||||
end
|
id = nil
|
||||||
|
path = Path.new(path).relative_to(Config.current.library_path).to_s
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
id = db.query_one? "select id from ids where path = (?) and " \
|
||||||
|
"signature = (?) and unavailable = 0",
|
||||||
|
path, signature.to_s, as: String
|
||||||
|
|
||||||
def bulk_insert_ids
|
should_update = id.nil?
|
||||||
get_db do |db|
|
id ||= db.query_one? "select id from ids where path = (?)", path,
|
||||||
db.transaction do |tx|
|
as: String
|
||||||
@insert_ids.each do |tp|
|
|
||||||
tx.connection.exec "insert into ids values (?, ?, ?)", tp[:path],
|
unless id
|
||||||
tp[:id], tp[:is_title] ? 1 : 0
|
rows = [] of Tuple(String, String)
|
||||||
|
db.query "select id, path from ids where signature = (?)",
|
||||||
|
signature.to_s do |rs|
|
||||||
|
rs.each do
|
||||||
|
rows << {rs.read(String), rs.read(String)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
row = rows.max_by?(&.[1].components_similarity(path))
|
||||||
|
id = row[0] if row
|
||||||
|
end
|
||||||
|
|
||||||
|
if id && should_update
|
||||||
|
db.exec "update ids set path = (?), signature = (?), " \
|
||||||
|
"unavailable = 0 where id = (?)", path, signature.to_s, id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@insert_ids.clear
|
id
|
||||||
|
end
|
||||||
|
|
||||||
|
def insert_entry_id(tp)
|
||||||
|
@@insert_entry_ids << tp
|
||||||
|
end
|
||||||
|
|
||||||
|
def insert_title_id(tp)
|
||||||
|
@@insert_title_ids << tp
|
||||||
|
end
|
||||||
|
|
||||||
|
def bulk_insert_ids
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.transaction do |tran|
|
||||||
|
conn = tran.connection
|
||||||
|
@@insert_title_ids.each do |tp|
|
||||||
|
path = Path.new(tp[:path])
|
||||||
|
.relative_to(Config.current.library_path).to_s
|
||||||
|
conn.exec "insert into titles (id, path, signature, " \
|
||||||
|
"unavailable) values (?, ?, ?, 0)",
|
||||||
|
tp[:id], path, tp[:signature].to_s
|
||||||
|
end
|
||||||
|
@@insert_entry_ids.each do |tp|
|
||||||
|
path = Path.new(tp[:path])
|
||||||
|
.relative_to(Config.current.library_path).to_s
|
||||||
|
conn.exec "insert into ids (id, path, signature, " \
|
||||||
|
"unavailable) values (?, ?, ?, 0)",
|
||||||
|
tp[:id], path, tp[:signature].to_s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
@@insert_entry_ids.clear
|
||||||
|
@@insert_title_ids.clear
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_title_sort_title(title_id : String)
|
||||||
|
sort_title = nil
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
sort_title =
|
||||||
|
db.query_one? "Select sort_title from titles where id = (?)",
|
||||||
|
title_id, as: String | Nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
sort_title
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_title_sort_title(title_id : String, sort_title : String | Nil)
|
||||||
|
sort_title = nil if sort_title == ""
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.exec "update titles set sort_title = (?) where id = (?)",
|
||||||
|
sort_title, title_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_entry_sort_title(entry_id : String)
|
||||||
|
sort_title = nil
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
sort_title =
|
||||||
|
db.query_one? "Select sort_title from ids where id = (?)",
|
||||||
|
entry_id, as: String | Nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
sort_title
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_entries_sort_title(ids : Array(String))
|
||||||
|
results = Hash(String, String | Nil).new
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.query "select id, sort_title from ids where id in " \
|
||||||
|
"(#{ids.join "," { |id| "'#{id}'" }})" do |rs|
|
||||||
|
rs.each do
|
||||||
|
id = rs.read String
|
||||||
|
sort_title = rs.read String | Nil
|
||||||
|
results[id] = sort_title
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
results
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_entry_sort_title(entry_id : String, sort_title : String | Nil)
|
||||||
|
sort_title = nil if sort_title == ""
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.exec "update ids set sort_title = (?) where id = (?)",
|
||||||
|
sort_title, entry_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def save_thumbnail(id : String, img : Image)
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.exec "insert into thumbnails values (?, ?, ?, ?, ?)", id, img.data,
|
||||||
|
img.filename, img.mime, img.size
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_thumbnail(id : String) : Image?
|
||||||
|
img = nil
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.query_one? "select * from thumbnails where id = (?)", id do |res|
|
||||||
|
img = Image.from_db res
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
img
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_title_tags(id : String) : Array(String)
|
||||||
|
tags = [] of String
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.query "select tag from tags where id = (?) order by tag", id do |rs|
|
||||||
|
rs.each do
|
||||||
|
tags << rs.read String
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
tags
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_tag_titles(tag : String) : Array(String)
|
||||||
|
tids = [] of String
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.query "select id from tags where tag = (?)", tag do |rs|
|
||||||
|
rs.each do
|
||||||
|
tids << rs.read String
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
tids
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_tags : Array(String)
|
||||||
|
tags = [] of String
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.query "select distinct tag from tags natural join titles " \
|
||||||
|
"where unavailable = 0" do |rs|
|
||||||
|
rs.each do
|
||||||
|
tags << rs.read String
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
tags
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_tag(id : String, tag : String)
|
||||||
|
err = nil
|
||||||
|
MainFiber.run do
|
||||||
|
begin
|
||||||
|
get_db do |db|
|
||||||
|
db.exec "insert into tags values (?, ?)", id, tag
|
||||||
|
end
|
||||||
|
rescue e
|
||||||
|
err = e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
raise err.not_nil! if err
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_tag(id : String, tag : String)
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.exec "delete from tags where id = (?) and tag = (?)", id, tag
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mark titles and entries that no longer exist on the file system as
|
||||||
|
# unavailable. By supplying `id_candidates` and `titles_candidates`, it
|
||||||
|
# only checks the existence of the candidate titles/entries to speed up
|
||||||
|
# the process.
|
||||||
|
def mark_unavailable(ids_candidates : Array(String)?,
|
||||||
|
titles_candidates : Array(String)?)
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
# Detect dangling entry IDs
|
||||||
|
trash_ids = [] of String
|
||||||
|
query = "select path, id from ids where unavailable = 0"
|
||||||
|
unless ids_candidates.nil?
|
||||||
|
query += " and id in (#{ids_candidates.join "," { |i| "'#{i}'" }})"
|
||||||
|
end
|
||||||
|
db.query query do |rs|
|
||||||
|
rs.each do
|
||||||
|
path = rs.read String
|
||||||
|
fullpath = Path.new(path).expand(Config.current.library_path).to_s
|
||||||
|
trash_ids << rs.read String unless File.exists? fullpath
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
unless trash_ids.empty?
|
||||||
|
Logger.debug "Marking #{trash_ids.size} entries as unavailable"
|
||||||
|
end
|
||||||
|
db.exec "update ids set unavailable = 1 where id in " \
|
||||||
|
"(#{trash_ids.join "," { |i| "'#{i}'" }})"
|
||||||
|
|
||||||
|
# Detect dangling title IDs
|
||||||
|
trash_titles = [] of String
|
||||||
|
query = "select path, id from titles where unavailable = 0"
|
||||||
|
unless titles_candidates.nil?
|
||||||
|
query += " and id in (#{titles_candidates.join "," { |i| "'#{i}'" }})"
|
||||||
|
end
|
||||||
|
db.query query do |rs|
|
||||||
|
rs.each do
|
||||||
|
path = rs.read String
|
||||||
|
fullpath = Path.new(path).expand(Config.current.library_path).to_s
|
||||||
|
trash_titles << rs.read String unless Dir.exists? fullpath
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
unless trash_titles.empty?
|
||||||
|
Logger.debug "Marking #{trash_titles.size} titles as unavailable"
|
||||||
|
end
|
||||||
|
db.exec "update titles set unavailable = 1 where id in " \
|
||||||
|
"(#{trash_titles.join "," { |i| "'#{i}'" }})"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def get_missing(tablename)
|
||||||
|
ary = [] of IDTuple
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.query "select id, path, signature from #{tablename} " \
|
||||||
|
"where unavailable = 1" do |rs|
|
||||||
|
rs.each do
|
||||||
|
ary << {
|
||||||
|
id: rs.read(String),
|
||||||
|
path: rs.read(String),
|
||||||
|
signature: rs.read(String?),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
ary
|
||||||
|
end
|
||||||
|
|
||||||
|
private def delete_missing(tablename, id : String? = nil)
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
if id
|
||||||
|
db.exec "delete from #{tablename} where id = (?) " \
|
||||||
|
"and unavailable = 1", id
|
||||||
|
else
|
||||||
|
db.exec "delete from #{tablename} where unavailable = 1"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def missing_entries
|
||||||
|
get_missing "ids"
|
||||||
|
end
|
||||||
|
|
||||||
|
def missing_titles
|
||||||
|
get_missing "titles"
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_missing_entry(id = nil)
|
||||||
|
delete_missing "ids", id
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_missing_title(id = nil)
|
||||||
|
delete_missing "titles", id
|
||||||
|
end
|
||||||
|
|
||||||
|
def save_md_token(username : String, token : String, expire : Time)
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
count = db.query_one "select count(*) from md_account where " \
|
||||||
|
"username = (?)", username, as: Int64
|
||||||
|
if count == 0
|
||||||
|
db.exec "insert into md_account values (?, ?, ?)", username, token,
|
||||||
|
expire.to_unix
|
||||||
|
else
|
||||||
|
db.exec "update md_account set token = (?), expire = (?) " \
|
||||||
|
"where username = (?)", token, expire.to_unix, username
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_md_token(username) : Tuple(String?, Time?)
|
||||||
|
token = nil
|
||||||
|
expires = nil
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.query_one? "select token, expire from md_account where " \
|
||||||
|
"username = (?)", username do |res|
|
||||||
|
token = res.read String
|
||||||
|
expires = Time.unix res.read Int64
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
{token, expires}
|
||||||
|
end
|
||||||
|
|
||||||
|
def count_titles : Int32
|
||||||
|
count = 0
|
||||||
|
MainFiber.run do
|
||||||
|
get_db do |db|
|
||||||
|
db.query "select count(*) from titles" do |rs|
|
||||||
|
rs.each do
|
||||||
|
count = rs.read Int32
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
count
|
||||||
end
|
end
|
||||||
|
|
||||||
def close
|
def close
|
||||||
unless @db.nil?
|
MainFiber.run do
|
||||||
@db.not_nil!.close
|
unless @db.nil?
|
||||||
|
@db.not_nil!.close
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class ChapterSorter
|
|||||||
.select do |key|
|
.select do |key|
|
||||||
keys[key].count >= str_ary.size / 2
|
keys[key].count >= str_ary.size / 2
|
||||||
end
|
end
|
||||||
.sort do |a_key, b_key|
|
.sort! do |a_key, b_key|
|
||||||
a = keys[a_key]
|
a = keys[a_key]
|
||||||
b = keys[b_key]
|
b = keys[b_key]
|
||||||
# Sort keys by the number of times they appear
|
# Sort keys by the number of times they appear
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ end
|
|||||||
def split_by_alphanumeric(str)
|
def split_by_alphanumeric(str)
|
||||||
arr = [] of String
|
arr = [] of String
|
||||||
str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match|
|
str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match|
|
||||||
arr += match.captures.select { |s| s != "" }
|
arr += match.captures.select &.!= ""
|
||||||
end
|
end
|
||||||
arr
|
arr
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
require "http_proxy"
|
||||||
|
|
||||||
|
# Monkey-patch `HTTP::Client` to make it respect the `*_PROXY`
|
||||||
|
# environment variables
|
||||||
|
module HTTP
|
||||||
|
class Client
|
||||||
|
private def self.exec(uri : URI, tls : TLSContext = nil)
|
||||||
|
Logger.debug "Setting proxy"
|
||||||
|
previous_def uri, tls do |client, path|
|
||||||
|
client.set_proxy get_proxy uri
|
||||||
|
yield client, path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def get_proxy(uri : URI) : HTTP::Proxy::Client?
|
||||||
|
no_proxy = ENV["no_proxy"]? || ENV["NO_PROXY"]?
|
||||||
|
return if no_proxy &&
|
||||||
|
no_proxy.split(",").any? &.== uri.hostname
|
||||||
|
|
||||||
|
case uri.scheme
|
||||||
|
when "http"
|
||||||
|
env_to_proxy "http_proxy"
|
||||||
|
when "https"
|
||||||
|
env_to_proxy "https_proxy"
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def env_to_proxy(key : String) : HTTP::Proxy::Client?
|
||||||
|
val = ENV[key.downcase]? || ENV[key.upcase]?
|
||||||
|
return if val.nil?
|
||||||
|
|
||||||
|
begin
|
||||||
|
uri = URI.parse val
|
||||||
|
HTTP::Proxy::Client.new uri.hostname.not_nil!, uri.port.not_nil!,
|
||||||
|
username: uri.user, password: uri.password
|
||||||
|
rescue
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
require "./util"
|
||||||
|
|
||||||
|
class File
|
||||||
|
abstract struct Info
|
||||||
|
def inode : UInt64
|
||||||
|
@stat.st_ino.to_u64
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns the signature of the file at filename.
|
||||||
|
# When it is not a supported file, returns 0. Otherwise, uses the inode
|
||||||
|
# number as its signature. On most file systems, the inode number is
|
||||||
|
# preserved even when the file is renamed, moved or edited.
|
||||||
|
# Some cases that would cause the inode number to change:
|
||||||
|
# - Reboot/remount on some file systems
|
||||||
|
# - Replaced with a copied file
|
||||||
|
# - Moved to a different device
|
||||||
|
# Since we are also using the relative paths to match ids, we won't lose
|
||||||
|
# information as long as the above changes do not happen together with
|
||||||
|
# a file/folder rename, with no library scan in between.
|
||||||
|
def self.signature(filename) : UInt64
|
||||||
|
if ArchiveEntry.is_valid?(filename) || is_supported_image_file(filename)
|
||||||
|
File.info(filename).inode
|
||||||
|
else
|
||||||
|
0u64
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Dir
|
||||||
|
# Returns the signature of the directory at dirname. See the comments for
|
||||||
|
# `File.signature` for more information.
|
||||||
|
def self.signature(dirname) : UInt64
|
||||||
|
signatures = [File.info(dirname).inode]
|
||||||
|
self.open dirname do |dir|
|
||||||
|
dir.entries.each do |fn|
|
||||||
|
next if fn.starts_with? "."
|
||||||
|
path = File.join dirname, fn
|
||||||
|
if File.directory? path
|
||||||
|
signatures << Dir.signature path
|
||||||
|
else
|
||||||
|
_sig = File.signature path
|
||||||
|
# Only add its signature value to `signatures` when it is a
|
||||||
|
# supported file
|
||||||
|
signatures << _sig if _sig > 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
Digest::CRC32.checksum(signatures.sort.join).to_u64
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns the contents signature of the directory at dirname for checking
|
||||||
|
# to rescan.
|
||||||
|
# Rescan conditions:
|
||||||
|
# - When a file added, moved, removed, renamed (including which in nested
|
||||||
|
# directories)
|
||||||
|
def self.contents_signature(dirname, cache = {} of String => String) : String
|
||||||
|
return cache[dirname] if cache[dirname]?
|
||||||
|
Fiber.yield
|
||||||
|
signatures = [] of String
|
||||||
|
self.open dirname do |dir|
|
||||||
|
dir.entries.sort.each do |fn|
|
||||||
|
next if fn.starts_with? "."
|
||||||
|
path = File.join dirname, fn
|
||||||
|
if File.directory? path
|
||||||
|
signatures << Dir.contents_signature path, cache
|
||||||
|
else
|
||||||
|
# Only add its signature value to `signatures` when it is a
|
||||||
|
# supported file
|
||||||
|
if ArchiveEntry.is_valid?(fn) || is_supported_image_file(fn)
|
||||||
|
signatures << fn
|
||||||
|
end
|
||||||
|
end
|
||||||
|
Fiber.yield
|
||||||
|
end
|
||||||
|
end
|
||||||
|
hash = Digest::SHA1.hexdigest(signatures.join)
|
||||||
|
cache[dirname] = hash
|
||||||
|
hash
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.directory_entry_signature(dirname, cache = {} of String => String)
|
||||||
|
return cache[dirname + "?entry"] if cache[dirname + "?entry"]?
|
||||||
|
Fiber.yield
|
||||||
|
signatures = [] of String
|
||||||
|
image_files = DirEntry.sorted_image_files dirname
|
||||||
|
if image_files.size > 0
|
||||||
|
image_files.each do |path|
|
||||||
|
signatures << File.signature(path).to_s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
hash = Digest::SHA1.hexdigest(signatures.join)
|
||||||
|
cache[dirname + "?entry"] = hash
|
||||||
|
hash
|
||||||
|
end
|
||||||
|
end
|
||||||
+131
-3
@@ -1,6 +1,19 @@
|
|||||||
IMGS_PER_PAGE = 5
|
IMGS_PER_PAGE = 5
|
||||||
UPLOAD_URL_PREFIX = "/uploads"
|
ENTRIES_IN_HOME_SECTIONS = 8
|
||||||
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"]
|
UPLOAD_URL_PREFIX = "/uploads"
|
||||||
|
STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt
|
||||||
|
/manifest.json)
|
||||||
|
SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
|
||||||
|
SUPPORTED_IMG_TYPES = %w(
|
||||||
|
image/jpeg
|
||||||
|
image/png
|
||||||
|
image/webp
|
||||||
|
image/apng
|
||||||
|
image/avif
|
||||||
|
image/gif
|
||||||
|
image/svg+xml
|
||||||
|
image/jxl
|
||||||
|
)
|
||||||
|
|
||||||
def random_str
|
def random_str
|
||||||
UUID.random.to_s.gsub "-", ""
|
UUID.random.to_s.gsub "-", ""
|
||||||
@@ -21,15 +34,37 @@ end
|
|||||||
|
|
||||||
def register_mime_types
|
def register_mime_types
|
||||||
{
|
{
|
||||||
|
# Comic Archives
|
||||||
".zip" => "application/zip",
|
".zip" => "application/zip",
|
||||||
".rar" => "application/x-rar-compressed",
|
".rar" => "application/x-rar-compressed",
|
||||||
".cbz" => "application/vnd.comicbook+zip",
|
".cbz" => "application/vnd.comicbook+zip",
|
||||||
".cbr" => "application/vnd.comicbook-rar",
|
".cbr" => "application/vnd.comicbook-rar",
|
||||||
|
|
||||||
|
# Favicon
|
||||||
|
".ico" => "image/x-icon",
|
||||||
|
|
||||||
|
# FontAwesome fonts
|
||||||
|
".woff" => "font/woff",
|
||||||
|
".woff2" => "font/woff2",
|
||||||
|
|
||||||
|
# Supported image formats. JPG, PNG, GIF, WebP, and SVG are already
|
||||||
|
# defiend by Crystal in `MIME.DEFAULT_TYPES`
|
||||||
|
".apng" => "image/apng",
|
||||||
|
".avif" => "image/avif",
|
||||||
|
".jxl" => "image/jxl",
|
||||||
}.each do |k, v|
|
}.each do |k, v|
|
||||||
MIME.register k, v
|
MIME.register k, v
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def is_supported_file(path)
|
||||||
|
SUPPORTED_FILE_EXTNAMES.includes? File.extname(path).downcase
|
||||||
|
end
|
||||||
|
|
||||||
|
def is_supported_image_file(path)
|
||||||
|
SUPPORTED_IMG_TYPES.includes? MIME.from_filename? path
|
||||||
|
end
|
||||||
|
|
||||||
struct Int
|
struct Int
|
||||||
def or(other : Int)
|
def or(other : Int)
|
||||||
if self == 0
|
if self == 0
|
||||||
@@ -60,3 +95,96 @@ class String
|
|||||||
self.chars.all? { |c| c.alphanumeric? || c == '_' }
|
self.chars.all? { |c| c.alphanumeric? || c == '_' }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def env_is_true?(key : String, default : Bool = false) : Bool
|
||||||
|
val = ENV[key.upcase]? || ENV[key.downcase]?
|
||||||
|
return default unless val
|
||||||
|
val.downcase.in? "1", "true"
|
||||||
|
end
|
||||||
|
|
||||||
|
def sort_titles(titles : Array(Title), opt : SortOptions, username : String)
|
||||||
|
cache_key = SortedTitlesCacheEntry.gen_key username, titles, opt
|
||||||
|
cached_titles = LRUCache.get cache_key
|
||||||
|
return cached_titles if cached_titles.is_a? Array(Title)
|
||||||
|
|
||||||
|
case opt.method
|
||||||
|
when .time_modified?
|
||||||
|
ary = titles.sort { |a, b| (a.mtime <=> b.mtime).or \
|
||||||
|
compare_numerically a.sort_title, b.sort_title }
|
||||||
|
when .progress?
|
||||||
|
ary = titles.sort do |a, b|
|
||||||
|
(a.load_percentage(username) <=> b.load_percentage(username)).or \
|
||||||
|
compare_numerically a.sort_title, b.sort_title
|
||||||
|
end
|
||||||
|
when .title?
|
||||||
|
ary = titles.sort do |a, b|
|
||||||
|
compare_numerically a.sort_title, b.sort_title
|
||||||
|
end
|
||||||
|
else
|
||||||
|
unless opt.method.auto?
|
||||||
|
Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \
|
||||||
|
"Auto instead"
|
||||||
|
end
|
||||||
|
ary = titles.sort { |a, b| compare_numerically a.sort_title, b.sort_title }
|
||||||
|
end
|
||||||
|
|
||||||
|
ary.reverse! unless opt.not_nil!.ascend
|
||||||
|
|
||||||
|
LRUCache.set generate_cache_entry cache_key, ary
|
||||||
|
ary
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_sorted_titles_cache(titles : Array(Title),
|
||||||
|
sort_methods : Array(SortMethod),
|
||||||
|
username : String)
|
||||||
|
[false, true].each do |ascend|
|
||||||
|
sort_methods.each do |sort_method|
|
||||||
|
sorted_titles_cache_key = SortedTitlesCacheEntry.gen_key username,
|
||||||
|
titles, SortOptions.new(sort_method, ascend)
|
||||||
|
LRUCache.invalidate sorted_titles_cache_key
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class String
|
||||||
|
# Returns the similarity (in [0, 1]) of two paths.
|
||||||
|
# For the two paths, separate them into arrays of components, count the
|
||||||
|
# number of matching components backwards, and divide the count by the
|
||||||
|
# number of components of the shorter path.
|
||||||
|
def components_similarity(other : String) : Float64
|
||||||
|
s, l = [self, other]
|
||||||
|
.map { |str| Path.new(str).parts }
|
||||||
|
.sort_by! &.size
|
||||||
|
|
||||||
|
match = s.reverse.zip(l.reverse).count { |a, b| a == b }
|
||||||
|
match / s.size
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Does the followings:
|
||||||
|
# - turns space-like characters into the normal whitespaces ( )
|
||||||
|
# - strips and collapses spaces
|
||||||
|
# - removes ASCII control characters
|
||||||
|
# - replaces slashes (/) with underscores (_)
|
||||||
|
# - removes leading dots (.)
|
||||||
|
# - removes the following special characters: \:*?"<>|
|
||||||
|
#
|
||||||
|
# If the sanitized string is empty, returns a random string instead.
|
||||||
|
def sanitize_filename(str : String) : String
|
||||||
|
sanitized = str
|
||||||
|
.gsub(/\s+/, " ")
|
||||||
|
.strip
|
||||||
|
.gsub(/\//, "_")
|
||||||
|
.gsub(/^[\.\s]+/, "")
|
||||||
|
.gsub(/[\177\000-\031\\:\*\?\"<>\|]/, "")
|
||||||
|
sanitized.size > 0 ? sanitized : random_str
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_cache_and_exit(path : String)
|
||||||
|
File.delete path
|
||||||
|
Logger.fatal "Invalid library cache deleted. Mango needs to " \
|
||||||
|
"perform a full reset to recover from this. " \
|
||||||
|
"Pleae restart Mango. This is NOT a bug."
|
||||||
|
Logger.fatal "Exiting"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ def validate_username(username)
|
|||||||
if username.size < 3
|
if username.size < 3
|
||||||
raise "Username should contain at least 3 characters"
|
raise "Username should contain at least 3 characters"
|
||||||
end
|
end
|
||||||
if (username =~ /^[A-Za-z0-9_]+$/).nil?
|
if (username =~ /^[a-zA-Z_][a-zA-Z0-9_\-]*$/).nil?
|
||||||
raise "Username should contain alphanumeric characters " \
|
raise "Username can only contain alphanumeric characters, " \
|
||||||
"and underscores only"
|
"underscores, and hyphens"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+110
-16
@@ -1,38 +1,100 @@
|
|||||||
# Web related helper functions/macros
|
# Web related helper functions/macros
|
||||||
|
|
||||||
|
def is_admin?(env) : Bool
|
||||||
|
is_admin = false
|
||||||
|
if !Config.current.auth_proxy_header_name.empty? ||
|
||||||
|
Config.current.disable_login
|
||||||
|
is_admin = Storage.default.username_is_admin get_username env
|
||||||
|
end
|
||||||
|
|
||||||
|
# The token (if exists) takes precedence over other authentication methods.
|
||||||
|
if token = env.session.string? "token"
|
||||||
|
is_admin = Storage.default.verify_admin token
|
||||||
|
end
|
||||||
|
|
||||||
|
is_admin
|
||||||
|
end
|
||||||
|
|
||||||
macro layout(name)
|
macro layout(name)
|
||||||
base_url = Config.current.base_url
|
base_url = Config.current.base_url
|
||||||
|
is_admin = is_admin? env
|
||||||
begin
|
begin
|
||||||
is_admin = false
|
|
||||||
if token = env.session.string? "token"
|
|
||||||
is_admin = @context.storage.verify_admin token
|
|
||||||
end
|
|
||||||
page = {{name}}
|
page = {{name}}
|
||||||
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
|
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
|
||||||
rescue e
|
rescue e
|
||||||
message = e.to_s
|
message = e.to_s
|
||||||
@context.error message
|
Logger.error message
|
||||||
|
page = "Error"
|
||||||
render "src/views/message.html.ecr", "src/views/layout.html.ecr"
|
render "src/views/message.html.ecr", "src/views/layout.html.ecr"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
macro send_error_page(msg)
|
||||||
|
message = {{msg}}
|
||||||
|
base_url = Config.current.base_url
|
||||||
|
is_admin = is_admin? env
|
||||||
|
page = "Error"
|
||||||
|
html = render "src/views/message.html.ecr", "src/views/layout.html.ecr"
|
||||||
|
send_file env, html.to_slice, "text/html"
|
||||||
|
end
|
||||||
|
|
||||||
macro send_img(env, img)
|
macro send_img(env, img)
|
||||||
|
cors
|
||||||
send_file {{env}}, {{img}}.data, {{img}}.mime
|
send_file {{env}}, {{img}}.data, {{img}}.mime
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def get_token_from_auth_header(env) : String?
|
||||||
|
value = env.request.headers["Authorization"]
|
||||||
|
if value && value.starts_with? "Bearer"
|
||||||
|
session_id = value.split(" ")[1]
|
||||||
|
return Kemal::Session.get(session_id).try &.string? "token"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
macro get_username(env)
|
macro get_username(env)
|
||||||
# if the request gets here, it has gone through the auth handler, and
|
begin
|
||||||
# we can be sure that a valid token exists, so we can use not_nil! here
|
# Check if we can get the session id from the cookie
|
||||||
token = env.session.string "token"
|
token = env.session.string? "token"
|
||||||
(@context.storage.verify_token token).not_nil!
|
if token.nil?
|
||||||
|
# If not, check if we can get the session id from the auth header
|
||||||
|
token = get_token_from_auth_header env
|
||||||
|
end
|
||||||
|
# If we still don't have a token, we handle it in `resuce` with `not_nil!`
|
||||||
|
(Storage.default.verify_token token.not_nil!).not_nil!
|
||||||
|
rescue e
|
||||||
|
if Config.current.disable_login
|
||||||
|
Config.current.default_username
|
||||||
|
elsif (header = Config.current.auth_proxy_header_name) && !header.empty?
|
||||||
|
env.request.headers[header]
|
||||||
|
else
|
||||||
|
raise e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
macro cors
|
||||||
|
env.response.headers["Access-Control-Allow-Methods"] = "HEAD,GET,PUT,POST," \
|
||||||
|
"DELETE,OPTIONS"
|
||||||
|
env.response.headers["Access-Control-Allow-Headers"] = "X-Requested-With," \
|
||||||
|
"X-HTTP-Method-Override, Content-Type, Cache-Control, Accept," \
|
||||||
|
"Authorization"
|
||||||
|
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_json(env, json)
|
def send_json(env, json)
|
||||||
|
cors
|
||||||
env.response.content_type = "application/json"
|
env.response.content_type = "application/json"
|
||||||
env.response.print json
|
env.response.print json
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def send_text(env, text)
|
||||||
|
cors
|
||||||
|
env.response.content_type = "text/plain"
|
||||||
|
env.response.print text
|
||||||
|
end
|
||||||
|
|
||||||
def send_attachment(env, path)
|
def send_attachment(env, path)
|
||||||
|
cors
|
||||||
send_file env, path, filename: File.basename(path), disposition: "attachment"
|
send_file env, path, filename: File.basename(path), disposition: "attachment"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -42,16 +104,11 @@ def redirect(env, path)
|
|||||||
end
|
end
|
||||||
|
|
||||||
def hash_to_query(hash)
|
def hash_to_query(hash)
|
||||||
hash.map { |k, v| "#{k}=#{v}" }.join("&")
|
hash.join "&" { |k, v| "#{k}=#{v}" }
|
||||||
end
|
end
|
||||||
|
|
||||||
def request_path_startswith(env, ary)
|
def request_path_startswith(env, ary)
|
||||||
ary.each do |prefix|
|
ary.any? { |prefix| env.request.path.starts_with? prefix }
|
||||||
if env.request.path.starts_with? prefix
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
false
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def requesting_static_file(env)
|
def requesting_static_file(env)
|
||||||
@@ -81,3 +138,40 @@ macro get_sort_opt
|
|||||||
sort_opt = SortOptions.new sort_method, is_ascending
|
sort_opt = SortOptions.new sort_method, is_ascending
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
macro get_and_save_sort_opt(dir)
|
||||||
|
sort_method = env.params.query["sort"]?
|
||||||
|
|
||||||
|
if sort_method
|
||||||
|
is_ascending = true
|
||||||
|
|
||||||
|
ascend = env.params.query["ascend"]?
|
||||||
|
if ascend && ascend.to_i? == 0
|
||||||
|
is_ascending = false
|
||||||
|
end
|
||||||
|
|
||||||
|
sort_opt = SortOptions.new sort_method, is_ascending
|
||||||
|
|
||||||
|
TitleInfo.new {{dir}} do |info|
|
||||||
|
info.sort_by[username] = sort_opt.to_tuple
|
||||||
|
info.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
module HTTP
|
||||||
|
class Client
|
||||||
|
private def self.exec(uri : URI, tls : TLSContext = nil)
|
||||||
|
previous_def uri, tls do |client, path|
|
||||||
|
if client.tls? && env_is_true? "DISABLE_SSL_VERIFICATION"
|
||||||
|
Logger.debug "Disabling SSL verification"
|
||||||
|
client.tls.verify_mode = OpenSSL::SSL::VerifyMode::NONE
|
||||||
|
end
|
||||||
|
Logger.debug "Setting read timeout"
|
||||||
|
client.read_timeout = Config.current.download_timeout_seconds.seconds
|
||||||
|
Logger.debug "Requesting #{uri}"
|
||||||
|
yield client, path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user