mirror of
https://github.com/janeczku/calibre-web
synced 2024-06-13 17:06:50 +00:00
Compare commits
2183 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
7aa122e6cd | ||
|
cfdc07e6b2 | ||
|
7295ebaee1 | ||
|
d74ea7dcf2 | ||
|
c73ec52842 | ||
|
6022f48040 | ||
|
e0be168779 | ||
|
d3118c0aa9 | ||
|
04370944b9 | ||
|
d6aa29b095 | ||
|
ab11919c0b | ||
|
014a247847 | ||
|
58c269881f | ||
|
e91dd5729a | ||
|
6760d6971c | ||
|
ad05534ed2 | ||
|
ee451fb236 | ||
|
ab13fcf60c | ||
|
c7fa2ac71a | ||
|
6f60ec7b99 | ||
|
93f1ccbea9 | ||
|
894fd9d30a | ||
|
2b1efdb50e | ||
|
fc9a9cb9ac | ||
|
e99be72ff7 | ||
|
f1ceff2b52 | ||
|
7e85894b3a | ||
|
5c49c8cdd7 | ||
|
c8c3b3cba3 | ||
|
4911843146 | ||
|
2c37546598 | ||
|
25a875b628 | ||
|
506f0a33cf | ||
|
921caf6716 | ||
|
60ed1904f5 | ||
|
cb62d36e44 | ||
|
737d758362 | ||
|
8e27912ff5 | ||
|
3a603cec22 | ||
|
b1d7badef4 | ||
|
e591211b57 | ||
|
a305c35de4 | ||
|
51d306b11d | ||
|
abb418fe86 | ||
|
0925f34557 | ||
|
15952a764c | ||
|
28c810b514 | ||
|
cfe61a26d0 | ||
|
fcc95bd895 | ||
|
964e7de920 | ||
|
14b578dd3a | ||
|
becb84a73d | ||
|
c901ccbb01 | ||
|
f987fb0aba | ||
|
c30460d76b | ||
|
97380b4b3f | ||
|
4fbd064b85 | ||
|
abbd9a5888 | ||
|
e860b4e097 | ||
|
23a8a4657d | ||
|
b38a1b2298 | ||
|
0ebfba8d05 | ||
|
990ad8d72d | ||
|
c3fc125501 | ||
|
3c4ed0de1a | ||
|
117c92233d | ||
|
2ba14acf4f | ||
|
80a2d07009 | ||
|
ff9e1ed7c8 | ||
|
8e5bee5352 | ||
|
d659430116 | ||
|
859dac462b | ||
|
2bea4dbd06 | ||
|
0180b4b6b5 | ||
|
2bfb02c448 | ||
|
4864254e37 | ||
|
09dce28a0e | ||
|
e55d09d8bb | ||
|
92c162b2fd | ||
|
57fb5001e2 | ||
|
64e5314148 | ||
|
873602a5c9 | ||
|
09e966e18a | ||
|
f7718cae0c | ||
|
90e728516c | ||
|
7c04b68c88 | ||
|
8549689a0f | ||
|
d8f5c17518 | ||
|
05367d2df5 | ||
|
eb6fbfc90c | ||
|
c2267b6902 | ||
|
0e5520a261 | ||
|
6f5e9f167e | ||
|
ce83fb6816 | ||
|
fbfb7adef6 | ||
|
cc52ad5d27 | ||
|
706b9c4013 | ||
|
6972c1b841 | ||
|
b9c329535d | ||
|
8fdf7a94ab | ||
|
31a344b410 | ||
|
3814fbf08f | ||
|
ffc13a5565 | ||
|
74c61d9685 | ||
|
b8031cd53f | ||
|
898e76fc37 | ||
|
af71a1a2ed | ||
|
e0327db08f | ||
|
bf2ac97c47 | ||
|
902fa254b0 | ||
|
f0cc93abd3 | ||
|
977f07364b | ||
|
00acd745f4 | ||
|
d272f43424 | ||
|
7a8d8375d0 | ||
|
3aa75ef4a7 | ||
|
25fb8d934f | ||
|
f08c8faaff | ||
|
bc0ebdb78d | ||
|
2a4b3cb7af | ||
|
4401cf66d1 | ||
|
d353c9b6d3 | ||
|
0aba96c032 | ||
|
c60b7e9192 | ||
|
23033255b8 | ||
|
31c8909dea | ||
|
9ef89dbcc3 | ||
|
1086296d1d | ||
|
d341faf204 | ||
|
2334e8f9c9 | ||
|
90ad570578 | ||
|
fd90d6e375 | ||
|
7fbbb85f47 | ||
|
52c7557878 | ||
|
794cd354ca | ||
|
389e3f09f5 | ||
|
285979b68d | ||
|
3a012c900e | ||
|
ec45de3212 | ||
|
f644a2a136 | ||
|
01108aac42 | ||
|
400c745692 | ||
|
9841a4d068 | ||
|
7fd1d10fca | ||
|
4f6bbfa8b8 | ||
|
cf6810db87 | ||
|
5afff2231e | ||
|
d611582b78 | ||
|
3bbd8ee27e | ||
|
f78e0ff938 | ||
|
bd71391bfb | ||
|
20b2936cc1 | ||
|
19825a635a | ||
|
0d611d35de | ||
|
effd026fe2 | ||
|
d68e57c4fc | ||
|
184ce23351 | ||
|
2fbc3da451 | ||
|
fad6550ff1 | ||
|
b7aaa0f24d | ||
|
5040bb762c | ||
|
55deca1ec8 | ||
|
40a16f4717 | ||
|
d26e60724a | ||
|
d877fa1c68 | ||
|
d55bafdfa9 | ||
|
a2a431802a | ||
|
b2e4907165 | ||
|
6c2e40f544 | ||
|
5e3d0ec2ad | ||
|
c550d6c90d | ||
|
3b1d0b4013 | ||
|
3d07efbb4f | ||
|
c0ae5bb381 | ||
|
6e755a26f9 | ||
|
c45188beb2 | ||
|
0736c53d7b | ||
|
f0f8011d24 | ||
|
65f3ecb924 | ||
|
87b3999ec8 | ||
|
e32312b54a | ||
|
d7ea569e5d | ||
|
96958e7266 | ||
|
2c339ed10c | ||
|
dc2c30f508 | ||
|
0c43d80163 | ||
|
df71a86f94 | ||
|
7ed56b4397 | ||
|
5ceb2b6d83 | ||
|
8abea1ddd0 | ||
|
11816d3405 | ||
|
198bff928f | ||
|
cac200ba61 | ||
|
8cc36ab081 | ||
|
b3d1558df8 | ||
|
a045b6f467 | ||
|
7a961c9011 | ||
|
444ac181f8 | ||
|
4bbcec21e4 | ||
|
5509d4598b | ||
|
fab35e69ec | ||
|
4f0f5b1495 | ||
|
cfa309f0d1 | ||
|
885d914f18 | ||
|
b580f418f7 | ||
|
6a14e2cf68 | ||
|
8535bb5821 | ||
|
b2a26a421c | ||
|
7aea7fc0bb | ||
|
52172044e6 | ||
|
9b99427c84 | ||
|
0499e578cd | ||
|
b3a85ffcbb | ||
|
074e611705 | ||
|
a1899bf582 | ||
|
f7ff3e7cba | ||
|
3a08b91ffa | ||
|
d253804a50 | ||
|
ba0e5399d6 | ||
|
7818c4a7b0 | ||
|
3f6a12898b | ||
|
caf69669cb | ||
|
de59181be7 | ||
|
966c9236b9 | ||
|
34c6010ad0 | ||
|
60e904967b | ||
|
3efcbcc679 | ||
|
7bb4bc934c | ||
|
dcb8a0f77b | ||
|
2f12b2e315 | ||
|
279f0569e4 | ||
|
6723369d65 | ||
|
4b93ac034f | ||
|
fda62dde1d | ||
|
df74fdb4d1 | ||
|
cce538d5a7 | ||
|
e8b0051b31 | ||
|
fe55958ecc | ||
|
7b321d63c1 | ||
|
986eaf9f02 | ||
|
caf8ed77d7 | ||
|
ee5cfa1f36 | ||
|
5eef476135 | ||
|
b5e4a88357 | ||
|
256f4bb428 | ||
|
a4d45512ee | ||
|
074687c330 | ||
|
2f7b175dda | ||
|
a256bd5260 | ||
|
fdd1410b06 | ||
|
3f5583017f | ||
|
63b7d70f33 | ||
|
500758050c | ||
|
4b4c0daab0 | ||
|
709a4e51ba | ||
|
eff0750d77 | ||
|
e63a04093c | ||
|
07d97d18d0 | ||
|
d8f30983d5 | ||
|
062efc4e78 | ||
|
4e6c9c2703 | ||
|
4dc5885723 | ||
|
39638d3c9c | ||
|
3ef34c8f15 | ||
|
932abbf090 | ||
|
860443079d | ||
|
bd4b7ffaba | ||
|
33e35eeb52 | ||
|
ed09814460 | ||
|
e52eb74121 | ||
|
dc7fbce4f7 | ||
|
dad0fd5a1c | ||
|
8a87c152b4 | ||
|
16baa306c5 | ||
|
2eb334fb3d | ||
|
cb7356a04d | ||
|
63a561bf9b | ||
|
cc733454b2 | ||
|
940544577a | ||
|
9e0fc320cb | ||
|
bf3ca20fb2 | ||
|
c7e1736ade | ||
|
bc6a50550e | ||
|
fe4dc1bb8f | ||
|
f2369609e8 | ||
|
de4d6ec7df | ||
|
7754f4aa5d | ||
|
524751ea51 | ||
|
8111d0dd51 | ||
|
fad5929253 | ||
|
9f28144779 | ||
|
42fd6973a0 | ||
|
b2e20ff50c | ||
|
6075b3dd1d | ||
|
37871ea8cb | ||
|
e2785c3985 | ||
|
dba83a2900 | ||
|
33c19b20f4 | ||
|
d2f39d3dce | ||
|
1c8bc78b48 | ||
|
6c6841f8b0 | ||
|
592216588c | ||
|
f4db0f04d2 | ||
|
b16e3a6e2c | ||
|
13c0d30a8f | ||
|
b9c942befc | ||
|
a68a0dd037 | ||
|
a952c36ab7 | ||
|
5f0c7737fe | ||
|
38484624e9 | ||
|
1451a67912 | ||
|
a72f0a160b | ||
|
253386b0a5 | ||
|
6c8ffb3e7e | ||
|
7d26e6fc85 | ||
|
085a6b88a3 | ||
|
bde36e3cd4 | ||
|
9646b6e2dd | ||
|
d35e781d41 | ||
|
321db4d712 | ||
|
2b9f920454 | ||
|
45acd3febe | ||
|
cbd7ca2f3e | ||
|
ba7fee3918 | ||
|
1210ccb43f | ||
|
04f1f6493b | ||
|
46d2d217ee | ||
|
e3fffa8a8f | ||
|
dfb49bfca9 | ||
|
224777f5e3 | ||
|
7ade4615a4 | ||
|
cbd679eb24 | ||
|
b277ed3359 | ||
|
fa95b07a95 | ||
|
87bc8c6d96 | ||
|
db2bc6a2c2 | ||
|
cf850c6ed5 | ||
|
a6b54e398b | ||
|
28eeb9eec3 | ||
|
7d76f2ae33 | ||
|
49e4f540c9 | ||
|
64e9b13311 | ||
|
3cf778b591 | ||
|
942bcff5c4 | ||
|
5c5db34a52 | ||
|
ae850172a3 | ||
|
7ff4747f63 | ||
|
76b0411c33 | ||
|
a414db0243 | ||
|
162ac73bee | ||
|
fc31132f4e | ||
|
856dce8add | ||
|
3debe4aa4b | ||
|
b28a2cc58c | ||
|
5fd0e4c046 | ||
|
595f01e7a3 | ||
|
c79aa75f00 | ||
|
0177a8bcca | ||
|
38c601bb10 | ||
|
e7a6fe0bec | ||
|
6119eb3681 | ||
|
6b2ca9537d | ||
|
3cb9a9b04a | ||
|
3d8256b6a6 | ||
|
660d1fb1ff | ||
|
fa3fe47059 | ||
|
89bc72958e | ||
|
73ea18b8ce | ||
|
7ca07f06ce | ||
|
8ee34bf428 | ||
|
66d5b5a697 | ||
|
ce48e06c45 | ||
|
f4ecfe4aca | ||
|
dda20eb912 | ||
|
c4326c9495 | ||
|
63a3edd429 | ||
|
3b45234beb | ||
|
8d0a699078 | ||
|
5b5146a793 | ||
|
7a4e6fbdfb | ||
|
14d14637cd | ||
|
fb42f6bfff | ||
|
4b7a0f3662 | ||
|
275675b48a | ||
|
907606295d | ||
|
794c6ba254 | ||
|
ac13f6042a | ||
|
f8fbc807f1 | ||
|
98da7dd5b0 | ||
|
1c3b69c710 | ||
|
1dd638a786 | ||
|
6da7d05c6c | ||
|
3f72c3fffe | ||
|
cf9a7d538f | ||
|
ea9e8d4384 | ||
|
b9769a0975 | ||
|
3a262661b5 | ||
|
d2056ceb51 | ||
|
e71a3452e1 | ||
|
189da65fac | ||
|
0e6b7f96d3 | ||
|
1babb566fb | ||
|
c4e4acfc26 | ||
|
6afb429185 | ||
|
f241b260d7 | ||
|
260a694834 | ||
|
508e2b4d0a | ||
|
9701a97a57 | ||
|
4913f06e0d | ||
|
d545ea9e6f | ||
|
1ad8dc102a | ||
|
36cb454d1c | ||
|
1899cda8d1 | ||
|
8dd4d0be1b | ||
|
d48d6880af | ||
|
94a6931d48 | ||
|
c21a870b8e | ||
|
791bc9621a | ||
|
2d6fe483ba | ||
|
ad43f07dab | ||
|
77637d81dd | ||
|
a2bf6dfb7b | ||
|
1cd05d614c | ||
|
d75f681247 | ||
|
2be2920833 | ||
|
d6184619f5 | ||
|
43ee85fbb5 | ||
|
8022b1bb36 | ||
|
9e75c65af8 | ||
|
7881950e66 | ||
|
031658ae94 | ||
|
48c2c7b543 | ||
|
beb619c2c2 | ||
|
ed22209e6c | ||
|
364c48edd8 | ||
|
e178efb58c | ||
|
4105c64320 | ||
|
b3335f6733 | ||
|
fba95956de | ||
|
ce0b3d8d10 | ||
|
9545aa2a0b | ||
|
4629eec774 | ||
|
4977381b1c | ||
|
6c1631acba | ||
|
1489228649 | ||
|
74efa52f26 | ||
|
1ca1281346 | ||
|
631496775e | ||
|
c5e539bbcd | ||
|
02ec853e3b | ||
|
d0411fd9c7 | ||
|
567cb2e097 | ||
|
a635e136be | ||
|
5ffb3e917f | ||
|
5dc3385ae5 | ||
|
66e0a81d23 | ||
|
1f6eb2def6 | ||
|
7d3af5bbd0 | ||
|
043a612d1a | ||
|
928e24fd1a | ||
|
3361c41c6d | ||
|
85a6616606 | ||
|
c15b603fef | ||
|
b12e47d0e5 | ||
|
389263f5e7 | ||
|
307b4526f6 | ||
|
7d023ce741 | ||
|
2ddbaa2150 | ||
|
29fef4a314 | ||
|
9450084d6e | ||
|
b52c7aac53 | ||
|
e8c461b14f | ||
|
9409b9db9c | ||
|
a992aafc13 | ||
|
b663f1ce83 | ||
|
b45d69ef2d | ||
|
a80735d7d3 | ||
|
adfbd447ed | ||
|
73567db4fb | ||
|
3d59a78c9f | ||
|
8ba23ac3ee | ||
|
397cd987cb | ||
|
7eef44f73c | ||
|
e22ecda137 | ||
|
a003cd9758 | ||
|
44f6655dd2 | ||
|
bd52f08a30 | ||
|
edc9703716 | ||
|
56d697122c | ||
|
d39a43e838 | ||
|
9df3a2558d | ||
|
7339c804a3 | ||
|
4d61c5535e | ||
|
09e1ec3d08 | ||
|
8421a017f4 | ||
|
27eb514ca4 | ||
|
b4d9e400d9 | ||
|
67bc23ee0c | ||
|
b898b37e29 | ||
|
10dcf39d50 | ||
|
e676e1685b | ||
|
59a5ccd05c | ||
|
04908e22fe | ||
|
0f67e57be4 | ||
|
071d19b8b3 | ||
|
1ffa190938 | ||
|
c10708ed07 | ||
|
b4851e1d70 | ||
|
26be5ee237 | ||
|
241aa77d41 | ||
|
ca0ee5d391 | ||
|
110d283a50 | ||
|
f6a9030c33 | ||
|
452093db47 | ||
|
9fa56a2323 | ||
|
3a133901e4 | ||
|
7750ebde0f | ||
|
2472e03a69 | ||
|
6598c4d259 | ||
|
a9b20ca136 | ||
|
bf0375d51d | ||
|
89d226e36b | ||
|
ec8844c7d4 | ||
|
e5c8a7ce50 | ||
|
dc3cafd23d | ||
|
9de474e665 | ||
|
cd143b7ef4 | ||
|
8a5112502d | ||
|
b5d5660d04 | ||
|
fc9c641e55 | ||
|
68e21e1098 | ||
|
828be29a80 | ||
|
46e5305f23 | ||
|
a3f7dc2a5a | ||
|
9bcbe523d7 | ||
|
ae3e3559b8 | ||
|
a72f16fd3a | ||
|
c2545315e1 | ||
|
61a0c72f8e | ||
|
1e44cb3b6c | ||
|
462aa47ed6 | ||
|
e176d63ca6 | ||
|
80b0e88650 | ||
|
0b4731913e | ||
|
fc7ce8da2d | ||
|
c89bc12c9b | ||
|
4913673e8f | ||
|
fc004f4f0c | ||
|
7344ef353c | ||
|
3bde8a5d95 | ||
|
c5c3874243 | ||
|
c4104ddaf4 | ||
|
0d34f41a48 | ||
|
b47c1d2431 | ||
|
a77aef83c6 | ||
|
e39c6130c3 | ||
|
12071f3e64 | ||
|
92b6dbf26f | ||
|
98b554a3a0 | ||
|
03359599ed | ||
|
3c4330ba51 | ||
|
8c781ad4a4 | ||
|
5e9ec706c5 | ||
|
07c67b09db | ||
|
b1c70d5b4a | ||
|
c5fc30a1be | ||
|
29fd4ae4a2 | ||
|
4ef8c35fb7 | ||
|
04326af2da | ||
|
d6a31e5db8 | ||
|
73d48e4ac1 | ||
|
b206b7a5d8 | ||
|
02e1be09df | ||
|
f85b587d0a | ||
|
89d522e389 | ||
|
2816a75c3e | ||
|
78b45f716a | ||
|
91df265d40 | ||
|
7e7f54cfa7 | ||
|
80bc14c0cf | ||
|
7685818b16 | ||
|
f44d42f834 | ||
|
bf12542df5 | ||
|
25f2af3f03 | ||
|
909797dc49 | ||
|
07d4e60655 | ||
|
d90cfce97f | ||
|
543fe12862 | ||
|
4f66d6b3b1 | ||
|
c36138b144 | ||
|
7f6e88ce5e | ||
|
aa442d8c51 | ||
|
a3cd217cea | ||
|
0f3f918153 | ||
|
790080f2a0 | ||
|
4ea80e9810 | ||
|
034d57134d | ||
|
f6101fd462 | ||
|
1fa7de397a | ||
|
3b7cd38d5e | ||
|
78fb7a9756 | ||
|
7ae9f89bbf | ||
|
8a6a8dcbe8 | ||
|
fbac3e38ac | ||
|
c1f1952b04 | ||
|
5e4cf839bc | ||
|
056ecf0d90 | ||
|
0c2f67bc7b | ||
|
1bcb714fac | ||
|
8cb3fe32a5 | ||
|
ae5053e072 | ||
|
cde51e743a | ||
|
3233b357f8 | ||
|
49655e9f2d | ||
|
7b45324149 | ||
|
858d099509 | ||
|
12f3a13d1d | ||
|
813d303ea7 | ||
|
c1ca18f7dc | ||
|
e8e4d87d39 | ||
|
5d5a94c9e5 | ||
|
258b4a6767 | ||
|
ef4b5e2881 | ||
|
a968ddaef2 | ||
|
aaa749933d | ||
|
2e007a160e | ||
|
e7464f2694 | ||
|
47414ada69 | ||
|
9410b47144 | ||
|
db03fb3edd | ||
|
2b03cae017 | ||
|
21ebdc0130 | ||
|
6e8445fed5 | ||
|
d83c731030 | ||
|
ae9a970782 | ||
|
1e723dff3a | ||
|
8421a17a82 | ||
|
bc96ff9a39 | ||
|
bf049d8240 | ||
|
6e783cd7ee | ||
|
069dc2766f | ||
|
2f5b9e41ac | ||
|
9a8093db31 | ||
|
5c342d4e7c | ||
|
3c98cd1b9a | ||
|
2303fc0814 | ||
|
a8680a45ca | ||
|
d75d95f401 | ||
|
fbb6de7195 | ||
|
3cbbf6fa86 | ||
|
c92d65aad3 | ||
|
c61e5d6ac0 | ||
|
130af069aa | ||
|
09b381101b | ||
|
35bb899879 | ||
|
6184e2b7bc | ||
|
2f3e5eadeb | ||
|
fe5d684d2c | ||
|
df53a5d8c9 | ||
|
83b99fcb1a | ||
|
028e6855a7 | ||
|
adf6728f14 | ||
|
652d0fd86f | ||
|
1136383b9a | ||
|
d770e5392e | ||
|
3d2e7e847e | ||
|
a63af5882e | ||
|
2d0af0ab49 | ||
|
d912c1c476 | ||
|
42b0226f1a | ||
|
8adae6ed0c | ||
|
fee76741a0 | ||
|
f36d3a76be | ||
|
afaf496fbe | ||
|
c06754975e | ||
|
834edadc28 | ||
|
7861f8a89a | ||
|
73d359af05 | ||
|
036cd7be48 | ||
|
baffe1f537 | ||
|
2f949ce1dd | ||
|
32a3c45ee0 | ||
|
14a6e7c42c | ||
|
2a5e9a97bb | ||
|
504e58abdb | ||
|
a6a8f7eb43 | ||
|
5070cc4c23 | ||
|
0d49b56883 | ||
|
f5b79930ad | ||
|
c0d0660986 | ||
|
ec53570118 | ||
|
8cb5989c97 | ||
|
39459603d4 | ||
|
f34fc002da | ||
|
06e8845641 | ||
|
034ab73ccc | ||
|
57cd8160a0 | ||
|
399ddc5d6f | ||
|
d9a83e0638 | ||
|
8f3bb2e338 | ||
|
4545f4a20d | ||
|
296f76b5fb | ||
|
3b5e5f9b90 | ||
|
8e2536c53b | ||
|
4379669cf8 | ||
|
2b31b6a306 | ||
|
3a0dacc6a6 | ||
|
547ea93dc9 | ||
|
d80297e1a8 | ||
|
49692b4a45 | ||
|
b54a170a00 | ||
|
34478079d8 | ||
|
753319c8b6 | ||
|
c53817859a | ||
|
153a443fca | ||
|
9efd644360 | ||
|
598618e428 | ||
|
965352c8d9 | ||
|
97cf20764b | ||
|
695ce83681 | ||
|
86b779f39b | ||
|
8007e450b3 | ||
|
0aac961cde | ||
|
ef7c6731bc | ||
|
e9b674f46e | ||
|
8f665ebd58 | ||
|
7bb3cac7fb | ||
|
9c5970bbfc | ||
|
ba23ada1fe | ||
|
86b621e768 | ||
|
5f70406b30 | ||
|
6b026513cb | ||
|
0436f0f9b2 | ||
|
295888c654 | ||
|
7317084a4e | ||
|
0981337cdf | ||
|
461dd05e2f | ||
|
764389ea2a | ||
|
4a0dde0371 | ||
|
e22b3da601 | ||
|
7c623941de | ||
|
3bb41aca6d | ||
|
0c3c0c0664 | ||
|
411c13977f | ||
|
41f89af959 | ||
|
895f68033f | ||
|
2d49589e4b | ||
|
89877835b3 | ||
|
0ce41aef56 | ||
|
5b3015619d | ||
|
6ca08a7cc1 | ||
|
7254ce6c81 | ||
|
cfa6b405da | ||
|
26a8ac1425 | ||
|
1ce45f3253 | ||
|
a03c95329c | ||
|
e0bf829def | ||
|
0bc15636f2 | ||
|
61bfeae936 | ||
|
95e0255aa1 | ||
|
23e47ba4e6 | ||
|
4dcc44803c | ||
|
111ab121b1 | ||
|
1e04b51148 | ||
|
3ae1b97d72 | ||
|
3123a914a4 | ||
|
f6b46bb170 | ||
|
bb7f4cf74e | ||
|
39ac37861f | ||
|
62ff6f7e8a | ||
|
032fced9c7 | ||
|
ae9c5da777 | ||
|
42f8209a4a | ||
|
e757be6953 | ||
|
4f3c396450 | ||
|
50bb74d748 | ||
|
3416323767 | ||
|
18ce310b30 | ||
|
6339d25af0 | ||
|
477b202c38 | ||
|
326d6e7b9d | ||
|
baf32f9045 | ||
|
3c4cd22d9e | ||
|
d9d6fb33ba | ||
|
17b4643b7c | ||
|
239f389c5c | ||
|
8362c82d54 | ||
|
62e7aca0fb | ||
|
6a37c7ca9d | ||
|
e0e0422010 | ||
|
128db26301 | ||
|
01ab75a158 | ||
|
d9c10b830a | ||
|
35f6f4c727 | ||
|
3b216bfa07 | ||
|
e8e2f789e5 | ||
|
d8f5bdea6d | ||
|
127bf98aac | ||
|
ede273a8f9 | ||
|
bc7a305285 | ||
|
d759df0df6 | ||
|
bbef41290f | ||
|
81b85445d8 | ||
|
35209ede67 | ||
|
0c0313f375 | ||
|
6bf0753978 | ||
|
a02f621f08 | ||
|
de1bc3f9af | ||
|
01090169a7 | ||
|
b564a97cdf | ||
|
a118fffc99 | ||
|
5b59aab81a | ||
|
7d3d0c661e | ||
|
f6f20ebc77 | ||
|
58cb54c76f | ||
|
3fc326fa48 | ||
|
20b5a9a2c0 | ||
|
4eaa9413f9 | ||
|
a50aff67a2 | ||
|
3c1f5fd37f | ||
|
8ae066c387 | ||
|
0feb62c142 | ||
|
96b1e8960b | ||
|
df67079573 | ||
|
e3dbf7a88d | ||
|
8dd0585919 | ||
|
c830a5936e | ||
|
405f3c181f | ||
|
6d839d5cc7 | ||
|
bbadfa2251 | ||
|
7b8b2f93a0 | ||
|
c1030dfd13 | ||
|
a90177afa0 | ||
|
c095ee3c14 | ||
|
ae1f515446 | ||
|
0548fbb685 | ||
|
f22e4d996c | ||
|
3e0d8763c3 | ||
|
47f5e2ffb4 | ||
|
f39dc100b4 | ||
|
573c9f9fb4 | ||
|
785726deee | ||
|
7eb875f388 | ||
|
4edd1914b4 | ||
|
222929e741 | ||
|
70b67077cc | ||
|
ec73558b03 | ||
|
bdedec90dd | ||
|
d45085215f | ||
|
1a6579312f | ||
|
b85627da5c | ||
|
2e815147fb | ||
|
592288cb22 | ||
|
2e3a3ee460 | ||
|
b7927a0df1 | ||
|
021298374e | ||
|
92f65882b2 | ||
|
0693cb1ddb | ||
|
bea14d1784 | ||
|
f0399d04b7 | ||
|
9b57fa25de | ||
|
afbe77de3d | ||
|
d26d357151 | ||
|
4db9691cfc | ||
|
45c433caab | ||
|
51bf35c2e4 | ||
|
d64589914f | ||
|
362fdc5716 | ||
|
d55626d445 | ||
|
25422b3411 | ||
|
42bf40d7bb | ||
|
920acaca99 | ||
|
a184f4e71a | ||
|
4569188008 | ||
|
9d9acb058d | ||
|
7d67168a4a | ||
|
09751d8b87 | ||
|
6e15280fac | ||
|
f78d2245aa | ||
|
fd5ab0ef53 | ||
|
d217676350 | ||
|
cd5711e651 | ||
|
3bf173d958 | ||
|
98d630d453 | ||
|
eb2e816bfd | ||
|
bd01e840ca | ||
|
91a21ababe | ||
|
f4096b136e | ||
|
e2eab808c0 | ||
|
3ac08a8c0d | ||
|
7598dfe952 | ||
|
5ed3b1cf53 | ||
|
7640ac1b3b | ||
|
66874f8163 | ||
|
3f91313303 | ||
|
8438b2a07b | ||
|
67e3721530 | ||
|
2252d661c0 | ||
|
87e526642c | ||
|
7f9da94a18 | ||
|
ec7c2db971 | ||
|
3f56f0dca7 | ||
|
7fc04b353b | ||
|
a8689ae26b | ||
|
fcd2b68359 | ||
|
a1d372630d | ||
|
fc859afb92 | ||
|
2e0d0a2429 | ||
|
d084a06e63 | ||
|
e880238cb9 | ||
|
f58c5bee1c | ||
|
cbb9edac19 | ||
|
1b8bd27b3c | ||
|
1e9d88fa98 | ||
|
6cb713d62c | ||
|
642af2f973 | ||
|
6deb527769 | ||
|
9273843062 | ||
|
2989586c6d | ||
|
5ede079401 | ||
|
7ad419dc8c | ||
|
bcdc976414 | ||
|
6aad9378b8 | ||
|
6f5390ead5 | ||
|
d624b67e93 | ||
|
01cc97c1b2 | ||
|
8e5bb02a28 | ||
|
4fd4cf4355 | ||
|
baba205bce | ||
|
7c016265d2 | ||
|
974549b1af | ||
|
add502d236 | ||
|
294c594cbb | ||
|
60aa016734 | ||
|
27e8fbd248 | ||
|
d856c4d78e | ||
|
25b09a532f | ||
|
c1f4ca36b6 | ||
|
58379159fb | ||
|
17470b3b56 | ||
|
42cc13d1e2 | ||
|
ecc5cb167e | ||
|
d72210c6ae | ||
|
61deda1076 | ||
|
1e0ff0f9c2 | ||
|
00bf1f5ec9 | ||
|
2b6f5b1565 | ||
|
e5abc5f281 | ||
|
95371d0d7f | ||
|
c7df8a1a34 | ||
|
b414d91964 | ||
|
82164fc8e7 | ||
|
a5415e00d5 | ||
|
434029270b | ||
|
b3b85bf692 | ||
|
a36c6da3ae | ||
|
9e72c3b40d | ||
|
a0cebfbfb1 | ||
|
64e833f5d6 | ||
|
4da64ceb23 | ||
|
99ec99b539 | ||
|
3077b854d7 | ||
|
a8317d900b | ||
|
668bf3a15e | ||
|
481e52b503 | ||
|
bbb65ec804 | ||
|
4b7b646692 | ||
|
65cfb1ccbc | ||
|
9e9d7b3642 | ||
|
1294672809 | ||
|
857584a929 | ||
|
75c68d92ec | ||
|
b4772d9b66 | ||
|
3cfffa1487 | ||
|
382cd9458f | ||
|
be7ac7e163 | ||
|
3a1a32f053 | ||
|
27f71d910b | ||
|
f6a2b8a9ef | ||
|
9f260128cf | ||
|
cdd38350fe | ||
|
516e76de4f | ||
|
4c7b5999f7 | ||
|
bb20979c71 | ||
|
917909cfdb | ||
|
aefed40a2f | ||
|
88278672d8 | ||
|
bd0071354c | ||
|
c6bf62a6eb | ||
|
95544ef885 | ||
|
fe4db16a7e | ||
|
9b127a9f97 | ||
|
9e4aeac16d | ||
|
9653308300 | ||
|
7671a1d5c8 | ||
|
bd6b5ac873 | ||
|
00dc60da79 | ||
|
708861bcd5 | ||
|
24a2c0a5cf | ||
|
1e1d3a7c81 | ||
|
2ebddcfee3 | ||
|
4517f5b0cb | ||
|
666015e867 | ||
|
9d5e9b28ae | ||
|
6f1e78b9a3 | ||
|
d5aa345da6 | ||
|
e060c62742 | ||
|
5695268d1b | ||
|
cea10d3945 | ||
|
fe21d15194 | ||
|
369d5059c3 | ||
|
56a9c62421 | ||
|
b4262b1317 | ||
|
50d703e2d8 | ||
|
5aefc893de | ||
|
6e5d9d7657 | ||
|
1be7dfcdca | ||
|
e58eb8dac1 | ||
|
d02eb842c7 | ||
|
f3efef1f60 | ||
|
659481c83b | ||
|
bad4c01474 | ||
|
cd53d57516 | ||
|
ac54899415 | ||
|
abe46e1862 | ||
|
3be47d6e57 | ||
|
25f608d109 | ||
|
aca5324914 | ||
|
d8f9e2feb2 | ||
|
ed26d34961 | ||
|
50919d4721 | ||
|
5edde53fed | ||
|
1c15e10ac0 | ||
|
52be2ad4a2 | ||
|
43fdef5e53 | ||
|
b699796236 | ||
|
b82d03c12c | ||
|
b8eb557761 | ||
|
ba40c6693e | ||
|
e064a3ec2b | ||
|
f6561456f7 | ||
|
deb91996a8 | ||
|
cd3791f5f4 | ||
|
cd1fe6dde0 | ||
|
3f6a466ca7 | ||
|
50f4fe6546 | ||
|
9e7f69e38a | ||
|
46205a1f83 | ||
|
861277460d | ||
|
83babf88fc | ||
|
58735caff3 | ||
|
26071d4e7a | ||
|
844eb6c379 | ||
|
1b8410e786 | ||
|
fed9eff7b8 | ||
|
bf5de95fdc | ||
|
2e25e797dd | ||
|
c1ac68e2ae | ||
|
623a92ebec | ||
|
0bd544704d | ||
|
2edcd16119 | ||
|
af202bd6d1 | ||
|
d5a332a84e | ||
|
d6884164a5 | ||
|
be28a91315 | ||
|
707556a36d | ||
|
356a4f588e | ||
|
241b31458d | ||
|
efb04ddd8f | ||
|
524ed07a6c | ||
|
8bee2b9552 | ||
|
781ca7a3f3 | ||
|
d648785471 | ||
|
9a08bcd2bc | ||
|
04a5db5c1d | ||
|
6f6d8df431 | ||
|
9ebde57fc3 | ||
|
8dc1e9bfa4 | ||
|
e3db2796c9 | ||
|
42ef049b63 | ||
|
8930426f2a | ||
|
ceffa3a108 | ||
|
c0a06eec46 | ||
|
86ef1d47e8 | ||
|
b835aa660a | ||
|
4d92d7da3a | ||
|
d1e6a85803 | ||
|
d8bad7394a | ||
|
f5062d1354 | ||
|
1d79d9ded2 | ||
|
10e212fcde | ||
|
1fa267ce1b | ||
|
abcf9a1808 | ||
|
d4cfad6363 | ||
|
3a24561ca2 | ||
|
5c19a8aacc | ||
|
3946ef8f0d | ||
|
6a2868c68d | ||
|
7ae3255ea9 | ||
|
91e6d94c83 | ||
|
e615073893 | ||
|
32e27712f0 | ||
|
ff39fac50f | ||
|
80d3ba42b4 | ||
|
d25cfb7499 | ||
|
24ad15be11 | ||
|
a35c635987 | ||
|
aa9fdd2ada | ||
|
afa585eb65 | ||
|
03acb77162 | ||
|
28ca39ca13 | ||
|
b05c17b8bb | ||
|
3839af65c4 | ||
|
275e073c42 | ||
|
c4876010e9 | ||
|
bce69b2dfc | ||
|
fb97e39d9f | ||
|
913690df78 | ||
|
e68a3f18fa | ||
|
1534827ad7 | ||
|
b36422bc05 | ||
|
1e2335dea0 | ||
|
e052590270 | ||
|
41ca85268f | ||
|
0e9709f304 | ||
|
ceec1051d5 | ||
|
81ccc11c9c | ||
|
702e96ddd6 | ||
|
53603f79bd | ||
|
250cafe814 | ||
|
16633ef1d3 | ||
|
f0a5225524 | ||
|
a32b36bf81 | ||
|
302679719d | ||
|
a63baa1758 | ||
|
752192a057 | ||
|
e245a147d9 | ||
|
0ec2bcd897 | ||
|
19c75d6790 | ||
|
0654156dd6 | ||
|
e4b0434733 | ||
|
b32721c293 | ||
|
c5e39a7523 | ||
|
0db837c0ce | ||
|
53dae32897 | ||
|
018f3ca250 | ||
|
a1a8a0cf29 | ||
|
f9c3e751f6 | ||
|
c7b057ec51 | ||
|
85ea762054 | ||
|
56cd62ed90 | ||
|
9a8c342e61 | ||
|
3b81ea37f4 | ||
|
3c8bfc31e4 | ||
|
59881367fe | ||
|
c8ebaee0f7 | ||
|
d5d0ad50fa | ||
|
6ef792d65d | ||
|
20fa9f5523 | ||
|
616cc2018a | ||
|
e69b1adccd | ||
|
280efad939 | ||
|
15ec6bec95 | ||
|
aae81c3d24 | ||
|
259ac94b93 | ||
|
a27314464a | ||
|
87f07003f4 | ||
|
1bf065fd04 | ||
|
f8de7e75cc | ||
|
7d586b3745 | ||
|
a56e071a19 | ||
|
480aecb16c | ||
|
ec7803fa76 | ||
|
91938fd18a | ||
|
aa2d3d2b36 | ||
|
d5e9cdc5b7 | ||
|
305e75c0ae | ||
|
0d247fef6a | ||
|
94da61c57e | ||
|
430ccd9ab1 | ||
|
47d94d9bd6 | ||
|
a6d1f6039d | ||
|
31234a4b98 | ||
|
476275ea53 | ||
|
792d4a65bc | ||
|
0e2dca5f4d | ||
|
557296b7be | ||
|
2236191263 | ||
|
1138c86868 | ||
|
08500c66a8 | ||
|
67836006c5 | ||
|
fa03a9ee25 | ||
|
bf4564e365 | ||
|
6a96664381 | ||
|
e6e3032f02 | ||
|
6d424f0a30 | ||
|
a6f0375db3 | ||
|
1833e8fdb4 | ||
|
70151c2e11 | ||
|
ab69962e8a | ||
|
075fe994af | ||
|
1aca1b9fdd | ||
|
9fea5a55f4 | ||
|
1a0bf45c34 | ||
|
67874e07b6 | ||
|
ced0bcfe33 | ||
|
8c0aa79f78 | ||
|
93e8c5be32 | ||
|
d9f86aecd2 | ||
|
e9552fedef | ||
|
84e1c6e809 | ||
|
aadd6fd7e0 | ||
|
59987ed359 | ||
|
a79600c81f | ||
|
e8a4c3c6b9 | ||
|
62fce26651 | ||
|
25add6511f | ||
|
b2a28cd39a | ||
|
6de3aebf3a | ||
|
1b7e422772 | ||
|
b0f48b7f00 | ||
|
94b07d05c1 | ||
|
4392fec7f5 | ||
|
c1d0ec076b | ||
|
a47d6cd937 | ||
|
dcdb5e2a9e | ||
|
240325dc18 | ||
|
e599d2ec05 | ||
|
c62a417cbf | ||
|
98fc1ee0a7 | ||
|
78d0fd811b | ||
|
54d06e580d | ||
|
a62fca1e55 | ||
|
7b1d23eba4 | ||
|
2502c279fc | ||
|
878fc8c2fd | ||
|
64902eebcb | ||
|
e520f6afc4 | ||
|
0cc1ae9324 | ||
|
1b2124aa35 | ||
|
bd4fde9e63 | ||
|
c85cfa90a4 | ||
|
a0449b50c8 | ||
|
010c0bfc7d | ||
|
394b063b8c | ||
|
251780bed6 | ||
|
decc2a9c79 | ||
|
b62d84424e | ||
|
e15324d2cd | ||
|
20b84a9459 | ||
|
109198a374 | ||
|
dafc68f049 | ||
|
3e5c944365 | ||
|
d95838309e | ||
|
4745fc0db1 | ||
|
b3648187ff | ||
|
b35483edf8 | ||
|
f29088e854 | ||
|
b009dfe4ee | ||
|
eba94f430c | ||
|
06347b7e3c | ||
|
6bc426b21d | ||
|
e0fac8d2c0 | ||
|
4a849aab7f | ||
|
ad2d0c84e4 | ||
|
6bf360fbfb | ||
|
380292a8aa | ||
|
f9949da745 | ||
|
b0cc52e0aa | ||
|
9ef705650b | ||
|
c6dadbe75e | ||
|
0a65c05083 | ||
|
21026340ad | ||
|
eb2273247f | ||
|
02fc698f1c | ||
|
8dc11e89bd | ||
|
12c3264000 | ||
|
b34672ed19 | ||
|
541c8c4b93 | ||
|
b97373bf37 | ||
|
c0b561cb5a | ||
|
bd3ccfd0a9 | ||
|
5470acd3af | ||
|
64696fe973 | ||
|
f2cd93dc54 | ||
|
ed2fa4cdd8 | ||
|
c2c4636961 | ||
|
03cca1d1c0 | ||
|
354b075885 | ||
|
de7f039b99 | ||
|
c2e530af17 | ||
|
19797d5d23 | ||
|
144c2b5fc7 | ||
|
577b525508 | ||
|
7da40d1c2e | ||
|
4e3a5ca33b | ||
|
97e4707f72 | ||
|
541dd6b579 | ||
|
450ee43677 | ||
|
e57229e670 | ||
|
c0b2e886d2 | ||
|
bb4749c65b | ||
|
1ef2a96465 | ||
|
5aa37c68a2 | ||
|
6e5a1a1f4d | ||
|
b624ce16c3 | ||
|
3be5b5f919 | ||
|
04ac5b69ac | ||
|
9cc14ac5c7 | ||
|
755eb1405b | ||
|
0138294b36 | ||
|
c0a4addf30 | ||
|
39bbee0eeb | ||
|
39dda3f534 | ||
|
1cb8dbe795 | ||
|
85f1ca6101 | ||
|
e13820bbf0 | ||
|
3973362457 | ||
|
2f3f13afb9 | ||
|
b38877e193 | ||
|
0e1dbb5377 | ||
|
f07cc8b103 | ||
|
67775bc797 | ||
|
05933a5f0c | ||
|
e15ebd5aac | ||
|
d32b2ca524 | ||
|
d0a895628e | ||
|
90f2b3fb21 | ||
|
0f95800dde | ||
|
ff99cd2456 | ||
|
04971f8672 | ||
|
b6177b27f4 | ||
|
ae97e87506 | ||
|
df91ca500f | ||
|
2d73f541c0 | ||
|
7561eabe52 | ||
|
067fb1b0b7 | ||
|
78071841cc | ||
|
fac232229e | ||
|
a43021e87c | ||
|
eb599373ee | ||
|
7b529b5f9e | ||
|
1a0de3b3cf | ||
|
947154dc9c | ||
|
8acd1f1fe4 | ||
|
665210e506 | ||
|
91b9370a21 | ||
|
ca0aa600b4 | ||
|
1c42c4c969 | ||
|
389f0ae452 | ||
|
6e406311c3 | ||
|
00a24756f4 | ||
|
9025f312cc | ||
|
d5e453edd3 | ||
|
26cc64bdd6 | ||
|
87884c1af2 | ||
|
c0623a949c | ||
|
42a23ea23c | ||
|
710c31d1ca | ||
|
f637ba4dad | ||
|
80d2c60cef | ||
|
b17d71e4c3 | ||
|
3c5bd3a605 | ||
|
7cc8d1e693 | ||
|
99520d54a5 | ||
|
e10a8c078b | ||
|
7a196fed7c | ||
|
c418c7e725 | ||
|
e35748deff | ||
|
b57efbe31c | ||
|
8f91437701 | ||
|
a7eb547ca4 | ||
|
0ceb12f74f | ||
|
4960624de7 | ||
|
2c92f24d89 | ||
|
0b7c679dba | ||
|
aab3cdc58a | ||
|
3c63f5b204 | ||
|
0b97bbf827 | ||
|
970dbb0c59 | ||
|
d21d3c2ceb | ||
|
0f6493b8ce | ||
|
f26beec1d3 | ||
|
c4225e29ed | ||
|
9243326cd3 | ||
|
86beb8023a | ||
|
f4e134742b | ||
|
2b17bf4114 | ||
|
c4f0fc8f7b | ||
|
3d6c836e7d | ||
|
c279055af4 | ||
|
657cba042a | ||
|
7a58e48cae | ||
|
670eab62bf | ||
|
fc85586809 | ||
|
837fc4988d | ||
|
4664b47851 | ||
|
9864d932e0 | ||
|
436f60caa9 | ||
|
e9530eda9d | ||
|
f4ddac16f9 | ||
|
33bdc07f55 | ||
|
130a4ed2d3 | ||
|
59ebc1af8a | ||
|
10731696df | ||
|
82e15d2e98 | ||
|
9c842f1895 | ||
|
8cc849488b | ||
|
fcaa232967 | ||
|
d3f8153b90 | ||
|
99ecf22790 | ||
|
b1b7ee65b4 | ||
|
6889456662 | ||
|
081553dc9f | ||
|
1e7a2c400b | ||
|
30e897af48 | ||
|
dd30ac4fbd | ||
|
5d8d796807 | ||
|
f3d88fc746 | ||
|
d87ccae6c9 | ||
|
2760a7816d | ||
|
8f5c649d0f | ||
|
fcf9e7a1ef | ||
|
2be7b6480a | ||
|
f5ded86c02 | ||
|
8abfaf0ffd | ||
|
b070ba142f | ||
|
bd7c6828bf | ||
|
d0671ec58c | ||
|
1e40ffd1cc | ||
|
da2c3e9ed7 | ||
|
f62d6abb69 | ||
|
12ad7a6322 | ||
|
b75247ea3a | ||
|
9a963bbe79 | ||
|
994bc8b0e4 | ||
|
2451605033 | ||
|
10942527f3 | ||
|
4909ed5ccd | ||
|
5cf5df68dc | ||
|
dd32cc99ea | ||
|
79092dc8eb | ||
|
6229e4610a | ||
|
bfe36d3f4a | ||
|
d42bf44fad | ||
|
33e352819c | ||
|
53ee0aaee1 | ||
|
42707a19bd | ||
|
0888706790 | ||
|
16453a05f8 | ||
|
2fbb7466d3 | ||
|
f29f94f45f | ||
|
cd973868fc | ||
|
3c35f02cac | ||
|
22c93e2389 | ||
|
8c751eb532 | ||
|
4df443e007 | ||
|
f52fa41439 | ||
|
f77d72fd86 | ||
|
9b80c84794 | ||
|
725fc658f8 | ||
|
24bbf226a1 | ||
|
e4e27662f5 | ||
|
83474da7b5 | ||
|
9146e5f287 | ||
|
ff4502c63a | ||
|
9711bd8fe1 | ||
|
05139e53be | ||
|
870b2642a9 | ||
|
d31b26ae7d | ||
|
5511925ba2 | ||
|
f96b20717d | ||
|
940c9c45d7 | ||
|
87d6008dfc | ||
|
b9c0c8d2dc | ||
|
81c30d5fd5 | ||
|
0aa33d88a5 | ||
|
e64a504bb1 | ||
|
bc876a159e | ||
|
4aa1a838ed | ||
|
095a51edd0 | ||
|
a3a11bdf3f | ||
|
70b503f3d4 | ||
|
bbf609b880 | ||
|
0992bafe30 | ||
|
c810c5275a | ||
|
3c1b06872d | ||
|
cefdd2f66c | ||
|
5dac13b1da | ||
|
6014b04b2a | ||
|
8aebf48193 | ||
|
fbb905957b | ||
|
e0ce135838 | ||
|
60497c60c1 | ||
|
251a77c8b4 | ||
|
2b7c1345ee | ||
|
69b7d94774 | ||
|
7b7494b8a4 | ||
|
8fe762709b | ||
|
a3f17deb17 | ||
|
9390dcdd43 | ||
|
e6fb460071 | ||
|
6137fdeb33 | ||
|
4a4d02ea6a | ||
|
be26e5f152 | ||
|
127bfba135 | ||
|
7efae3c125 | ||
|
1e5af21000 | ||
|
33a0a4c173 | ||
|
eeb7974e05 | ||
|
f45ea1a31c | ||
|
a866dbaa80 | ||
|
62447d6b89 | ||
|
93f0724b83 | ||
|
724762843d | ||
|
ff16afbf0b | ||
|
9d7ef25062 | ||
|
88078d65e9 | ||
|
b07a97c17e | ||
|
41e7d65e2a | ||
|
7fa5865cf6 | ||
|
e0b8fe3b1a | ||
|
4a11dd1e16 | ||
|
34a474101f | ||
|
e6799e7a04 | ||
|
0f83f9992c | ||
|
d2ad78eb40 | ||
|
0b32738f4e | ||
|
a9cedb3fca | ||
|
51f9cd4bb4 | ||
|
a1668e2411 | ||
|
9418045a2c | ||
|
a7da6d210a | ||
|
59a41dc844 | ||
|
e09f2c9beb | ||
|
4bc3c8d9ac | ||
|
9acea8adf4 | ||
|
e5f754ed0e | ||
|
263a8f9048 | ||
|
6f9e52792a | ||
|
4a9b01e93b | ||
|
b7de23e895 | ||
|
f358f78da8 | ||
|
b8ab66369e | ||
|
54a78d5565 | ||
|
2c8d055ca4 | ||
|
8cc06683df | ||
|
774799f316 | ||
|
b4324cd685 | ||
|
ca212c8737 | ||
|
6fe4ed3e24 | ||
|
a659f2e49d | ||
|
760fbbb357 | ||
|
56388145b5 | ||
|
cd60db417c | ||
|
5cce01215f | ||
|
d53daaa387 | ||
|
bc8bdfe385 | ||
|
4578af7a6d | ||
|
9e1cdd8f57 | ||
|
682c3b834f | ||
|
e269bab186 | ||
|
33a89c5d89 | ||
|
139047b22b | ||
|
9b50114852 | ||
|
b100d198e8 | ||
|
7849f2fb4b | ||
|
b35ecddde3 | ||
|
bde7921016 | ||
|
35c60eaee5 | ||
|
ee28e3346b | ||
|
d33b0587cb | ||
|
7e0ed537b7 | ||
|
1e351eb01d | ||
|
1a83bddf8c | ||
|
2a63c35743 | ||
|
27dcbcd7e1 | ||
|
af24d4edbe | ||
|
0cf4c7b7b7 | ||
|
eef21759cd | ||
|
242a2767a1 | ||
|
623372387d | ||
|
626051e489 | ||
|
abf0f4d699 | ||
|
2bea447de5 | ||
|
541fc7e14e | ||
|
f6538b6110 | ||
|
2d3ae71a3d | ||
|
e48bdf9d5a | ||
|
21fce9a5b5 | ||
|
774b9ae12d | ||
|
9a20faf640 | ||
|
123493ee59 | ||
|
2d498dd138 | ||
|
376214e2d2 | ||
|
62da469fd1 | ||
|
d64009e23e | ||
|
fd8b642d64 | ||
|
d5ed5cd665 | ||
|
fa95d064ff | ||
|
1905e0ee6f | ||
|
96b18faea1 | ||
|
0d7f2e157a | ||
|
2d66936d8b | ||
|
b637a63e71 | ||
|
1ae778d81e | ||
|
f4412ee96b | ||
|
9130aceb5a | ||
|
b8336c03c3 | ||
|
76c3ade394 | ||
|
d9b22fd513 | ||
|
88ea998f8b | ||
|
8b605aeaa8 | ||
|
db91577485 | ||
|
5d9404863d | ||
|
d3986ca14a | ||
|
2508c1abb2 | ||
|
983e3b2274 | ||
|
72a02e087c | ||
|
352b4a0b73 | ||
|
d957b2d20f | ||
|
dcab8af8ab | ||
|
e1987c34d9 | ||
|
a80a8aab1c | ||
|
d6fbcdb09d | ||
|
d39b28b011 | ||
|
8f36128fe3 | ||
|
986d4c99bd | ||
|
f677dcb1f4 | ||
|
1a9b220ec2 | ||
|
d15d252af7 | ||
|
5e3618716d | ||
|
f13522559d | ||
|
777c2726d3 | ||
|
c25afdc203 | ||
|
c25f6d7c38 | ||
|
388e46ee81 | ||
|
242866948f | ||
|
15bb0ce990 | ||
|
a82911ea5d | ||
|
9a8f20317b | ||
|
b605a0f622 | ||
|
046a074c3a | ||
|
7c96fac95c | ||
|
a3ef53102d | ||
|
68513b775b | ||
|
a79dcc93f6 | ||
|
7aabfc573b | ||
|
22dde5d18e | ||
|
56505457eb | ||
|
5930c6d5fb | ||
|
6b162a4e49 | ||
|
c3b9888b31 | ||
|
cb8dfdde4c | ||
|
ff5d333fc8 | ||
|
8eb4b6288a | ||
|
f18836be90 | ||
|
3372070a58 | ||
|
f99e2ebd13 | ||
|
4feb26eefb | ||
|
2da7cd2064 | ||
|
9d7daf7afd | ||
|
cb1ebc1cd0 | ||
|
6ad56a0859 | ||
|
8515781564 | ||
|
7e2bfbd255 | ||
|
e2325f7ba4 | ||
|
9afdab8c52 | ||
|
f620d4a9ca | ||
|
fb1e763bbe | ||
|
ecea7e7493 | ||
|
560ade00b4 | ||
|
81ea24ad54 | ||
|
747b25046a | ||
|
b9536812f4 | ||
|
eed2f0a430 | ||
|
31fe8cd263 | ||
|
a3fadbaa1a | ||
|
e2be655d74 | ||
|
2cd653c773 | ||
|
6538aff02f | ||
|
c1e3dec9be | ||
|
42c13ae135 | ||
|
9bd51c650b | ||
|
ba1c1c87c4 | ||
|
ceefba2743 | ||
|
70da46b05e | ||
|
32b7b39223 | ||
|
2343c79126 | ||
|
09a5a69f86 | ||
|
4081895a78 | ||
|
a522566a0c | ||
|
013c4e9c35 | ||
|
067f289050 | ||
|
06511b92aa | ||
|
32f4c9eabf | ||
|
14bc345883 | ||
|
d76b4fd7ea | ||
|
83cdd7e9fb | ||
|
50441bae62 | ||
|
5b0766a9b0 | ||
|
d979fe8e5f | ||
|
f2c52fd278 | ||
|
87d60308f2 | ||
|
64ebc56c87 | ||
|
58d485cbb5 | ||
|
e99dd3310c | ||
|
400f6e02a5 | ||
|
700b0609df | ||
|
bc52f90ed4 | ||
|
0771546dad | ||
|
95a1a71a66 | ||
|
b9b8e3f632 | ||
|
1e03a2ae40 | ||
|
25da6aeeca | ||
|
00b422807b | ||
|
130701a7bb | ||
|
ff88e68904 | ||
|
55fcf23d2b | ||
|
1b0b4c4cc5 | ||
|
cd57731593 | ||
|
37a80b935d | ||
|
4d61aec153 | ||
|
1adf25d50b | ||
|
ae33aee3f6 | ||
|
2c99e71626 | ||
|
e7f7775efa | ||
|
8b60a19577 | ||
|
5701e08db9 | ||
|
72a2fc49f8 | ||
|
d2617322c6 | ||
|
fa82745f64 | ||
|
19b2a334e4 | ||
|
627c2adf08 | ||
|
e3e137ca50 | ||
|
0978be580f | ||
|
5792838333 | ||
|
f9995583a5 | ||
|
4cc68bd139 | ||
|
d2510ad1bc | ||
|
4d81d3613c | ||
|
7d28963a32 | ||
|
b2594468b4 | ||
|
754b9832e9 | ||
|
097ac879ea | ||
|
bc0416cbb4 | ||
|
2814617e4b | ||
|
da9dfd166d | ||
|
1be07a42df | ||
|
1d83a6a898 | ||
|
2ff286b672 | ||
|
e8620a0986 | ||
|
7fb18bbdc7 | ||
|
5b67b687d3 | ||
|
9adcfa99f4 | ||
|
e723aaa5f6 | ||
|
d128b037a5 | ||
|
99fda00442 | ||
|
f574f8faf0 | ||
|
cedfa90d76 | ||
|
f1e6f6e505 | ||
|
b4f95cced7 | ||
|
e16c0caebb | ||
|
52489a484a | ||
|
e1d5c2c578 | ||
|
49f49632ad | ||
|
6dadc6fb1e | ||
|
a58a2f5fe4 | ||
|
c33c6bbff0 | ||
|
093f90d4c1 | ||
|
20ffa325d3 | ||
|
5027304801 | ||
|
85d5afd6d9 | ||
|
df295e92ee | ||
|
2e67bd2407 | ||
|
9aa01ee8cf | ||
|
6b993ad329 | ||
|
d70ded0993 | ||
|
3dacdcc8bb | ||
|
bb03026589 | ||
|
2f69e3141e | ||
|
1f4564da76 | ||
|
3b8e5ddfb3 | ||
|
edc293f96a | ||
|
3fa4149bb0 | ||
|
4b68a6ff23 | ||
|
28116c49dc | ||
|
6e6f144b7a | ||
|
e3f4f24c3e | ||
|
eb37e3a52b | ||
|
95d540630e | ||
|
23d66a0d68 | ||
|
0d64692c84 | ||
|
1cb640e51e | ||
|
2d98285545 | ||
|
376001100a | ||
|
e2954249f8 | ||
|
cc0b0196f4 | ||
|
6dfa171b4e | ||
|
b140073988 | ||
|
c22bc857b0 | ||
|
497fbdcdfc | ||
|
861f1b2ca3 | ||
|
6108ef4c89 | ||
|
a9c0bcb4a2 | ||
|
8f9de05768 | ||
|
e61e94f0fa | ||
|
85aac02593 | ||
|
9a678c41fe | ||
|
7c8f6ce62f | ||
|
9a896ea81e | ||
|
422c1893c0 | ||
|
f1cb5276d7 | ||
|
bed1b24340 | ||
|
da909ff084 | ||
|
8f743b70a4 | ||
|
a761017116 | ||
|
f06cc25a99 | ||
|
eff8480d5c | ||
|
eec303de49 | ||
|
07a936c5e8 | ||
|
a6002a2c6c | ||
|
7ba014ba49 | ||
|
165c649f31 | ||
|
4cf71dd336 | ||
|
c0a401216b | ||
|
2d712a3841 | ||
|
a2b5b4dd17 | ||
|
0480edce2a | ||
|
4eded82102 | ||
|
ec4ff83465 | ||
|
8745b8b051 | ||
|
4e28c3cadb | ||
|
76c724c783 | ||
|
032cb59388 | ||
|
e29247774c | ||
|
18d16f9a8b | ||
|
d137735be1 | ||
|
65929c02bc | ||
|
22466d6b98 | ||
|
23fe79c618 | ||
|
b202ca5619 | ||
|
7929711fea | ||
|
49a028a599 | ||
|
670cbcd336 | ||
|
449d31e8a1 | ||
|
fe82583813 | ||
|
1450a21d00 | ||
|
ae15544aed | ||
|
4b7a37cf7d | ||
|
cb7727900c | ||
|
e012726cd4 | ||
|
f49688fdb9 | ||
|
e32b017431 | ||
|
393869e538 | ||
|
d3bde0408f | ||
|
34d3225984 | ||
|
eaed53e25b | ||
|
feacbe8ebd | ||
|
bdf6052388 | ||
|
ec0a0190e7 | ||
|
99d653eece | ||
|
f825c5ae83 | ||
|
173484c30e | ||
|
884270093b | ||
|
62e7ab8c2b | ||
|
ded480207b | ||
|
ef49e2b5b3 | ||
|
b0a055a870 | ||
|
9b9e29a3b6 | ||
|
1a9a436cbe | ||
|
62dd29d2f3 | ||
|
d406d91856 | ||
|
98494c610a | ||
|
649a553fa4 | ||
|
e17a06d7bd | ||
|
4b14cc6a74 | ||
|
65560ab65e | ||
|
b65095fd0f | ||
|
28733179d2 | ||
|
960d23ca50 | ||
|
057f70ea9c | ||
|
3e378bd665 | ||
|
c120138f26 | ||
|
31b20362ec | ||
|
e7eb5b6ea6 | ||
|
fe91ed815e | ||
|
9e5cad0dc8 | ||
|
36cbc42363 | ||
|
4cb82ea9bd | ||
|
572ac4a17b | ||
|
6c552f6b43 | ||
|
9723129436 | ||
|
dc5191f8ba | ||
|
d79726899f | ||
|
8ab5710098 | ||
|
61d628d596 | ||
|
078fc25845 | ||
|
67eb4b317a | ||
|
6322919bc7 | ||
|
b81b8a1dea | ||
|
f3a3797850 | ||
|
5ec1283bb1 | ||
|
8634b0c6f0 | ||
|
d89830af61 | ||
|
ef1736b571 | ||
|
d951ee4b83 | ||
|
04081f62c4 | ||
|
0f28dc5e55 | ||
|
508f49df18 | ||
|
bec280c6b1 | ||
|
6a8ae9c0c4 | ||
|
ac22483f98 | ||
|
59d56d5c83 | ||
|
cdaad2fb4a | ||
|
843279bacb | ||
|
a000de0270 | ||
|
bf41b04cfa | ||
|
9ce2e8ea53 | ||
|
f066926fc9 | ||
|
4c38b0ab10 | ||
|
cf35c9dcef | ||
|
45ff9394f2 | ||
|
414043ded1 | ||
|
2533c9c14e | ||
|
f10f0dada6 | ||
|
282859bc1b | ||
|
a3ae97a5a3 | ||
|
ed8275a20b | ||
|
f2add3f788 | ||
|
ad144922fb | ||
|
b9c0f2a3d3 | ||
|
0cc07362b8 | ||
|
4ee5dcaff3 | ||
|
4d44746a88 | ||
|
1535bdbcd8 | ||
|
ecb160b28d | ||
|
7e9941def0 | ||
|
f9c6fb30bf | ||
|
94ad93ebd7 | ||
|
0e1ec5034e | ||
|
76b0505bd9 | ||
|
b309c1fc91 | ||
|
b9a802a19c | ||
|
a882ad3e65 | ||
|
969105b205 | ||
|
28bfb06c67 | ||
|
704dcb3e58 | ||
|
a7e723d8d4 | ||
|
4c6f5096be | ||
|
1a1d105fae | ||
|
42a0639bb5 | ||
|
e27b08203d | ||
|
1ca4583896 | ||
|
25fc6f1937 | ||
|
0ccc3f7252 | ||
|
66acd1821d | ||
|
93a0217d5f | ||
|
711a697570 | ||
|
f8139f3198 | ||
|
df01022f49 | ||
|
450411a732 | ||
|
f80c67828b | ||
|
d1889a5e06 | ||
|
12263ff02f | ||
|
76f914cbc2 | ||
|
c1f5252b3f | ||
|
20c6f79a44 | ||
|
ee6f1405d4 | ||
|
ee3541d74e | ||
|
e048388213 | ||
|
3ff3431b17 | ||
|
8608ff11f7 | ||
|
7e0d9fbace | ||
|
c6c9cfea22 | ||
|
a758976c69 | ||
|
a14192b7e0 | ||
|
601464083b | ||
|
ccca5d4d1c | ||
|
ba10657829 | ||
|
6315655f93 | ||
|
533cb23b73 | ||
|
0b95424a0d | ||
|
852f252d13 | ||
|
080042882f | ||
|
dde5b08c47 | ||
|
88d2c60ee8 | ||
|
eeff5a5d43 | ||
|
67dd4a72b0 | ||
|
a0b8cc21cc | ||
|
329a7a03a5 | ||
|
d0a3503d74 | ||
|
59b78f9984 | ||
|
bf75f16169 | ||
|
2f833dc457 | ||
|
22344a3971 | ||
|
8dde6ba60f | ||
|
d44f283a05 | ||
|
c18d5786dd | ||
|
f26ccfe16c | ||
|
1c681ee378 | ||
|
4c1ae44bbe | ||
|
94b5ec91cc | ||
|
6bfbf3ee41 | ||
|
fc24fa337e | ||
|
7b4306b1d6 | ||
|
4516cc0d65 | ||
|
308784c601 | ||
|
9145c9a52c | ||
|
4038cb5b85 | ||
|
628658972c | ||
|
db0fe9a755 | ||
|
fdf10e3d2e | ||
|
ded3e06a9b | ||
|
0dd0605a1f | ||
|
827b0c6e50 | ||
|
d1b533848d | ||
|
13ae28edab | ||
|
82253219e8 | ||
|
94592b74a6 | ||
|
eef2112e1e | ||
|
b83d56eff2 | ||
|
4c539b6db4 | ||
|
2ad329e64c | ||
|
4de89ec6ce | ||
|
fc885c8fa2 | ||
|
ef2c98ba39 | ||
|
41d922867e | ||
|
89223760e6 | ||
|
9a8a1f75ca | ||
|
52e8e7e4b0 | ||
|
93f65484de | ||
|
7d08cde8b8 | ||
|
d3c2bf7dd4 | ||
|
54cf3652b0 | ||
|
5607d2086d | ||
|
27ed918896 | ||
|
9b2df9cfd9 | ||
|
5dd08e438c | ||
|
2f8c8c3a28 | ||
|
dde7cf18f7 | ||
|
48aadbf716 | ||
|
5514af60e5 | ||
|
e8449eb02f | ||
|
9ea21a7ecb | ||
|
fec86c2862 | ||
|
11e5d4c5b7 | ||
|
b852fb0e26 | ||
|
0fc18bf3b1 | ||
|
d4be584782 | ||
|
2120d72901 | ||
|
6f9c08f906 | ||
|
70c666c380 | ||
|
46197d82b5 | ||
|
e4eab17595 | ||
|
cf10244f20 | ||
|
570684d308 | ||
|
244db8d894 | ||
|
96d6018ecc | ||
|
73ad6dd0c4 | ||
|
d0e15da352 | ||
|
b1b293a3ec | ||
|
5f0660a4e5 | ||
|
ec3a3a73ef | ||
|
087c4c5929 | ||
|
e0ee2e0801 | ||
|
c79a9e9858 | ||
|
b3fdce36af | ||
|
b7535b9526 | ||
|
0cf1cc5587 | ||
|
098dab889a | ||
|
cc856c7cd1 | ||
|
a20a155d39 | ||
|
1a458fe39f | ||
|
051ffdda35 | ||
|
a48418364c | ||
|
b75497231e | ||
|
5cceeb6ef2 | ||
|
29b94c5615 | ||
|
81fc1eccd3 | ||
|
22fae51c9d | ||
|
4332f7a640 | ||
|
d0e603e62d | ||
|
38c28f4358 | ||
|
742799b4ac | ||
|
5405dc5141 | ||
|
013793f989 | ||
|
2468cf63cc | ||
|
8e9b5d7e50 | ||
|
a33515d907 | ||
|
a276a33081 | ||
|
e325c3dbf6 | ||
|
b3fef625a8 | ||
|
13994a5f96 | ||
|
e8ac62cdd8 | ||
|
1bc7134ec2 | ||
|
273572f44c | ||
|
ac37483d47 | ||
|
0cc2b49296 | ||
|
44d5adc16f | ||
|
f749fc641b | ||
|
87c76c09e2 | ||
|
e787f9dd9f | ||
|
ec7d5b17ab | ||
|
47641eee59 | ||
|
42abe28cc1 | ||
|
b48afa38ac | ||
|
f7269d8df2 | ||
|
ce0fab8d2f | ||
|
33472567de | ||
|
f5e12328dc | ||
|
486c0f2937 | ||
|
e69c4cd1dc | ||
|
e0d3ccd8d1 | ||
|
16a3deec2c | ||
|
92db00692a | ||
|
fe88010a72 | ||
|
99ae4be2c2 | ||
|
a9085752c1 | ||
|
e1fbc9255c | ||
|
f33e25ac40 | ||
|
51365ab006 | ||
|
d61b7e48d7 | ||
|
f590b24f85 | ||
|
308fd55483 | ||
|
fefb44e612 | ||
|
dd3b562f1a | ||
|
30c9aa3df9 | ||
|
688184e255 | ||
|
75fb7c2e95 | ||
|
264b4b669e | ||
|
51f12c51ad | ||
|
03d134697c | ||
|
e706e1a68d | ||
|
ff3f42db95 | ||
|
e8aedeac36 | ||
|
2bf6b263ed | ||
|
bf166b757a | ||
|
b4165335a7 | ||
|
2a1bf2fa71 | ||
|
718d50a037 | ||
|
41960ada4a | ||
|
0a02caad04 | ||
|
ff75bdba9e | ||
|
189243a9b0 | ||
|
34e339c506 | ||
|
a437c603c6 | ||
|
4e940f7fa0 | ||
|
69fde7dead | ||
|
4368c182a0 | ||
|
48f4b12c0e | ||
|
6a6c1b6b21 | ||
|
51808d2ad4 | ||
|
148f1109c6 | ||
|
e1439b529b | ||
|
0adcd1b3d9 | ||
|
fcefd8031a | ||
|
64bebaa1d1 | ||
|
0138ff9e16 | ||
|
9bc085a23e | ||
|
0a92d79ec0 | ||
|
b95f6563cc | ||
|
ca2bcc647d | ||
|
b90048d72e | ||
|
a5fe08e84c | ||
|
2874cf531c | ||
|
ea7058e896 | ||
|
38bb10d36b | ||
|
58943bb156 | ||
|
2d66da3cb9 | ||
|
b7efbf9040 | ||
|
5222e157cb | ||
|
1e3a948977 | ||
|
20cc5107da | ||
|
a6a4d5f09b | ||
|
028c53287d | ||
|
24a9bea137 | ||
|
ef1a2d1730 | ||
|
6abff36e07 | ||
|
4ef7615d88 | ||
|
77c2783a3e | ||
|
5b1dfc123f | ||
|
38a255e069 |
1
.gitattributes
vendored
1
.gitattributes
vendored
|
@ -1,4 +1,5 @@
|
||||||
constants.py ident export-subst
|
constants.py ident export-subst
|
||||||
/test export-ignore
|
/test export-ignore
|
||||||
|
/library export-ignore
|
||||||
cps/static/css/libs/* linguist-vendored
|
cps/static/css/libs/* linguist-vendored
|
||||||
cps/static/js/libs/* linguist-vendored
|
cps/static/js/libs/* linguist-vendored
|
||||||
|
|
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
custom: ["https://PayPal.Me/calibreweb",]
|
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -7,10 +7,22 @@ assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Describe the bug/problem**
|
## Short Notice from the maintainer
|
||||||
|
|
||||||
|
After 6 years of more or less intensive programming on Calibre-Web, I need a break.
|
||||||
|
The last few months, maintaining Calibre-Web has felt more like work than a hobby. I felt pressured and teased by people to solve "their" problems and merge PRs for "their" Calibre-Web.
|
||||||
|
I have turned off all notifications from Github/Discord and will now concentrate undisturbed on the development of “my” Calibre-Web over the next few weeks/months.
|
||||||
|
I will look into the issues and maybe also the PRs from time to time, but don't expect a quick response from me.
|
||||||
|
|
||||||
|
|
||||||
|
Please also have a look at our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md)
|
||||||
|
|
||||||
|
**Describe the bug/problem**
|
||||||
|
|
||||||
A clear and concise description of what the bug is. If you are asking for support, please check our [Wiki](https://github.com/janeczku/calibre-web/wiki) if your question is already answered there.
|
A clear and concise description of what the bug is. If you are asking for support, please check our [Wiki](https://github.com/janeczku/calibre-web/wiki) if your question is already answered there.
|
||||||
|
|
||||||
**To Reproduce**
|
**To Reproduce**
|
||||||
|
|
||||||
Steps to reproduce the behavior:
|
Steps to reproduce the behavior:
|
||||||
1. Go to '...'
|
1. Go to '...'
|
||||||
2. Click on '....'
|
2. Click on '....'
|
||||||
|
@ -18,21 +30,26 @@ Steps to reproduce the behavior:
|
||||||
4. See error
|
4. See error
|
||||||
|
|
||||||
**Logfile**
|
**Logfile**
|
||||||
|
|
||||||
Add content of calibre-web.log file or the relevant error, try to reproduce your problem with "debug" log-level to get more output.
|
Add content of calibre-web.log file or the relevant error, try to reproduce your problem with "debug" log-level to get more output.
|
||||||
|
|
||||||
**Expected behavior**
|
**Expected behavior**
|
||||||
|
|
||||||
A clear and concise description of what you expected to happen.
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
**Screenshots**
|
**Screenshots**
|
||||||
|
|
||||||
If applicable, add screenshots to help explain your problem.
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
**Environment (please complete the following information):**
|
**Environment (please complete the following information):**
|
||||||
- OS: [e.g. Windows 10/raspian]
|
|
||||||
- Python version [e.g. python2.7]
|
- OS: [e.g. Windows 10/Raspberry Pi OS]
|
||||||
- Calibre-Web version [e.g. 0.6.5 or master@16.02.20, 19:55 ]:
|
- Python version: [e.g. python2.7]
|
||||||
- Docker container [ None/Technosoft2000/Linuxuser]:
|
- Calibre-Web version: [e.g. 0.6.8 or 087c4c59 (git rev-parse --short HEAD)]:
|
||||||
- Special Hardware [e.g. Rasperry Pi Zero]
|
- Docker container: [None/LinuxServer]:
|
||||||
- Browser [e.g. chrome, safari]
|
- Special Hardware: [e.g. Rasperry Pi Zero]
|
||||||
|
- Browser: [e.g. Chrome 83.0.4103.97, Safari 13.3.7, Firefox 68.0.1 ESR]
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
Add any other context about the problem here. [e.g. access via reverse proxy]
|
Add any other context about the problem here. [e.g. access via reverse proxy, database background sync, special database location]
|
||||||
|
|
||||||
|
|
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
blank_issues_enabled: false
|
9
.github/ISSUE_TEMPLATE/feature_request.md
vendored
9
.github/ISSUE_TEMPLATE/feature_request.md
vendored
|
@ -7,6 +7,15 @@ assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
# Short Notice from the maintainer
|
||||||
|
|
||||||
|
After 6 years of more or less intensive programming on Calibre-Web, I need a break.
|
||||||
|
The last few months, maintaining Calibre-Web has felt more like work than a hobby. I felt pressured and teased by people to solve "their" problems and merge PRs for "their" Calibre-Web.
|
||||||
|
I have turned off all notifications from Github/Discord and will now concentrate undisturbed on the development of “my” Calibre-Web over the next few weeks/months.
|
||||||
|
I will look into the issues and maybe also the PRs from time to time, but don't expect a quick response from me.
|
||||||
|
|
||||||
|
Please have a look at our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md)
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
**Is your feature request related to a problem? Please describe.**
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
|
7
.gitignore
vendored
7
.gitignore
vendored
|
@ -6,10 +6,12 @@ __pycache__/
|
||||||
|
|
||||||
# Distribution / packaging
|
# Distribution / packaging
|
||||||
.Python
|
.Python
|
||||||
|
.python-version
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
eggs/
|
eggs/
|
||||||
dist/
|
dist/
|
||||||
|
executable/
|
||||||
build/
|
build/
|
||||||
vendor/
|
vendor/
|
||||||
.eggs/
|
.eggs/
|
||||||
|
@ -21,12 +23,15 @@ vendor/
|
||||||
# calibre-web
|
# calibre-web
|
||||||
*.db
|
*.db
|
||||||
*.log
|
*.log
|
||||||
|
cps/cache
|
||||||
|
|
||||||
.idea/
|
.idea/
|
||||||
*.bak
|
*.bak
|
||||||
*.log.*
|
*.log.*
|
||||||
|
.key
|
||||||
|
|
||||||
settings.yaml
|
settings.yaml
|
||||||
gdrive_credentials
|
gdrive_credentials
|
||||||
client_secrets.json
|
client_secrets.json
|
||||||
|
gmail.json
|
||||||
|
/.key
|
||||||
|
|
|
@ -1,49 +1,46 @@
|
||||||
## How to contribute to Calibre-Web
|
## How to contribute to Calibre-Web
|
||||||
|
|
||||||
First of all, we would like to thank you for reading this text. we are happy you are willing to contribute to Calibre-Web
|
First of all, we would like to thank you for reading this text. We are happy you are willing to contribute to Calibre-Web.
|
||||||
|
|
||||||
### **General**
|
### **General**
|
||||||
|
|
||||||
**Communication language** is english. Google translated texts are not as bad as you might think, they are usually understandable, so don't worry if you generate your post that way.
|
**Communication language** is English. Google translated texts are not as bad as you might think, they are usually understandable, so don't worry if you generate your post that way.
|
||||||
|
|
||||||
**Calibre-Web** is not **Calibre**. If you are having a question regarding Calibre please post this at their [repository](https://github.com/kovidgoyal/calibre).
|
**Calibre-Web** is not **Calibre**. If you are having a question regarding Calibre please post this at their [repository](https://github.com/kovidgoyal/calibre).
|
||||||
|
|
||||||
**Docker-Containers** of Calibre-Web are maintained by different persons than the people who drive Calibre-Web. If we come to the conclusion during our analysis that the problem is related to Docker, we might ask you to open a new issue at the reprository of the Docker Container.
|
**Docker-Containers** of Calibre-Web are maintained by different persons than the people who drive Calibre-Web. If we come to the conclusion during our analysis that the problem is related to Docker, we might ask you to open a new issue at the repository of the Docker Container.
|
||||||
|
|
||||||
If you are having **Basic Installation Problems** with python or it's dependencies, please consider using your favorite search engine to find a solution. In case you can't find a solution, we are happy to help you.
|
If you are having **Basic Installation Problems** with python or its dependencies, please consider using your favorite search engine to find a solution. In case you can't find a solution, we are happy to help you.
|
||||||
|
|
||||||
We can offer only very limited support regarding configuration of **Reverse-Proxy Installations**, **OPDS-Reader** or other programs in combination with Calibre-Web.
|
We can offer only very limited support regarding configuration of **Reverse-Proxy Installations**, **OPDS-Reader** or other programs in combination with Calibre-Web.
|
||||||
|
|
||||||
### **Translation**
|
### **Translation**
|
||||||
|
|
||||||
Some of the user languages in Calibre-Web having missing translations. We are happy to add the missing texts if you are translate them. Create a Pull Request, create an issue with the .po file attached, or write an email to "ozzie.fernandez.isaacs@googlemail.com" with attached translation file. To display all book languages in your native language an additional file is used (iso_language_names.py). The content of this file is autogenerated with the corresponding translations of Calibre, please do not edit this file on your own.
|
Some of the user languages in Calibre-Web having missing translations. We are happy to add the missing texts if you translate them. Create a Pull Request, create an issue with the .po file attached, or write an email to "ozzie.fernandez.isaacs@googlemail.com" with attached translation file. To display all book languages in your native language an additional file is used (iso_language_names.py). The content of this file is auto-generated with the corresponding translations of Calibre, please do not edit this file on your own.
|
||||||
|
|
||||||
### **Documentation**
|
### **Documentation**
|
||||||
|
|
||||||
The Calibre-Web documentation is hosted in the Github [Wiki](https://github.com/janeczku/calibre-web/wiki). The Wiki is open to everybody, if you find a problem, feel free to correct it. If information is missing, you are welcome to add it. The content will be reviewed time by time. Please try to be consitent with the form with the other Wiki pages (e.g. the project name is Calibre-Web with 2 capital letters and a dash in between).
|
The Calibre-Web documentation is hosted in the Github [Wiki](https://github.com/janeczku/calibre-web/wiki). The Wiki is open to everybody, if you find a problem, feel free to correct it. If information is missing, you are welcome to add it. The content will be reviewed time by time. Please try to be consistent with the form with the other Wiki pages (e.g. the project name is Calibre-Web with 2 capital letters and a dash in between).
|
||||||
|
|
||||||
### **Reporting a bug**
|
### **Reporting a bug**
|
||||||
|
|
||||||
Do not open up a GitHub issue if the bug is a **security vulnerability** in Calibre-Web. Please write intead an email to "ozzie.fernandez.isaacs@googlemail.com".
|
Do not open up a GitHub issue if the bug is a **security vulnerability** in Calibre-Web. Instead, please write an email to "ozzie.fernandez.isaacs@googlemail.com".
|
||||||
|
|
||||||
Ensure the ***bug was not already reported** by searching on GitHub under [Issues](https://github.com/janeczku/calibre-web/issues). Please also check if a solution for your problem can be found in the [wiki](https://github.com/janeczku/calibre-web/wiki).
|
Ensure the **bug was not already reported** by searching on GitHub under [Issues](https://github.com/janeczku/calibre-web/issues). Please also check if a solution for your problem can be found in the [wiki](https://github.com/janeczku/calibre-web/wiki).
|
||||||
|
|
||||||
If you're unable to find an **open issue** addressing the problem, open a [new one](https://github.com/janeczku/calibre-web/issues/new?assignees=&labels=&template=bug_report.md&title=). Be sure to include a **title** and **clear description**, as much relevant information as possible, the **issue form** helps you providing the right information. Deleting the form and just pasting the stack trace doesn't speed up fixing the problem. If your issue could be resolved, consider closing the issue.
|
If you're unable to find an **open issue** addressing the problem, open a [new one](https://github.com/janeczku/calibre-web/issues/new/choose). Be sure to include a **title** and **clear description**, as much relevant information as possible, the **issue form** helps you providing the right information. Deleting the form and just pasting the stack trace doesn't speed up fixing the problem. If your issue could be resolved, consider closing the issue.
|
||||||
|
|
||||||
### **Feature Request**
|
### **Feature Request**
|
||||||
|
|
||||||
If there is afeature missing in Calibre-Web and you can't find a feature request in the [Issues](https://github.com/janeczku/calibre-web/issues) section, you could create a [feature request](https://github.com/janeczku/calibre-web/issues/new?assignees=&labels=&template=feature_request.md&title=).
|
If there is a feature missing in Calibre-Web and you can't find a feature request in the [Issues](https://github.com/janeczku/calibre-web/issues) section, you could create a [feature request](https://github.com/janeczku/calibre-web/issues/new?assignees=&labels=&template=feature_request.md&title=).
|
||||||
We will not extend Calibre-Web with any more login abilitys or add further files storages, or file syncing ability. Furthermore Calibre-Web is made for home usage for company inhouse usage, so requests regarding any sorts of social interaction capability, payment routines, search engine or web site analytics integration will not be implemeted.
|
We will not extend Calibre-Web with any more login abilities or add further files storages, or file syncing ability. Furthermore Calibre-Web is made for home usage for company in-house usage, so requests regarding any sorts of social interaction capability, payment routines, search engine or web site analytics integration will not be implemented.
|
||||||
|
|
||||||
### **Contributing code to Calibre-Web**
|
### **Contributing code to Calibre-Web**
|
||||||
|
|
||||||
Open a new GitHub pull request with the patch. Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable.
|
Open a new GitHub pull request with the patch. Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable.
|
||||||
|
|
||||||
In case your code enhances features of Calibre-Web: Create your pull request for the development branch if your enhancement consits of more than some lines of code in a local section of Calibre-Webs code. This makes it easier to test it and check all implication before it's made public.
|
In case your code enhances features of Calibre-Web: Create your pull request for the development branch if your enhancement consists of more than some lines of code in a local section of Calibre-Webs code. This makes it easier to test it and check all implication before it's made public.
|
||||||
|
|
||||||
Please check if your code runs on Python 2.7 (still necessary in 2020) and mainly on python 3. If possible and the feature is related to operating system functions, try to check it on Windows and Linux.
|
|
||||||
Calibre-Web is automatically tested on Linux in combination with python 3.7. The code for testing is in a [seperate repo](https://github.com/OzzieIsaacs/calibre-web-test) on Github. It uses unittest and performs real system tests with selenium, would be great if you could consider also writing some tests.
|
|
||||||
A static code analysis is done by Codacy, but it's partly broken and doesn't run automatically. You could check your code with ESLint before contributing, a configuration file can be found in the projects root folder.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Please check if your code runs with python 3, python 2 is no longer supported. If possible and the feature is related to operating system functions, try to check it on Windows and Linux.
|
||||||
|
Calibre-Web is automatically tested on Linux in combination with python 3.8. The code for testing is in a [separate repo](https://github.com/OzzieIsaacs/calibre-web-test) on Github. It uses unit tests and performs real system tests with selenium; it would be great if you could consider also writing some tests.
|
||||||
|
A static code analysis is done by Codacy, but it's partly broken and doesn't run automatically. You could check your code with ESLint before contributing, a configuration file can be found in the projects root folder.
|
||||||
|
|
160
README.md
Normal file → Executable file
160
README.md
Normal file → Executable file
|
@ -1,6 +1,37 @@
|
||||||
# About
|
# Short Notice from the maintainer
|
||||||
|
|
||||||
|
After 6 years of more or less intensive programming on Calibre-Web, I need a break.
|
||||||
|
The last few months, maintaining Calibre-Web has felt more like work than a hobby. I felt pressured and teased by people to solve "their" problems and merge PRs for "their" Calibre-Web.
|
||||||
|
I have turned off all notifications from Github/Discord and will now concentrate undisturbed on the development of “my” Calibre-Web over the next few weeks/months.
|
||||||
|
I will look into the issues and maybe also the PRs from time to time, but don't expect a quick response from me.
|
||||||
|
|
||||||
|
# Calibre-Web
|
||||||
|
|
||||||
|
Calibre-Web is a web app that offers a clean and intuitive interface for browsing, reading, and downloading eBooks using a valid [Calibre](https://calibre-ebook.com) database.
|
||||||
|
|
||||||
|
[![License](https://img.shields.io/github/license/janeczku/calibre-web?style=flat-square)](https://github.com/janeczku/calibre-web/blob/master/LICENSE)
|
||||||
|
![Commit Activity](https://img.shields.io/github/commit-activity/w/janeczku/calibre-web?logo=github&style=flat-square&label=commits)
|
||||||
|
[![All Releases](https://img.shields.io/github/downloads/janeczku/calibre-web/total?logo=github&style=flat-square)](https://github.com/janeczku/calibre-web/releases)
|
||||||
|
[![PyPI](https://img.shields.io/pypi/v/calibreweb?logo=pypi&logoColor=fff&style=flat-square)](https://pypi.org/project/calibreweb/)
|
||||||
|
[![PyPI - Downloads](https://img.shields.io/pypi/dm/calibreweb?logo=pypi&logoColor=fff&style=flat-square)](https://pypi.org/project/calibreweb/)
|
||||||
|
[![Discord](https://img.shields.io/discord/838810113564344381?label=Discord&logo=discord&style=flat-square)](https://discord.gg/h2VsJ2NEfB)
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Table of Contents</strong> (click to expand)</summary>
|
||||||
|
|
||||||
|
1. [About](#calibre-web)
|
||||||
|
2. [Features](#features)
|
||||||
|
3. [Installation](#installation)
|
||||||
|
- [Installation via pip (recommended)](#installation-via-pip-recommended)
|
||||||
|
- [Quick start](#quick-start)
|
||||||
|
- [Requirements](#requirements)
|
||||||
|
4. [Docker Images](#docker-images)
|
||||||
|
5. [Contributor Recognition](#contributor-recognition)
|
||||||
|
6. [Contact](#contact)
|
||||||
|
7. [Contributing to Calibre-Web](#contributing-to-calibre-web)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
Calibre-Web is a web app providing a clean interface for browsing, reading and downloading eBooks using an existing [Calibre](https://calibre-ebook.com) database.
|
|
||||||
|
|
||||||
*This software is a fork of [library](https://github.com/mutschler/calibreserver) and licensed under the GPL v3 License.*
|
*This software is a fork of [library](https://github.com/mutschler/calibreserver) and licensed under the GPL v3 License.*
|
||||||
|
|
||||||
|
@ -8,80 +39,87 @@ Calibre-Web is a web app providing a clean interface for browsing, reading and d
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Bootstrap 3 HTML5 interface
|
- Modern and responsive Bootstrap 3 HTML5 interface
|
||||||
- full graphical setup
|
- Full graphical setup
|
||||||
- User management with fine-grained per-user permissions
|
- Comprehensive user management with fine-grained per-user permissions
|
||||||
- Admin interface
|
- Admin interface
|
||||||
- User Interface in czech, dutch, english, finnish, french, german, hungarian, italian, japanese, khmer, polish, russian, simplified chinese, spanish, swedish, turkish, ukrainian
|
- Multilingual user interface supporting 20+ languages ([supported languages](https://github.com/janeczku/calibre-web/wiki/Translation-Status))
|
||||||
- OPDS feed for eBook reader apps
|
- OPDS feed for eBook reader apps
|
||||||
- Filter and search by titles, authors, tags, series and language
|
- Advanced search and filtering options
|
||||||
- Create a custom book collection (shelves)
|
- Custom book collection (shelves) creation
|
||||||
- Support for editing eBook metadata and deleting eBooks from Calibre library
|
- eBook metadata editing and deletion support
|
||||||
- Support for converting eBooks through Calibre binaries
|
- Metadata download from various sources (extensible via plugins)
|
||||||
- Restrict eBook download to logged-in users
|
- eBook conversion through Calibre binaries
|
||||||
- Support for public user registration
|
- eBook download restriction to logged-in users
|
||||||
- Send eBooks to Kindle devices with the click of a button
|
- Public user registration support
|
||||||
- Sync your Kobo devices through Calibre-Web with your Calibre library
|
- Send eBooks to E-Readers with a single click
|
||||||
- Support for reading eBooks directly in the browser (.txt, .epub, .pdf, .cbr, .cbt, .cbz)
|
- Sync Kobo devices with your Calibre library
|
||||||
- Upload new books in many formats, including audio formats (.mp3, .m4a, .m4b)
|
- In-browser eBook reading support for multiple formats
|
||||||
- Support for Calibre Custom Columns
|
- Upload new books in various formats, including audio formats
|
||||||
- Ability to hide content based on categories and Custom Column content per user
|
- Calibre Custom Columns support
|
||||||
|
- Content hiding based on categories and Custom Column content per user
|
||||||
- Self-update capability
|
- Self-update capability
|
||||||
- "Magic Link" login to make it easy to log on eReaders
|
- "Magic Link" login for easy access on eReaders
|
||||||
- Login via LDAP, google/github oauth and via proxy authentication
|
- LDAP, Google/GitHub OAuth, and proxy authentication support
|
||||||
|
|
||||||
## Quick start
|
## Installation
|
||||||
|
|
||||||
1. Install dependencies by running `pip3 install --target vendor -r requirements.txt` (python3.x) or `pip install --target vendor -r requirements.txt` (python2.7).
|
#### Installation via pip (recommended)
|
||||||
2. Execute the command: `python cps.py` (or `nohup python cps.py` - recommended if you want to exit the terminal window)
|
1. Create a virtual environment for Calibre-Web to avoid conflicts with existing Python dependencies
|
||||||
3. Point your browser to `http://localhost:8083` or `http://localhost:8083/opds` for the OPDS catalog
|
2. Install Calibre-Web via pip: `pip install calibreweb` (or `pip3` depending on your OS/distro)
|
||||||
4. Set `Location of Calibre database` to the path of the folder where your Calibre library (metadata.db) lives, push "submit" button\
|
3. Install optional features via pip as needed, see [this page](https://github.com/janeczku/calibre-web/wiki/Dependencies-in-Calibre-Web-Linux-and-Windows) for details
|
||||||
Optionally a Google Drive can be used to host the calibre library [-> Using Google Drive integration](https://github.com/janeczku/calibre-web/wiki/Configuration#using-google-drive-integration)
|
4. Start Calibre-Web by typing `cps`
|
||||||
5. Go to Login page
|
|
||||||
|
|
||||||
**Default admin login:**\
|
*Note: Raspberry Pi OS users may encounter issues during installation. If so, please update pip (`./venv/bin/python3 -m pip install --upgrade pip`) and/or install cargo (`sudo apt install cargo`) before retrying the installation.*
|
||||||
*Username:* admin\
|
|
||||||
*Password:* admin123
|
|
||||||
|
|
||||||
**Issues with Ubuntu:**
|
Refer to the Wiki for additional installation examples: [manual installation](https://github.com/janeczku/calibre-web/wiki/Manual-installation), [Linux Mint](https://github.com/janeczku/calibre-web/wiki/How-To:-Install-Calibre-Web-in-Linux-Mint-19-or-20), [Cloud Provider](https://github.com/janeczku/calibre-web/wiki/How-To:-Install-Calibre-Web-on-a-Cloud-Provider).
|
||||||
Please note that running the above install command can fail on some versions of Ubuntu, saying `"can't combine user with prefix"`. This is a [known bug](https://github.com/pypa/pip/issues/3826) and can be remedied by using the command `pip install --system --target vendor -r requirements.txt` instead.
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. Open your browser and navigate to `http://localhost:8083` or `http://localhost:8083/opds` for the OPDS catalog
|
||||||
|
2. Log in with the default admin credentials
|
||||||
|
3. If you don't have a Calibre database, you can use [this database](https://github.com/janeczku/calibre-web/raw/master/library/metadata.db) (move it out of the Calibre-Web folder to prevent overwriting during updates)
|
||||||
|
4. Set `Location of Calibre database` to the path of the folder containing your Calibre library (metadata.db) and click "Save"
|
||||||
|
5. Optionally, use Google Drive to host your Calibre library by following the [Google Drive integration guide](https://github.com/janeczku/calibre-web/wiki/G-Drive-Setup#using-google-drive-integration)
|
||||||
|
6. Configure your Calibre-Web instance via the admin page, referring to the [Basic Configuration](https://github.com/janeczku/calibre-web/wiki/Configuration#basic-configuration) and [UI Configuration](https://github.com/janeczku/calibre-web/wiki/Configuration#ui-configuration) guides
|
||||||
|
|
||||||
|
#### Default Admin Login:
|
||||||
|
- **Username:** admin
|
||||||
|
- **Password:** admin123
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
python 3.x+, (Python 2.7+)
|
- Python 3.5+
|
||||||
|
- [Imagemagick](https://imagemagick.org/script/download.php) for cover extraction from EPUBs (Windows users may need to install [Ghostscript](https://ghostscript.com/releases/gsdnld.html) for PDF cover extraction)
|
||||||
Optionally, to enable on-the-fly conversion from one ebook format to another when using the send-to-kindle feature, or during editing of ebooks metadata:
|
- Optional: [Calibre desktop program](https://calibre-ebook.com/download) for on-the-fly conversion and metadata editing (set "calibre's converter tool" path on the setup page)
|
||||||
|
- Optional: [Kepubify tool](https://github.com/pgaskin/kepubify/releases/latest) for Kobo device support (place the binary in `/opt/kepubify` on Linux or `C:\Program Files\kepubify` on Windows)
|
||||||
[Download and install](https://calibre-ebook.com/download) the Calibre desktop program for your platform and enter the folder including program name (normally /opt/calibre/ebook-convert, or C:\Program Files\calibre\ebook-convert.exe) in the field "calibre's converter tool" on the setup page.
|
|
||||||
|
|
||||||
\*** DEPRECATED \*** Support will be removed in future releases
|
|
||||||
|
|
||||||
[Download](http://www.amazon.com/gp/feature.html?docId=1000765211) Amazon's KindleGen tool for your platform and place the binary named `kindlegen` in the `vendor` folder.
|
|
||||||
|
|
||||||
## Docker Images
|
## Docker Images
|
||||||
|
|
||||||
Pre-built Docker images are available in these Docker Hub repositories:
|
Pre-built Docker images are available in the following Docker Hub repositories (maintained by the LinuxServer team):
|
||||||
|
|
||||||
#### **Technosoft2000 - x64**
|
#### **LinuxServer - x64, aarch64**
|
||||||
+ Docker Hub - [https://hub.docker.com/r/technosoft2000/calibre-web/](https://hub.docker.com/r/technosoft2000/calibre-web/)
|
- [Docker Hub](https://hub.docker.com/r/linuxserver/calibre-web)
|
||||||
+ Github - [https://github.com/Technosoft2000/docker-calibre-web](https://github.com/Technosoft2000/docker-calibre-web)
|
- [GitHub](https://github.com/linuxserver/docker-calibre-web)
|
||||||
|
- [GitHub - Optional Calibre layer](https://github.com/linuxserver/docker-mods/tree/universal-calibre)
|
||||||
|
|
||||||
Includes the Calibre `ebook-convert` binary.
|
Include the environment variable `DOCKER_MODS=linuxserver/mods:universal-calibre` in your Docker run/compose file to add the Calibre `ebook-convert` binary (x64 only). Omit this variable for a lightweight image.
|
||||||
+ The "path to convertertool" should be set to `/opt/calibre/ebook-convert`
|
|
||||||
|
|
||||||
#### **LinuxServer - x64, armhf, aarch64**
|
Both the Calibre-Web and Calibre-Mod images are automatically rebuilt on new releases and updates.
|
||||||
+ Docker Hub - [https://hub.docker.com/r/linuxserver/calibre-web/](https://hub.docker.com/r/linuxserver/calibre-web/)
|
|
||||||
+ Github - [https://github.com/linuxserver/docker-calibre-web](https://github.com/linuxserver/docker-calibre-web)
|
|
||||||
+ Github - (Optional Calibre layer) - [https://github.com/linuxserver/docker-calibre-web/tree/calibre](https://github.com/linuxserver/docker-calibre-web/tree/calibre)
|
|
||||||
|
|
||||||
This image has the option to pull in an extra docker manifest layer to include the Calibre `ebook-convert` binary. Just include the environmental variable `DOCKER_MODS=linuxserver/calibre-web:calibre` in your docker run/docker compose file. **(x64 only)**
|
- Set "path to convertertool" to `/usr/bin/ebook-convert`
|
||||||
|
- Set "path to unrar" to `/usr/bin/unrar`
|
||||||
If you do not need this functionality then this can be omitted, keeping the image as lightweight as possible.
|
|
||||||
|
|
||||||
Both the Calibre-Web and Calibre-Mod images are rebuilt automatically on new releases of Calibre-Web and Calibre respectively, and on updates to any included base image packages on a weekly basis if required.
|
|
||||||
+ The "path to convertertool" should be set to `/usr/bin/ebook-convert`
|
|
||||||
+ The "path to unrar" should be set to `/usr/bin/unrar`
|
|
||||||
|
|
||||||
# Wiki
|
## Contributor Recognition
|
||||||
|
|
||||||
For further information, How To's and FAQ please check the [Wiki](https://github.com/janeczku/calibre-web/wiki)
|
We would like to thank all the [contributors](https://github.com/janeczku/calibre-web/graphs/contributors) and maintainers of Calibre-Web for their valuable input and dedication to the project. Your contributions are greatly appreciated.
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
Join us on [Discord](https://discord.gg/h2VsJ2NEfB)
|
||||||
|
|
||||||
|
For more information, How To's, and FAQs, please visit the [Wiki](https://github.com/janeczku/calibre-web/wiki)
|
||||||
|
|
||||||
|
## Contributing to Calibre-Web
|
||||||
|
|
||||||
|
Check out our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md)
|
||||||
|
|
52
SECURITY.md
Normal file
52
SECURITY.md
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Please report security issues to ozzie.fernandez.isaacs@googlemail.com
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
To receive fixes for security vulnerabilities it is required to always upgrade to the latest version of Calibre-Web. See https://github.com/janeczku/calibre-web/releases/latest for the latest release.
|
||||||
|
|
||||||
|
## History
|
||||||
|
|
||||||
|
| Fixed in | Description |CVE number |
|
||||||
|
|---------------|--------------------------------------------------------------------------------------------------------------------|---------|
|
||||||
|
| 3rd July 2018 | Guest access acts as a backdoor ||
|
||||||
|
| V 0.6.7 | Hardcoded secret key for sessions |CVE-2020-12627 |
|
||||||
|
| V 0.6.13 | Calibre-Web Metadata cross site scripting |CVE-2021-25964|
|
||||||
|
| V 0.6.13 | Name of Shelves are only visible to users who can access the corresponding shelf Thanks to @ibarrionuevo ||
|
||||||
|
| V 0.6.13 | JavaScript could get executed in the description field. Thanks to @ranjit-git and Hagai Wechsler (WhiteSource) ||
|
||||||
|
| V 0.6.13 | JavaScript could get executed in a custom column of type "comment" field ||
|
||||||
|
| V 0.6.13 | JavaScript could get executed after converting a book to another format with a title containing javascript code ||
|
||||||
|
| V 0.6.13 | JavaScript could get executed after converting a book to another format with a username containing javascript code ||
|
||||||
|
| V 0.6.13 | JavaScript could get executed in the description series, categories or publishers title ||
|
||||||
|
| V 0.6.13 | JavaScript could get executed in the shelf title ||
|
||||||
|
| V 0.6.13 | Login with the old session cookie after logout. Thanks to @ibarrionuevo ||
|
||||||
|
| V 0.6.14 | CSRF was possible. Thanks to @mik317 and Hagai Wechsler (WhiteSource) |CVE-2021-25965|
|
||||||
|
| V 0.6.14 | Migrated some routes to POST-requests (CSRF protection). Thanks to @scara31 |CVE-2021-4164|
|
||||||
|
| V 0.6.15 | Fix for "javascript:" script links in identifier. Thanks to @scara31 |CVE-2021-4170|
|
||||||
|
| V 0.6.15 | Cross-Site Scripting vulnerability on uploaded cover file names. Thanks to @ibarrionuevo ||
|
||||||
|
| V 0.6.15 | Creating public shelfs is now denied if user is missing the edit public shelf right. Thanks to @ibarrionuevo ||
|
||||||
|
| V 0.6.15 | Changed error message in case of trying to delete a shelf unauthorized. Thanks to @ibarrionuevo ||
|
||||||
|
| V 0.6.16 | JavaScript could get executed on authors page. Thanks to @alicaz |CVE-2022-0352|
|
||||||
|
| V 0.6.16 | Localhost can no longer be used to upload covers. Thanks to @scara31 |CVE-2022-0339|
|
||||||
|
| V 0.6.16 | Another case where public shelfs could be created without permission is prevented. Thanks to @nhiephon |CVE-2022-0273|
|
||||||
|
| V 0.6.16 | It's prevented to get the name of a private shelfs. Thanks to @nhiephon |CVE-2022-0405|
|
||||||
|
| V 0.6.17 | The SSRF Protection can no longer be bypassed via an HTTP redirect. Thanks to @416e6e61 |CVE-2022-0767|
|
||||||
|
| V 0.6.17 | The SSRF Protection can no longer be bypassed via 0.0.0.0 and it's ipv6 equivalent. Thanks to @r0hanSH |CVE-2022-0766|
|
||||||
|
| V 0.6.18 | Possible SQL Injection is prevented in user table Thanks to Iman Sharafaldin (Forward Security) |CVE-2022-30765|
|
||||||
|
| V 0.6.18 | The SSRF protection no longer can be bypassed by IPV6/IPV4 embedding. Thanks to @416e6e61 |CVE-2022-0939|
|
||||||
|
| V 0.6.18 | The SSRF protection no longer can be bypassed to connect to other servers in the local network. Thanks to @michaellrowley |CVE-2022-0990|
|
||||||
|
| V 0.6.20 | Credentials for emails are now stored encrypted ||
|
||||||
|
| V 0.6.20 | Login is rate limited ||
|
||||||
|
| V 0.6.20 | Passwordstrength can be forced ||
|
||||||
|
| V 0.6.21 | SMTP server credentials are no longer returned to client ||
|
||||||
|
| V 0.6.21 | Cross-site scripting (XSS) stored in href bypasses filter using data wrapper no longer possible ||
|
||||||
|
| V 0.6.21 | Cross-site scripting (XSS) is no longer possible via pathchooser ||
|
||||||
|
| V 0.6.21 | Error Handling at non existent rating, language, and user downloaded books was fixed ||
|
||||||
|
|
||||||
|
|
||||||
|
## Statement regarding Log4j (CVE-2021-44228 and related)
|
||||||
|
|
||||||
|
Calibre-web is not affected by bugs related to Log4j. Calibre-Web is a python program, therefore not using Java, and not using the Java logging feature log4j.
|
|
@ -1,3 +1,4 @@
|
||||||
[python: **.py]
|
[python: **.py]
|
||||||
|
|
||||||
|
# has to be executed with jinja2 >=2.9 to have autoescape enabled automatically
|
||||||
[jinja2: **/templates/**.*ml]
|
[jinja2: **/templates/**.*ml]
|
||||||
extensions=jinja2.ext.autoescape,jinja2.ext.with_
|
|
77
cps.py
77
cps.py
|
@ -2,7 +2,7 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
# Copyright (C) 2012-2019 OzzieIsaacs
|
# Copyright (C) 2022 OzzieIsaacs
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
@ -17,63 +17,38 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
# Insert local directories into path
|
# Add local path to sys.path, so we can import cps
|
||||||
if sys.version_info < (3, 0):
|
path = os.path.dirname(os.path.abspath(__file__))
|
||||||
sys.path.append(os.path.dirname(os.path.abspath(__file__.decode('utf-8'))))
|
sys.path.insert(0, path)
|
||||||
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__.decode('utf-8'))), 'vendor'))
|
|
||||||
else:
|
from cps.main import main
|
||||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'vendor'))
|
|
||||||
|
|
||||||
|
|
||||||
from cps import create_app
|
def hide_console_windows():
|
||||||
from cps import web_server
|
import ctypes
|
||||||
from cps.opds import opds
|
import os
|
||||||
from cps.web import web
|
|
||||||
from cps.jinjia import jinjia
|
|
||||||
from cps.about import about
|
|
||||||
from cps.shelf import shelf
|
|
||||||
from cps.admin import admi
|
|
||||||
from cps.gdrive import gdrive
|
|
||||||
from cps.editbooks import editbook
|
|
||||||
|
|
||||||
try:
|
hwnd = ctypes.windll.kernel32.GetConsoleWindow()
|
||||||
from cps.kobo import kobo, get_kobo_activated
|
if hwnd != 0:
|
||||||
from cps.kobo_auth import kobo_auth
|
try:
|
||||||
kobo_available = get_kobo_activated()
|
import win32process
|
||||||
except ImportError:
|
except ImportError:
|
||||||
kobo_available = False
|
print("To hide console window install 'pywin32' using 'pip install pywin32'")
|
||||||
|
return
|
||||||
try:
|
ctypes.windll.user32.ShowWindow(hwnd, 0)
|
||||||
from cps.oauth_bb import oauth
|
ctypes.windll.kernel32.CloseHandle(hwnd)
|
||||||
oauth_available = True
|
_, pid = win32process.GetWindowThreadProcessId(hwnd)
|
||||||
except ImportError:
|
os.system('taskkill /PID ' + str(pid) + ' /f')
|
||||||
oauth_available = False
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
app = create_app()
|
|
||||||
app.register_blueprint(web)
|
|
||||||
app.register_blueprint(opds)
|
|
||||||
app.register_blueprint(jinjia)
|
|
||||||
app.register_blueprint(about)
|
|
||||||
app.register_blueprint(shelf)
|
|
||||||
app.register_blueprint(admi)
|
|
||||||
app.register_blueprint(gdrive)
|
|
||||||
app.register_blueprint(editbook)
|
|
||||||
if kobo_available:
|
|
||||||
app.register_blueprint(kobo)
|
|
||||||
app.register_blueprint(kobo_auth)
|
|
||||||
if oauth_available:
|
|
||||||
app.register_blueprint(oauth)
|
|
||||||
success = web_server.start()
|
|
||||||
sys.exit(0 if success else 1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
if os.name == "nt":
|
||||||
|
hide_console_windows()
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
52
cps/MyLoginManager.py
Normal file
52
cps/MyLoginManager.py
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2018-2019 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11,
|
||||||
|
# andy29485, idalin, Kyosfonica, wuqi, Kennyl, lemmsh,
|
||||||
|
# falgh1, grunjol, csitko, ytils, xybydy, trasba, vrabe,
|
||||||
|
# ruben-herold, marblepebble, JackED42, SiphonSquirrel,
|
||||||
|
# apetresc, nanu-c, mutschler, GammaC0de, vuolter
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
from flask_login import LoginManager, confirm_login
|
||||||
|
from flask import session, current_app
|
||||||
|
from flask_login.utils import decode_cookie
|
||||||
|
from flask_login.signals import user_loaded_from_cookie
|
||||||
|
|
||||||
|
class MyLoginManager(LoginManager):
|
||||||
|
def _session_protection_failed(self):
|
||||||
|
sess = session._get_current_object()
|
||||||
|
ident = self._session_identifier_generator()
|
||||||
|
if(sess and not (len(sess) == 1
|
||||||
|
and sess.get('csrf_token', None))) and ident != sess.get('_id', None):
|
||||||
|
return super(). _session_protection_failed()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _load_user_from_remember_cookie(self, cookie):
|
||||||
|
user_id = decode_cookie(cookie)
|
||||||
|
if user_id is not None:
|
||||||
|
session["_user_id"] = user_id
|
||||||
|
session["_fresh"] = False
|
||||||
|
user = None
|
||||||
|
if self._user_callback:
|
||||||
|
user = self._user_callback(user_id)
|
||||||
|
if user is not None:
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
user_loaded_from_cookie.send(app, user=user)
|
||||||
|
# if session was restored from remember me cookie make login valid
|
||||||
|
confirm_login()
|
||||||
|
return user
|
||||||
|
return None
|
204
cps/__init__.py
Normal file → Executable file
204
cps/__init__.py
Normal file → Executable file
|
@ -19,23 +19,39 @@
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
__package__ = "cps"
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
|
||||||
from babel import Locale as LC
|
from flask import Flask
|
||||||
from babel import negotiate_locale
|
from .MyLoginManager import MyLoginManager
|
||||||
from babel.core import UnknownLocaleError
|
|
||||||
from flask import Flask, request, g
|
|
||||||
from flask_login import LoginManager
|
|
||||||
from flask_babel import Babel
|
|
||||||
from flask_principal import Principal
|
from flask_principal import Principal
|
||||||
|
|
||||||
from . import config_sql, logger, cache_buster, cli, ub, db
|
from . import logger
|
||||||
|
from .cli import CliParameter
|
||||||
|
from .constants import CONFIG_DIR
|
||||||
from .reverseproxy import ReverseProxied
|
from .reverseproxy import ReverseProxied
|
||||||
from .server import WebServer
|
from .server import WebServer
|
||||||
|
from .dep_check import dependency_check
|
||||||
|
from .updater import Updater
|
||||||
|
from .babel import babel, get_locale
|
||||||
|
from . import config_sql
|
||||||
|
from . import cache_buster
|
||||||
|
from . import ub, db
|
||||||
|
|
||||||
|
try:
|
||||||
|
from flask_limiter import Limiter
|
||||||
|
limiter_present = True
|
||||||
|
except ImportError:
|
||||||
|
limiter_present = False
|
||||||
|
try:
|
||||||
|
from flask_wtf.csrf import CSRFProtect
|
||||||
|
wtf_present = True
|
||||||
|
except ImportError:
|
||||||
|
wtf_present = False
|
||||||
|
|
||||||
|
|
||||||
mimetypes.init()
|
mimetypes.init()
|
||||||
mimetypes.add_type('application/xhtml+xml', '.xhtml')
|
mimetypes.add_type('application/xhtml+xml', '.xhtml')
|
||||||
|
@ -44,93 +60,153 @@ mimetypes.add_type('application/fb2+zip', '.fb2')
|
||||||
mimetypes.add_type('application/x-mobipocket-ebook', '.mobi')
|
mimetypes.add_type('application/x-mobipocket-ebook', '.mobi')
|
||||||
mimetypes.add_type('application/x-mobipocket-ebook', '.prc')
|
mimetypes.add_type('application/x-mobipocket-ebook', '.prc')
|
||||||
mimetypes.add_type('application/vnd.amazon.ebook', '.azw')
|
mimetypes.add_type('application/vnd.amazon.ebook', '.azw')
|
||||||
|
mimetypes.add_type('application/x-mobi8-ebook', '.azw3')
|
||||||
mimetypes.add_type('application/x-cbr', '.cbr')
|
mimetypes.add_type('application/x-cbr', '.cbr')
|
||||||
mimetypes.add_type('application/x-cbz', '.cbz')
|
mimetypes.add_type('application/x-cbz', '.cbz')
|
||||||
mimetypes.add_type('application/x-cbt', '.cbt')
|
mimetypes.add_type('application/x-cbt', '.cbt')
|
||||||
mimetypes.add_type('image/vnd.djvu', '.djvu')
|
mimetypes.add_type('application/x-cb7', '.cb7')
|
||||||
|
mimetypes.add_type('image/vnd.djv', '.djv')
|
||||||
mimetypes.add_type('application/mpeg', '.mpeg')
|
mimetypes.add_type('application/mpeg', '.mpeg')
|
||||||
mimetypes.add_type('application/mpeg', '.mp3')
|
mimetypes.add_type('application/mpeg', '.mp3')
|
||||||
mimetypes.add_type('application/mp4', '.m4a')
|
mimetypes.add_type('application/mp4', '.m4a')
|
||||||
mimetypes.add_type('application/mp4', '.m4b')
|
mimetypes.add_type('application/mp4', '.m4b')
|
||||||
mimetypes.add_type('application/ogg', '.ogg')
|
mimetypes.add_type('application/ogg', '.ogg')
|
||||||
mimetypes.add_type('application/ogg', '.oga')
|
mimetypes.add_type('application/ogg', '.oga')
|
||||||
|
mimetypes.add_type('text/css', '.css')
|
||||||
app = Flask(__name__)
|
mimetypes.add_type('text/javascript; charset=UTF-8', '.js')
|
||||||
|
|
||||||
lm = LoginManager()
|
|
||||||
lm.login_view = 'web.login'
|
|
||||||
lm.anonymous_user = ub.Anonymous
|
|
||||||
|
|
||||||
ub.init_db(cli.settingspath)
|
|
||||||
# pylint: disable=no-member
|
|
||||||
config = config_sql.load_configuration(ub.session)
|
|
||||||
|
|
||||||
searched_ids = {}
|
|
||||||
web_server = WebServer()
|
|
||||||
|
|
||||||
babel = Babel()
|
|
||||||
_BABEL_TRANSLATIONS = set()
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
from . import services
|
app = Flask(__name__)
|
||||||
|
app.config.update(
|
||||||
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
|
SESSION_COOKIE_SAMESITE='Lax',
|
||||||
|
REMEMBER_COOKIE_SAMESITE='Lax', # will be available in flask-login 0.5.1 earliest
|
||||||
|
WTF_CSRF_SSL_STRICT=False
|
||||||
|
)
|
||||||
|
|
||||||
|
lm = MyLoginManager()
|
||||||
|
|
||||||
|
cli_param = CliParameter()
|
||||||
|
|
||||||
|
config = config_sql.ConfigSQL()
|
||||||
|
|
||||||
|
if wtf_present:
|
||||||
|
csrf = CSRFProtect()
|
||||||
|
else:
|
||||||
|
csrf = None
|
||||||
|
|
||||||
|
calibre_db = db.CalibreDB()
|
||||||
|
|
||||||
|
web_server = WebServer()
|
||||||
|
|
||||||
|
updater_thread = Updater()
|
||||||
|
|
||||||
|
if limiter_present:
|
||||||
|
limiter = Limiter(key_func=True, headers_enabled=True, auto_check=False, swallow_errors=False)
|
||||||
|
else:
|
||||||
|
limiter = None
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app.wsgi_app = ReverseProxied(app.wsgi_app)
|
if csrf:
|
||||||
# For python2 convert path to unicode
|
csrf.init_app(app)
|
||||||
|
|
||||||
|
cli_param.init()
|
||||||
|
|
||||||
|
ub.init_db(cli_param.settings_path)
|
||||||
|
# pylint: disable=no-member
|
||||||
|
encrypt_key, error = config_sql.get_encryption_key(os.path.dirname(cli_param.settings_path))
|
||||||
|
|
||||||
|
config_sql.load_configuration(ub.session, encrypt_key)
|
||||||
|
config.init_config(ub.session, encrypt_key, cli_param)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
log.error(error)
|
||||||
|
|
||||||
|
ub.password_change(cli_param.user_credentials)
|
||||||
|
|
||||||
if sys.version_info < (3, 0):
|
if sys.version_info < (3, 0):
|
||||||
app.static_folder = app.static_folder.decode('utf-8')
|
log.info(
|
||||||
app.root_path = app.root_path.decode('utf-8')
|
'*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, '
|
||||||
app.instance_path = app.instance_path .decode('utf-8')
|
'please update your installation to Python3 ***')
|
||||||
|
print(
|
||||||
|
'*** Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2, '
|
||||||
|
'please update your installation to Python3 ***')
|
||||||
|
web_server.stop(True)
|
||||||
|
sys.exit(5)
|
||||||
|
|
||||||
cache_buster.init_cache_busting(app)
|
lm.login_view = 'web.login'
|
||||||
|
lm.anonymous_user = ub.Anonymous
|
||||||
|
lm.session_protection = 'strong' if config.config_session == 1 else "basic"
|
||||||
|
|
||||||
|
db.CalibreDB.update_config(config)
|
||||||
|
db.CalibreDB.setup_db(config.config_calibre_dir, cli_param.settings_path)
|
||||||
|
calibre_db.init_db()
|
||||||
|
|
||||||
|
updater_thread.init_updater(config, web_server)
|
||||||
|
# Perform dry run of updater and exit afterward
|
||||||
|
if cli_param.dry_run:
|
||||||
|
updater_thread.dry_run()
|
||||||
|
sys.exit(0)
|
||||||
|
updater_thread.start()
|
||||||
|
requirements = dependency_check()
|
||||||
|
for res in requirements:
|
||||||
|
if res['found'] == "not installed":
|
||||||
|
message = ('Cannot import {name} module, it is needed to run calibre-web, '
|
||||||
|
'please install it using "pip install {name}"').format(name=res["name"])
|
||||||
|
log.info(message)
|
||||||
|
print("*** " + message + " ***")
|
||||||
|
web_server.stop(True)
|
||||||
|
sys.exit(8)
|
||||||
|
for res in requirements + dependency_check(True):
|
||||||
|
log.info('*** "{}" version does not meet the requirements. '
|
||||||
|
'Should: {}, Found: {}, please consider installing required version ***'
|
||||||
|
.format(res['name'],
|
||||||
|
res['target'],
|
||||||
|
res['found']))
|
||||||
|
app.wsgi_app = ReverseProxied(app.wsgi_app)
|
||||||
|
|
||||||
|
if os.environ.get('FLASK_DEBUG'):
|
||||||
|
cache_buster.init_cache_busting(app)
|
||||||
log.info('Starting Calibre Web...')
|
log.info('Starting Calibre Web...')
|
||||||
Principal(app)
|
Principal(app)
|
||||||
lm.init_app(app)
|
lm.init_app(app)
|
||||||
app.secret_key = os.getenv('SECRET_KEY', config_sql.get_flask_session_key(ub.session))
|
app.secret_key = os.getenv('SECRET_KEY', config_sql.get_flask_session_key(ub.session))
|
||||||
|
|
||||||
web_server.init_app(app, config)
|
web_server.init_app(app, config)
|
||||||
db.setup_db(config)
|
if hasattr(babel, "localeselector"):
|
||||||
|
babel.init_app(app)
|
||||||
|
babel.localeselector(get_locale)
|
||||||
|
else:
|
||||||
|
babel.init_app(app, locale_selector=get_locale)
|
||||||
|
|
||||||
babel.init_app(app)
|
from . import services
|
||||||
_BABEL_TRANSLATIONS.update(str(item) for item in babel.list_translations())
|
|
||||||
_BABEL_TRANSLATIONS.add('en')
|
|
||||||
|
|
||||||
if services.ldap:
|
if services.ldap:
|
||||||
services.ldap.init_app(app, config)
|
services.ldap.init_app(app, config)
|
||||||
if services.goodreads_support:
|
if services.goodreads_support:
|
||||||
services.goodreads_support.connect(config.config_goodreads_api_key,
|
services.goodreads_support.connect(config.config_goodreads_api_key,
|
||||||
config.config_goodreads_api_secret,
|
|
||||||
config.config_use_goodreads)
|
config.config_use_goodreads)
|
||||||
|
config.store_calibre_uuid(calibre_db, db.Library_Id)
|
||||||
|
# Configure rate limiter
|
||||||
|
# https://limits.readthedocs.io/en/stable/storage.html
|
||||||
|
app.config.update(RATELIMIT_ENABLED=config.config_ratelimiter)
|
||||||
|
if config.config_limiter_uri != "" and not cli_param.memory_backend:
|
||||||
|
app.config.update(RATELIMIT_STORAGE_URI=config.config_limiter_uri)
|
||||||
|
if config.config_limiter_options != "":
|
||||||
|
app.config.update(RATELIMIT_STORAGE_OPTIONS=config.config_limiter_options)
|
||||||
|
try:
|
||||||
|
limiter.init_app(app)
|
||||||
|
except Exception as e:
|
||||||
|
log.error('Wrong Flask Limiter configuration, falling back to default: {}'.format(e))
|
||||||
|
app.config.update(RATELIMIT_STORAGE_URI=None)
|
||||||
|
limiter.init_app(app)
|
||||||
|
|
||||||
|
# Register scheduled tasks
|
||||||
|
from .schedule import register_scheduled_tasks, register_startup_tasks
|
||||||
|
register_scheduled_tasks(config.schedule_reconnect)
|
||||||
|
register_startup_tasks()
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
@babel.localeselector
|
|
||||||
def get_locale():
|
|
||||||
# if a user is logged in, use the locale from the user settings
|
|
||||||
user = getattr(g, 'user', None)
|
|
||||||
# user = None
|
|
||||||
if user is not None and hasattr(user, "locale"):
|
|
||||||
if user.nickname != 'Guest': # if the account is the guest account bypass the config lang settings
|
|
||||||
return user.locale
|
|
||||||
|
|
||||||
preferred = list()
|
|
||||||
if request.accept_languages:
|
|
||||||
for x in request.accept_languages.values():
|
|
||||||
try:
|
|
||||||
preferred.append(str(LC.parse(x.replace('-', '_'))))
|
|
||||||
except (UnknownLocaleError, ValueError) as e:
|
|
||||||
log.debug('Could not parse locale "%s": %s', x, e)
|
|
||||||
|
|
||||||
return negotiate_locale(preferred or ['en'], _BABEL_TRANSLATIONS)
|
|
||||||
|
|
||||||
|
|
||||||
@babel.timezoneselector
|
|
||||||
def get_timezone():
|
|
||||||
user = getattr(g, 'user', None)
|
|
||||||
return user.timezone if user else None
|
|
||||||
|
|
||||||
from .updater import Updater
|
|
||||||
updater_thread = Updater()
|
|
||||||
updater_thread.start()
|
|
||||||
|
|
100
cps/about.py
100
cps/about.py
|
@ -20,75 +20,65 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
import sys
|
import sys
|
||||||
import platform
|
import platform
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
import babel, pytz, requests, sqlalchemy
|
import flask
|
||||||
import werkzeug, flask, flask_login, flask_principal, jinja2
|
import flask_login
|
||||||
|
import jinja2
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
from . import db, converter, uploader, server, isoLanguages, constants
|
from . import db, calibre_db, converter, uploader, constants, dep_check
|
||||||
from .web import render_title_template
|
from .render_template import render_title_template
|
||||||
try:
|
|
||||||
from flask_login import __version__ as flask_loginVersion
|
|
||||||
except ImportError:
|
|
||||||
from flask_login.__about__ import __version__ as flask_loginVersion
|
|
||||||
try:
|
|
||||||
import unidecode
|
|
||||||
# _() necessary to make babel aware of string for translation
|
|
||||||
unidecode_version = _(u'installed')
|
|
||||||
except ImportError:
|
|
||||||
unidecode_version = _(u'not installed')
|
|
||||||
|
|
||||||
try:
|
|
||||||
from flask_dance import __version__ as flask_danceVersion
|
|
||||||
except ImportError:
|
|
||||||
flask_danceVersion = None
|
|
||||||
|
|
||||||
from . import services
|
|
||||||
|
|
||||||
about = flask.Blueprint('about', __name__)
|
about = flask.Blueprint('about', __name__)
|
||||||
|
|
||||||
|
modules = dict()
|
||||||
|
req = dep_check.load_dependencies(False)
|
||||||
|
opt = dep_check.load_dependencies(True)
|
||||||
|
for i in (req + opt):
|
||||||
|
modules[i[1]] = i[0]
|
||||||
|
modules['Jinja2'] = jinja2.__version__
|
||||||
|
modules['pySqlite'] = sqlite3.version
|
||||||
|
modules['SQLite'] = sqlite3.sqlite_version
|
||||||
|
sorted_modules = OrderedDict((sorted(modules.items(), key=lambda x: x[0].casefold())))
|
||||||
|
|
||||||
_VERSIONS = OrderedDict(
|
|
||||||
Platform = '{0[0]} {0[2]} {0[3]} {0[4]} {0[5]}'.format(platform.uname()),
|
def collect_stats():
|
||||||
Python=sys.version,
|
if constants.NIGHTLY_VERSION[0] == "$Format:%H$":
|
||||||
Calibre_Web=constants.STABLE_VERSION['version'] + ' - '
|
calibre_web_version = constants.STABLE_VERSION['version'].replace("b", " Beta")
|
||||||
+ constants.NIGHTLY_VERSION[0].replace('%','%%') + ' - '
|
else:
|
||||||
+ constants.NIGHTLY_VERSION[1].replace('%','%%'),
|
calibre_web_version = (constants.STABLE_VERSION['version'].replace("b", " Beta") + ' - '
|
||||||
WebServer=server.VERSION,
|
+ constants.NIGHTLY_VERSION[0].replace('%', '%%') + ' - '
|
||||||
Flask=flask.__version__,
|
+ constants.NIGHTLY_VERSION[1].replace('%', '%%'))
|
||||||
Flask_Login=flask_loginVersion,
|
|
||||||
Flask_Principal=flask_principal.__version__,
|
if getattr(sys, 'frozen', False):
|
||||||
Werkzeug=werkzeug.__version__,
|
calibre_web_version += " - Exe-Version"
|
||||||
Babel=babel.__version__,
|
elif constants.HOME_CONFIG:
|
||||||
Jinja2=jinja2.__version__,
|
calibre_web_version += " - pyPi"
|
||||||
Requests=requests.__version__,
|
|
||||||
SqlAlchemy=sqlalchemy.__version__,
|
_VERSIONS = {'Calibre Web': calibre_web_version}
|
||||||
pySqlite=sqlite3.version,
|
_VERSIONS.update(OrderedDict(
|
||||||
SQLite=sqlite3.sqlite_version,
|
Python=sys.version,
|
||||||
iso639=isoLanguages.__version__,
|
Platform='{0[0]} {0[2]} {0[3]} {0[4]} {0[5]}'.format(platform.uname()),
|
||||||
pytz=pytz.__version__,
|
))
|
||||||
Unidecode = unidecode_version,
|
_VERSIONS.update(uploader.get_magick_version())
|
||||||
Flask_SimpleLDAP = u'installed' if bool(services.ldap) else None,
|
_VERSIONS['Unrar'] = converter.get_unrar_version()
|
||||||
python_LDAP = services.ldapVersion if bool(services.ldapVersion) else None,
|
_VERSIONS['Ebook converter'] = converter.get_calibre_version()
|
||||||
Goodreads = u'installed' if bool(services.goodreads_support) else None,
|
_VERSIONS['Kepubify'] = converter.get_kepubify_version()
|
||||||
jsonschema = services.SyncToken.__version__ if bool(services.SyncToken) else None,
|
_VERSIONS.update(sorted_modules)
|
||||||
flask_dance = flask_danceVersion
|
return _VERSIONS
|
||||||
)
|
|
||||||
_VERSIONS.update(uploader.get_versions())
|
|
||||||
|
|
||||||
|
|
||||||
@about.route("/stats")
|
@about.route("/stats")
|
||||||
@flask_login.login_required
|
@flask_login.login_required
|
||||||
def stats():
|
def stats():
|
||||||
counter = db.session.query(db.Books).count()
|
counter = calibre_db.session.query(db.Books).count()
|
||||||
authors = db.session.query(db.Authors).count()
|
authors = calibre_db.session.query(db.Authors).count()
|
||||||
categorys = db.session.query(db.Tags).count()
|
categories = calibre_db.session.query(db.Tags).count()
|
||||||
series = db.session.query(db.Series).count()
|
series = calibre_db.session.query(db.Series).count()
|
||||||
_VERSIONS['ebook converter'] = _(converter.get_version())
|
return render_title_template('stats.html', bookcounter=counter, authorcounter=authors, versions=collect_stats(),
|
||||||
return render_title_template('stats.html', bookcounter=counter, authorcounter=authors, versions=_VERSIONS,
|
categorycounter=categories, seriecounter=series, title=_("Statistics"), page="stat")
|
||||||
categorycounter=categorys, seriecounter=series, title=_(u"Statistics"), page="stat")
|
|
||||||
|
|
2406
cps/admin.py
Normal file → Executable file
2406
cps/admin.py
Normal file → Executable file
File diff suppressed because it is too large
Load Diff
40
cps/babel.py
Normal file
40
cps/babel.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
from babel import negotiate_locale
|
||||||
|
from flask_babel import Babel, Locale
|
||||||
|
from babel.core import UnknownLocaleError
|
||||||
|
from flask import request
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
from . import logger
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
babel = Babel()
|
||||||
|
|
||||||
|
def get_locale():
|
||||||
|
# if a user is logged in, use the locale from the user settings
|
||||||
|
if current_user is not None and hasattr(current_user, "locale"):
|
||||||
|
# if the account is the guest account bypass the config lang settings
|
||||||
|
if current_user.name != 'Guest':
|
||||||
|
return current_user.locale
|
||||||
|
|
||||||
|
preferred = list()
|
||||||
|
if request.accept_languages:
|
||||||
|
for x in request.accept_languages.values():
|
||||||
|
try:
|
||||||
|
preferred.append(str(Locale.parse(x.replace('-', '_'))))
|
||||||
|
except (UnknownLocaleError, ValueError) as e:
|
||||||
|
log.debug('Could not parse locale "%s": %s', x, e)
|
||||||
|
|
||||||
|
return negotiate_locale(preferred or ['en'], get_available_translations())
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_locale_language(user_language):
|
||||||
|
return Locale.parse(user_language).get_language_name(get_locale())
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_locale():
|
||||||
|
return [Locale('en')] + babel.list_translations()
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_translations():
|
||||||
|
return set(str(item) for item in get_available_locale())
|
|
@ -19,7 +19,6 @@
|
||||||
# Inspired by https://github.com/ChrisTM/Flask-CacheBust
|
# Inspired by https://github.com/ChrisTM/Flask-CacheBust
|
||||||
# Uses query strings so CSS font files are found without having to resort to absolute URLs
|
# Uses query strings so CSS font files are found without having to resort to absolute URLs
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
import os
|
import os
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
|
@ -48,22 +47,26 @@ def init_cache_busting(app):
|
||||||
for filename in filenames:
|
for filename in filenames:
|
||||||
# compute version component
|
# compute version component
|
||||||
rooted_filename = os.path.join(dirpath, filename)
|
rooted_filename = os.path.join(dirpath, filename)
|
||||||
with open(rooted_filename, 'rb') as f:
|
try:
|
||||||
file_hash = hashlib.md5(f.read()).hexdigest()[:7]
|
with open(rooted_filename, 'rb') as f:
|
||||||
|
file_hash = hashlib.md5(f.read()).hexdigest()[:7] # nosec
|
||||||
|
# save version to tables
|
||||||
|
file_path = rooted_filename.replace(static_folder, "")
|
||||||
|
file_path = file_path.replace("\\", "/") # Convert Windows path to web path
|
||||||
|
hash_table[file_path] = file_hash
|
||||||
|
except PermissionError:
|
||||||
|
log.error("No permission to access {} file.".format(rooted_filename))
|
||||||
|
|
||||||
# save version to tables
|
|
||||||
file_path = rooted_filename.replace(static_folder, "")
|
|
||||||
file_path = file_path.replace("\\", "/") # Convert Windows path to web path
|
|
||||||
hash_table[file_path] = file_hash
|
|
||||||
log.debug('Finished computing cache-busting values')
|
log.debug('Finished computing cache-busting values')
|
||||||
|
|
||||||
def bust_filename(filename):
|
def bust_filename(file_name):
|
||||||
return hash_table.get(filename, "")
|
return hash_table.get(file_name, "")
|
||||||
|
|
||||||
def unbust_filename(filename):
|
def unbust_filename(file_name):
|
||||||
return filename.split("?", 1)[0]
|
return file_name.split("?", 1)[0]
|
||||||
|
|
||||||
@app.url_defaults
|
@app.url_defaults
|
||||||
|
# pylint: disable=unused-variable
|
||||||
def reverse_to_cache_busted_url(endpoint, values):
|
def reverse_to_cache_busted_url(endpoint, values):
|
||||||
"""
|
"""
|
||||||
Make `url_for` produce busted filenames when using the 'static' endpoint.
|
Make `url_for` produce busted filenames when using the 'static' endpoint.
|
||||||
|
|
53
cps/clean_html.py
Normal file
53
cps/clean_html.py
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2018-2019 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from . import logger
|
||||||
|
from lxml.etree import ParserError
|
||||||
|
|
||||||
|
try:
|
||||||
|
# at least bleach 6.0 is needed -> incomplatible change from list arguments to set arguments
|
||||||
|
from bleach import clean_text as clean_html
|
||||||
|
BLEACH = True
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
BLEACH = False
|
||||||
|
from nh3 import clean as clean_html
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
BLEACH = False
|
||||||
|
from lxml.html.clean import clean_html
|
||||||
|
except ImportError:
|
||||||
|
clean_html = None
|
||||||
|
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
|
def clean_string(unsafe_text, book_id=0):
|
||||||
|
try:
|
||||||
|
if BLEACH:
|
||||||
|
safe_text = clean_html(unsafe_text, tags=set(), attributes=set())
|
||||||
|
else:
|
||||||
|
safe_text = clean_html(unsafe_text)
|
||||||
|
except ParserError as e:
|
||||||
|
log.error("Comments of book {} are corrupted: {}".format(book_id, e))
|
||||||
|
safe_text = ""
|
||||||
|
except TypeError as e:
|
||||||
|
log.error("Comments can't be parsed, maybe 'lxml' is too new, try installing 'bleach': {}".format(e))
|
||||||
|
safe_text = ""
|
||||||
|
return safe_text
|
167
cps/cli.py
167
cps/cli.py
|
@ -16,7 +16,6 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import argparse
|
import argparse
|
||||||
|
@ -25,88 +24,112 @@ import socket
|
||||||
from .constants import CONFIG_DIR as _CONFIG_DIR
|
from .constants import CONFIG_DIR as _CONFIG_DIR
|
||||||
from .constants import STABLE_VERSION as _STABLE_VERSION
|
from .constants import STABLE_VERSION as _STABLE_VERSION
|
||||||
from .constants import NIGHTLY_VERSION as _NIGHTLY_VERSION
|
from .constants import NIGHTLY_VERSION as _NIGHTLY_VERSION
|
||||||
|
from .constants import DEFAULT_SETTINGS_FILE, DEFAULT_GDRIVE_FILE
|
||||||
|
|
||||||
|
|
||||||
def version_info():
|
def version_info():
|
||||||
if _NIGHTLY_VERSION[1].startswith('$Format'):
|
if _NIGHTLY_VERSION[1].startswith('$Format'):
|
||||||
return "Calibre-Web version: %s - unkown git-clone" % _STABLE_VERSION['version']
|
return "Calibre-Web version: %s - unknown git-clone" % _STABLE_VERSION['version'].replace("b", " Beta")
|
||||||
return "Calibre-Web version: %s -%s" % (_STABLE_VERSION['version'], _NIGHTLY_VERSION[1])
|
return "Calibre-Web version: %s -%s" % (_STABLE_VERSION['version'].replace("b", " Beta"), _NIGHTLY_VERSION[1])
|
||||||
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description='Calibre Web is a web app'
|
class CliParameter(object):
|
||||||
' providing a interface for browsing, reading and downloading eBooks\n', prog='cps.py')
|
|
||||||
parser.add_argument('-p', metavar='path', help='path and name to settings db, e.g. /opt/cw.db')
|
|
||||||
parser.add_argument('-g', metavar='path', help='path and name to gdrive db, e.g. /opt/gd.db')
|
|
||||||
parser.add_argument('-c', metavar='path',
|
|
||||||
help='path and name to SSL certfile, e.g. /opt/test.cert, works only in combination with keyfile')
|
|
||||||
parser.add_argument('-k', metavar='path',
|
|
||||||
help='path and name to SSL keyfile, e.g. /opt/test.key, works only in combination with certfile')
|
|
||||||
parser.add_argument('-v', '--version', action='version', help='Shows version number and exits Calibre-web',
|
|
||||||
version=version_info())
|
|
||||||
parser.add_argument('-i', metavar='ip-address', help='Server IP-Address to listen')
|
|
||||||
parser.add_argument('-s', metavar='user:pass', help='Sets specific username to new password')
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if sys.version_info < (3, 0):
|
def init(self):
|
||||||
if args.p:
|
self.arg_parser()
|
||||||
args.p = args.p.decode('utf-8')
|
|
||||||
if args.g:
|
|
||||||
args.g = args.g.decode('utf-8')
|
|
||||||
if args.k:
|
|
||||||
args.k = args.k.decode('utf-8')
|
|
||||||
if args.c:
|
|
||||||
args.c = args.c.decode('utf-8')
|
|
||||||
if args.s:
|
|
||||||
args.s = args.s.decode('utf-8')
|
|
||||||
|
|
||||||
|
def arg_parser(self):
|
||||||
|
parser = argparse.ArgumentParser(description='Calibre Web is a web app providing '
|
||||||
|
'a interface for browsing, reading and downloading eBooks\n',
|
||||||
|
prog='cps.py')
|
||||||
|
parser.add_argument('-p', metavar='path', help='path and name to settings db, e.g. /opt/cw.db')
|
||||||
|
parser.add_argument('-g', metavar='path', help='path and name to gdrive db, e.g. /opt/gd.db')
|
||||||
|
parser.add_argument('-c', metavar='path', help='path and name to SSL certfile, e.g. /opt/test.cert, '
|
||||||
|
'works only in combination with keyfile')
|
||||||
|
parser.add_argument('-k', metavar='path', help='path and name to SSL keyfile, e.g. /opt/test.key, '
|
||||||
|
'works only in combination with certfile')
|
||||||
|
parser.add_argument('-o', metavar='path', help='path and name Calibre-Web logfile')
|
||||||
|
parser.add_argument('-v', '--version', action='version', help='Shows version number and exits Calibre-Web',
|
||||||
|
version=version_info())
|
||||||
|
parser.add_argument('-i', metavar='ip-address', help='Server IP-Address to listen')
|
||||||
|
parser.add_argument('-m', action='store_true', help='Use Memory-backend as limiter backend, use this parameter in case of miss configured backend')
|
||||||
|
parser.add_argument('-s', metavar='user:pass',
|
||||||
|
help='Sets specific username to new password and exits Calibre-Web')
|
||||||
|
parser.add_argument('-f', action='store_true', help='Flag is depreciated and will be removed in next version')
|
||||||
|
parser.add_argument('-l', action='store_true', help='Allow loading covers from localhost')
|
||||||
|
parser.add_argument('-d', action='store_true', help='Dry run of updater to check file permissions in advance '
|
||||||
|
'and exits Calibre-Web')
|
||||||
|
parser.add_argument('-r', action='store_true', help='Enable public database reconnect route under /reconnect')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
settingspath = args.p or os.path.join(_CONFIG_DIR, "app.db")
|
self.logpath = args.o or ""
|
||||||
gdpath = args.g or os.path.join(_CONFIG_DIR, "gdrive.db")
|
self.settings_path = args.p or os.path.join(_CONFIG_DIR, DEFAULT_SETTINGS_FILE)
|
||||||
|
self.gd_path = args.g or os.path.join(_CONFIG_DIR, DEFAULT_GDRIVE_FILE)
|
||||||
|
|
||||||
# handle and check parameter for ssl encryption
|
if os.path.isdir(self.settings_path):
|
||||||
certfilepath = None
|
self.settings_path = os.path.join(self.settings_path, DEFAULT_SETTINGS_FILE)
|
||||||
keyfilepath = None
|
|
||||||
if args.c:
|
|
||||||
if os.path.isfile(args.c):
|
|
||||||
certfilepath = args.c
|
|
||||||
else:
|
|
||||||
print("Certfilepath is invalid. Exiting...")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if args.c == "":
|
if os.path.isdir(self.gd_path):
|
||||||
certfilepath = ""
|
self.gd_path = os.path.join(self.gd_path, DEFAULT_GDRIVE_FILE)
|
||||||
|
|
||||||
if args.k:
|
# handle and check parameter for ssl encryption
|
||||||
if os.path.isfile(args.k):
|
self.certfilepath = None
|
||||||
keyfilepath = args.k
|
self.keyfilepath = None
|
||||||
else:
|
if args.c:
|
||||||
print("Keyfilepath is invalid. Exiting...")
|
if os.path.isfile(args.c):
|
||||||
sys.exit(1)
|
self.certfilepath = args.c
|
||||||
|
|
||||||
if (args.k and not args.c) or (not args.k and args.c):
|
|
||||||
print("Certfile and Keyfile have to be used together. Exiting...")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if args.k == "":
|
|
||||||
keyfilepath = ""
|
|
||||||
|
|
||||||
# handle and check ipadress argument
|
|
||||||
ipadress = args.i or None
|
|
||||||
if ipadress:
|
|
||||||
try:
|
|
||||||
# try to parse the given ip address with socket
|
|
||||||
if hasattr(socket, 'inet_pton'):
|
|
||||||
if ':' in ipadress:
|
|
||||||
socket.inet_pton(socket.AF_INET6, ipadress)
|
|
||||||
else:
|
else:
|
||||||
socket.inet_pton(socket.AF_INET, ipadress)
|
print("Certfile path is invalid. Exiting...")
|
||||||
else:
|
sys.exit(1)
|
||||||
# on windows python < 3.4, inet_pton is not available
|
|
||||||
# inet_atom only handles IPv4 addresses
|
|
||||||
socket.inet_aton(ipadress)
|
|
||||||
except socket.error as err:
|
|
||||||
print(ipadress, ':', err)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# handle and check user password argument
|
if args.c == "":
|
||||||
user_password = args.s or None
|
self.certfilepath = ""
|
||||||
|
|
||||||
|
if args.k:
|
||||||
|
if os.path.isfile(args.k):
|
||||||
|
self.keyfilepath = args.k
|
||||||
|
else:
|
||||||
|
print("Keyfile path is invalid. Exiting...")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if (args.k and not args.c) or (not args.k and args.c):
|
||||||
|
print("Certfile and Keyfile have to be used together. Exiting...")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.k == "":
|
||||||
|
self.keyfilepath = ""
|
||||||
|
|
||||||
|
# overwrite limiter backend
|
||||||
|
self.memory_backend = args.m or None
|
||||||
|
# dry run updater
|
||||||
|
self.dry_run = args.d or None
|
||||||
|
# enable reconnect endpoint for docker database reconnect
|
||||||
|
self.reconnect_enable = args.r or os.environ.get("CALIBRE_RECONNECT", None)
|
||||||
|
# load covers from localhost
|
||||||
|
self.allow_localhost = args.l or os.environ.get("CALIBRE_LOCALHOST", None)
|
||||||
|
# handle and check ip address argument
|
||||||
|
self.ip_address = args.i or None
|
||||||
|
if self.ip_address:
|
||||||
|
try:
|
||||||
|
# try to parse the given ip address with socket
|
||||||
|
if hasattr(socket, 'inet_pton'):
|
||||||
|
if ':' in self.ip_address:
|
||||||
|
socket.inet_pton(socket.AF_INET6, self.ip_address)
|
||||||
|
else:
|
||||||
|
socket.inet_pton(socket.AF_INET, self.ip_address)
|
||||||
|
else:
|
||||||
|
# on Windows python < 3.4, inet_pton is not available
|
||||||
|
# inet_atom only handles IPv4 addresses
|
||||||
|
socket.inet_aton(self.ip_address)
|
||||||
|
except socket.error as err:
|
||||||
|
print(self.ip_address, ':', err)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# handle and check user password argument
|
||||||
|
self.user_credentials = args.s or None
|
||||||
|
if self.user_credentials and ":" not in self.user_credentials:
|
||||||
|
print("No valid 'username:password' format")
|
||||||
|
sys.exit(3)
|
||||||
|
|
||||||
|
if args.f:
|
||||||
|
print("Warning: -f flag is depreciated and will be removed in next version")
|
||||||
|
|
195
cps/comic.py
195
cps/comic.py
|
@ -1,7 +1,7 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
# Copyright (C) 2018 OzzieIsaacs
|
# Copyright (C) 2018-2022 OzzieIsaacs
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
@ -16,130 +16,173 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from . import logger, isoLanguages
|
from . import logger, isoLanguages, cover
|
||||||
from .constants import BookMeta
|
from .constants import BookMeta
|
||||||
|
|
||||||
|
try:
|
||||||
|
from wand.image import Image
|
||||||
|
use_IM = True
|
||||||
|
except (ImportError, RuntimeError) as e:
|
||||||
|
use_IM = False
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from comicapi.comicarchive import ComicArchive, MetaDataStyle
|
from comicapi.comicarchive import ComicArchive, MetaDataStyle
|
||||||
use_comic_meta = True
|
use_comic_meta = True
|
||||||
except ImportError as e:
|
try:
|
||||||
log.debug('cannot import comicapi, extracting comic metadata will not work: %s', e)
|
from comicapi import __version__ as comic_version
|
||||||
|
except ImportError:
|
||||||
|
comic_version = ''
|
||||||
|
try:
|
||||||
|
from comicapi.comicarchive import load_archive_plugins
|
||||||
|
import comicapi.utils
|
||||||
|
comicapi.utils.add_rar_paths()
|
||||||
|
except ImportError:
|
||||||
|
load_archive_plugins = None
|
||||||
|
except (ImportError, LookupError) as e:
|
||||||
|
log.debug('Cannot import comicapi, extracting comic metadata will not work: %s', e)
|
||||||
import zipfile
|
import zipfile
|
||||||
import tarfile
|
import tarfile
|
||||||
try:
|
try:
|
||||||
import rarfile
|
import rarfile
|
||||||
use_rarfile = True
|
use_rarfile = True
|
||||||
except ImportError as e:
|
except (ImportError, SyntaxError) as e:
|
||||||
log.debug('cannot import rarfile, extracting cover files from rar files will not work: %s', e)
|
log.debug('Cannot import rarfile, extracting cover files from rar files will not work: %s', e)
|
||||||
use_rarfile = False
|
use_rarfile = False
|
||||||
|
try:
|
||||||
|
import py7zr
|
||||||
|
use_7zip = True
|
||||||
|
except (ImportError, SyntaxError) as e:
|
||||||
|
log.debug('Cannot import py7zr, extracting cover files from CB7 files will not work: %s', e)
|
||||||
|
use_7zip = False
|
||||||
use_comic_meta = False
|
use_comic_meta = False
|
||||||
|
|
||||||
|
|
||||||
def _extractCover(tmp_file_name, original_file_extension, rarExceutable):
|
def _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_executable):
|
||||||
cover_data = extension = None
|
cover_data = extension = None
|
||||||
if use_comic_meta:
|
if original_file_extension.upper() == '.CBZ':
|
||||||
archive = ComicArchive(tmp_file_name)
|
cf = zipfile.ZipFile(tmp_file_name)
|
||||||
for index, name in enumerate(archive.getPageNameList()):
|
for name in cf.namelist():
|
||||||
ext = os.path.splitext(name)
|
ext = os.path.splitext(name)
|
||||||
if len(ext) > 1:
|
if len(ext) > 1:
|
||||||
extension = ext[1].lower()
|
extension = ext[1].lower()
|
||||||
if extension == '.jpg' or extension == '.jpeg':
|
if extension in cover.COVER_EXTENSIONS:
|
||||||
cover_data = archive.getPage(index)
|
cover_data = cf.read(name)
|
||||||
break
|
break
|
||||||
else:
|
elif original_file_extension.upper() == '.CBT':
|
||||||
if original_file_extension.upper() == '.CBZ':
|
cf = tarfile.TarFile(tmp_file_name)
|
||||||
cf = zipfile.ZipFile(tmp_file_name)
|
for name in cf.getnames():
|
||||||
|
ext = os.path.splitext(name)
|
||||||
|
if len(ext) > 1:
|
||||||
|
extension = ext[1].lower()
|
||||||
|
if extension in cover.COVER_EXTENSIONS:
|
||||||
|
cover_data = cf.extractfile(name).read()
|
||||||
|
break
|
||||||
|
elif original_file_extension.upper() == '.CBR' and use_rarfile:
|
||||||
|
try:
|
||||||
|
rarfile.UNRAR_TOOL = rar_executable
|
||||||
|
cf = rarfile.RarFile(tmp_file_name)
|
||||||
for name in cf.namelist():
|
for name in cf.namelist():
|
||||||
ext = os.path.splitext(name)
|
ext = os.path.splitext(name)
|
||||||
if len(ext) > 1:
|
if len(ext) > 1:
|
||||||
extension = ext[1].lower()
|
extension = ext[1].lower()
|
||||||
if extension == '.jpg' or extension == '.jpeg':
|
if extension in cover.COVER_EXTENSIONS:
|
||||||
cover_data = cf.read(name)
|
cover_data = cf.read([name])
|
||||||
break
|
break
|
||||||
elif original_file_extension.upper() == '.CBT':
|
except Exception as ex:
|
||||||
cf = tarfile.TarFile(tmp_file_name)
|
log.error('Rarfile failed with error: {}'.format(ex))
|
||||||
for name in cf.getnames():
|
elif original_file_extension.upper() == '.CB7' and use_7zip:
|
||||||
ext = os.path.splitext(name)
|
cf = py7zr.SevenZipFile(tmp_file_name)
|
||||||
if len(ext) > 1:
|
for name in cf.getnames():
|
||||||
extension = ext[1].lower()
|
ext = os.path.splitext(name)
|
||||||
if extension == '.jpg' or extension == '.jpeg':
|
if len(ext) > 1:
|
||||||
cover_data = cf.extractfile(name).read()
|
extension = ext[1].lower()
|
||||||
break
|
if extension in cover.COVER_EXTENSIONS:
|
||||||
elif original_file_extension.upper() == '.CBR' and use_rarfile:
|
try:
|
||||||
try:
|
cover_data = cf.read([name])[name].read()
|
||||||
rarfile.UNRAR_TOOL = rarExceutable
|
except (py7zr.Bad7zFile, OSError) as ex:
|
||||||
cf = rarfile.RarFile(tmp_file_name)
|
log.error('7Zip file failed with error: {}'.format(ex))
|
||||||
for name in cf.getnames():
|
break
|
||||||
ext = os.path.splitext(name)
|
return cover_data, extension
|
||||||
if len(ext) > 1:
|
|
||||||
extension = ext[1].lower()
|
|
||||||
if extension == '.jpg' or extension == '.jpeg':
|
|
||||||
cover_data = cf.read(name)
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
log.debug('Rarfile failed with error: %s', e)
|
|
||||||
|
|
||||||
prefix = os.path.dirname(tmp_file_name)
|
|
||||||
if cover_data:
|
|
||||||
tmp_cover_name = prefix + '/cover' + extension
|
|
||||||
image = open(tmp_cover_name, 'wb')
|
|
||||||
image.write(cover_data)
|
|
||||||
image.close()
|
|
||||||
else:
|
|
||||||
tmp_cover_name = None
|
|
||||||
return tmp_cover_name
|
|
||||||
|
|
||||||
|
|
||||||
def get_comic_info(tmp_file_path, original_file_name, original_file_extension, rarExceutable):
|
def _extract_cover(tmp_file_name, original_file_extension, rar_executable):
|
||||||
|
cover_data = extension = None
|
||||||
if use_comic_meta:
|
if use_comic_meta:
|
||||||
archive = ComicArchive(tmp_file_path, rar_exe_path=rarExceutable)
|
try:
|
||||||
if archive.seemsToBeAComicArchive():
|
archive = ComicArchive(tmp_file_name, rar_exe_path=rar_executable)
|
||||||
if archive.hasMetadata(MetaDataStyle.CIX):
|
except TypeError:
|
||||||
|
archive = ComicArchive(tmp_file_name)
|
||||||
|
name_list = archive.getPageNameList if hasattr(archive, "getPageNameList") else archive.get_page_name_list
|
||||||
|
for index, name in enumerate(name_list()):
|
||||||
|
ext = os.path.splitext(name)
|
||||||
|
if len(ext) > 1:
|
||||||
|
extension = ext[1].lower()
|
||||||
|
if extension in cover.COVER_EXTENSIONS:
|
||||||
|
get_page = archive.getPage if hasattr(archive, "getPageNameList") else archive.get_page
|
||||||
|
cover_data = get_page(index)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
cover_data, extension = _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_executable)
|
||||||
|
return cover.cover_processing(tmp_file_name, cover_data, extension)
|
||||||
|
|
||||||
|
|
||||||
|
def get_comic_info(tmp_file_path, original_file_name, original_file_extension, rar_executable):
|
||||||
|
if use_comic_meta:
|
||||||
|
try:
|
||||||
|
archive = ComicArchive(tmp_file_path, rar_exe_path=rar_executable)
|
||||||
|
except TypeError:
|
||||||
|
load_archive_plugins(force=True, rar=rar_executable)
|
||||||
|
archive = ComicArchive(tmp_file_path)
|
||||||
|
if hasattr(archive, "seemsToBeAComicArchive"):
|
||||||
|
seems_archive = archive.seemsToBeAComicArchive
|
||||||
|
else:
|
||||||
|
seems_archive = archive.seems_to_be_a_comic_archive
|
||||||
|
if seems_archive():
|
||||||
|
has_metadata = archive.hasMetadata if hasattr(archive, "hasMetadata") else archive.has_metadata
|
||||||
|
if has_metadata(MetaDataStyle.CIX):
|
||||||
style = MetaDataStyle.CIX
|
style = MetaDataStyle.CIX
|
||||||
elif archive.hasMetadata(MetaDataStyle.CBI):
|
elif has_metadata(MetaDataStyle.CBI):
|
||||||
style = MetaDataStyle.CBI
|
style = MetaDataStyle.CBI
|
||||||
else:
|
else:
|
||||||
style = None
|
style = None
|
||||||
|
|
||||||
# if style is not None:
|
read_metadata = archive.readMetadata if hasattr(archive, "readMetadata") else archive.read_metadata
|
||||||
loadedMetadata = archive.readMetadata(style)
|
loaded_metadata = read_metadata(style)
|
||||||
|
|
||||||
lang = loadedMetadata.language
|
lang = loaded_metadata.language or ""
|
||||||
if lang:
|
loaded_metadata.language = isoLanguages.get_lang3(lang)
|
||||||
if len(lang) == 2:
|
|
||||||
loadedMetadata.language = isoLanguages.get(part1=lang).name
|
|
||||||
elif len(lang) == 3:
|
|
||||||
loadedMetadata.language = isoLanguages.get(part3=lang).name
|
|
||||||
else:
|
|
||||||
loadedMetadata.language = ""
|
|
||||||
|
|
||||||
return BookMeta(
|
return BookMeta(
|
||||||
file_path=tmp_file_path,
|
file_path=tmp_file_path,
|
||||||
extension=original_file_extension,
|
extension=original_file_extension,
|
||||||
title=loadedMetadata.title or original_file_name,
|
title=loaded_metadata.title or original_file_name,
|
||||||
author=" & ".join([credit["person"] for credit in loadedMetadata.credits if credit["role"] == "Writer"]) or u'Unknown',
|
author=" & ".join([credit["person"]
|
||||||
cover=_extractCover(tmp_file_path, original_file_extension, rarExceutable),
|
for credit in loaded_metadata.credits if credit["role"] == "Writer"]) or 'Unknown',
|
||||||
description=loadedMetadata.comments or "",
|
cover=_extract_cover(tmp_file_path, original_file_extension, rar_executable),
|
||||||
|
description=loaded_metadata.comments or "",
|
||||||
tags="",
|
tags="",
|
||||||
series=loadedMetadata.series or "",
|
series=loaded_metadata.series or "",
|
||||||
series_id=loadedMetadata.issue or "",
|
series_id=loaded_metadata.issue or "",
|
||||||
languages=loadedMetadata.language)
|
languages=loaded_metadata.language,
|
||||||
|
publisher="",
|
||||||
|
pubdate="",
|
||||||
|
identifiers=[])
|
||||||
|
|
||||||
return BookMeta(
|
return BookMeta(
|
||||||
file_path=tmp_file_path,
|
file_path=tmp_file_path,
|
||||||
extension=original_file_extension,
|
extension=original_file_extension,
|
||||||
title=original_file_name,
|
title=original_file_name,
|
||||||
author=u'Unknown',
|
author='Unknown',
|
||||||
cover=_extractCover(tmp_file_path, original_file_extension, rarExceutable),
|
cover=_extract_cover(tmp_file_path, original_file_extension, rar_executable),
|
||||||
description="",
|
description="",
|
||||||
tags="",
|
tags="",
|
||||||
series="",
|
series="",
|
||||||
series_id="",
|
series_id="",
|
||||||
languages="")
|
languages="",
|
||||||
|
publisher="",
|
||||||
|
pubdate="",
|
||||||
|
identifiers=[])
|
||||||
|
|
|
@ -16,26 +16,36 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
import os
|
import os
|
||||||
import json
|
|
||||||
import sys
|
import sys
|
||||||
|
import json
|
||||||
|
|
||||||
from sqlalchemy import exc, Column, String, Integer, SmallInteger, Boolean, BLOB
|
from sqlalchemy import Column, String, Integer, SmallInteger, Boolean, BLOB, JSON
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.exc import OperationalError
|
||||||
|
from sqlalchemy.sql.expression import text
|
||||||
|
from sqlalchemy import exists
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
import cryptography.exceptions
|
||||||
|
from base64 import urlsafe_b64decode
|
||||||
|
try:
|
||||||
|
# Compatibility with sqlalchemy 2.0
|
||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
except ImportError:
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
|
||||||
from . import constants, cli, logger, ub
|
from . import constants, logger
|
||||||
|
from .subproc_wrapper import process_wait
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
_Base = declarative_base()
|
_Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
class _Flask_Settings(_Base):
|
class _Flask_Settings(_Base):
|
||||||
__tablename__ = 'flask_settings'
|
__tablename__ = 'flask_settings'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
flask_session_key = Column(BLOB, default="")
|
flask_session_key = Column(BLOB, default=b"")
|
||||||
|
|
||||||
def __init__(self, key):
|
def __init__(self, key):
|
||||||
self.flask_session_key = key
|
self.flask_session_key = key
|
||||||
|
@ -51,27 +61,34 @@ class _Settings(_Base):
|
||||||
mail_port = Column(Integer, default=25)
|
mail_port = Column(Integer, default=25)
|
||||||
mail_use_ssl = Column(SmallInteger, default=0)
|
mail_use_ssl = Column(SmallInteger, default=0)
|
||||||
mail_login = Column(String, default='mail@example.com')
|
mail_login = Column(String, default='mail@example.com')
|
||||||
mail_password = Column(String, default='mypassword')
|
mail_password_e = Column(String)
|
||||||
|
mail_password = Column(String)
|
||||||
mail_from = Column(String, default='automailer <mail@example.com>')
|
mail_from = Column(String, default='automailer <mail@example.com>')
|
||||||
|
mail_size = Column(Integer, default=25*1024*1024)
|
||||||
|
mail_server_type = Column(SmallInteger, default=0)
|
||||||
|
mail_gmail_token = Column(JSON, default={})
|
||||||
|
|
||||||
config_calibre_dir = Column(String)
|
config_calibre_dir = Column(String)
|
||||||
|
config_calibre_uuid = Column(String)
|
||||||
|
config_calibre_split = Column(Boolean, default=False)
|
||||||
|
config_calibre_split_dir = Column(String)
|
||||||
config_port = Column(Integer, default=constants.DEFAULT_PORT)
|
config_port = Column(Integer, default=constants.DEFAULT_PORT)
|
||||||
|
config_external_port = Column(Integer, default=constants.DEFAULT_PORT)
|
||||||
config_certfile = Column(String)
|
config_certfile = Column(String)
|
||||||
config_keyfile = Column(String)
|
config_keyfile = Column(String)
|
||||||
|
config_trustedhosts = Column(String, default='')
|
||||||
config_calibre_web_title = Column(String, default=u'Calibre-Web')
|
config_calibre_web_title = Column(String, default='Calibre-Web')
|
||||||
config_books_per_page = Column(Integer, default=60)
|
config_books_per_page = Column(Integer, default=60)
|
||||||
config_random_books = Column(Integer, default=4)
|
config_random_books = Column(Integer, default=4)
|
||||||
config_authors_max = Column(Integer, default=0)
|
config_authors_max = Column(Integer, default=0)
|
||||||
config_read_column = Column(Integer, default=0)
|
config_read_column = Column(Integer, default=0)
|
||||||
config_title_regex = Column(String, default=u'^(A|The|An|Der|Die|Das|Den|Ein|Eine|Einen|Dem|Des|Einem|Eines)\s+')
|
config_title_regex = Column(String, default=r'^(A|The|An|Der|Die|Das|Den|Ein|Eine|Einen|Dem|Des|Einem|Eines|Le|La|Les|L\'|Un|Une)\s+')
|
||||||
config_mature_content_tags = Column(String, default='')
|
|
||||||
config_theme = Column(Integer, default=0)
|
config_theme = Column(Integer, default=0)
|
||||||
|
|
||||||
config_log_level = Column(SmallInteger, default=logger.DEFAULT_LOG_LEVEL)
|
config_log_level = Column(SmallInteger, default=logger.DEFAULT_LOG_LEVEL)
|
||||||
config_logfile = Column(String)
|
config_logfile = Column(String, default=logger.DEFAULT_LOG_FILE)
|
||||||
config_access_log = Column(SmallInteger, default=0)
|
config_access_log = Column(SmallInteger, default=0)
|
||||||
config_access_logfile = Column(String)
|
config_access_logfile = Column(String, default=logger.DEFAULT_ACCESS_LOG)
|
||||||
|
|
||||||
config_uploading = Column(SmallInteger, default=0)
|
config_uploading = Column(SmallInteger, default=0)
|
||||||
config_anonbrowse = Column(SmallInteger, default=0)
|
config_anonbrowse = Column(SmallInteger, default=0)
|
||||||
|
@ -81,6 +98,8 @@ class _Settings(_Base):
|
||||||
|
|
||||||
config_default_role = Column(SmallInteger, default=0)
|
config_default_role = Column(SmallInteger, default=0)
|
||||||
config_default_show = Column(SmallInteger, default=constants.ADMIN_USER_SIDEBAR)
|
config_default_show = Column(SmallInteger, default=constants.ADMIN_USER_SIDEBAR)
|
||||||
|
config_default_language = Column(String(3), default="all")
|
||||||
|
config_default_locale = Column(String(2), default="en")
|
||||||
config_columns_to_ignore = Column(String)
|
config_columns_to_ignore = Column(String)
|
||||||
|
|
||||||
config_denied_tags = Column(String, default="")
|
config_denied_tags = Column(String, default="")
|
||||||
|
@ -91,55 +110,102 @@ class _Settings(_Base):
|
||||||
|
|
||||||
config_use_google_drive = Column(Boolean, default=False)
|
config_use_google_drive = Column(Boolean, default=False)
|
||||||
config_google_drive_folder = Column(String)
|
config_google_drive_folder = Column(String)
|
||||||
config_google_drive_watch_changes_response = Column(String)
|
config_google_drive_watch_changes_response = Column(JSON, default={})
|
||||||
|
|
||||||
config_use_goodreads = Column(Boolean, default=False)
|
config_use_goodreads = Column(Boolean, default=False)
|
||||||
config_goodreads_api_key = Column(String)
|
config_goodreads_api_key = Column(String)
|
||||||
config_goodreads_api_secret = Column(String)
|
config_register_email = Column(Boolean, default=False)
|
||||||
|
|
||||||
config_login_type = Column(Integer, default=0)
|
config_login_type = Column(Integer, default=0)
|
||||||
|
|
||||||
config_kobo_proxy = Column(Boolean, default=False)
|
config_kobo_proxy = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
|
||||||
config_ldap_provider_url = Column(String, default='example.org')
|
config_ldap_provider_url = Column(String, default='example.org')
|
||||||
config_ldap_port = Column(SmallInteger, default=389)
|
config_ldap_port = Column(SmallInteger, default=389)
|
||||||
config_ldap_authentication = Column(SmallInteger, default=constants.LDAP_AUTH_SIMPLE)
|
config_ldap_authentication = Column(SmallInteger, default=constants.LDAP_AUTH_SIMPLE)
|
||||||
config_ldap_serv_username = Column(String, default='cn=admin,dc=example,dc=org')
|
config_ldap_serv_username = Column(String, default='cn=admin,dc=example,dc=org')
|
||||||
config_ldap_serv_password = Column(String, default="")
|
config_ldap_serv_password_e = Column(String)
|
||||||
|
config_ldap_serv_password = Column(String)
|
||||||
config_ldap_encryption = Column(SmallInteger, default=0)
|
config_ldap_encryption = Column(SmallInteger, default=0)
|
||||||
|
config_ldap_cacert_path = Column(String, default="")
|
||||||
config_ldap_cert_path = Column(String, default="")
|
config_ldap_cert_path = Column(String, default="")
|
||||||
|
config_ldap_key_path = Column(String, default="")
|
||||||
config_ldap_dn = Column(String, default='dc=example,dc=org')
|
config_ldap_dn = Column(String, default='dc=example,dc=org')
|
||||||
config_ldap_user_object = Column(String, default='uid=%s')
|
config_ldap_user_object = Column(String, default='uid=%s')
|
||||||
|
config_ldap_member_user_object = Column(String, default='')
|
||||||
config_ldap_openldap = Column(Boolean, default=True)
|
config_ldap_openldap = Column(Boolean, default=True)
|
||||||
config_ldap_group_object_filter = Column(String, default='(&(objectclass=posixGroup)(cn=%s))')
|
config_ldap_group_object_filter = Column(String, default='(&(objectclass=posixGroup)(cn=%s))')
|
||||||
config_ldap_group_members_field = Column(String, default='memberUid')
|
config_ldap_group_members_field = Column(String, default='memberUid')
|
||||||
config_ldap_group_name = Column(String, default='calibreweb')
|
config_ldap_group_name = Column(String, default='calibreweb')
|
||||||
|
|
||||||
config_ebookconverter = Column(Integer, default=0)
|
config_kepubifypath = Column(String, default=None)
|
||||||
config_converterpath = Column(String)
|
config_converterpath = Column(String, default=None)
|
||||||
|
config_binariesdir = Column(String, default=None)
|
||||||
config_calibre = Column(String)
|
config_calibre = Column(String)
|
||||||
config_rarfile_location = Column(String)
|
config_rarfile_location = Column(String, default=None)
|
||||||
|
config_upload_formats = Column(String, default=','.join(constants.EXTENSIONS_UPLOAD))
|
||||||
|
config_unicode_filename = Column(Boolean, default=False)
|
||||||
|
config_embed_metadata = Column(Boolean, default=True)
|
||||||
|
|
||||||
config_updatechannel = Column(Integer, default=constants.UPDATE_STABLE)
|
config_updatechannel = Column(Integer, default=constants.UPDATE_STABLE)
|
||||||
|
|
||||||
config_reverse_proxy_login_header_name = Column(String)
|
config_reverse_proxy_login_header_name = Column(String)
|
||||||
config_allow_reverse_proxy_header_login = Column(Boolean, default=False)
|
config_allow_reverse_proxy_header_login = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
schedule_start_time = Column(Integer, default=4)
|
||||||
|
schedule_duration = Column(Integer, default=10)
|
||||||
|
schedule_generate_book_covers = Column(Boolean, default=False)
|
||||||
|
schedule_generate_series_covers = Column(Boolean, default=False)
|
||||||
|
schedule_reconnect = Column(Boolean, default=False)
|
||||||
|
schedule_metadata_backup = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
config_password_policy = Column(Boolean, default=True)
|
||||||
|
config_password_min_length = Column(Integer, default=8)
|
||||||
|
config_password_number = Column(Boolean, default=True)
|
||||||
|
config_password_lower = Column(Boolean, default=True)
|
||||||
|
config_password_upper = Column(Boolean, default=True)
|
||||||
|
config_password_character = Column(Boolean, default=True)
|
||||||
|
config_password_special = Column(Boolean, default=True)
|
||||||
|
config_session = Column(Integer, default=1)
|
||||||
|
config_ratelimiter = Column(Boolean, default=True)
|
||||||
|
config_limiter_uri = Column(String, default="")
|
||||||
|
config_limiter_options = Column(String, default="")
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.__class__.__name__
|
return self.__class__.__name__
|
||||||
|
|
||||||
|
|
||||||
# Class holds all application specific settings in calibre-web
|
# Class holds all application specific settings in calibre-web
|
||||||
class _ConfigSQL(object):
|
class ConfigSQL(object):
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
def __init__(self, session):
|
def __init__(self):
|
||||||
|
self.__dict__["dirty"] = list()
|
||||||
|
|
||||||
|
def init_config(self, session, secret_key, cli):
|
||||||
self._session = session
|
self._session = session
|
||||||
self._settings = None
|
self._settings = None
|
||||||
self.db_configured = None
|
self.db_configured = None
|
||||||
self.config_calibre_dir = None
|
self.config_calibre_dir = None
|
||||||
|
self._fernet = Fernet(secret_key)
|
||||||
|
self.cli = cli
|
||||||
self.load()
|
self.load()
|
||||||
|
|
||||||
|
change = False
|
||||||
|
|
||||||
|
if self.config_binariesdir == None: # pylint: disable=access-member-before-definition
|
||||||
|
change = True
|
||||||
|
self.config_binariesdir = autodetect_calibre_binaries()
|
||||||
|
self.config_converterpath = autodetect_converter_binary(self.config_binariesdir)
|
||||||
|
|
||||||
|
if self.config_kepubifypath == None: # pylint: disable=access-member-before-definition
|
||||||
|
change = True
|
||||||
|
self.config_kepubifypath = autodetect_kepubify_binary()
|
||||||
|
|
||||||
|
if self.config_rarfile_location == None: # pylint: disable=access-member-before-definition
|
||||||
|
change = True
|
||||||
|
self.config_rarfile_location = autodetect_unrar_binary()
|
||||||
|
if change:
|
||||||
|
self.save()
|
||||||
|
|
||||||
def _read_from_storage(self):
|
def _read_from_storage(self):
|
||||||
if self._settings is None:
|
if self._settings is None:
|
||||||
log.debug("_ConfigSQL._read_from_storage")
|
log.debug("_ConfigSQL._read_from_storage")
|
||||||
|
@ -147,21 +213,21 @@ class _ConfigSQL(object):
|
||||||
return self._settings
|
return self._settings
|
||||||
|
|
||||||
def get_config_certfile(self):
|
def get_config_certfile(self):
|
||||||
if cli.certfilepath:
|
if self.cli.certfilepath:
|
||||||
return cli.certfilepath
|
return self.cli.certfilepath
|
||||||
if cli.certfilepath == "":
|
if self.cli.certfilepath == "":
|
||||||
return None
|
return None
|
||||||
return self.config_certfile
|
return self.config_certfile
|
||||||
|
|
||||||
def get_config_keyfile(self):
|
def get_config_keyfile(self):
|
||||||
if cli.keyfilepath:
|
if self.cli.keyfilepath:
|
||||||
return cli.keyfilepath
|
return self.cli.keyfilepath
|
||||||
if cli.certfilepath == "":
|
if self.cli.certfilepath == "":
|
||||||
return None
|
return None
|
||||||
return self.config_keyfile
|
return self.config_keyfile
|
||||||
|
|
||||||
def get_config_ipaddress(self):
|
def get_config_ipaddress(self):
|
||||||
return cli.ipadress or ""
|
return self.cli.ip_address or ""
|
||||||
|
|
||||||
def _has_role(self, role_flag):
|
def _has_role(self, role_flag):
|
||||||
return constants.has_flag(self.config_default_role, role_flag)
|
return constants.has_flag(self.config_default_role, role_flag)
|
||||||
|
@ -197,40 +263,42 @@ class _ConfigSQL(object):
|
||||||
return self.show_element_new_user(constants.DETAIL_RANDOM)
|
return self.show_element_new_user(constants.DETAIL_RANDOM)
|
||||||
|
|
||||||
def list_denied_tags(self):
|
def list_denied_tags(self):
|
||||||
mct = self.config_denied_tags.split(",")
|
mct = self.config_denied_tags or ""
|
||||||
return [t.strip() for t in mct]
|
return [t.strip() for t in mct.split(",")]
|
||||||
|
|
||||||
def list_allowed_tags(self):
|
def list_allowed_tags(self):
|
||||||
mct = self.config_allowed_tags.split(",")
|
mct = self.config_allowed_tags or ""
|
||||||
return [t.strip() for t in mct]
|
return [t.strip() for t in mct.split(",")]
|
||||||
|
|
||||||
def list_denied_column_values(self):
|
def list_denied_column_values(self):
|
||||||
mct = self.config_denied_column_value.split(",")
|
mct = self.config_denied_column_value or ""
|
||||||
return [t.strip() for t in mct]
|
return [t.strip() for t in mct.split(",")]
|
||||||
|
|
||||||
def list_allowed_column_values(self):
|
def list_allowed_column_values(self):
|
||||||
mct = self.config_allowed_column_value.split(",")
|
mct = self.config_allowed_column_value or ""
|
||||||
return [t.strip() for t in mct]
|
return [t.strip() for t in mct.split(",")]
|
||||||
|
|
||||||
def get_log_level(self):
|
def get_log_level(self):
|
||||||
return logger.get_level_name(self.config_log_level)
|
return logger.get_level_name(self.config_log_level)
|
||||||
|
|
||||||
def get_mail_settings(self):
|
def get_mail_settings(self):
|
||||||
return {k:v for k, v in self.__dict__.items() if k.startswith('mail_')}
|
return {k: v for k, v in self.__dict__.items() if k.startswith('mail_')}
|
||||||
|
|
||||||
def get_mail_server_configured(self):
|
def get_mail_server_configured(self):
|
||||||
return not bool(self.mail_server == constants.DEFAULT_MAIL_SERVER)
|
return bool((self.mail_server != constants.DEFAULT_MAIL_SERVER and self.mail_server_type == 0)
|
||||||
|
or (self.mail_gmail_token != {} and self.mail_server_type == 1))
|
||||||
|
|
||||||
|
def get_scheduled_task_settings(self):
|
||||||
|
return {k: v for k, v in self.__dict__.items() if k.startswith('schedule_')}
|
||||||
|
|
||||||
def set_from_dictionary(self, dictionary, field, convertor=None, default=None, encode=None):
|
def set_from_dictionary(self, dictionary, field, convertor=None, default=None, encode=None):
|
||||||
'''Possibly updates a field of this object.
|
"""Possibly updates a field of this object.
|
||||||
The new value, if present, is grabbed from the given dictionary, and optionally passed through a convertor.
|
The new value, if present, is grabbed from the given dictionary, and optionally passed through a convertor.
|
||||||
|
|
||||||
:returns: `True` if the field has changed value
|
:returns: `True` if the field has changed value
|
||||||
'''
|
"""
|
||||||
new_value = dictionary.get(field, default)
|
new_value = dictionary.get(field, default)
|
||||||
if new_value is None:
|
if new_value is None:
|
||||||
# log.debug("_ConfigSQL set_from_dictionary field '%s' not found", field)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if field not in self.__dict__:
|
if field not in self.__dict__:
|
||||||
|
@ -247,12 +315,18 @@ class _ConfigSQL(object):
|
||||||
if current_value == new_value:
|
if current_value == new_value:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# log.debug("_ConfigSQL set_from_dictionary '%s' = %r (was %r)", field, new_value, current_value)
|
|
||||||
setattr(self, field, new_value)
|
setattr(self, field, new_value)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
storage = {}
|
||||||
|
for k, v in self.__dict__.items():
|
||||||
|
if k[0] != '_' and not k.endswith("_e") and not k == "cli":
|
||||||
|
storage[k] = v
|
||||||
|
return storage
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
'''Load all configuration values from the underlying storage.'''
|
"""Load all configuration values from the underlying storage."""
|
||||||
s = self._read_from_storage() # type: _Settings
|
s = self._read_from_storage() # type: _Settings
|
||||||
for k, v in s.__dict__.items():
|
for k, v in s.__dict__.items():
|
||||||
if k[0] != '_':
|
if k[0] != '_':
|
||||||
|
@ -261,33 +335,59 @@ class _ConfigSQL(object):
|
||||||
column = s.__class__.__dict__.get(k)
|
column = s.__class__.__dict__.get(k)
|
||||||
if column.default is not None:
|
if column.default is not None:
|
||||||
v = column.default.arg
|
v = column.default.arg
|
||||||
setattr(self, k, v)
|
if k.endswith("_e") and v is not None:
|
||||||
|
try:
|
||||||
if self.config_google_drive_watch_changes_response:
|
setattr(self, k, self._fernet.decrypt(v).decode())
|
||||||
self.config_google_drive_watch_changes_response = json.loads(self.config_google_drive_watch_changes_response)
|
except cryptography.fernet.InvalidToken:
|
||||||
|
setattr(self, k, "")
|
||||||
|
else:
|
||||||
|
setattr(self, k, v)
|
||||||
|
|
||||||
have_metadata_db = bool(self.config_calibre_dir)
|
have_metadata_db = bool(self.config_calibre_dir)
|
||||||
if have_metadata_db:
|
if have_metadata_db:
|
||||||
if not self.config_use_google_drive:
|
db_file = os.path.join(self.config_calibre_dir, 'metadata.db')
|
||||||
db_file = os.path.join(self.config_calibre_dir, 'metadata.db')
|
have_metadata_db = os.path.isfile(db_file)
|
||||||
have_metadata_db = os.path.isfile(db_file)
|
|
||||||
self.db_configured = have_metadata_db
|
self.db_configured = have_metadata_db
|
||||||
|
constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')]
|
||||||
logger.setup(self.config_logfile, self.config_log_level)
|
from . import cli_param
|
||||||
|
if os.environ.get('FLASK_DEBUG'):
|
||||||
|
logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG)
|
||||||
|
else:
|
||||||
|
# pylint: disable=access-member-before-definition
|
||||||
|
logfile = logger.setup(cli_param.logpath or self.config_logfile, self.config_log_level)
|
||||||
|
if logfile != os.path.abspath(self.config_logfile):
|
||||||
|
if logfile != os.path.abspath(cli_param.logpath):
|
||||||
|
log.warning("Log path %s not valid, falling back to default", self.config_logfile)
|
||||||
|
self.config_logfile = logfile
|
||||||
|
s.config_logfile = logfile
|
||||||
|
self._session.merge(s)
|
||||||
|
try:
|
||||||
|
self._session.commit()
|
||||||
|
except OperationalError as e:
|
||||||
|
log.error('Database error: %s', e)
|
||||||
|
self._session.rollback()
|
||||||
|
self.__dict__["dirty"] = list()
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
'''Apply all configuration values to the underlying storage.'''
|
"""Apply all configuration values to the underlying storage."""
|
||||||
s = self._read_from_storage() # type: _Settings
|
s = self._read_from_storage() # type: _Settings
|
||||||
|
|
||||||
for k, v in self.__dict__.items():
|
for k in self.dirty:
|
||||||
if k[0] == '_':
|
if k[0] == '_':
|
||||||
continue
|
continue
|
||||||
if hasattr(s, k):
|
if hasattr(s, k):
|
||||||
setattr(s, k, v)
|
if k.endswith("_e"):
|
||||||
|
setattr(s, k, self._fernet.encrypt(self.__dict__[k].encode()))
|
||||||
|
else:
|
||||||
|
setattr(s, k, self.__dict__[k])
|
||||||
|
|
||||||
log.debug("_ConfigSQL updating storage")
|
log.debug("_ConfigSQL updating storage")
|
||||||
self._session.merge(s)
|
self._session.merge(s)
|
||||||
self._session.commit()
|
try:
|
||||||
|
self._session.commit()
|
||||||
|
except OperationalError as e:
|
||||||
|
log.error('Database error: %s', e)
|
||||||
|
self._session.rollback()
|
||||||
self.load()
|
self.load()
|
||||||
|
|
||||||
def invalidate(self, error=None):
|
def invalidate(self, error=None):
|
||||||
|
@ -295,80 +395,181 @@ class _ConfigSQL(object):
|
||||||
log.error(error)
|
log.error(error)
|
||||||
log.warning("invalidating configuration")
|
log.warning("invalidating configuration")
|
||||||
self.db_configured = False
|
self.db_configured = False
|
||||||
self.config_calibre_dir = None
|
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
def get_book_path(self):
|
||||||
|
return self.config_calibre_split_dir if self.config_calibre_split_dir else self.config_calibre_dir
|
||||||
|
|
||||||
def _migrate_table(session, orm_class):
|
def store_calibre_uuid(self, calibre_db, Library_table):
|
||||||
|
try:
|
||||||
|
calibre_uuid = calibre_db.session.query(Library_table).one_or_none()
|
||||||
|
if self.config_calibre_uuid != calibre_uuid.uuid:
|
||||||
|
self.config_calibre_uuid = calibre_uuid.uuid
|
||||||
|
self.save()
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __setattr__(self, attr_name, attr_value):
|
||||||
|
super().__setattr__(attr_name, attr_value)
|
||||||
|
self.__dict__["dirty"].append(attr_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _encrypt_fields(session, secret_key):
|
||||||
|
try:
|
||||||
|
session.query(exists().where(_Settings.mail_password_e)).scalar()
|
||||||
|
except OperationalError:
|
||||||
|
with session.bind.connect() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE settings ADD column 'mail_password_e' String"))
|
||||||
|
conn.execute(text("ALTER TABLE settings ADD column 'config_ldap_serv_password_e' String"))
|
||||||
|
session.commit()
|
||||||
|
crypter = Fernet(secret_key)
|
||||||
|
settings = session.query(_Settings.mail_password, _Settings.config_ldap_serv_password).first()
|
||||||
|
if settings.mail_password:
|
||||||
|
session.query(_Settings).update(
|
||||||
|
{_Settings.mail_password_e: crypter.encrypt(settings.mail_password.encode())})
|
||||||
|
if settings.config_ldap_serv_password:
|
||||||
|
session.query(_Settings).update(
|
||||||
|
{_Settings.config_ldap_serv_password_e:
|
||||||
|
crypter.encrypt(settings.config_ldap_serv_password.encode())})
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_table(session, orm_class, secret_key=None):
|
||||||
|
if secret_key:
|
||||||
|
_encrypt_fields(session, secret_key)
|
||||||
changed = False
|
changed = False
|
||||||
|
|
||||||
for column_name, column in orm_class.__dict__.items():
|
for column_name, column in orm_class.__dict__.items():
|
||||||
if column_name[0] != '_':
|
if column_name[0] != '_':
|
||||||
try:
|
try:
|
||||||
session.query(column).first()
|
session.query(column).first()
|
||||||
except exc.OperationalError as err:
|
except OperationalError as err:
|
||||||
log.debug("%s: %s", column_name, err.args[0])
|
log.debug("%s: %s", column_name, err.args[0])
|
||||||
if column.default is not None:
|
|
||||||
if sys.version_info < (3, 0):
|
|
||||||
if isinstance(column.default.arg, unicode):
|
|
||||||
column.default.arg = column.default.arg.encode('utf-8')
|
|
||||||
if column.default is None:
|
if column.default is None:
|
||||||
column_default = ""
|
column_default = ""
|
||||||
else:
|
else:
|
||||||
if isinstance(column.default.arg, bool):
|
if isinstance(column.default.arg, bool):
|
||||||
column_default = ("DEFAULT %r" % int(column.default.arg))
|
column_default = "DEFAULT {}".format(int(column.default.arg))
|
||||||
else:
|
else:
|
||||||
column_default = ("DEFAULT %r" % column.default.arg)
|
column_default = "DEFAULT `{}`".format(column.default.arg)
|
||||||
alter_table = "ALTER TABLE %s ADD COLUMN `%s` %s %s" % (orm_class.__tablename__,
|
if isinstance(column.type, JSON):
|
||||||
column_name,
|
column_type = "JSON"
|
||||||
column.type,
|
else:
|
||||||
column_default)
|
column_type = column.type
|
||||||
|
alter_table = text("ALTER TABLE %s ADD COLUMN `%s` %s %s" % (orm_class.__tablename__,
|
||||||
|
column_name,
|
||||||
|
column_type,
|
||||||
|
column_default))
|
||||||
log.debug(alter_table)
|
log.debug(alter_table)
|
||||||
session.execute(alter_table)
|
session.execute(alter_table)
|
||||||
changed = True
|
changed = True
|
||||||
|
except json.decoder.JSONDecodeError as e:
|
||||||
|
log.error("Database corrupt column: {}".format(column_name))
|
||||||
|
log.debug(e)
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
session.commit()
|
try:
|
||||||
|
session.commit()
|
||||||
|
except OperationalError:
|
||||||
|
session.rollback()
|
||||||
|
|
||||||
def autodetect_calibre_binary():
|
|
||||||
|
def autodetect_calibre_binaries():
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
calibre_path = ["C:\\program files\calibre\calibre-convert.exe",
|
calibre_path = ["C:\\program files\\calibre\\",
|
||||||
"C:\\program files(x86)\calibre\calibre-convert.exe"]
|
"C:\\program files(x86)\\calibre\\",
|
||||||
|
"C:\\program files(x86)\\calibre2\\",
|
||||||
|
"C:\\program files\\calibre2\\"]
|
||||||
else:
|
else:
|
||||||
calibre_path = ["/opt/calibre/ebook-convert"]
|
calibre_path = ["/opt/calibre/"]
|
||||||
|
for element in calibre_path:
|
||||||
|
supported_binary_paths = [os.path.join(element, binary)
|
||||||
|
for binary in constants.SUPPORTED_CALIBRE_BINARIES.values()]
|
||||||
|
if all(os.path.isfile(binary_path) and os.access(binary_path, os.X_OK)
|
||||||
|
for binary_path in supported_binary_paths):
|
||||||
|
values = [process_wait([binary_path, "--version"],
|
||||||
|
pattern=r'\(calibre (.*)\)') for binary_path in supported_binary_paths]
|
||||||
|
if all(values):
|
||||||
|
version = values[0].group(1)
|
||||||
|
log.debug("calibre version %s", version)
|
||||||
|
return element
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def autodetect_converter_binary(calibre_path):
|
||||||
|
if sys.platform == "win32":
|
||||||
|
converter_path = os.path.join(calibre_path, "ebook-convert.exe")
|
||||||
|
else:
|
||||||
|
converter_path = os.path.join(calibre_path, "ebook-convert")
|
||||||
|
if calibre_path and os.path.isfile(converter_path) and os.access(converter_path, os.X_OK):
|
||||||
|
return converter_path
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def autodetect_unrar_binary():
|
||||||
|
if sys.platform == "win32":
|
||||||
|
calibre_path = ["C:\\program files\\WinRar\\unRAR.exe",
|
||||||
|
"C:\\program files(x86)\\WinRar\\unRAR.exe"]
|
||||||
|
else:
|
||||||
|
calibre_path = ["/usr/bin/unrar"]
|
||||||
for element in calibre_path:
|
for element in calibre_path:
|
||||||
if os.path.isfile(element) and os.access(element, os.X_OK):
|
if os.path.isfile(element) and os.access(element, os.X_OK):
|
||||||
return element
|
return element
|
||||||
return None
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _migrate_database(session):
|
def autodetect_kepubify_binary():
|
||||||
|
if sys.platform == "win32":
|
||||||
|
calibre_path = ["C:\\program files\\kepubify\\kepubify-windows-64Bit.exe",
|
||||||
|
"C:\\program files(x86)\\kepubify\\kepubify-windows-64Bit.exe"]
|
||||||
|
else:
|
||||||
|
calibre_path = ["/opt/kepubify/kepubify-linux-64bit", "/opt/kepubify/kepubify-linux-32bit"]
|
||||||
|
for element in calibre_path:
|
||||||
|
if os.path.isfile(element) and os.access(element, os.X_OK):
|
||||||
|
return element
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_database(session, secret_key):
|
||||||
# make sure the table is created, if it does not exist
|
# make sure the table is created, if it does not exist
|
||||||
_Base.metadata.create_all(session.bind)
|
_Base.metadata.create_all(session.bind)
|
||||||
_migrate_table(session, _Settings)
|
_migrate_table(session, _Settings, secret_key)
|
||||||
_migrate_table(session, _Flask_Settings)
|
_migrate_table(session, _Flask_Settings)
|
||||||
|
|
||||||
|
|
||||||
def load_configuration(session):
|
def load_configuration(session, secret_key):
|
||||||
_migrate_database(session)
|
_migrate_database(session, secret_key)
|
||||||
|
|
||||||
if not session.query(_Settings).count():
|
if not session.query(_Settings).count():
|
||||||
session.add(_Settings())
|
session.add(_Settings())
|
||||||
session.commit()
|
session.commit()
|
||||||
conf = _ConfigSQL(session)
|
|
||||||
# Migrate from global restrictions to user based restrictions
|
|
||||||
if bool(conf.config_default_show & constants.MATURE_CONTENT) and conf.config_denied_tags == "":
|
|
||||||
conf.config_denied_tags = conf.config_mature_content_tags
|
|
||||||
conf.save()
|
|
||||||
session.query(ub.User).filter(ub.User.mature_content != True). \
|
|
||||||
update({"denied_tags": conf.config_mature_content_tags}, synchronize_session=False)
|
|
||||||
session.commit()
|
|
||||||
return conf
|
|
||||||
|
|
||||||
def get_flask_session_key(session):
|
|
||||||
flask_settings = session.query(_Flask_Settings).one_or_none()
|
def get_flask_session_key(_session):
|
||||||
|
flask_settings = _session.query(_Flask_Settings).one_or_none()
|
||||||
if flask_settings == None:
|
if flask_settings == None:
|
||||||
flask_settings = _Flask_Settings(os.urandom(32))
|
flask_settings = _Flask_Settings(os.urandom(32))
|
||||||
session.add(flask_settings)
|
_session.add(flask_settings)
|
||||||
session.commit()
|
_session.commit()
|
||||||
return flask_settings.flask_session_key
|
return flask_settings.flask_session_key
|
||||||
|
|
||||||
|
|
||||||
|
def get_encryption_key(key_path):
|
||||||
|
key_file = os.path.join(key_path, ".key")
|
||||||
|
generate = True
|
||||||
|
error = ""
|
||||||
|
if os.path.exists(key_file) and os.path.getsize(key_file) > 32:
|
||||||
|
with open(key_file, "rb") as f:
|
||||||
|
key = f.read()
|
||||||
|
try:
|
||||||
|
urlsafe_b64decode(key)
|
||||||
|
generate = False
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if generate:
|
||||||
|
key = Fernet.generate_key()
|
||||||
|
try:
|
||||||
|
with open(key_file, "wb") as f:
|
||||||
|
f.write(key)
|
||||||
|
except PermissionError as e:
|
||||||
|
error = e
|
||||||
|
return key, error
|
||||||
|
|
102
cps/constants.py
102
cps/constants.py
|
@ -16,33 +16,48 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
from sqlalchemy import __version__ as sql_version
|
||||||
|
|
||||||
HOME_CONFIG = False
|
sqlalchemy_version2 = ([int(x) for x in sql_version.split('.')] >= [2, 0, 0])
|
||||||
|
|
||||||
|
# APP_MODE - production, development, or test
|
||||||
|
APP_MODE = os.environ.get('APP_MODE', 'production')
|
||||||
|
|
||||||
|
# if installed via pip this variable is set to true (empty file with name .HOMEDIR present)
|
||||||
|
HOME_CONFIG = os.path.isfile(os.path.join(os.path.dirname(os.path.abspath(__file__)), '.HOMEDIR'))
|
||||||
|
|
||||||
|
# In executables updater is not available, so variable is set to False there
|
||||||
|
UPDATER_AVAILABLE = True
|
||||||
|
|
||||||
# Base dir is parent of current file, necessary if called from different folder
|
# Base dir is parent of current file, necessary if called from different folder
|
||||||
if sys.version_info < (3, 0):
|
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
|
||||||
BASE_DIR = os.path.abspath(os.path.join(
|
# if executable file the files should be placed in the parent dir (parallel to the exe file)
|
||||||
os.path.dirname(os.path.abspath(__file__)),os.pardir)).decode('utf-8')
|
|
||||||
else:
|
|
||||||
BASE_DIR = os.path.abspath(os.path.join(
|
|
||||||
os.path.dirname(os.path.abspath(__file__)),os.pardir))
|
|
||||||
STATIC_DIR = os.path.join(BASE_DIR, 'cps', 'static')
|
STATIC_DIR = os.path.join(BASE_DIR, 'cps', 'static')
|
||||||
TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates')
|
TEMPLATES_DIR = os.path.join(BASE_DIR, 'cps', 'templates')
|
||||||
TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations')
|
TRANSLATIONS_DIR = os.path.join(BASE_DIR, 'cps', 'translations')
|
||||||
|
|
||||||
|
# Cache dir - use CACHE_DIR environment variable, otherwise use the default directory: cps/cache
|
||||||
|
DEFAULT_CACHE_DIR = os.path.join(BASE_DIR, 'cps', 'cache')
|
||||||
|
CACHE_DIR = os.environ.get('CACHE_DIR', DEFAULT_CACHE_DIR)
|
||||||
|
|
||||||
if HOME_CONFIG:
|
if HOME_CONFIG:
|
||||||
home_dir = os.path.join(os.path.expanduser("~"),".calibre-web")
|
home_dir = os.path.join(os.path.expanduser("~"), ".calibre-web")
|
||||||
if not os.path.exists(home_dir):
|
if not os.path.exists(home_dir):
|
||||||
os.makedirs(home_dir)
|
os.makedirs(home_dir)
|
||||||
CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', home_dir)
|
CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', home_dir)
|
||||||
else:
|
else:
|
||||||
CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', BASE_DIR)
|
CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', BASE_DIR)
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
CONFIG_DIR = os.path.abspath(os.path.join(CONFIG_DIR, os.pardir))
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_SETTINGS_FILE = "app.db"
|
||||||
|
DEFAULT_GDRIVE_FILE = "gdrive.db"
|
||||||
|
|
||||||
ROLE_USER = 0 << 0
|
ROLE_USER = 0 << 0
|
||||||
ROLE_ADMIN = 1 << 0
|
ROLE_ADMIN = 1 << 0
|
||||||
ROLE_DOWNLOAD = 1 << 1
|
ROLE_DOWNLOAD = 1 << 1
|
||||||
|
@ -81,9 +96,31 @@ SIDEBAR_PUBLISHER = 1 << 12
|
||||||
SIDEBAR_RATING = 1 << 13
|
SIDEBAR_RATING = 1 << 13
|
||||||
SIDEBAR_FORMAT = 1 << 14
|
SIDEBAR_FORMAT = 1 << 14
|
||||||
SIDEBAR_ARCHIVED = 1 << 15
|
SIDEBAR_ARCHIVED = 1 << 15
|
||||||
|
SIDEBAR_DOWNLOAD = 1 << 16
|
||||||
|
SIDEBAR_LIST = 1 << 17
|
||||||
|
|
||||||
|
sidebar_settings = {
|
||||||
|
"detail_random": DETAIL_RANDOM,
|
||||||
|
"sidebar_language": SIDEBAR_LANGUAGE,
|
||||||
|
"sidebar_series": SIDEBAR_SERIES,
|
||||||
|
"sidebar_category": SIDEBAR_CATEGORY,
|
||||||
|
"sidebar_random": SIDEBAR_RANDOM,
|
||||||
|
"sidebar_author": SIDEBAR_AUTHOR,
|
||||||
|
"sidebar_best_rated": SIDEBAR_BEST_RATED,
|
||||||
|
"sidebar_read_and_unread": SIDEBAR_READ_AND_UNREAD,
|
||||||
|
"sidebar_recent": SIDEBAR_RECENT,
|
||||||
|
"sidebar_sorted": SIDEBAR_SORTED,
|
||||||
|
"sidebar_publisher": SIDEBAR_PUBLISHER,
|
||||||
|
"sidebar_rating": SIDEBAR_RATING,
|
||||||
|
"sidebar_format": SIDEBAR_FORMAT,
|
||||||
|
"sidebar_archived": SIDEBAR_ARCHIVED,
|
||||||
|
"sidebar_download": SIDEBAR_DOWNLOAD,
|
||||||
|
"sidebar_list": SIDEBAR_LIST,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
ADMIN_USER_ROLES = sum(r for r in ALL_ROLES.values()) & ~ROLE_ANONYMOUS
|
ADMIN_USER_ROLES = sum(r for r in ALL_ROLES.values()) & ~ROLE_ANONYMOUS
|
||||||
ADMIN_USER_SIDEBAR = (SIDEBAR_ARCHIVED << 1) - 1
|
ADMIN_USER_SIDEBAR = (SIDEBAR_LIST << 1) - 1
|
||||||
|
|
||||||
UPDATE_STABLE = 0 << 0
|
UPDATE_STABLE = 0 << 0
|
||||||
AUTO_UPDATE_STABLE = 1 << 0
|
AUTO_UPDATE_STABLE = 1 << 0
|
||||||
|
@ -100,7 +137,7 @@ LDAP_AUTH_SIMPLE = 0
|
||||||
|
|
||||||
DEFAULT_MAIL_SERVER = "mail.example.org"
|
DEFAULT_MAIL_SERVER = "mail.example.org"
|
||||||
|
|
||||||
DEFAULT_PASSWORD = "admin123"
|
DEFAULT_PASSWORD = "admin123" # nosec
|
||||||
DEFAULT_PORT = 8083
|
DEFAULT_PORT = 8083
|
||||||
env_CALIBRE_PORT = os.environ.get("CALIBRE_PORT", DEFAULT_PORT)
|
env_CALIBRE_PORT = os.environ.get("CALIBRE_PORT", DEFAULT_PORT)
|
||||||
try:
|
try:
|
||||||
|
@ -110,12 +147,19 @@ except ValueError:
|
||||||
del env_CALIBRE_PORT
|
del env_CALIBRE_PORT
|
||||||
|
|
||||||
|
|
||||||
EXTENSIONS_AUDIO = {'mp3', 'm4a', 'm4b'}
|
EXTENSIONS_AUDIO = {'mp3', 'mp4', 'ogg', 'opus', 'wav', 'flac', 'm4a', 'm4b'}
|
||||||
EXTENSIONS_CONVERT = {'pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf', 'txt', 'htmlz', 'rtf', 'odt'}
|
EXTENSIONS_CONVERT_FROM = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf',
|
||||||
EXTENSIONS_UPLOAD = {'txt', 'pdf', 'epub', 'mobi', 'azw', 'azw3', 'cbr', 'cbz', 'cbt', 'djvu', 'prc', 'doc', 'docx',
|
'txt', 'htmlz', 'rtf', 'odt', 'cbz', 'cbr', 'prc']
|
||||||
'fb2', 'html', 'rtf', 'lit', 'odt', 'mp3', 'm4a', 'm4b'}
|
EXTENSIONS_CONVERT_TO = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2',
|
||||||
# EXTENSIONS_READER = set(['txt', 'pdf', 'epub', 'zip', 'cbz', 'tar', 'cbt'] +
|
'lit', 'lrf', 'txt', 'htmlz', 'rtf', 'odt']
|
||||||
# (['rar','cbr'] if feature_support['rar'] else []))
|
EXTENSIONS_UPLOAD = {'txt', 'pdf', 'epub', 'kepub', 'mobi', 'azw', 'azw3', 'cbr', 'cbz', 'cbt', 'cb7', 'djvu', 'djv',
|
||||||
|
'prc', 'doc', 'docx', 'fb2', 'html', 'rtf', 'lit', 'odt', 'mp3', 'mp4', 'ogg',
|
||||||
|
'opus', 'wav', 'flac', 'm4a', 'm4b'}
|
||||||
|
|
||||||
|
_extension = ""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
_extension = ".exe"
|
||||||
|
SUPPORTED_CALIBRE_BINARIES = {binary:binary + _extension for binary in ["ebook-convert", "calibredb"]}
|
||||||
|
|
||||||
|
|
||||||
def has_flag(value, bit_flag):
|
def has_flag(value, bit_flag):
|
||||||
|
@ -127,16 +171,28 @@ def selected_roles(dictionary):
|
||||||
|
|
||||||
# :rtype: BookMeta
|
# :rtype: BookMeta
|
||||||
BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, description, tags, series, '
|
BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, description, tags, series, '
|
||||||
'series_id, languages')
|
'series_id, languages, publisher, pubdate, identifiers')
|
||||||
|
|
||||||
STABLE_VERSION = {'version': '0.6.7'}
|
# python build process likes to have x.y.zbw -> b for beta and w a counting number
|
||||||
|
STABLE_VERSION = {'version': '0.6.22b'}
|
||||||
|
|
||||||
NIGHTLY_VERSION = {}
|
NIGHTLY_VERSION = dict()
|
||||||
NIGHTLY_VERSION[0] = '$Format:%H$'
|
NIGHTLY_VERSION[0] = '$Format:%H$'
|
||||||
NIGHTLY_VERSION[1] = '$Format:%cI$'
|
NIGHTLY_VERSION[1] = '$Format:%cI$'
|
||||||
# NIGHTLY_VERSION[0] = 'bb7d2c6273ae4560e83950d36d64533343623a57'
|
|
||||||
# NIGHTLY_VERSION[1] = '2018-09-09T10:13:08+02:00'
|
|
||||||
|
|
||||||
|
# CACHE
|
||||||
|
CACHE_TYPE_THUMBNAILS = 'thumbnails'
|
||||||
|
|
||||||
|
# Thumbnail Types
|
||||||
|
THUMBNAIL_TYPE_COVER = 1
|
||||||
|
THUMBNAIL_TYPE_SERIES = 2
|
||||||
|
THUMBNAIL_TYPE_AUTHOR = 3
|
||||||
|
|
||||||
|
# Thumbnails Sizes
|
||||||
|
COVER_THUMBNAIL_ORIGINAL = 0
|
||||||
|
COVER_THUMBNAIL_SMALL = 1
|
||||||
|
COVER_THUMBNAIL_MEDIUM = 2
|
||||||
|
COVER_THUMBNAIL_LARGE = 3
|
||||||
|
|
||||||
# clean-up the module namespace
|
# clean-up the module namespace
|
||||||
del sys, os, namedtuple
|
del sys, os, namedtuple
|
||||||
|
|
|
@ -16,10 +16,10 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from flask_babel import gettext as _
|
|
||||||
|
from flask_babel import lazy_gettext as N_
|
||||||
|
|
||||||
from . import config, logger
|
from . import config, logger
|
||||||
from .subproc_wrapper import process_wait
|
from .subproc_wrapper import process_wait
|
||||||
|
@ -27,10 +27,9 @@ from .subproc_wrapper import process_wait
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
# _() necessary to make babel aware of string for translation
|
# strings getting translated when used
|
||||||
_NOT_CONFIGURED = _('not configured')
|
_NOT_INSTALLED = N_('not installed')
|
||||||
_NOT_INSTALLED = 'not installed'
|
_EXECUTION_ERROR = N_('Execution permissions missing')
|
||||||
_EXECUTION_ERROR = 'Execution permissions missing'
|
|
||||||
|
|
||||||
|
|
||||||
def _get_command_version(path, pattern, argument=None):
|
def _get_command_version(path, pattern, argument=None):
|
||||||
|
@ -39,19 +38,25 @@ def _get_command_version(path, pattern, argument=None):
|
||||||
if argument:
|
if argument:
|
||||||
command.append(argument)
|
command.append(argument)
|
||||||
try:
|
try:
|
||||||
for line in process_wait(command):
|
match = process_wait(command, pattern=pattern)
|
||||||
if re.search(pattern, line):
|
if isinstance(match, re.Match):
|
||||||
return line
|
return match.string
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.warning("%s: %s", path, ex)
|
log.warning("%s: %s", path, ex)
|
||||||
return _EXECUTION_ERROR
|
return _EXECUTION_ERROR
|
||||||
return _NOT_INSTALLED
|
return _NOT_INSTALLED
|
||||||
|
|
||||||
|
|
||||||
def get_version():
|
def get_calibre_version():
|
||||||
version = None
|
return _get_command_version(config.config_converterpath, r'ebook-convert.*\(calibre', '--version')
|
||||||
if config.config_ebookconverter == 1:
|
|
||||||
version = _get_command_version(config.config_converterpath, r'Amazon kindlegen\(')
|
|
||||||
elif config.config_ebookconverter == 2:
|
def get_unrar_version():
|
||||||
version = _get_command_version(config.config_converterpath, r'ebook-convert.*\(calibre', '--version')
|
unrar_version = _get_command_version(config.config_rarfile_location, r'UNRAR.*\d')
|
||||||
return version or _NOT_CONFIGURED
|
if unrar_version == "not installed":
|
||||||
|
unrar_version = _get_command_version(config.config_rarfile_location, r'unrar.*\d', '-V')
|
||||||
|
return unrar_version
|
||||||
|
|
||||||
|
|
||||||
|
def get_kepubify_version():
|
||||||
|
return _get_command_version(config.config_kepubifypath, r'kepubify\s', '--version')
|
||||||
|
|
48
cps/cover.py
Normal file
48
cps/cover.py
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2022 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
try:
|
||||||
|
from wand.image import Image
|
||||||
|
use_IM = True
|
||||||
|
except (ImportError, RuntimeError) as e:
|
||||||
|
use_IM = False
|
||||||
|
|
||||||
|
|
||||||
|
NO_JPEG_EXTENSIONS = ['.png', '.webp', '.bmp']
|
||||||
|
COVER_EXTENSIONS = ['.png', '.webp', '.bmp', '.jpg', '.jpeg']
|
||||||
|
|
||||||
|
|
||||||
|
def cover_processing(tmp_file_name, img, extension):
|
||||||
|
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_name), 'cover.jpg')
|
||||||
|
if extension in NO_JPEG_EXTENSIONS:
|
||||||
|
if use_IM:
|
||||||
|
with Image(blob=img) as imgc:
|
||||||
|
imgc.format = 'jpeg'
|
||||||
|
imgc.transform_colorspace('rgb')
|
||||||
|
imgc.save(filename=tmp_cover_name)
|
||||||
|
return tmp_cover_name
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
if img:
|
||||||
|
with open(tmp_cover_name, 'wb') as f:
|
||||||
|
f.write(img)
|
||||||
|
return tmp_cover_name
|
||||||
|
else:
|
||||||
|
return None
|
80
cps/debug_info.py
Normal file
80
cps/debug_info.py
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2012-2019 cervinko, idalin, SiphonSquirrel, ouzklcn, akushsky,
|
||||||
|
# OzzieIsaacs, bodybybuddha, jkrehm, matthazinski, janeczku
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import glob
|
||||||
|
import zipfile
|
||||||
|
import json
|
||||||
|
from io import BytesIO
|
||||||
|
from flask_babel.speaklater import LazyString
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from flask import send_file, __version__
|
||||||
|
|
||||||
|
from . import logger, config
|
||||||
|
from .about import collect_stats
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
class lazyEncoder(json.JSONEncoder):
|
||||||
|
def default(self, obj):
|
||||||
|
if isinstance(obj, LazyString):
|
||||||
|
return str(obj)
|
||||||
|
# Let the base class default method raise the TypeError
|
||||||
|
return json.JSONEncoder.default(self, obj)
|
||||||
|
|
||||||
|
def assemble_logfiles(file_name):
|
||||||
|
log_list = sorted(glob.glob(file_name + '*'), reverse=True)
|
||||||
|
wfd = BytesIO()
|
||||||
|
for f in log_list:
|
||||||
|
with open(f, 'rb') as fd:
|
||||||
|
shutil.copyfileobj(fd, wfd)
|
||||||
|
wfd.seek(0)
|
||||||
|
if int(__version__.split('.')[0]) < 2:
|
||||||
|
return send_file(wfd,
|
||||||
|
as_attachment=True,
|
||||||
|
attachment_filename=os.path.basename(file_name))
|
||||||
|
else:
|
||||||
|
return send_file(wfd,
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=os.path.basename(file_name))
|
||||||
|
|
||||||
|
|
||||||
|
def send_debug():
|
||||||
|
file_list = glob.glob(logger.get_logfile(config.config_logfile) + '*')
|
||||||
|
file_list.extend(glob.glob(logger.get_accesslogfile(config.config_access_logfile) + '*'))
|
||||||
|
for element in [logger.LOG_TO_STDOUT, logger.LOG_TO_STDERR]:
|
||||||
|
if element in file_list:
|
||||||
|
file_list.remove(element)
|
||||||
|
memory_zip = BytesIO()
|
||||||
|
with zipfile.ZipFile(memory_zip, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr('settings.txt', json.dumps(config.to_dict(), sort_keys=True, indent=2))
|
||||||
|
zf.writestr('libs.txt', json.dumps(collect_stats(), sort_keys=True, indent=2, cls=lazyEncoder))
|
||||||
|
for fp in file_list:
|
||||||
|
zf.write(fp, os.path.basename(fp))
|
||||||
|
memory_zip.seek(0)
|
||||||
|
if int(__version__.split('.')[0]) < 2:
|
||||||
|
return send_file(memory_zip,
|
||||||
|
as_attachment=True,
|
||||||
|
attachment_filename="Calibre-Web-debug-pack.zip")
|
||||||
|
else:
|
||||||
|
return send_file(memory_zip,
|
||||||
|
as_attachment=True,
|
||||||
|
download_name="Calibre-Web-debug-pack.zip")
|
109
cps/dep_check.py
Normal file
109
cps/dep_check.py
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
|
||||||
|
from .constants import BASE_DIR
|
||||||
|
try:
|
||||||
|
from importlib.metadata import version
|
||||||
|
importlib = True
|
||||||
|
ImportNotFound = BaseException
|
||||||
|
except ImportError:
|
||||||
|
importlib = False
|
||||||
|
version = None
|
||||||
|
|
||||||
|
if not importlib:
|
||||||
|
try:
|
||||||
|
import pkg_resources
|
||||||
|
from pkg_resources import DistributionNotFound as ImportNotFound
|
||||||
|
pkgresources = True
|
||||||
|
except ImportError as e:
|
||||||
|
pkgresources = False
|
||||||
|
|
||||||
|
|
||||||
|
def load_dependencies(optional=False):
|
||||||
|
deps = list()
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
pip_installed = os.path.join(BASE_DIR, ".pip_installed")
|
||||||
|
if os.path.exists(pip_installed):
|
||||||
|
with open(pip_installed) as f:
|
||||||
|
exe_deps = json.loads("".join(f.readlines()))
|
||||||
|
else:
|
||||||
|
return deps
|
||||||
|
if importlib or pkgresources:
|
||||||
|
if optional:
|
||||||
|
req_path = os.path.join(BASE_DIR, "optional-requirements.txt")
|
||||||
|
else:
|
||||||
|
req_path = os.path.join(BASE_DIR, "requirements.txt")
|
||||||
|
if os.path.exists(req_path):
|
||||||
|
with open(req_path, 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
if not line.startswith('#') and not line == '\n' and not line.startswith('git'):
|
||||||
|
res = re.match(r'(.*?)([<=>\s]+)([\d\.]+),?\s?([<=>\s]+)?([\d\.]+)?', line.strip())
|
||||||
|
try:
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
dep_version = exe_deps[res.group(1).lower().replace('_', '-')]
|
||||||
|
else:
|
||||||
|
if importlib:
|
||||||
|
dep_version = version(res.group(1))
|
||||||
|
else:
|
||||||
|
dep_version = pkg_resources.get_distribution(res.group(1)).version
|
||||||
|
except (ImportNotFound, KeyError):
|
||||||
|
if optional:
|
||||||
|
continue
|
||||||
|
dep_version = "not installed"
|
||||||
|
deps.append([dep_version, res.group(1), res.group(2), res.group(3), res.group(4), res.group(5)])
|
||||||
|
return deps
|
||||||
|
|
||||||
|
|
||||||
|
def dependency_check(optional=False):
|
||||||
|
d = list()
|
||||||
|
deps = load_dependencies(optional)
|
||||||
|
for dep in deps:
|
||||||
|
try:
|
||||||
|
dep_version_int = [int(x) if x.isnumeric() else 0 for x in dep[0].split('.')]
|
||||||
|
low_check = [int(x) for x in dep[3].split('.')]
|
||||||
|
high_check = [int(x) for x in dep[5].split('.')]
|
||||||
|
except AttributeError:
|
||||||
|
high_check = []
|
||||||
|
except ValueError:
|
||||||
|
d.append({'name': dep[1],
|
||||||
|
'target': "available",
|
||||||
|
'found': "Not available"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dep[2].strip() == "==":
|
||||||
|
if dep_version_int != low_check:
|
||||||
|
d.append({'name': dep[1],
|
||||||
|
'found': dep[0],
|
||||||
|
"target": dep[2] + dep[3]})
|
||||||
|
continue
|
||||||
|
elif dep[2].strip() == ">=":
|
||||||
|
if dep_version_int < low_check:
|
||||||
|
d.append({'name': dep[1],
|
||||||
|
'found': dep[0],
|
||||||
|
"target": dep[2] + dep[3]})
|
||||||
|
continue
|
||||||
|
elif dep[2].strip() == ">":
|
||||||
|
if dep_version_int <= low_check:
|
||||||
|
d.append({'name': dep[1],
|
||||||
|
'found': dep[0],
|
||||||
|
"target": dep[2] + dep[3]})
|
||||||
|
continue
|
||||||
|
if dep[4] and dep[5]:
|
||||||
|
if dep[4].strip() == "<":
|
||||||
|
if dep_version_int >= high_check:
|
||||||
|
d.append(
|
||||||
|
{'name': dep[1],
|
||||||
|
'found': dep[0],
|
||||||
|
"target": dep[4] + dep[5]})
|
||||||
|
continue
|
||||||
|
elif dep[4].strip() == "<=":
|
||||||
|
if dep_version_int > high_check:
|
||||||
|
d.append(
|
||||||
|
{'name': dep[1],
|
||||||
|
'found': dep[0],
|
||||||
|
"target": dep[4] + dep[5]})
|
||||||
|
continue
|
||||||
|
return d
|
2141
cps/editbooks.py
2141
cps/editbooks.py
File diff suppressed because it is too large
Load Diff
63
cps/embed_helper.py
Normal file
63
cps/embed_helper.py
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2024 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
from uuid import uuid4
|
||||||
|
import os
|
||||||
|
|
||||||
|
from .file_helper import get_temp_dir
|
||||||
|
from .subproc_wrapper import process_open
|
||||||
|
from . import logger, config
|
||||||
|
from .constants import SUPPORTED_CALIBRE_BINARIES
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
|
def do_calibre_export(book_id, book_format):
|
||||||
|
try:
|
||||||
|
quotes = [3, 5, 7, 9]
|
||||||
|
tmp_dir = get_temp_dir()
|
||||||
|
calibredb_binarypath = get_calibre_binarypath("calibredb")
|
||||||
|
temp_file_name = str(uuid4())
|
||||||
|
my_env = os.environ.copy()
|
||||||
|
if config.config_calibre_split:
|
||||||
|
my_env['CALIBRE_OVERRIDE_DATABASE_PATH'] = os.path.join(config.config_calibre_dir, "metadata.db")
|
||||||
|
library_path = config.config_calibre_split_dir
|
||||||
|
else:
|
||||||
|
library_path = config.config_calibre_dir
|
||||||
|
opf_command = [calibredb_binarypath, 'export', '--dont-write-opf', '--with-library', library_path,
|
||||||
|
'--to-dir', tmp_dir, '--formats', book_format, "--template", "{}".format(temp_file_name),
|
||||||
|
str(book_id)]
|
||||||
|
p = process_open(opf_command, quotes, my_env)
|
||||||
|
_, err = p.communicate()
|
||||||
|
if err:
|
||||||
|
log.error('Metadata embedder encountered an error: %s', err)
|
||||||
|
return tmp_dir, temp_file_name
|
||||||
|
except OSError as ex:
|
||||||
|
# ToDo real error handling
|
||||||
|
log.error_or_exception(ex)
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def get_calibre_binarypath(binary):
|
||||||
|
binariesdir = config.config_binariesdir
|
||||||
|
if binariesdir:
|
||||||
|
try:
|
||||||
|
return os.path.join(binariesdir, SUPPORTED_CALIBRE_BINARIES[binary])
|
||||||
|
except KeyError as ex:
|
||||||
|
log.error("Binary not supported by Calibre-Web: %s", SUPPORTED_CALIBRE_BINARIES[binary])
|
||||||
|
pass
|
||||||
|
return ""
|
199
cps/epub.py
199
cps/epub.py
|
@ -16,27 +16,52 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
import os
|
import os
|
||||||
import zipfile
|
import zipfile
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
from . import isoLanguages
|
from . import isoLanguages, cover
|
||||||
|
from . import config, logger
|
||||||
|
from .helper import split_authors
|
||||||
|
from .epub_helper import get_content_opf, default_ns
|
||||||
from .constants import BookMeta
|
from .constants import BookMeta
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
def extractCover(zipFile, coverFile, coverpath, tmp_file_name):
|
|
||||||
if coverFile is None:
|
def _extract_cover(zip_file, cover_file, cover_path, tmp_file_name):
|
||||||
|
if cover_file is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cf = extension = None
|
||||||
|
zip_cover_path = os.path.join(cover_path, cover_file).replace('\\', '/')
|
||||||
|
|
||||||
|
prefix = os.path.splitext(tmp_file_name)[0]
|
||||||
|
tmp_cover_name = prefix + '.' + os.path.basename(zip_cover_path)
|
||||||
|
ext = os.path.splitext(tmp_cover_name)
|
||||||
|
if len(ext) > 1:
|
||||||
|
extension = ext[1].lower()
|
||||||
|
if extension in cover.COVER_EXTENSIONS:
|
||||||
|
cf = zip_file.read(zip_cover_path)
|
||||||
|
return cover.cover_processing(tmp_file_name, cf, extension)
|
||||||
|
|
||||||
|
def get_epub_layout(book, book_data):
|
||||||
|
file_path = os.path.normpath(os.path.join(config.get_book_path(),
|
||||||
|
book.path, book_data.name + "." + book_data.format.lower()))
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree, __ = get_content_opf(file_path, default_ns)
|
||||||
|
p = tree.xpath('/pkg:package/pkg:metadata', namespaces=default_ns)[0]
|
||||||
|
|
||||||
|
layout = p.xpath('pkg:meta[@property="rendition:layout"]/text()', namespaces=default_ns)
|
||||||
|
except (etree.XMLSyntaxError, KeyError, IndexError, OSError) as e:
|
||||||
|
log.error("Could not parse epub metadata of book {} during kobo sync: {}".format(book.id, e))
|
||||||
|
layout = []
|
||||||
|
|
||||||
|
if len(layout) == 0:
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
zipCoverPath = os.path.join(coverpath, coverFile).replace('\\', '/')
|
return layout[0]
|
||||||
cf = zipFile.read(zipCoverPath)
|
|
||||||
prefix = os.path.splitext(tmp_file_name)[0]
|
|
||||||
tmp_cover_name = prefix + '.' + os.path.basename(zipCoverPath)
|
|
||||||
image = open(tmp_cover_name, 'wb')
|
|
||||||
image.write(cf)
|
|
||||||
image.close()
|
|
||||||
return tmp_cover_name
|
|
||||||
|
|
||||||
|
|
||||||
def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
|
def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
|
||||||
|
@ -46,87 +71,62 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
|
||||||
'dc': 'http://purl.org/dc/elements/1.1/'
|
'dc': 'http://purl.org/dc/elements/1.1/'
|
||||||
}
|
}
|
||||||
|
|
||||||
epubZip = zipfile.ZipFile(tmp_file_path)
|
tree, cf_name = get_content_opf(tmp_file_path, ns)
|
||||||
|
|
||||||
txt = epubZip.read('META-INF/container.xml')
|
cover_path = os.path.dirname(cf_name)
|
||||||
tree = etree.fromstring(txt)
|
|
||||||
cfname = tree.xpath('n:rootfiles/n:rootfile/@full-path', namespaces=ns)[0]
|
|
||||||
cf = epubZip.read(cfname)
|
|
||||||
tree = etree.fromstring(cf)
|
|
||||||
|
|
||||||
coverpath = os.path.dirname(cfname)
|
|
||||||
|
|
||||||
p = tree.xpath('/pkg:package/pkg:metadata', namespaces=ns)[0]
|
p = tree.xpath('/pkg:package/pkg:metadata', namespaces=ns)[0]
|
||||||
|
|
||||||
epub_metadata = {}
|
epub_metadata = {}
|
||||||
|
|
||||||
for s in ['title', 'description', 'creator', 'language', 'subject']:
|
for s in ['title', 'description', 'creator', 'language', 'subject', 'publisher', 'date']:
|
||||||
tmp = p.xpath('dc:%s/text()' % s, namespaces=ns)
|
tmp = p.xpath('dc:%s/text()' % s, namespaces=ns)
|
||||||
if len(tmp) > 0:
|
if len(tmp) > 0:
|
||||||
if s == 'creator':
|
if s == 'creator':
|
||||||
epub_metadata[s] = ' & '.join(p.xpath('dc:%s/text()' % s, namespaces=ns))
|
epub_metadata[s] = ' & '.join(split_authors(tmp))
|
||||||
elif s == 'subject':
|
elif s == 'subject':
|
||||||
epub_metadata[s] = ', '.join(p.xpath('dc:%s/text()' % s, namespaces=ns))
|
epub_metadata[s] = ', '.join(tmp)
|
||||||
|
elif s == 'date':
|
||||||
|
epub_metadata[s] = tmp[0][:10]
|
||||||
else:
|
else:
|
||||||
epub_metadata[s] = p.xpath('dc:%s/text()' % s, namespaces=ns)[0]
|
epub_metadata[s] = tmp[0].strip()
|
||||||
else:
|
else:
|
||||||
epub_metadata[s] = u'Unknown'
|
epub_metadata[s] = 'Unknown'
|
||||||
|
|
||||||
if epub_metadata['subject'] == u'Unknown':
|
if epub_metadata['subject'] == 'Unknown':
|
||||||
epub_metadata['subject'] = ''
|
epub_metadata['subject'] = ''
|
||||||
|
|
||||||
if epub_metadata['description'] == u'Unknown':
|
if epub_metadata['publisher'] == 'Unknown':
|
||||||
|
epub_metadata['publisher'] = ''
|
||||||
|
|
||||||
|
if epub_metadata['date'] == 'Unknown':
|
||||||
|
epub_metadata['date'] = ''
|
||||||
|
|
||||||
|
if epub_metadata['description'] == 'Unknown':
|
||||||
description = tree.xpath("//*[local-name() = 'description']/text()")
|
description = tree.xpath("//*[local-name() = 'description']/text()")
|
||||||
if len(description) > 0:
|
if len(description) > 0:
|
||||||
epub_metadata['description'] = description
|
epub_metadata['description'] = description
|
||||||
else:
|
else:
|
||||||
epub_metadata['description'] = ""
|
epub_metadata['description'] = ""
|
||||||
|
|
||||||
if epub_metadata['language'] == u'Unknown':
|
lang = epub_metadata['language'].split('-', 1)[0].lower()
|
||||||
epub_metadata['language'] = ""
|
epub_metadata['language'] = isoLanguages.get_lang3(lang)
|
||||||
else:
|
|
||||||
lang = epub_metadata['language'].split('-', 1)[0].lower()
|
|
||||||
if len(lang) == 2:
|
|
||||||
epub_metadata['language'] = isoLanguages.get(part1=lang).name
|
|
||||||
elif len(lang) == 3:
|
|
||||||
epub_metadata['language'] = isoLanguages.get(part3=lang).name
|
|
||||||
else:
|
|
||||||
epub_metadata['language'] = ""
|
|
||||||
|
|
||||||
series = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series']/@content", namespaces=ns)
|
epub_metadata = parse_epub_series(ns, tree, epub_metadata)
|
||||||
if len(series) > 0:
|
|
||||||
epub_metadata['series'] = series[0]
|
|
||||||
else:
|
|
||||||
epub_metadata['series'] = ''
|
|
||||||
|
|
||||||
series_id = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series_index']/@content", namespaces=ns)
|
epub_zip = zipfile.ZipFile(tmp_file_path)
|
||||||
if len(series_id) > 0:
|
cover_file = parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path)
|
||||||
epub_metadata['series_id'] = series_id[0]
|
|
||||||
else:
|
|
||||||
epub_metadata['series_id'] = '1'
|
|
||||||
|
|
||||||
coversection = tree.xpath("/pkg:package/pkg:manifest/pkg:item[@id='cover-image']/@href", namespaces=ns)
|
identifiers = []
|
||||||
coverfile = None
|
for node in p.xpath('dc:identifier', namespaces=ns):
|
||||||
if len(coversection) > 0:
|
try:
|
||||||
coverfile = extractCover(epubZip, coversection[0], coverpath, tmp_file_path)
|
identifier_name = node.attrib.values()[-1]
|
||||||
else:
|
except IndexError:
|
||||||
meta_cover = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='cover']/@content", namespaces=ns)
|
continue
|
||||||
if len(meta_cover) > 0:
|
identifier_value = node.text
|
||||||
coversection = tree.xpath("/pkg:package/pkg:manifest/pkg:item[@id='"+meta_cover[0]+"']/@href", namespaces=ns)
|
if identifier_name in ('uuid', 'calibre') or identifier_value is None:
|
||||||
else:
|
continue
|
||||||
coversection = tree.xpath("/pkg:package/pkg:guide/pkg:reference/@href", namespaces=ns)
|
identifiers.append([identifier_name, identifier_value])
|
||||||
if len(coversection) > 0:
|
|
||||||
filetype = coversection[0].rsplit('.', 1)[-1]
|
|
||||||
if filetype == "xhtml" or filetype == "html": # if cover is (x)html format
|
|
||||||
markup = epubZip.read(os.path.join(coverpath, coversection[0]))
|
|
||||||
markupTree = etree.fromstring(markup)
|
|
||||||
# no matter xhtml or html with no namespace
|
|
||||||
imgsrc = markupTree.xpath("//*[local-name() = 'img']/@src")
|
|
||||||
# imgsrc maybe startwith "../"" so fullpath join then relpath to cwd
|
|
||||||
filename = os.path.relpath(os.path.join(os.path.dirname(os.path.join(coverpath, coversection[0])), imgsrc[0]))
|
|
||||||
coverfile = extractCover(epubZip, filename, "", tmp_file_path)
|
|
||||||
else:
|
|
||||||
coverfile = extractCover(epubZip, coversection[0], coverpath, tmp_file_path)
|
|
||||||
|
|
||||||
if not epub_metadata['title']:
|
if not epub_metadata['title']:
|
||||||
title = original_file_name
|
title = original_file_name
|
||||||
|
@ -138,9 +138,66 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
|
||||||
extension=original_file_extension,
|
extension=original_file_extension,
|
||||||
title=title.encode('utf-8').decode('utf-8'),
|
title=title.encode('utf-8').decode('utf-8'),
|
||||||
author=epub_metadata['creator'].encode('utf-8').decode('utf-8'),
|
author=epub_metadata['creator'].encode('utf-8').decode('utf-8'),
|
||||||
cover=coverfile,
|
cover=cover_file,
|
||||||
description=epub_metadata['description'],
|
description=epub_metadata['description'],
|
||||||
tags=epub_metadata['subject'].encode('utf-8').decode('utf-8'),
|
tags=epub_metadata['subject'].encode('utf-8').decode('utf-8'),
|
||||||
series=epub_metadata['series'].encode('utf-8').decode('utf-8'),
|
series=epub_metadata['series'].encode('utf-8').decode('utf-8'),
|
||||||
series_id=epub_metadata['series_id'].encode('utf-8').decode('utf-8'),
|
series_id=epub_metadata['series_id'].encode('utf-8').decode('utf-8'),
|
||||||
languages=epub_metadata['language'])
|
languages=epub_metadata['language'],
|
||||||
|
publisher=epub_metadata['publisher'].encode('utf-8').decode('utf-8'),
|
||||||
|
pubdate=epub_metadata['date'],
|
||||||
|
identifiers=identifiers)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path):
|
||||||
|
cover_section = tree.xpath("/pkg:package/pkg:manifest/pkg:item[@id='cover-image']/@href", namespaces=ns)
|
||||||
|
for cs in cover_section:
|
||||||
|
cover_file = _extract_cover(epub_zip, cs, cover_path, tmp_file_path)
|
||||||
|
if cover_file:
|
||||||
|
return cover_file
|
||||||
|
|
||||||
|
meta_cover = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='cover']/@content", namespaces=ns)
|
||||||
|
if len(meta_cover) > 0:
|
||||||
|
cover_section = tree.xpath(
|
||||||
|
"/pkg:package/pkg:manifest/pkg:item[@id='"+meta_cover[0]+"']/@href", namespaces=ns)
|
||||||
|
if not cover_section:
|
||||||
|
cover_section = tree.xpath(
|
||||||
|
"/pkg:package/pkg:manifest/pkg:item[@properties='" + meta_cover[0] + "']/@href", namespaces=ns)
|
||||||
|
else:
|
||||||
|
cover_section = tree.xpath("/pkg:package/pkg:guide/pkg:reference/@href", namespaces=ns)
|
||||||
|
|
||||||
|
cover_file = None
|
||||||
|
for cs in cover_section:
|
||||||
|
if cs.endswith('.xhtml') or cs.endswith('.html'):
|
||||||
|
markup = epub_zip.read(os.path.join(cover_path, cs))
|
||||||
|
markup_tree = etree.fromstring(markup)
|
||||||
|
# no matter xhtml or html with no namespace
|
||||||
|
img_src = markup_tree.xpath("//*[local-name() = 'img']/@src")
|
||||||
|
# Alternative image source
|
||||||
|
if not len(img_src):
|
||||||
|
img_src = markup_tree.xpath("//attribute::*[contains(local-name(), 'href')]")
|
||||||
|
if len(img_src):
|
||||||
|
# img_src maybe start with "../"" so fullpath join then relpath to cwd
|
||||||
|
filename = os.path.relpath(os.path.join(os.path.dirname(os.path.join(cover_path, cover_section[0])),
|
||||||
|
img_src[0]))
|
||||||
|
cover_file = _extract_cover(epub_zip, filename, "", tmp_file_path)
|
||||||
|
else:
|
||||||
|
cover_file = _extract_cover(epub_zip, cs, cover_path, tmp_file_path)
|
||||||
|
if cover_file:
|
||||||
|
break
|
||||||
|
return cover_file
|
||||||
|
|
||||||
|
|
||||||
|
def parse_epub_series(ns, tree, epub_metadata):
|
||||||
|
series = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series']/@content", namespaces=ns)
|
||||||
|
if len(series) > 0:
|
||||||
|
epub_metadata['series'] = series[0]
|
||||||
|
else:
|
||||||
|
epub_metadata['series'] = ''
|
||||||
|
|
||||||
|
series_id = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series_index']/@content", namespaces=ns)
|
||||||
|
if len(series_id) > 0:
|
||||||
|
epub_metadata['series_id'] = series_id[0]
|
||||||
|
else:
|
||||||
|
epub_metadata['series_id'] = '1'
|
||||||
|
return epub_metadata
|
||||||
|
|
166
cps/epub_helper.py
Normal file
166
cps/epub_helper.py
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2018 lemmsh, Kennyl, Kyosfonica, matthazinski
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import zipfile
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
from . import isoLanguages
|
||||||
|
|
||||||
|
default_ns = {
|
||||||
|
'n': 'urn:oasis:names:tc:opendocument:xmlns:container',
|
||||||
|
'pkg': 'http://www.idpf.org/2007/opf',
|
||||||
|
}
|
||||||
|
|
||||||
|
OPF_NAMESPACE = "http://www.idpf.org/2007/opf"
|
||||||
|
PURL_NAMESPACE = "http://purl.org/dc/elements/1.1/"
|
||||||
|
|
||||||
|
OPF = "{%s}" % OPF_NAMESPACE
|
||||||
|
PURL = "{%s}" % PURL_NAMESPACE
|
||||||
|
|
||||||
|
etree.register_namespace("opf", OPF_NAMESPACE)
|
||||||
|
etree.register_namespace("dc", PURL_NAMESPACE)
|
||||||
|
|
||||||
|
OPF_NS = {None: OPF_NAMESPACE} # the default namespace (no prefix)
|
||||||
|
NSMAP = {'dc': PURL_NAMESPACE, 'opf': OPF_NAMESPACE}
|
||||||
|
|
||||||
|
|
||||||
|
def updateEpub(src, dest, filename, data, ):
|
||||||
|
# create a temp copy of the archive without filename
|
||||||
|
with zipfile.ZipFile(src, 'r') as zin:
|
||||||
|
with zipfile.ZipFile(dest, 'w') as zout:
|
||||||
|
zout.comment = zin.comment # preserve the comment
|
||||||
|
for item in zin.infolist():
|
||||||
|
if item.filename != filename:
|
||||||
|
zout.writestr(item, zin.read(item.filename))
|
||||||
|
|
||||||
|
# now add filename with its new data
|
||||||
|
with zipfile.ZipFile(dest, mode='a', compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr(filename, data)
|
||||||
|
|
||||||
|
|
||||||
|
def get_content_opf(file_path, ns=default_ns):
|
||||||
|
epubZip = zipfile.ZipFile(file_path)
|
||||||
|
txt = epubZip.read('META-INF/container.xml')
|
||||||
|
tree = etree.fromstring(txt)
|
||||||
|
cf_name = tree.xpath('n:rootfiles/n:rootfile/@full-path', namespaces=ns)[0]
|
||||||
|
cf = epubZip.read(cf_name)
|
||||||
|
|
||||||
|
return etree.fromstring(cf), cf_name
|
||||||
|
|
||||||
|
|
||||||
|
def create_new_metadata_backup(book, custom_columns, export_language, translated_cover_name, lang_type=3):
|
||||||
|
# generate root package element
|
||||||
|
package = etree.Element(OPF + "package", nsmap=OPF_NS)
|
||||||
|
package.set("unique-identifier", "uuid_id")
|
||||||
|
package.set("version", "2.0")
|
||||||
|
|
||||||
|
# generate metadata element and all sub elements of it
|
||||||
|
metadata = etree.SubElement(package, "metadata", nsmap=NSMAP)
|
||||||
|
identifier = etree.SubElement(metadata, PURL + "identifier", id="calibre_id", nsmap=NSMAP)
|
||||||
|
identifier.set(OPF + "scheme", "calibre")
|
||||||
|
identifier.text = str(book.id)
|
||||||
|
identifier2 = etree.SubElement(metadata, PURL + "identifier", id="uuid_id", nsmap=NSMAP)
|
||||||
|
identifier2.set(OPF + "scheme", "uuid")
|
||||||
|
identifier2.text = book.uuid
|
||||||
|
for i in book.identifiers:
|
||||||
|
identifier = etree.SubElement(metadata, PURL + "identifier", nsmap=NSMAP)
|
||||||
|
identifier.set(OPF + "scheme", i.format_type())
|
||||||
|
identifier.text = str(i.val)
|
||||||
|
title = etree.SubElement(metadata, PURL + "title", nsmap=NSMAP)
|
||||||
|
title.text = book.title
|
||||||
|
for author in book.authors:
|
||||||
|
creator = etree.SubElement(metadata, PURL + "creator", nsmap=NSMAP)
|
||||||
|
creator.text = str(author.name)
|
||||||
|
creator.set(OPF + "file-as", book.author_sort) # ToDo Check
|
||||||
|
creator.set(OPF + "role", "aut")
|
||||||
|
contributor = etree.SubElement(metadata, PURL + "contributor", nsmap=NSMAP)
|
||||||
|
contributor.text = "calibre (5.7.2) [https://calibre-ebook.com]"
|
||||||
|
contributor.set(OPF + "file-as", "calibre") # ToDo Check
|
||||||
|
contributor.set(OPF + "role", "bkp")
|
||||||
|
|
||||||
|
date = etree.SubElement(metadata, PURL + "date", nsmap=NSMAP)
|
||||||
|
date.text = '{d.year:04}-{d.month:02}-{d.day:02}T{d.hour:02}:{d.minute:02}:{d.second:02}'.format(d=book.pubdate)
|
||||||
|
if book.comments and book.comments[0].text:
|
||||||
|
for b in book.comments:
|
||||||
|
description = etree.SubElement(metadata, PURL + "description", nsmap=NSMAP)
|
||||||
|
description.text = b.text
|
||||||
|
for b in book.publishers:
|
||||||
|
publisher = etree.SubElement(metadata, PURL + "publisher", nsmap=NSMAP)
|
||||||
|
publisher.text = str(b.name)
|
||||||
|
if not book.languages:
|
||||||
|
language = etree.SubElement(metadata, PURL + "language", nsmap=NSMAP)
|
||||||
|
language.text = export_language
|
||||||
|
else:
|
||||||
|
for b in book.languages:
|
||||||
|
language = etree.SubElement(metadata, PURL + "language", nsmap=NSMAP)
|
||||||
|
language.text = str(b.lang_code) if lang_type == 3 else isoLanguages.get(part3=b.lang_code).part1
|
||||||
|
for b in book.tags:
|
||||||
|
subject = etree.SubElement(metadata, PURL + "subject", nsmap=NSMAP)
|
||||||
|
subject.text = str(b.name)
|
||||||
|
etree.SubElement(metadata, "meta", name="calibre:author_link_map",
|
||||||
|
content="{" + ", ".join(['"' + str(a.name) + '": ""' for a in book.authors]) + "}",
|
||||||
|
nsmap=NSMAP)
|
||||||
|
for b in book.series:
|
||||||
|
etree.SubElement(metadata, "meta", name="calibre:series",
|
||||||
|
content=str(str(b.name)),
|
||||||
|
nsmap=NSMAP)
|
||||||
|
if book.series:
|
||||||
|
etree.SubElement(metadata, "meta", name="calibre:series_index",
|
||||||
|
content=str(book.series_index),
|
||||||
|
nsmap=NSMAP)
|
||||||
|
if len(book.ratings) and book.ratings[0].rating > 0:
|
||||||
|
etree.SubElement(metadata, "meta", name="calibre:rating",
|
||||||
|
content=str(book.ratings[0].rating),
|
||||||
|
nsmap=NSMAP)
|
||||||
|
etree.SubElement(metadata, "meta", name="calibre:timestamp",
|
||||||
|
content='{d.year:04}-{d.month:02}-{d.day:02}T{d.hour:02}:{d.minute:02}:{d.second:02}'.format(
|
||||||
|
d=book.timestamp),
|
||||||
|
nsmap=NSMAP)
|
||||||
|
etree.SubElement(metadata, "meta", name="calibre:title_sort",
|
||||||
|
content=book.sort,
|
||||||
|
nsmap=NSMAP)
|
||||||
|
sequence = 0
|
||||||
|
for cc in custom_columns:
|
||||||
|
value = None
|
||||||
|
extra = None
|
||||||
|
cc_entry = getattr(book, "custom_column_" + str(cc.id))
|
||||||
|
if cc_entry.__len__():
|
||||||
|
value = [c.value for c in cc_entry] if cc.is_multiple else cc_entry[0].value
|
||||||
|
extra = cc_entry[0].extra if hasattr(cc_entry[0], "extra") else None
|
||||||
|
etree.SubElement(metadata, "meta", name="calibre:user_metadata:#{}".format(cc.label),
|
||||||
|
content=cc.to_json(value, extra, sequence),
|
||||||
|
nsmap=NSMAP)
|
||||||
|
sequence += 1
|
||||||
|
|
||||||
|
# generate guide element and all sub elements of it
|
||||||
|
# Title is translated from default export language
|
||||||
|
guide = etree.SubElement(package, "guide")
|
||||||
|
etree.SubElement(guide, "reference", type="cover", title=translated_cover_name, href="cover.jpg")
|
||||||
|
|
||||||
|
return package
|
||||||
|
|
||||||
|
def replace_metadata(tree, package):
|
||||||
|
rep_element = tree.xpath('/pkg:package/pkg:metadata', namespaces=default_ns)[0]
|
||||||
|
new_element = package.xpath('//metadata', namespaces=default_ns)[0]
|
||||||
|
tree.replace(rep_element, new_element)
|
||||||
|
return etree.tostring(tree,
|
||||||
|
xml_declaration=True,
|
||||||
|
encoding='utf-8',
|
||||||
|
pretty_print=True).decode('utf-8')
|
||||||
|
|
||||||
|
|
71
cps/error_handler.py
Normal file
71
cps/error_handler.py
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2018-2020 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from flask import render_template
|
||||||
|
from werkzeug.exceptions import default_exceptions
|
||||||
|
try:
|
||||||
|
from werkzeug.exceptions import FailedDependency
|
||||||
|
except ImportError:
|
||||||
|
from werkzeug.exceptions import UnprocessableEntity as FailedDependency
|
||||||
|
|
||||||
|
from . import config, app, logger, services
|
||||||
|
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
# custom error page
|
||||||
|
def error_http(error):
|
||||||
|
return render_template('http_error.html',
|
||||||
|
error_code="Error {0}".format(error.code),
|
||||||
|
error_name=error.name,
|
||||||
|
issue=False,
|
||||||
|
unconfigured=not config.db_configured,
|
||||||
|
instance=config.config_calibre_web_title
|
||||||
|
), error.code
|
||||||
|
|
||||||
|
|
||||||
|
def internal_error(error):
|
||||||
|
return render_template('http_error.html',
|
||||||
|
error_code="500 Internal Server Error",
|
||||||
|
error_name='The server encountered an internal error and was unable to complete your '
|
||||||
|
'request. There is an error in the application.',
|
||||||
|
issue=True,
|
||||||
|
unconfigured=False,
|
||||||
|
error_stack=traceback.format_exc().split("\n"),
|
||||||
|
instance=config.config_calibre_web_title
|
||||||
|
), 500
|
||||||
|
|
||||||
|
def init_errorhandler():
|
||||||
|
# http error handling
|
||||||
|
for ex in default_exceptions:
|
||||||
|
if ex < 500:
|
||||||
|
app.register_error_handler(ex, error_http)
|
||||||
|
elif ex == 500:
|
||||||
|
app.register_error_handler(ex, internal_error)
|
||||||
|
|
||||||
|
|
||||||
|
if services.ldap:
|
||||||
|
# Only way of catching the LDAPException upon logging in with LDAP server down
|
||||||
|
@app.errorhandler(services.ldap.LDAPException)
|
||||||
|
# pylint: disable=unused-variable
|
||||||
|
def handle_exception(e):
|
||||||
|
log.debug('LDAP server not accessible while trying to login to opds feed')
|
||||||
|
return error_http(FailedDependency())
|
||||||
|
|
42
cps/fb2.py
42
cps/fb2.py
|
@ -16,7 +16,6 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
from .constants import BookMeta
|
from .constants import BookMeta
|
||||||
|
@ -29,52 +28,55 @@ def get_fb2_info(tmp_file_path, original_file_extension):
|
||||||
'l': 'http://www.w3.org/1999/xlink',
|
'l': 'http://www.w3.org/1999/xlink',
|
||||||
}
|
}
|
||||||
|
|
||||||
fb2_file = open(tmp_file_path)
|
fb2_file = open(tmp_file_path, encoding="utf-8")
|
||||||
tree = etree.fromstring(fb2_file.read())
|
tree = etree.fromstring(fb2_file.read().encode())
|
||||||
|
|
||||||
authors = tree.xpath('/fb:FictionBook/fb:description/fb:title-info/fb:author', namespaces=ns)
|
authors = tree.xpath('/fb:FictionBook/fb:description/fb:title-info/fb:author', namespaces=ns)
|
||||||
|
|
||||||
def get_author(element):
|
def get_author(element):
|
||||||
last_name = element.xpath('fb:last-name/text()', namespaces=ns)
|
last_name = element.xpath('fb:last-name/text()', namespaces=ns)
|
||||||
if len(last_name):
|
if len(last_name):
|
||||||
last_name = last_name[0].encode('utf-8')
|
last_name = last_name[0]
|
||||||
else:
|
else:
|
||||||
last_name = u''
|
last_name = ''
|
||||||
middle_name = element.xpath('fb:middle-name/text()', namespaces=ns)
|
middle_name = element.xpath('fb:middle-name/text()', namespaces=ns)
|
||||||
if len(middle_name):
|
if len(middle_name):
|
||||||
middle_name = middle_name[0].encode('utf-8')
|
middle_name = middle_name[0]
|
||||||
else:
|
else:
|
||||||
middle_name = u''
|
middle_name = ''
|
||||||
first_name = element.xpath('fb:first-name/text()', namespaces=ns)
|
first_name = element.xpath('fb:first-name/text()', namespaces=ns)
|
||||||
if len(first_name):
|
if len(first_name):
|
||||||
first_name = first_name[0].encode('utf-8')
|
first_name = first_name[0]
|
||||||
else:
|
else:
|
||||||
first_name = u''
|
first_name = ''
|
||||||
return (first_name.decode('utf-8') + u' '
|
return (first_name + ' '
|
||||||
+ middle_name.decode('utf-8') + u' '
|
+ middle_name + ' '
|
||||||
+ last_name.decode('utf-8')).encode('utf-8')
|
+ last_name)
|
||||||
|
|
||||||
author = str(", ".join(map(get_author, authors)))
|
author = str(", ".join(map(get_author, authors)))
|
||||||
|
|
||||||
title = tree.xpath('/fb:FictionBook/fb:description/fb:title-info/fb:book-title/text()', namespaces=ns)
|
title = tree.xpath('/fb:FictionBook/fb:description/fb:title-info/fb:book-title/text()', namespaces=ns)
|
||||||
if len(title):
|
if len(title):
|
||||||
title = str(title[0].encode('utf-8'))
|
title = str(title[0])
|
||||||
else:
|
else:
|
||||||
title = u''
|
title = ''
|
||||||
description = tree.xpath('/fb:FictionBook/fb:description/fb:publish-info/fb:book-name/text()', namespaces=ns)
|
description = tree.xpath('/fb:FictionBook/fb:description/fb:publish-info/fb:book-name/text()', namespaces=ns)
|
||||||
if len(description):
|
if len(description):
|
||||||
description = str(description[0].encode('utf-8'))
|
description = str(description[0])
|
||||||
else:
|
else:
|
||||||
description = u''
|
description = ''
|
||||||
|
|
||||||
return BookMeta(
|
return BookMeta(
|
||||||
file_path=tmp_file_path,
|
file_path=tmp_file_path,
|
||||||
extension=original_file_extension,
|
extension=original_file_extension,
|
||||||
title=title.decode('utf-8'),
|
title=title,
|
||||||
author=author.decode('utf-8'),
|
author=author,
|
||||||
cover=None,
|
cover=None,
|
||||||
description=description.decode('utf-8'),
|
description=description,
|
||||||
tags="",
|
tags="",
|
||||||
series="",
|
series="",
|
||||||
series_id="",
|
series_id="",
|
||||||
languages="")
|
languages="",
|
||||||
|
publisher="",
|
||||||
|
pubdate="",
|
||||||
|
identifiers=[])
|
||||||
|
|
32
cps/file_helper.py
Normal file
32
cps/file_helper.py
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2023 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from tempfile import gettempdir
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
def get_temp_dir():
|
||||||
|
tmp_dir = os.path.join(gettempdir(), 'calibre_web')
|
||||||
|
if not os.path.isdir(tmp_dir):
|
||||||
|
os.mkdir(tmp_dir)
|
||||||
|
return tmp_dir
|
||||||
|
|
||||||
|
|
||||||
|
def del_temp_dir():
|
||||||
|
tmp_dir = os.path.join(gettempdir(), 'calibre_web')
|
||||||
|
shutil.rmtree(tmp_dir)
|
95
cps/fs.py
Normal file
95
cps/fs.py
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2020 mmonkey
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from . import logger
|
||||||
|
from .constants import CACHE_DIR
|
||||||
|
from os import makedirs, remove
|
||||||
|
from os.path import isdir, isfile, join
|
||||||
|
from shutil import rmtree
|
||||||
|
|
||||||
|
|
||||||
|
class FileSystem:
|
||||||
|
_instance = None
|
||||||
|
_cache_dir = CACHE_DIR
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super(FileSystem, cls).__new__(cls)
|
||||||
|
cls.log = logger.create()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def get_cache_dir(self, cache_type=None):
|
||||||
|
if not isdir(self._cache_dir):
|
||||||
|
try:
|
||||||
|
makedirs(self._cache_dir)
|
||||||
|
except OSError:
|
||||||
|
self.log.info(f'Failed to create path {self._cache_dir} (Permission denied).')
|
||||||
|
raise
|
||||||
|
|
||||||
|
path = join(self._cache_dir, cache_type)
|
||||||
|
if cache_type and not isdir(path):
|
||||||
|
try:
|
||||||
|
makedirs(path)
|
||||||
|
except OSError:
|
||||||
|
self.log.info(f'Failed to create path {path} (Permission denied).')
|
||||||
|
raise
|
||||||
|
|
||||||
|
return path if cache_type else self._cache_dir
|
||||||
|
|
||||||
|
def get_cache_file_dir(self, filename, cache_type=None):
|
||||||
|
path = join(self.get_cache_dir(cache_type), filename[:2])
|
||||||
|
if not isdir(path):
|
||||||
|
try:
|
||||||
|
makedirs(path)
|
||||||
|
except OSError:
|
||||||
|
self.log.info(f'Failed to create path {path} (Permission denied).')
|
||||||
|
raise
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
def get_cache_file_path(self, filename, cache_type=None):
|
||||||
|
return join(self.get_cache_file_dir(filename, cache_type), filename) if filename else None
|
||||||
|
|
||||||
|
def get_cache_file_exists(self, filename, cache_type=None):
|
||||||
|
path = self.get_cache_file_path(filename, cache_type)
|
||||||
|
return isfile(path)
|
||||||
|
|
||||||
|
def delete_cache_dir(self, cache_type=None):
|
||||||
|
if not cache_type and isdir(self._cache_dir):
|
||||||
|
try:
|
||||||
|
rmtree(self._cache_dir)
|
||||||
|
except OSError:
|
||||||
|
self.log.info(f'Failed to delete path {self._cache_dir} (Permission denied).')
|
||||||
|
raise
|
||||||
|
|
||||||
|
path = join(self._cache_dir, cache_type)
|
||||||
|
if cache_type and isdir(path):
|
||||||
|
try:
|
||||||
|
rmtree(path)
|
||||||
|
except OSError:
|
||||||
|
self.log.info(f'Failed to delete path {path} (Permission denied).')
|
||||||
|
raise
|
||||||
|
|
||||||
|
def delete_cache_file(self, filename, cache_type=None):
|
||||||
|
path = self.get_cache_file_path(filename, cache_type)
|
||||||
|
if isfile(path):
|
||||||
|
try:
|
||||||
|
remove(path)
|
||||||
|
except OSError:
|
||||||
|
self.log.info(f'Failed to delete path {path} (Permission denied).')
|
||||||
|
raise
|
129
cps/gdrive.py
129
cps/gdrive.py
|
@ -20,12 +20,9 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import tempfile
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from time import time
|
from time import time
|
||||||
from shutil import move, copyfile
|
from shutil import move, copyfile
|
||||||
|
@ -34,37 +31,37 @@ from flask import Blueprint, flash, request, redirect, url_for, abort
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_login import login_required
|
from flask_login import login_required
|
||||||
|
|
||||||
|
from . import logger, gdriveutils, config, ub, calibre_db, csrf
|
||||||
|
from .admin import admin_required
|
||||||
|
from .file_helper import get_temp_dir
|
||||||
|
|
||||||
|
gdrive = Blueprint('gdrive', __name__, url_prefix='/gdrive')
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from googleapiclient.errors import HttpError
|
from googleapiclient.errors import HttpError
|
||||||
except ImportError:
|
except ImportError as err:
|
||||||
pass
|
log.debug("Cannot import googleapiclient, using GDrive will not work: %s", err)
|
||||||
|
|
||||||
from . import logger, gdriveutils, config, db
|
|
||||||
from .web import admin_required
|
|
||||||
|
|
||||||
|
|
||||||
gdrive = Blueprint('gdrive', __name__)
|
|
||||||
log = logger.create()
|
|
||||||
|
|
||||||
current_milli_time = lambda: int(round(time() * 1000))
|
current_milli_time = lambda: int(round(time() * 1000))
|
||||||
|
|
||||||
gdrive_watch_callback_token = 'target=calibreweb-watch_files'
|
gdrive_watch_callback_token = 'target=calibreweb-watch_files' #nosec
|
||||||
|
|
||||||
|
|
||||||
@gdrive.route("/gdrive/authenticate")
|
@gdrive.route("/authenticate")
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def authenticate_google_drive():
|
def authenticate_google_drive():
|
||||||
try:
|
try:
|
||||||
authUrl = gdriveutils.Gauth.Instance().auth.GetAuthUrl()
|
authUrl = gdriveutils.Gauth.Instance().auth.GetAuthUrl()
|
||||||
except gdriveutils.InvalidConfigError:
|
except gdriveutils.InvalidConfigError:
|
||||||
flash(_(u'Google Drive setup not completed, try to deactivate and activate Google Drive again'),
|
flash(_('Google Drive setup not completed, try to deactivate and activate Google Drive again'),
|
||||||
category="error")
|
category="error")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
return redirect(authUrl)
|
return redirect(authUrl)
|
||||||
|
|
||||||
|
|
||||||
@gdrive.route("/gdrive/callback")
|
@gdrive.route("/callback")
|
||||||
def google_drive_callback():
|
def google_drive_callback():
|
||||||
auth_code = request.args.get('code')
|
auth_code = request.args.get('code')
|
||||||
if not auth_code:
|
if not auth_code:
|
||||||
|
@ -73,41 +70,37 @@ def google_drive_callback():
|
||||||
credentials = gdriveutils.Gauth.Instance().auth.flow.step2_exchange(auth_code)
|
credentials = gdriveutils.Gauth.Instance().auth.flow.step2_exchange(auth_code)
|
||||||
with open(gdriveutils.CREDENTIALS, 'w') as f:
|
with open(gdriveutils.CREDENTIALS, 'w') as f:
|
||||||
f.write(credentials.to_json())
|
f.write(credentials.to_json())
|
||||||
except ValueError as error:
|
except (ValueError, AttributeError) as error:
|
||||||
log.error(error)
|
log.error(error)
|
||||||
return redirect(url_for('admin.configuration'))
|
return redirect(url_for('admin.db_configuration'))
|
||||||
|
|
||||||
|
|
||||||
@gdrive.route("/gdrive/watch/subscribe")
|
@gdrive.route("/watch/subscribe")
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def watch_gdrive():
|
def watch_gdrive():
|
||||||
if not config.config_google_drive_watch_changes_response:
|
if not config.config_google_drive_watch_changes_response:
|
||||||
with open(gdriveutils.CLIENT_SECRETS, 'r') as settings:
|
with open(gdriveutils.CLIENT_SECRETS, 'r') as settings:
|
||||||
filedata = json.load(settings)
|
filedata = json.load(settings)
|
||||||
if filedata['web']['redirect_uris'][0].endswith('/'):
|
address = filedata['web']['redirect_uris'][0].rstrip('/').replace('/gdrive/callback', '/gdrive/watch/callback')
|
||||||
filedata['web']['redirect_uris'][0] = filedata['web']['redirect_uris'][0][:-((len('/gdrive/callback')+1))]
|
|
||||||
else:
|
|
||||||
filedata['web']['redirect_uris'][0] = filedata['web']['redirect_uris'][0][:-(len('/gdrive/callback'))]
|
|
||||||
address = '%s/gdrive/watch/callback' % filedata['web']['redirect_uris'][0]
|
|
||||||
notification_id = str(uuid4())
|
notification_id = str(uuid4())
|
||||||
try:
|
try:
|
||||||
result = gdriveutils.watchChange(gdriveutils.Gdrive.Instance().drive, notification_id,
|
result = gdriveutils.watchChange(gdriveutils.Gdrive.Instance().drive, notification_id,
|
||||||
'web_hook', address, gdrive_watch_callback_token, current_milli_time() + 604800*1000)
|
'web_hook', address, gdrive_watch_callback_token, current_milli_time() + 604800*1000)
|
||||||
config.config_google_drive_watch_changes_response = json.dumps(result)
|
config.config_google_drive_watch_changes_response = result
|
||||||
# after save(), config_google_drive_watch_changes_response will be a json object, not string
|
|
||||||
config.save()
|
config.save()
|
||||||
except HttpError as e:
|
except HttpError as e:
|
||||||
reason=json.loads(e.content)['error']['errors'][0]
|
reason=json.loads(e.content)['error']['errors'][0]
|
||||||
if reason['reason'] == u'push.webhookUrlUnauthorized':
|
if reason['reason'] == 'push.webhookUrlUnauthorized':
|
||||||
flash(_(u'Callback domain is not verified, please follow steps to verify domain in google developer console'), category="error")
|
flash(_('Callback domain is not verified, '
|
||||||
|
'please follow steps to verify domain in google developer console'), category="error")
|
||||||
else:
|
else:
|
||||||
flash(reason['message'], category="error")
|
flash(reason['message'], category="error")
|
||||||
|
|
||||||
return redirect(url_for('admin.configuration'))
|
return redirect(url_for('admin.db_configuration'))
|
||||||
|
|
||||||
|
|
||||||
@gdrive.route("/gdrive/watch/revoke")
|
@gdrive.route("/watch/revoke")
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def revoke_watch_gdrive():
|
def revoke_watch_gdrive():
|
||||||
|
@ -116,46 +109,48 @@ def revoke_watch_gdrive():
|
||||||
try:
|
try:
|
||||||
gdriveutils.stopChannel(gdriveutils.Gdrive.Instance().drive, last_watch_response['id'],
|
gdriveutils.stopChannel(gdriveutils.Gdrive.Instance().drive, last_watch_response['id'],
|
||||||
last_watch_response['resourceId'])
|
last_watch_response['resourceId'])
|
||||||
except HttpError:
|
except (HttpError, AttributeError):
|
||||||
pass
|
pass
|
||||||
config.config_google_drive_watch_changes_response = None
|
config.config_google_drive_watch_changes_response = {}
|
||||||
config.save()
|
config.save()
|
||||||
return redirect(url_for('admin.configuration'))
|
return redirect(url_for('admin.db_configuration'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
@csrf.exempt
|
||||||
|
@gdrive.route("/watch/callback", methods=['GET', 'POST'])
|
||||||
|
def on_received_watch_confirmation():
|
||||||
|
if not config.config_google_drive_watch_changes_response:
|
||||||
|
return ''
|
||||||
|
if request.headers.get('X-Goog-Channel-Token') != gdrive_watch_callback_token \
|
||||||
|
or request.headers.get('X-Goog-Resource-State') != 'change' \
|
||||||
|
or not request.data:
|
||||||
|
return ''
|
||||||
|
|
||||||
@gdrive.route("/gdrive/watch/callback", methods=['GET', 'POST'])
|
log.debug('%r', request.headers)
|
||||||
def on_received_watch_confirmation():
|
log.debug('%r', request.data)
|
||||||
log.debug('%r', request.headers)
|
log.info('Change received from gdrive')
|
||||||
if request.headers.get('X-Goog-Channel-Token') == gdrive_watch_callback_token \
|
|
||||||
and request.headers.get('X-Goog-Resource-State') == 'change' \
|
|
||||||
and request.data:
|
|
||||||
|
|
||||||
data = request.data
|
try:
|
||||||
|
j = json.loads(request.data)
|
||||||
|
log.info('Getting change details')
|
||||||
|
response = gdriveutils.getChangeById(gdriveutils.Gdrive.Instance().drive, j['id'])
|
||||||
|
log.debug('%r', response)
|
||||||
|
if response:
|
||||||
|
dbpath = os.path.join(config.config_calibre_dir, "metadata.db").encode()
|
||||||
|
if not response['deleted'] and response['file']['title'] == 'metadata.db' \
|
||||||
|
and response['file']['md5Checksum'] != hashlib.md5(dbpath): # nosec
|
||||||
|
tmp_dir = get_temp_dir()
|
||||||
|
|
||||||
def updateMetaData():
|
log.info('Database file updated')
|
||||||
log.info('Change received from gdrive')
|
copyfile(dbpath, os.path.join(tmp_dir, "metadata.db_" + str(current_milli_time())))
|
||||||
log.debug('%r', data)
|
log.info('Backing up existing and downloading updated metadata.db')
|
||||||
try:
|
gdriveutils.downloadFile(None, "metadata.db", os.path.join(tmp_dir, "tmp_metadata.db"))
|
||||||
j = json.loads(data)
|
log.info('Setting up new DB')
|
||||||
log.info('Getting change details')
|
# prevent error on windows, as os.rename does on existing files, also allow cross hdd move
|
||||||
response = gdriveutils.getChangeById(gdriveutils.Gdrive.Instance().drive, j['id'])
|
move(os.path.join(tmp_dir, "tmp_metadata.db"), dbpath)
|
||||||
log.debug('%r', response)
|
calibre_db.reconnect_db(config, ub.app_DB_path)
|
||||||
if response:
|
except Exception as ex:
|
||||||
if sys.version_info < (3, 0):
|
log.error_or_exception(ex)
|
||||||
dbpath = os.path.join(config.config_calibre_dir, "metadata.db")
|
return ''
|
||||||
else:
|
except AttributeError:
|
||||||
dbpath = os.path.join(config.config_calibre_dir, "metadata.db").encode()
|
pass
|
||||||
if not response['deleted'] and response['file']['title'] == 'metadata.db' and response['file']['md5Checksum'] != hashlib.md5(dbpath):
|
|
||||||
tmpDir = tempfile.gettempdir()
|
|
||||||
log.info('Database file updated')
|
|
||||||
copyfile(dbpath, os.path.join(tmpDir, "metadata.db_" + str(current_milli_time())))
|
|
||||||
log.info('Backing up existing and downloading updated metadata.db')
|
|
||||||
gdriveutils.downloadFile(None, "metadata.db", os.path.join(tmpDir, "tmp_metadata.db"))
|
|
||||||
log.info('Setting up new DB')
|
|
||||||
# prevent error on windows, as os.rename does on exisiting files
|
|
||||||
move(os.path.join(tmpDir, "tmp_metadata.db"), dbpath)
|
|
||||||
db.setup_db(config)
|
|
||||||
except Exception as e:
|
|
||||||
log.exception(e)
|
|
||||||
updateMetaData()
|
|
||||||
return ''
|
|
||||||
|
|
|
@ -16,28 +16,54 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
|
import chardet
|
||||||
|
import ssl
|
||||||
|
|
||||||
from flask import Response, stream_with_context
|
from flask import Response, stream_with_context
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy import Column, UniqueConstraint
|
from sqlalchemy import Column, UniqueConstraint
|
||||||
from sqlalchemy import String, Integer
|
from sqlalchemy import String, Integer
|
||||||
from sqlalchemy.orm import sessionmaker, scoped_session
|
from sqlalchemy.orm import sessionmaker, scoped_session
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
try:
|
||||||
|
# Compatibility with sqlalchemy 2.0
|
||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
except ImportError:
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.exc import OperationalError, InvalidRequestError, IntegrityError
|
||||||
|
from sqlalchemy.orm.exc import StaleDataError
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from pydrive.auth import GoogleAuth
|
from httplib2 import __version__ as httplib2_version
|
||||||
from pydrive.drive import GoogleDrive
|
|
||||||
from pydrive.auth import RefreshError
|
|
||||||
from apiclient import errors
|
|
||||||
gdrive_support = True
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
gdrive_support = False
|
httplib2_version = "not installed"
|
||||||
|
|
||||||
from . import logger, cli, config
|
try:
|
||||||
|
from apiclient import errors
|
||||||
|
from httplib2 import ServerNotFoundError
|
||||||
|
importError = None
|
||||||
|
gdrive_support = True
|
||||||
|
except ImportError as e:
|
||||||
|
importError = e
|
||||||
|
gdrive_support = False
|
||||||
|
try:
|
||||||
|
from pydrive2.auth import GoogleAuth
|
||||||
|
from pydrive2.drive import GoogleDrive
|
||||||
|
from pydrive2.auth import RefreshError
|
||||||
|
from pydrive2.files import ApiRequestError
|
||||||
|
except ImportError as err:
|
||||||
|
try:
|
||||||
|
from pydrive.auth import GoogleAuth
|
||||||
|
from pydrive.drive import GoogleDrive
|
||||||
|
from pydrive.auth import RefreshError
|
||||||
|
from pydrive.files import ApiRequestError
|
||||||
|
except ImportError as err:
|
||||||
|
importError = err
|
||||||
|
gdrive_support = False
|
||||||
|
|
||||||
|
from . import logger, cli_param, config
|
||||||
from .constants import CONFIG_DIR as _CONFIG_DIR
|
from .constants import CONFIG_DIR as _CONFIG_DIR
|
||||||
|
|
||||||
|
|
||||||
|
@ -50,6 +76,8 @@ if gdrive_support:
|
||||||
logger.get('googleapiclient.discovery_cache').setLevel(logger.logging.ERROR)
|
logger.get('googleapiclient.discovery_cache').setLevel(logger.logging.ERROR)
|
||||||
if not logger.is_debug_enabled():
|
if not logger.is_debug_enabled():
|
||||||
logger.get('googleapiclient.discovery').setLevel(logger.logging.ERROR)
|
logger.get('googleapiclient.discovery').setLevel(logger.logging.ERROR)
|
||||||
|
else:
|
||||||
|
log.debug("Cannot import pydrive, httplib2, using gdrive will not work: {}".format(importError))
|
||||||
|
|
||||||
|
|
||||||
class Singleton:
|
class Singleton:
|
||||||
|
@ -83,7 +111,7 @@ class Singleton:
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
self._instance = self._decorated()
|
self._instance = self._decorated()
|
||||||
return self._instance
|
return self._instance
|
||||||
except ImportError as e:
|
except (ImportError, NameError) as e:
|
||||||
log.debug(e)
|
log.debug(e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@ -97,7 +125,11 @@ class Singleton:
|
||||||
@Singleton
|
@Singleton
|
||||||
class Gauth:
|
class Gauth:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.auth = GoogleAuth(settings_file=SETTINGS_YAML)
|
try:
|
||||||
|
self.auth = GoogleAuth(settings_file=SETTINGS_YAML)
|
||||||
|
except NameError as error:
|
||||||
|
log.error(error)
|
||||||
|
self.auth = None
|
||||||
|
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
|
@ -105,15 +137,16 @@ class Gdrive:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.drive = getDrive(gauth=Gauth.Instance().auth)
|
self.drive = getDrive(gauth=Gauth.Instance().auth)
|
||||||
|
|
||||||
|
|
||||||
def is_gdrive_ready():
|
def is_gdrive_ready():
|
||||||
return os.path.exists(SETTINGS_YAML) and os.path.exists(CREDENTIALS)
|
return os.path.exists(SETTINGS_YAML) and os.path.exists(CREDENTIALS)
|
||||||
|
|
||||||
|
|
||||||
engine = create_engine('sqlite:///{0}'.format(cli.gdpath), echo=False)
|
engine = create_engine('sqlite:///{0}'.format(cli_param.gd_path), echo=False)
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
# Open session for database connection
|
# Open session for database connection
|
||||||
Session = sessionmaker()
|
Session = sessionmaker(autoflush=False)
|
||||||
Session.configure(bind=engine)
|
Session.configure(bind=engine)
|
||||||
session = scoped_session(Session)
|
session = scoped_session(Session)
|
||||||
|
|
||||||
|
@ -140,29 +173,12 @@ class PermissionAdded(Base):
|
||||||
return str(self.gdrive_id)
|
return str(self.gdrive_id)
|
||||||
|
|
||||||
|
|
||||||
def migrate():
|
if not os.path.exists(cli_param.gd_path):
|
||||||
if not engine.dialect.has_table(engine.connect(), "permissions_added"):
|
|
||||||
PermissionAdded.__table__.create(bind = engine)
|
|
||||||
for sql in session.execute("select sql from sqlite_master where type='table'"):
|
|
||||||
if 'CREATE TABLE gdrive_ids' in sql[0]:
|
|
||||||
currUniqueConstraint = 'UNIQUE (gdrive_id)'
|
|
||||||
if currUniqueConstraint in sql[0]:
|
|
||||||
sql=sql[0].replace(currUniqueConstraint, 'UNIQUE (gdrive_id, path)')
|
|
||||||
sql=sql.replace(GdriveId.__tablename__, GdriveId.__tablename__ + '2')
|
|
||||||
session.execute(sql)
|
|
||||||
session.execute("INSERT INTO gdrive_ids2 (id, gdrive_id, path) SELECT id, "
|
|
||||||
"gdrive_id, path FROM gdrive_ids;")
|
|
||||||
session.commit()
|
|
||||||
session.execute('DROP TABLE %s' % 'gdrive_ids')
|
|
||||||
session.execute('ALTER TABLE gdrive_ids2 RENAME to gdrive_ids')
|
|
||||||
break
|
|
||||||
|
|
||||||
if not os.path.exists(cli.gdpath):
|
|
||||||
try:
|
try:
|
||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
except Exception:
|
except Exception as ex:
|
||||||
|
log.error("Error connect to database: {} - {}".format(cli_param.gd_path, ex))
|
||||||
raise
|
raise
|
||||||
migrate()
|
|
||||||
|
|
||||||
|
|
||||||
def getDrive(drive=None, gauth=None):
|
def getDrive(drive=None, gauth=None):
|
||||||
|
@ -176,9 +192,9 @@ def getDrive(drive=None, gauth=None):
|
||||||
try:
|
try:
|
||||||
gauth.Refresh()
|
gauth.Refresh()
|
||||||
except RefreshError as e:
|
except RefreshError as e:
|
||||||
log.error("Google Drive error: %s", e)
|
log.error("Google Drive error: {}".format(e))
|
||||||
except Exception as e:
|
except Exception as ex:
|
||||||
log.exception(e)
|
log.error_or_exception(ex)
|
||||||
else:
|
else:
|
||||||
# Initialize the saved creds
|
# Initialize the saved creds
|
||||||
gauth.Authorize()
|
gauth.Authorize()
|
||||||
|
@ -188,18 +204,22 @@ def getDrive(drive=None, gauth=None):
|
||||||
try:
|
try:
|
||||||
drive.auth.Refresh()
|
drive.auth.Refresh()
|
||||||
except RefreshError as e:
|
except RefreshError as e:
|
||||||
log.error("Google Drive error: %s", e)
|
log.error("Google Drive error: {}".format(e))
|
||||||
return drive
|
return drive
|
||||||
|
|
||||||
def listRootFolders():
|
def listRootFolders():
|
||||||
drive = getDrive(Gdrive.Instance().drive)
|
try:
|
||||||
folder = "'root' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false"
|
drive = getDrive(Gdrive.Instance().drive)
|
||||||
fileList = drive.ListFile({'q': folder}).GetList()
|
folder = "'root' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false"
|
||||||
|
fileList = drive.ListFile({'q': folder}).GetList()
|
||||||
|
except (ServerNotFoundError, ssl.SSLError, RefreshError) as e:
|
||||||
|
log.info("GDrive Error {}".format(e))
|
||||||
|
fileList = []
|
||||||
return fileList
|
return fileList
|
||||||
|
|
||||||
|
|
||||||
def getEbooksFolder(drive):
|
def getEbooksFolder(drive):
|
||||||
return getFolderInFolder('root',config.config_google_drive_folder,drive)
|
return getFolderInFolder('root', config.config_google_drive_folder, drive)
|
||||||
|
|
||||||
|
|
||||||
def getFolderInFolder(parentId, folderName, drive):
|
def getFolderInFolder(parentId, folderName, drive):
|
||||||
|
@ -228,8 +248,12 @@ def getEbooksFolderId(drive=None):
|
||||||
log.error('Error gDrive, root ID not found')
|
log.error('Error gDrive, root ID not found')
|
||||||
gDriveId.path = '/'
|
gDriveId.path = '/'
|
||||||
session.merge(gDriveId)
|
session.merge(gDriveId)
|
||||||
session.commit()
|
try:
|
||||||
return
|
session.commit()
|
||||||
|
except OperationalError as ex:
|
||||||
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
|
session.rollback()
|
||||||
|
return gDriveId.gdrive_id
|
||||||
|
|
||||||
|
|
||||||
def getFile(pathId, fileName, drive):
|
def getFile(pathId, fileName, drive):
|
||||||
|
@ -243,37 +267,47 @@ def getFile(pathId, fileName, drive):
|
||||||
|
|
||||||
def getFolderId(path, drive):
|
def getFolderId(path, drive):
|
||||||
# drive = getDrive(drive)
|
# drive = getDrive(drive)
|
||||||
currentFolderId = getEbooksFolderId(drive)
|
currentFolderId = None
|
||||||
sqlCheckPath = path if path[-1] == '/' else path + '/'
|
try:
|
||||||
storedPathName = session.query(GdriveId).filter(GdriveId.path == sqlCheckPath).first()
|
currentFolderId = getEbooksFolderId(drive)
|
||||||
|
sqlCheckPath = path if path[-1] == '/' else path + '/'
|
||||||
|
storedPathName = session.query(GdriveId).filter(GdriveId.path == sqlCheckPath).first()
|
||||||
|
|
||||||
if not storedPathName:
|
if not storedPathName:
|
||||||
dbChange = False
|
dbChange = False
|
||||||
s = path.split('/')
|
s = path.split('/')
|
||||||
for i, x in enumerate(s):
|
for i, x in enumerate(s):
|
||||||
if len(x) > 0:
|
if len(x) > 0:
|
||||||
currentPath = "/".join(s[:i+1])
|
currentPath = "/".join(s[:i+1])
|
||||||
if currentPath[-1] != '/':
|
if currentPath[-1] != '/':
|
||||||
currentPath = currentPath + '/'
|
currentPath = currentPath + '/'
|
||||||
storedPathName = session.query(GdriveId).filter(GdriveId.path == currentPath).first()
|
storedPathName = session.query(GdriveId).filter(GdriveId.path == currentPath).first()
|
||||||
if storedPathName:
|
if storedPathName:
|
||||||
currentFolderId = storedPathName.gdrive_id
|
currentFolderId = storedPathName.gdrive_id
|
||||||
else:
|
|
||||||
currentFolder = getFolderInFolder(currentFolderId, x, drive)
|
|
||||||
if currentFolder:
|
|
||||||
gDriveId = GdriveId()
|
|
||||||
gDriveId.gdrive_id = currentFolder['id']
|
|
||||||
gDriveId.path = currentPath
|
|
||||||
session.merge(gDriveId)
|
|
||||||
dbChange = True
|
|
||||||
currentFolderId = currentFolder['id']
|
|
||||||
else:
|
else:
|
||||||
currentFolderId = None
|
currentFolder = getFolderInFolder(currentFolderId, x, drive)
|
||||||
break
|
if currentFolder:
|
||||||
if dbChange:
|
gDriveId = GdriveId()
|
||||||
session.commit()
|
gDriveId.gdrive_id = currentFolder['id']
|
||||||
else:
|
gDriveId.path = currentPath
|
||||||
currentFolderId = storedPathName.gdrive_id
|
session.merge(gDriveId)
|
||||||
|
dbChange = True
|
||||||
|
currentFolderId = currentFolder['id']
|
||||||
|
else:
|
||||||
|
currentFolderId = None
|
||||||
|
break
|
||||||
|
if dbChange:
|
||||||
|
session.commit()
|
||||||
|
else:
|
||||||
|
currentFolderId = storedPathName.gdrive_id
|
||||||
|
except (OperationalError, IntegrityError, StaleDataError) as ex:
|
||||||
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
|
session.rollback()
|
||||||
|
except ApiRequestError as ex:
|
||||||
|
log.error('{} {}'.format(ex.error['message'], path))
|
||||||
|
session.rollback()
|
||||||
|
except RefreshError as ex:
|
||||||
|
log.error(ex)
|
||||||
return currentFolderId
|
return currentFolderId
|
||||||
|
|
||||||
|
|
||||||
|
@ -291,7 +325,7 @@ def getFileFromEbooksFolder(path, fileName):
|
||||||
|
|
||||||
|
|
||||||
def moveGdriveFileRemote(origin_file_id, new_title):
|
def moveGdriveFileRemote(origin_file_id, new_title):
|
||||||
origin_file_id['title']= new_title
|
origin_file_id['title'] = new_title
|
||||||
origin_file_id.Upload()
|
origin_file_id.Upload()
|
||||||
|
|
||||||
|
|
||||||
|
@ -307,17 +341,28 @@ def moveGdriveFolderRemote(origin_file, target_folder):
|
||||||
children = drive.auth.service.children().list(folderId=previous_parents).execute()
|
children = drive.auth.service.children().list(folderId=previous_parents).execute()
|
||||||
gFileTargetDir = getFileFromEbooksFolder(None, target_folder)
|
gFileTargetDir = getFileFromEbooksFolder(None, target_folder)
|
||||||
if not gFileTargetDir:
|
if not gFileTargetDir:
|
||||||
# Folder is not existing, create, and move folder
|
|
||||||
gFileTargetDir = drive.CreateFile(
|
gFileTargetDir = drive.CreateFile(
|
||||||
{'title': target_folder, 'parents': [{"kind": "drive#fileLink", 'id': getEbooksFolderId()}],
|
{'title': target_folder, 'parents': [{"kind": "drive#fileLink", 'id': getEbooksFolderId()}],
|
||||||
"mimeType": "application/vnd.google-apps.folder"})
|
"mimeType": "application/vnd.google-apps.folder"})
|
||||||
gFileTargetDir.Upload()
|
gFileTargetDir.Upload()
|
||||||
# Move the file to the new folder
|
# Move the file to the new folder
|
||||||
drive.auth.service.files().update(fileId=origin_file['id'],
|
drive.auth.service.files().update(fileId=origin_file['id'],
|
||||||
addParents=gFileTargetDir['id'],
|
addParents=gFileTargetDir['id'],
|
||||||
removeParents=previous_parents,
|
removeParents=previous_parents,
|
||||||
fields='id, parents').execute()
|
fields='id, parents').execute()
|
||||||
# if previous_parents has no childs anymore, delete original fileparent
|
|
||||||
|
elif gFileTargetDir['title'] != target_folder:
|
||||||
|
# Folder is not existing, create, and move folder
|
||||||
|
drive.auth.service.files().patch(fileId=origin_file['id'],
|
||||||
|
body={'title': target_folder},
|
||||||
|
fields='title').execute()
|
||||||
|
else:
|
||||||
|
# Move the file to the new folder
|
||||||
|
drive.auth.service.files().update(fileId=origin_file['id'],
|
||||||
|
addParents=gFileTargetDir['id'],
|
||||||
|
removeParents=previous_parents,
|
||||||
|
fields='id, parents').execute()
|
||||||
|
# if previous_parents has no children anymore, delete original fileparent
|
||||||
if len(children['items']) == 1:
|
if len(children['items']) == 1:
|
||||||
deleteDatabaseEntry(previous_parents)
|
deleteDatabaseEntry(previous_parents)
|
||||||
drive.auth.service.files().delete(fileId=previous_parents).execute()
|
drive.auth.service.files().delete(fileId=previous_parents).execute()
|
||||||
|
@ -358,29 +403,33 @@ def copyToDrive(drive, uploadFile, createRoot, replaceFiles,
|
||||||
driveFile.Upload()
|
driveFile.Upload()
|
||||||
|
|
||||||
|
|
||||||
def uploadFileToEbooksFolder(destFile, f):
|
def uploadFileToEbooksFolder(destFile, f, string=False):
|
||||||
drive = getDrive(Gdrive.Instance().drive)
|
drive = getDrive(Gdrive.Instance().drive)
|
||||||
parent = getEbooksFolder(drive)
|
parent = getEbooksFolder(drive)
|
||||||
splitDir = destFile.split('/')
|
splitDir = destFile.split('/')
|
||||||
for i, x in enumerate(splitDir):
|
for i, x in enumerate(splitDir):
|
||||||
if i == len(splitDir)-1:
|
if i == len(splitDir)-1:
|
||||||
existingFiles = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" %
|
existing_Files = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" %
|
||||||
(x.replace("'", r"\'"), parent['id'])}).GetList()
|
(x.replace("'", r"\'"), parent['id'])}).GetList()
|
||||||
if len(existingFiles) > 0:
|
if len(existing_Files) > 0:
|
||||||
driveFile = existingFiles[0]
|
driveFile = existing_Files[0]
|
||||||
else:
|
else:
|
||||||
driveFile = drive.CreateFile({'title': x, 'parents': [{"kind": "drive#fileLink", 'id': parent['id']}],})
|
driveFile = drive.CreateFile({'title': x,
|
||||||
driveFile.SetContentFile(f)
|
'parents': [{"kind": "drive#fileLink", 'id': parent['id']}], })
|
||||||
|
if not string:
|
||||||
|
driveFile.SetContentFile(f)
|
||||||
|
else:
|
||||||
|
driveFile.SetContentString(f)
|
||||||
driveFile.Upload()
|
driveFile.Upload()
|
||||||
else:
|
else:
|
||||||
existingFolder = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" %
|
existing_Folder = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" %
|
||||||
(x.replace("'", r"\'"), parent['id'])}).GetList()
|
(x.replace("'", r"\'"), parent['id'])}).GetList()
|
||||||
if len(existingFolder) == 0:
|
if len(existing_Folder) == 0:
|
||||||
parent = drive.CreateFile({'title': x, 'parents': [{"kind": "drive#fileLink", 'id': parent['id']}],
|
parent = drive.CreateFile({'title': x, 'parents': [{"kind": "drive#fileLink", 'id': parent['id']}],
|
||||||
"mimeType": "application/vnd.google-apps.folder"})
|
"mimeType": "application/vnd.google-apps.folder"})
|
||||||
parent.Upload()
|
parent.Upload()
|
||||||
else:
|
else:
|
||||||
parent = existingFolder[0]
|
parent = existing_Folder[0]
|
||||||
|
|
||||||
|
|
||||||
def watchChange(drive, channel_id, channel_type, channel_address,
|
def watchChange(drive, channel_id, channel_type, channel_address,
|
||||||
|
@ -467,15 +516,21 @@ def getChangeById (drive, change_id):
|
||||||
except (errors.HttpError) as error:
|
except (errors.HttpError) as error:
|
||||||
log.error(error)
|
log.error(error)
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as ex:
|
||||||
log.error(e)
|
log.error(ex)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Deletes the local hashes database to force search for new folder names
|
# Deletes the local hashes database to force search for new folder names
|
||||||
def deleteDatabaseOnChange():
|
def deleteDatabaseOnChange():
|
||||||
session.query(GdriveId).delete()
|
try:
|
||||||
session.commit()
|
session.query(GdriveId).delete()
|
||||||
|
session.commit()
|
||||||
|
except (OperationalError, InvalidRequestError) as ex:
|
||||||
|
session.rollback()
|
||||||
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
|
session.rollback()
|
||||||
|
|
||||||
|
|
||||||
def updateGdriveCalibreFromLocal():
|
def updateGdriveCalibreFromLocal():
|
||||||
copyToDrive(Gdrive.Instance().drive, config.config_calibre_dir, False, True)
|
copyToDrive(Gdrive.Instance().drive, config.config_calibre_dir, False, True)
|
||||||
|
@ -485,20 +540,29 @@ def updateGdriveCalibreFromLocal():
|
||||||
|
|
||||||
# update gdrive.db on edit of books title
|
# update gdrive.db on edit of books title
|
||||||
def updateDatabaseOnEdit(ID,newPath):
|
def updateDatabaseOnEdit(ID,newPath):
|
||||||
sqlCheckPath = newPath if newPath[-1] == '/' else newPath + u'/'
|
sqlCheckPath = newPath if newPath[-1] == '/' else newPath + '/'
|
||||||
storedPathName = session.query(GdriveId).filter(GdriveId.gdrive_id == ID).first()
|
storedPathName = session.query(GdriveId).filter(GdriveId.gdrive_id == ID).first()
|
||||||
if storedPathName:
|
if storedPathName:
|
||||||
storedPathName.path = sqlCheckPath
|
storedPathName.path = sqlCheckPath
|
||||||
session.commit()
|
try:
|
||||||
|
session.commit()
|
||||||
|
except OperationalError as ex:
|
||||||
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
|
session.rollback()
|
||||||
|
|
||||||
|
|
||||||
# Deletes the hashes in database of deleted book
|
# Deletes the hashes in database of deleted book
|
||||||
def deleteDatabaseEntry(ID):
|
def deleteDatabaseEntry(ID):
|
||||||
session.query(GdriveId).filter(GdriveId.gdrive_id == ID).delete()
|
session.query(GdriveId).filter(GdriveId.gdrive_id == ID).delete()
|
||||||
session.commit()
|
try:
|
||||||
|
session.commit()
|
||||||
|
except OperationalError as ex:
|
||||||
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
|
session.rollback()
|
||||||
|
|
||||||
|
|
||||||
# Gets cover file from gdrive
|
# Gets cover file from gdrive
|
||||||
|
# ToDo: Check is this right everyone get read permissions on cover files?
|
||||||
def get_cover_via_gdrive(cover_path):
|
def get_cover_via_gdrive(cover_path):
|
||||||
df = getFileFromEbooksFolder(cover_path, 'cover.jpg')
|
df = getFileFromEbooksFolder(cover_path, 'cover.jpg')
|
||||||
if df:
|
if df:
|
||||||
|
@ -512,7 +576,34 @@ def get_cover_via_gdrive(cover_path):
|
||||||
permissionAdded = PermissionAdded()
|
permissionAdded = PermissionAdded()
|
||||||
permissionAdded.gdrive_id = df['id']
|
permissionAdded.gdrive_id = df['id']
|
||||||
session.add(permissionAdded)
|
session.add(permissionAdded)
|
||||||
session.commit()
|
try:
|
||||||
|
session.commit()
|
||||||
|
except OperationalError as ex:
|
||||||
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
|
session.rollback()
|
||||||
|
return df.metadata.get('webContentLink')
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Gets cover file from gdrive
|
||||||
|
def get_metadata_backup_via_gdrive(metadata_path):
|
||||||
|
df = getFileFromEbooksFolder(metadata_path, 'metadata.opf')
|
||||||
|
if df:
|
||||||
|
if not session.query(PermissionAdded).filter(PermissionAdded.gdrive_id == df['id']).first():
|
||||||
|
df.GetPermissions()
|
||||||
|
df.InsertPermission({
|
||||||
|
'type': 'anyone',
|
||||||
|
'value': 'anyone',
|
||||||
|
'role': 'writer', # ToDo needs write access
|
||||||
|
'withLink': True})
|
||||||
|
permissionAdded = PermissionAdded()
|
||||||
|
permissionAdded.gdrive_id = df['id']
|
||||||
|
session.add(permissionAdded)
|
||||||
|
try:
|
||||||
|
session.commit()
|
||||||
|
except OperationalError as ex:
|
||||||
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
|
session.rollback()
|
||||||
return df.metadata.get('webContentLink')
|
return df.metadata.get('webContentLink')
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
@ -526,21 +617,24 @@ def partial(total_byte_len, part_size_limit):
|
||||||
return s
|
return s
|
||||||
|
|
||||||
# downloads files in chunks from gdrive
|
# downloads files in chunks from gdrive
|
||||||
def do_gdrive_download(df, headers):
|
def do_gdrive_download(df, headers, convert_encoding=False):
|
||||||
total_size = int(df.metadata.get('fileSize'))
|
total_size = int(df.metadata.get('fileSize'))
|
||||||
download_url = df.metadata.get('downloadUrl')
|
download_url = df.metadata.get('downloadUrl')
|
||||||
s = partial(total_size, 1024 * 1024) # I'm downloading BIG files, so 100M chunk size is fine for me
|
s = partial(total_size, 1024 * 1024) # I'm downloading BIG files, so 100M chunk size is fine for me
|
||||||
|
|
||||||
def stream():
|
def stream(convert_encoding):
|
||||||
for byte in s:
|
for byte in s:
|
||||||
headers = {"Range": 'bytes=%s-%s' % (byte[0], byte[1])}
|
headers = {"Range": 'bytes={}-{}'.format(byte[0], byte[1])}
|
||||||
resp, content = df.auth.Get_Http_Object().request(download_url, headers=headers)
|
resp, content = df.auth.Get_Http_Object().request(download_url, headers=headers)
|
||||||
if resp.status == 206:
|
if resp.status == 206:
|
||||||
|
if convert_encoding:
|
||||||
|
result = chardet.detect(content)
|
||||||
|
content = content.decode(result['encoding']).encode('utf-8')
|
||||||
yield content
|
yield content
|
||||||
else:
|
else:
|
||||||
log.warning('An error occurred: %s', resp)
|
log.warning('An error occurred: {}'.format(resp))
|
||||||
return
|
return
|
||||||
return Response(stream_with_context(stream()), headers=headers)
|
return Response(stream_with_context(stream(convert_encoding)), headers=headers)
|
||||||
|
|
||||||
|
|
||||||
_SETTINGS_YAML_TEMPLATE = """
|
_SETTINGS_YAML_TEMPLATE = """
|
||||||
|
@ -583,8 +677,12 @@ def get_error_text(client_secrets=None):
|
||||||
if not os.path.isfile(CLIENT_SECRETS):
|
if not os.path.isfile(CLIENT_SECRETS):
|
||||||
return 'client_secrets.json is missing or not readable'
|
return 'client_secrets.json is missing or not readable'
|
||||||
|
|
||||||
with open(CLIENT_SECRETS, 'r') as settings:
|
try:
|
||||||
filedata = json.load(settings)
|
with open(CLIENT_SECRETS, 'r') as settings:
|
||||||
|
filedata = json.load(settings)
|
||||||
|
except PermissionError:
|
||||||
|
return 'client_secrets.json is missing or not readable'
|
||||||
|
|
||||||
if 'web' not in filedata:
|
if 'web' not in filedata:
|
||||||
return 'client_secrets.json is not configured for web application'
|
return 'client_secrets.json is not configured for web application'
|
||||||
if 'redirect_uris' not in filedata['web']:
|
if 'redirect_uris' not in filedata['web']:
|
||||||
|
|
29
cps/gevent_wsgi.py
Normal file
29
cps/gevent_wsgi.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2022 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
from gevent.pywsgi import WSGIHandler
|
||||||
|
|
||||||
|
class MyWSGIHandler(WSGIHandler):
|
||||||
|
def get_environ(self):
|
||||||
|
env = super().get_environ()
|
||||||
|
path, __ = self.path.split('?', 1) if '?' in self.path else (self.path, '')
|
||||||
|
env['RAW_URI'] = path
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
1484
cps/helper.py
1484
cps/helper.py
File diff suppressed because it is too large
Load Diff
|
@ -16,9 +16,10 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
|
|
||||||
from .iso_language_names import LANGUAGE_NAMES as _LANGUAGE_NAMES
|
from .iso_language_names import LANGUAGE_NAMES as _LANGUAGE_NAMES
|
||||||
|
from . import logger
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -48,21 +49,61 @@ except ImportError:
|
||||||
|
|
||||||
|
|
||||||
def get_language_names(locale):
|
def get_language_names(locale):
|
||||||
return _LANGUAGE_NAMES.get(locale)
|
names = _LANGUAGE_NAMES.get(str(locale))
|
||||||
|
if names is None:
|
||||||
|
names = _LANGUAGE_NAMES.get(locale.language)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
def get_language_name(locale, lang_code):
|
def get_language_name(locale, lang_code):
|
||||||
return get_language_names(locale)[lang_code]
|
UNKNOWN_TRANSLATION = "Unknown"
|
||||||
|
names = get_language_names(locale)
|
||||||
|
if names is None:
|
||||||
|
log.error(f"Missing language names for locale: {str(locale)}/{locale.language}")
|
||||||
|
return UNKNOWN_TRANSLATION
|
||||||
|
|
||||||
|
name = names.get(lang_code, UNKNOWN_TRANSLATION)
|
||||||
|
if name == UNKNOWN_TRANSLATION:
|
||||||
|
log.error("Missing translation for language name: {}".format(lang_code))
|
||||||
|
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
def get_language_codes(locale, language_names, remainder=None):
|
def get_language_codes(locale, language_names, remainder=None):
|
||||||
language_names = set(x.strip().lower() for x in language_names if x)
|
language_names = set(x.strip().lower() for x in language_names if x)
|
||||||
|
lang = list()
|
||||||
for k, v in get_language_names(locale).items():
|
for k, v in get_language_names(locale).items():
|
||||||
v = v.lower()
|
v = v.lower()
|
||||||
if v in language_names:
|
if v in language_names:
|
||||||
|
lang.append(k)
|
||||||
language_names.remove(v)
|
language_names.remove(v)
|
||||||
yield k
|
if remainder is not None and language_names:
|
||||||
|
|
||||||
if remainder is not None:
|
|
||||||
remainder.extend(language_names)
|
remainder.extend(language_names)
|
||||||
|
return lang
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_valid_language_codes(locale, language_names, remainder=None):
|
||||||
|
lang = list()
|
||||||
|
if "" in language_names:
|
||||||
|
language_names.remove("")
|
||||||
|
for k, __ in get_language_names(locale).items():
|
||||||
|
if k in language_names:
|
||||||
|
lang.append(k)
|
||||||
|
language_names.remove(k)
|
||||||
|
if remainder is not None and len(language_names):
|
||||||
|
remainder.extend(language_names)
|
||||||
|
return lang
|
||||||
|
|
||||||
|
|
||||||
|
def get_lang3(lang):
|
||||||
|
try:
|
||||||
|
if len(lang) == 2:
|
||||||
|
ret_value = get(part1=lang).part3
|
||||||
|
elif len(lang) == 3:
|
||||||
|
ret_value = lang
|
||||||
|
else:
|
||||||
|
ret_value = ""
|
||||||
|
except KeyError:
|
||||||
|
ret_value = lang
|
||||||
|
return ret_value
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
100
cps/jinjia.py
100
cps/jinjia.py
|
@ -22,18 +22,17 @@
|
||||||
|
|
||||||
# custom jinja filters
|
# custom jinja filters
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
from markupsafe import escape
|
||||||
import datetime
|
import datetime
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
from uuid import uuid4
|
||||||
|
|
||||||
from babel.dates import format_date
|
# from babel.dates import format_date
|
||||||
from flask import Blueprint, request, url_for
|
from flask import Blueprint, request, url_for
|
||||||
from flask_babel import get_locale
|
from flask_babel import format_date
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
|
|
||||||
from . import logger
|
from . import constants, logger
|
||||||
|
|
||||||
|
|
||||||
jinjia = Blueprint('jinjia', __name__)
|
jinjia = Blueprint('jinjia', __name__)
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
@ -44,6 +43,8 @@ log = logger.create()
|
||||||
def url_for_other_page(page):
|
def url_for_other_page(page):
|
||||||
args = request.view_args.copy()
|
args = request.view_args.copy()
|
||||||
args['page'] = page
|
args['page'] = page
|
||||||
|
for get, val in request.args.items():
|
||||||
|
args[get] = val
|
||||||
return url_for(request.endpoint, **args)
|
return url_for(request.endpoint, **args)
|
||||||
|
|
||||||
|
|
||||||
|
@ -76,22 +77,18 @@ def mimetype_filter(val):
|
||||||
@jinjia.app_template_filter('formatdate')
|
@jinjia.app_template_filter('formatdate')
|
||||||
def formatdate_filter(val):
|
def formatdate_filter(val):
|
||||||
try:
|
try:
|
||||||
conformed_timestamp = re.sub(r"[:]|([-](?!((\d{2}[:]\d{2})|(\d{4}))$))", '', val)
|
return format_date(val, format='medium')
|
||||||
formatdate = datetime.datetime.strptime(conformed_timestamp[:15], "%Y%m%d %H%M%S")
|
|
||||||
return format_date(formatdate, format='medium', locale=get_locale())
|
|
||||||
except AttributeError as e:
|
except AttributeError as e:
|
||||||
log.error('Babel error: %s, Current user locale: %s, Current User: %s', e,
|
log.error('Babel error: %s, Current user locale: %s, Current User: %s', e,
|
||||||
current_user.locale,
|
current_user.locale,
|
||||||
current_user.nickname
|
current_user.name
|
||||||
)
|
)
|
||||||
return formatdate
|
return val
|
||||||
|
|
||||||
|
|
||||||
@jinjia.app_template_filter('formatdateinput')
|
@jinjia.app_template_filter('formatdateinput')
|
||||||
def format_date_input(val):
|
def format_date_input(val):
|
||||||
conformed_timestamp = re.sub(r"[:]|([-](?!((\d{2}[:]\d{2})|(\d{4}))$))", '', val)
|
input_date = val.isoformat().split('T', 1)[0] # Hack to support dates <1900
|
||||||
date_obj = datetime.datetime.strptime(conformed_timestamp[:15], "%Y%m%d %H%M%S")
|
|
||||||
input_date = date_obj.isoformat().split('T', 1)[0] # Hack to support dates <1900
|
|
||||||
return '' if input_date == "0101-01-01" else input_date
|
return '' if input_date == "0101-01-01" else input_date
|
||||||
|
|
||||||
|
|
||||||
|
@ -113,8 +110,73 @@ def yesno(value, yes, no):
|
||||||
return yes if value else no
|
return yes if value else no
|
||||||
|
|
||||||
|
|
||||||
'''@jinjia.app_template_filter('canread')
|
@jinjia.app_template_filter('formatfloat')
|
||||||
def canread(ext):
|
def formatfloat(value, decimals=1):
|
||||||
if isinstance(ext, db.Data):
|
value = 0 if not value else value
|
||||||
ext = ext.format
|
return ('{0:.' + str(decimals) + 'f}').format(value).rstrip('0').rstrip('.')
|
||||||
return ext.lower() in EXTENSIONS_READER'''
|
|
||||||
|
|
||||||
|
@jinjia.app_template_filter('formatseriesindex')
|
||||||
|
def formatseriesindex_filter(series_index):
|
||||||
|
if series_index:
|
||||||
|
try:
|
||||||
|
if int(series_index) - series_index == 0:
|
||||||
|
return int(series_index)
|
||||||
|
else:
|
||||||
|
return series_index
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return series_index
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
@jinjia.app_template_filter('escapedlink')
|
||||||
|
def escapedlink_filter(url, text):
|
||||||
|
return "<a href='{}'>{}</a>".format(url, escape(text))
|
||||||
|
|
||||||
|
|
||||||
|
@jinjia.app_template_filter('uuidfilter')
|
||||||
|
def uuidfilter(var):
|
||||||
|
return uuid4()
|
||||||
|
|
||||||
|
|
||||||
|
@jinjia.app_template_filter('cache_timestamp')
|
||||||
|
def cache_timestamp(rolling_period='month'):
|
||||||
|
if rolling_period == 'day':
|
||||||
|
return str(int(datetime.datetime.today().replace(hour=1, minute=1).timestamp()))
|
||||||
|
elif rolling_period == 'year':
|
||||||
|
return str(int(datetime.datetime.today().replace(day=1).timestamp()))
|
||||||
|
else:
|
||||||
|
return str(int(datetime.datetime.today().replace(month=1, day=1).timestamp()))
|
||||||
|
|
||||||
|
|
||||||
|
@jinjia.app_template_filter('last_modified')
|
||||||
|
def book_last_modified(book):
|
||||||
|
return str(int(book.last_modified.timestamp()))
|
||||||
|
|
||||||
|
|
||||||
|
@jinjia.app_template_filter('get_cover_srcset')
|
||||||
|
def get_cover_srcset(book):
|
||||||
|
srcset = list()
|
||||||
|
resolutions = {
|
||||||
|
constants.COVER_THUMBNAIL_SMALL: 'sm',
|
||||||
|
constants.COVER_THUMBNAIL_MEDIUM: 'md',
|
||||||
|
constants.COVER_THUMBNAIL_LARGE: 'lg'
|
||||||
|
}
|
||||||
|
for resolution, shortname in resolutions.items():
|
||||||
|
url = url_for('web.get_cover', book_id=book.id, resolution=shortname, c=book_last_modified(book))
|
||||||
|
srcset.append(f'{url} {resolution}x')
|
||||||
|
return ', '.join(srcset)
|
||||||
|
|
||||||
|
|
||||||
|
@jinjia.app_template_filter('get_series_srcset')
|
||||||
|
def get_cover_srcset(series):
|
||||||
|
srcset = list()
|
||||||
|
resolutions = {
|
||||||
|
constants.COVER_THUMBNAIL_SMALL: 'sm',
|
||||||
|
constants.COVER_THUMBNAIL_MEDIUM: 'md',
|
||||||
|
constants.COVER_THUMBNAIL_LARGE: 'lg'
|
||||||
|
}
|
||||||
|
for resolution, shortname in resolutions.items():
|
||||||
|
url = url_for('web.get_series_cover', series_id=series.id, resolution=shortname, c=cache_timestamp())
|
||||||
|
srcset.append(f'{url} {resolution}x')
|
||||||
|
return ', '.join(srcset)
|
||||||
|
|
591
cps/kobo.py
591
cps/kobo.py
File diff suppressed because it is too large
Load Diff
140
cps/kobo_auth.py
140
cps/kobo_auth.py
|
@ -62,27 +62,71 @@ particular calls to non-Kobo specific endpoints such as the CalibreWeb book down
|
||||||
from binascii import hexlify
|
from binascii import hexlify
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from os import urandom
|
from os import urandom
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
from flask import g, Blueprint, url_for, abort, request
|
from flask import g, Blueprint, abort, request
|
||||||
from flask_login import login_user, login_required
|
from flask_login import login_user, current_user, login_required
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
from flask_limiter import RateLimitExceeded
|
||||||
|
|
||||||
from . import logger, ub, lm
|
from . import logger, config, calibre_db, db, helper, ub, lm, limiter
|
||||||
from .web import render_title_template
|
from .render_template import render_title_template
|
||||||
|
|
||||||
try:
|
|
||||||
from functools import wraps
|
|
||||||
except ImportError:
|
|
||||||
pass # We're not using Python 3
|
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth")
|
||||||
|
|
||||||
def register_url_value_preprocessor(kobo):
|
|
||||||
@kobo.url_value_preprocessor
|
@kobo_auth.route("/generate_auth_token/<int:user_id>")
|
||||||
def pop_auth_token(__, values):
|
@login_required
|
||||||
g.auth_token = values.pop("auth_token")
|
def generate_auth_token(user_id):
|
||||||
|
warning = False
|
||||||
|
host_list = request.host.rsplit(':')
|
||||||
|
if len(host_list) == 1:
|
||||||
|
host = ':'.join(host_list)
|
||||||
|
else:
|
||||||
|
host = ':'.join(host_list[0:-1])
|
||||||
|
if host.startswith('127.') or host.lower() == 'localhost' or host.startswith('[::ffff:7f') or host == "[::1]":
|
||||||
|
warning = _('Please access Calibre-Web from non localhost to get valid api_endpoint for kobo device')
|
||||||
|
|
||||||
|
# Generate auth token if none is existing for this user
|
||||||
|
auth_token = ub.session.query(ub.RemoteAuthToken).filter(
|
||||||
|
ub.RemoteAuthToken.user_id == user_id
|
||||||
|
).filter(ub.RemoteAuthToken.token_type==1).first()
|
||||||
|
|
||||||
|
if not auth_token:
|
||||||
|
auth_token = ub.RemoteAuthToken()
|
||||||
|
auth_token.user_id = user_id
|
||||||
|
auth_token.expiration = datetime.max
|
||||||
|
auth_token.auth_token = (hexlify(urandom(16))).decode("utf-8")
|
||||||
|
auth_token.token_type = 1
|
||||||
|
|
||||||
|
ub.session.add(auth_token)
|
||||||
|
ub.session_commit()
|
||||||
|
|
||||||
|
books = calibre_db.session.query(db.Books).join(db.Data).all()
|
||||||
|
|
||||||
|
for book in books:
|
||||||
|
formats = [data.format for data in book.data]
|
||||||
|
if 'KEPUB' not in formats and config.config_kepubifypath and 'EPUB' in formats:
|
||||||
|
helper.convert_book_format(book.id, config.config_calibre_dir, 'EPUB', 'KEPUB', current_user.name)
|
||||||
|
|
||||||
|
return render_title_template(
|
||||||
|
"generate_kobo_auth_url.html",
|
||||||
|
title=_("Kobo Setup"),
|
||||||
|
auth_token=auth_token.auth_token,
|
||||||
|
warning = warning
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@kobo_auth.route("/deleteauthtoken/<int:user_id>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def delete_auth_token(user_id):
|
||||||
|
# Invalidate any previously generated Kobo Auth token for this user
|
||||||
|
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\
|
||||||
|
.filter(ub.RemoteAuthToken.token_type==1).delete()
|
||||||
|
|
||||||
|
return ub.session_commit()
|
||||||
|
|
||||||
|
|
||||||
def disable_failed_auth_redirect_for_blueprint(bp):
|
def disable_failed_auth_redirect_for_blueprint(bp):
|
||||||
|
@ -96,11 +140,25 @@ def get_auth_token():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def register_url_value_preprocessor(kobo):
|
||||||
|
@kobo.url_value_preprocessor
|
||||||
|
# pylint: disable=unused-variable
|
||||||
|
def pop_auth_token(__, values):
|
||||||
|
g.auth_token = values.pop("auth_token")
|
||||||
|
|
||||||
|
|
||||||
def requires_kobo_auth(f):
|
def requires_kobo_auth(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def inner(*args, **kwargs):
|
def inner(*args, **kwargs):
|
||||||
auth_token = get_auth_token()
|
auth_token = get_auth_token()
|
||||||
if auth_token is not None:
|
if auth_token is not None:
|
||||||
|
try:
|
||||||
|
limiter.check()
|
||||||
|
except RateLimitExceeded:
|
||||||
|
return abort(429)
|
||||||
|
except (ConnectionError, Exception) as e:
|
||||||
|
log.error("Connection error to limiter backend: %s", e)
|
||||||
|
return abort(429)
|
||||||
user = (
|
user = (
|
||||||
ub.session.query(ub.User)
|
ub.session.query(ub.User)
|
||||||
.join(ub.RemoteAuthToken)
|
.join(ub.RemoteAuthToken)
|
||||||
|
@ -109,56 +167,8 @@ def requires_kobo_auth(f):
|
||||||
)
|
)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
login_user(user)
|
login_user(user)
|
||||||
|
[limiter.limiter.storage.clear(k.key) for k in limiter.current_limits]
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
log.debug("Received Kobo request without a recognizable auth token.")
|
log.debug("Received Kobo request without a recognizable auth token.")
|
||||||
return abort(401)
|
return abort(401)
|
||||||
return inner
|
return inner
|
||||||
|
|
||||||
|
|
||||||
kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth")
|
|
||||||
|
|
||||||
|
|
||||||
@kobo_auth.route("/generate_auth_token/<int:user_id>")
|
|
||||||
@login_required
|
|
||||||
def generate_auth_token(user_id):
|
|
||||||
host = ':'.join(request.host.rsplit(':')[0:-1])
|
|
||||||
if host.startswith('127.') or host.lower() == 'localhost' or host.startswith('[::ffff:7f'):
|
|
||||||
warning = _('PLease access calibre-web from non localhost to get valid api_endpoint for kobo device')
|
|
||||||
return render_title_template(
|
|
||||||
"generate_kobo_auth_url.html",
|
|
||||||
title=_(u"Kobo Setup"),
|
|
||||||
warning = warning
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Invalidate any prevously generated Kobo Auth token for this user.
|
|
||||||
auth_token = ub.session.query(ub.RemoteAuthToken).filter(
|
|
||||||
ub.RemoteAuthToken.user_id == user_id
|
|
||||||
).filter(ub.RemoteAuthToken.token_type==1).first()
|
|
||||||
|
|
||||||
if not auth_token:
|
|
||||||
auth_token = ub.RemoteAuthToken()
|
|
||||||
auth_token.user_id = user_id
|
|
||||||
auth_token.expiration = datetime.max
|
|
||||||
auth_token.auth_token = (hexlify(urandom(16))).decode("utf-8")
|
|
||||||
auth_token.token_type = 1
|
|
||||||
|
|
||||||
ub.session.add(auth_token)
|
|
||||||
ub.session.commit()
|
|
||||||
return render_title_template(
|
|
||||||
"generate_kobo_auth_url.html",
|
|
||||||
title=_(u"Kobo Setup"),
|
|
||||||
kobo_auth_url=url_for(
|
|
||||||
"kobo.TopLevelEndpoint", auth_token=auth_token.auth_token, _external=True
|
|
||||||
),
|
|
||||||
warning = False
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@kobo_auth.route("/deleteauthtoken/<int:user_id>")
|
|
||||||
@login_required
|
|
||||||
def delete_auth_token(user_id):
|
|
||||||
# Invalidate any prevously generated Kobo Auth token for this user.
|
|
||||||
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\
|
|
||||||
.filter(ub.RemoteAuthToken.token_type==1).delete()
|
|
||||||
ub.session.commit()
|
|
||||||
return ""
|
|
||||||
|
|
88
cps/kobo_sync_status.py
Normal file
88
cps/kobo_sync_status.py
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2021 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
from flask_login import current_user
|
||||||
|
from . import ub
|
||||||
|
import datetime
|
||||||
|
from sqlalchemy.sql.expression import or_, and_, true
|
||||||
|
from sqlalchemy import exc
|
||||||
|
|
||||||
|
# Add the current book id to kobo_synced_books table for current user, if entry is already present,
|
||||||
|
# do nothing (safety precaution)
|
||||||
|
def add_synced_books(book_id):
|
||||||
|
is_present = ub.session.query(ub.KoboSyncedBooks).filter(ub.KoboSyncedBooks.book_id == book_id)\
|
||||||
|
.filter(ub.KoboSyncedBooks.user_id == current_user.id).count()
|
||||||
|
if not is_present:
|
||||||
|
synced_book = ub.KoboSyncedBooks()
|
||||||
|
synced_book.user_id = current_user.id
|
||||||
|
synced_book.book_id = book_id
|
||||||
|
ub.session.add(synced_book)
|
||||||
|
ub.session_commit()
|
||||||
|
|
||||||
|
|
||||||
|
# Select all entries of current book in kobo_synced_books table, which are from current user and delete them
|
||||||
|
def remove_synced_book(book_id, all=False, session=None):
|
||||||
|
if not all:
|
||||||
|
user = ub.KoboSyncedBooks.user_id == current_user.id
|
||||||
|
else:
|
||||||
|
user = true()
|
||||||
|
if not session:
|
||||||
|
ub.session.query(ub.KoboSyncedBooks).filter(ub.KoboSyncedBooks.book_id == book_id).filter(user).delete()
|
||||||
|
ub.session_commit()
|
||||||
|
else:
|
||||||
|
session.query(ub.KoboSyncedBooks).filter(ub.KoboSyncedBooks.book_id == book_id).filter(user).delete()
|
||||||
|
ub.session_commit(_session=session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def change_archived_books(book_id, state=None, message=None):
|
||||||
|
archived_book = ub.session.query(ub.ArchivedBook).filter(and_(ub.ArchivedBook.user_id == int(current_user.id),
|
||||||
|
ub.ArchivedBook.book_id == book_id)).first()
|
||||||
|
if not archived_book:
|
||||||
|
archived_book = ub.ArchivedBook(user_id=current_user.id, book_id=book_id)
|
||||||
|
|
||||||
|
archived_book.is_archived = state if state else not archived_book.is_archived
|
||||||
|
archived_book.last_modified = datetime.datetime.utcnow() # toDo. Check utc timestamp
|
||||||
|
|
||||||
|
ub.session.merge(archived_book)
|
||||||
|
ub.session_commit(message)
|
||||||
|
return archived_book.is_archived
|
||||||
|
|
||||||
|
|
||||||
|
# select all books which are synced by the current user and do not belong to a synced shelf and set them to archive
|
||||||
|
# select all shelves from current user which are synced and do not belong to the "only sync" shelves
|
||||||
|
def update_on_sync_shelfs(user_id):
|
||||||
|
books_to_archive = (ub.session.query(ub.KoboSyncedBooks)
|
||||||
|
.join(ub.BookShelf, ub.KoboSyncedBooks.book_id == ub.BookShelf.book_id, isouter=True)
|
||||||
|
.join(ub.Shelf, ub.Shelf.user_id == user_id, isouter=True)
|
||||||
|
.filter(or_(ub.Shelf.kobo_sync == 0, ub.Shelf.kobo_sync == None))
|
||||||
|
.filter(ub.KoboSyncedBooks.user_id == user_id).all())
|
||||||
|
for b in books_to_archive:
|
||||||
|
change_archived_books(b.book_id, True)
|
||||||
|
ub.session.query(ub.KoboSyncedBooks) \
|
||||||
|
.filter(ub.KoboSyncedBooks.book_id == b.book_id) \
|
||||||
|
.filter(ub.KoboSyncedBooks.user_id == user_id).delete()
|
||||||
|
ub.session_commit()
|
||||||
|
|
||||||
|
# Search all shelf which are currently not synced
|
||||||
|
shelves_to_archive = ub.session.query(ub.Shelf).filter(ub.Shelf.user_id == user_id).filter(
|
||||||
|
ub.Shelf.kobo_sync == 0).all()
|
||||||
|
for a in shelves_to_archive:
|
||||||
|
ub.session.add(ub.ShelfArchive(uuid=a.uuid, user_id=user_id))
|
||||||
|
ub.session_commit()
|
|
@ -16,7 +16,6 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import inspect
|
import inspect
|
||||||
|
@ -41,6 +40,29 @@ logging.addLevelName(logging.WARNING, "WARN")
|
||||||
logging.addLevelName(logging.CRITICAL, "CRIT")
|
logging.addLevelName(logging.CRITICAL, "CRIT")
|
||||||
|
|
||||||
|
|
||||||
|
class _Logger(logging.Logger):
|
||||||
|
|
||||||
|
def error_or_exception(self, message, stacklevel=2, *args, **kwargs):
|
||||||
|
is_debug = self.getEffectiveLevel() <= logging.DEBUG
|
||||||
|
if sys.version_info > (3, 7):
|
||||||
|
if is_debug:
|
||||||
|
self.exception(message, stacklevel=stacklevel, *args, **kwargs)
|
||||||
|
else:
|
||||||
|
self.error(message, stacklevel=stacklevel, *args, **kwargs)
|
||||||
|
else:
|
||||||
|
if is_debug:
|
||||||
|
self.exception(message, stack_info=True, *args, **kwargs)
|
||||||
|
else:
|
||||||
|
self.error(message, *args, **kwargs)
|
||||||
|
|
||||||
|
def debug_no_auth(self, message, *args, **kwargs):
|
||||||
|
message = message.strip("\r\n")
|
||||||
|
if message.startswith("send: AUTH"):
|
||||||
|
self.debug(message[:16], *args, **kwargs)
|
||||||
|
else:
|
||||||
|
self.debug(message, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def get(name=None):
|
def get(name=None):
|
||||||
return logging.getLogger(name)
|
return logging.getLogger(name)
|
||||||
|
|
||||||
|
@ -58,6 +80,7 @@ def create():
|
||||||
def is_debug_enabled():
|
def is_debug_enabled():
|
||||||
return logging.root.level <= logging.DEBUG
|
return logging.root.level <= logging.DEBUG
|
||||||
|
|
||||||
|
|
||||||
def is_info_enabled(logger):
|
def is_info_enabled(logger):
|
||||||
return logging.getLogger(logger).level <= logging.INFO
|
return logging.getLogger(logger).level <= logging.INFO
|
||||||
|
|
||||||
|
@ -82,7 +105,6 @@ def _absolute_log_file(log_file, default_log_file):
|
||||||
if not os.path.dirname(log_file):
|
if not os.path.dirname(log_file):
|
||||||
log_file = os.path.join(_CONFIG_DIR, log_file)
|
log_file = os.path.join(_CONFIG_DIR, log_file)
|
||||||
return os.path.abspath(log_file)
|
return os.path.abspath(log_file)
|
||||||
|
|
||||||
return default_log_file
|
return default_log_file
|
||||||
|
|
||||||
|
|
||||||
|
@ -95,11 +117,12 @@ def get_accesslogfile(log_file):
|
||||||
|
|
||||||
|
|
||||||
def setup(log_file, log_level=None):
|
def setup(log_file, log_level=None):
|
||||||
'''
|
"""
|
||||||
Configure the logging output.
|
Configure the logging output.
|
||||||
May be called multiple times.
|
May be called multiple times.
|
||||||
'''
|
"""
|
||||||
log_level = log_level or DEFAULT_LOG_LEVEL
|
log_level = log_level or DEFAULT_LOG_LEVEL
|
||||||
|
logging.setLoggerClass(_Logger)
|
||||||
logging.getLogger(__package__).setLevel(log_level)
|
logging.getLogger(__package__).setLevel(log_level)
|
||||||
|
|
||||||
r = logging.root
|
r = logging.root
|
||||||
|
@ -107,7 +130,7 @@ def setup(log_file, log_level=None):
|
||||||
# avoid spamming the log with debug messages from libraries
|
# avoid spamming the log with debug messages from libraries
|
||||||
r.setLevel(log_level)
|
r.setLevel(log_level)
|
||||||
|
|
||||||
# Otherwise name get's destroyed on windows
|
# Otherwise, name gets destroyed on Windows
|
||||||
if log_file != LOG_TO_STDERR and log_file != LOG_TO_STDOUT:
|
if log_file != LOG_TO_STDERR and log_file != LOG_TO_STDOUT:
|
||||||
log_file = _absolute_log_file(log_file, DEFAULT_LOG_FILE)
|
log_file = _absolute_log_file(log_file, DEFAULT_LOG_FILE)
|
||||||
|
|
||||||
|
@ -115,7 +138,7 @@ def setup(log_file, log_level=None):
|
||||||
if previous_handler:
|
if previous_handler:
|
||||||
# if the log_file has not changed, don't create a new handler
|
# if the log_file has not changed, don't create a new handler
|
||||||
if getattr(previous_handler, 'baseFilename', None) == log_file:
|
if getattr(previous_handler, 'baseFilename', None) == log_file:
|
||||||
return
|
return "" if log_file == DEFAULT_LOG_FILE else log_file
|
||||||
logging.debug("logging to %s level %s", log_file, r.level)
|
logging.debug("logging to %s level %s", log_file, r.level)
|
||||||
|
|
||||||
if log_file == LOG_TO_STDERR or log_file == LOG_TO_STDOUT:
|
if log_file == LOG_TO_STDERR or log_file == LOG_TO_STDOUT:
|
||||||
|
@ -127,34 +150,43 @@ def setup(log_file, log_level=None):
|
||||||
file_handler.baseFilename = log_file
|
file_handler.baseFilename = log_file
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
file_handler = RotatingFileHandler(log_file, maxBytes=50000, backupCount=2)
|
file_handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=2, encoding='utf-8')
|
||||||
except IOError:
|
except (IOError, PermissionError):
|
||||||
if log_file == DEFAULT_LOG_FILE:
|
if log_file == DEFAULT_LOG_FILE:
|
||||||
raise
|
raise
|
||||||
file_handler = RotatingFileHandler(DEFAULT_LOG_FILE, maxBytes=50000, backupCount=2)
|
file_handler = RotatingFileHandler(DEFAULT_LOG_FILE, maxBytes=100000, backupCount=2, encoding='utf-8')
|
||||||
|
log_file = ""
|
||||||
file_handler.setFormatter(FORMATTER)
|
file_handler.setFormatter(FORMATTER)
|
||||||
|
|
||||||
for h in r.handlers:
|
for h in r.handlers:
|
||||||
r.removeHandler(h)
|
r.removeHandler(h)
|
||||||
h.close()
|
h.close()
|
||||||
r.addHandler(file_handler)
|
r.addHandler(file_handler)
|
||||||
|
logging.captureWarnings(True)
|
||||||
|
return "" if log_file == DEFAULT_LOG_FILE else log_file
|
||||||
|
|
||||||
|
|
||||||
def create_access_log(log_file, log_name, formatter):
|
def create_access_log(log_file, log_name, formatter):
|
||||||
'''
|
"""
|
||||||
One-time configuration for the web server's access log.
|
One-time configuration for the web server's access log.
|
||||||
'''
|
"""
|
||||||
log_file = _absolute_log_file(log_file, DEFAULT_ACCESS_LOG)
|
log_file = _absolute_log_file(log_file, DEFAULT_ACCESS_LOG)
|
||||||
logging.debug("access log: %s", log_file)
|
logging.debug("access log: %s", log_file)
|
||||||
|
|
||||||
access_log = logging.getLogger(log_name)
|
access_log = logging.getLogger(log_name)
|
||||||
access_log.propagate = False
|
access_log.propagate = False
|
||||||
access_log.setLevel(logging.INFO)
|
access_log.setLevel(logging.INFO)
|
||||||
|
try:
|
||||||
|
file_handler = RotatingFileHandler(log_file, maxBytes=50000, backupCount=2, encoding='utf-8')
|
||||||
|
except (IOError, PermissionError):
|
||||||
|
if log_file == DEFAULT_ACCESS_LOG:
|
||||||
|
raise
|
||||||
|
file_handler = RotatingFileHandler(DEFAULT_ACCESS_LOG, maxBytes=50000, backupCount=2, encoding='utf-8')
|
||||||
|
log_file = ""
|
||||||
|
|
||||||
file_handler = RotatingFileHandler(log_file, maxBytes=50000, backupCount=2)
|
|
||||||
file_handler.setFormatter(formatter)
|
file_handler.setFormatter(formatter)
|
||||||
access_log.addHandler(file_handler)
|
access_log.addHandler(file_handler)
|
||||||
return access_log
|
return access_log, "" if _absolute_log_file(log_file, DEFAULT_ACCESS_LOG) == DEFAULT_ACCESS_LOG else log_file
|
||||||
|
|
||||||
|
|
||||||
# Enable logging of smtp lib debug output
|
# Enable logging of smtp lib debug output
|
||||||
|
|
81
cps/main.py
Normal file
81
cps/main.py
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2012-2022 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from . import create_app, limiter
|
||||||
|
from .jinjia import jinjia
|
||||||
|
from .remotelogin import remotelogin
|
||||||
|
from flask import request
|
||||||
|
|
||||||
|
|
||||||
|
def request_username():
|
||||||
|
return request.authorization.username
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
from .web import web
|
||||||
|
from .opds import opds
|
||||||
|
from .admin import admi
|
||||||
|
from .gdrive import gdrive
|
||||||
|
from .editbooks import editbook
|
||||||
|
from .about import about
|
||||||
|
from .search import search
|
||||||
|
from .search_metadata import meta
|
||||||
|
from .shelf import shelf
|
||||||
|
from .tasks_status import tasks
|
||||||
|
from .error_handler import init_errorhandler
|
||||||
|
try:
|
||||||
|
from .kobo import kobo, get_kobo_activated
|
||||||
|
from .kobo_auth import kobo_auth
|
||||||
|
from flask_limiter.util import get_remote_address
|
||||||
|
kobo_available = get_kobo_activated()
|
||||||
|
except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator)
|
||||||
|
kobo_available = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .oauth_bb import oauth
|
||||||
|
oauth_available = True
|
||||||
|
except ImportError:
|
||||||
|
oauth_available = False
|
||||||
|
|
||||||
|
from . import web_server
|
||||||
|
init_errorhandler()
|
||||||
|
|
||||||
|
app.register_blueprint(search)
|
||||||
|
app.register_blueprint(tasks)
|
||||||
|
app.register_blueprint(web)
|
||||||
|
app.register_blueprint(opds)
|
||||||
|
limiter.limit("3/minute",key_func=request_username)(opds)
|
||||||
|
app.register_blueprint(jinjia)
|
||||||
|
app.register_blueprint(about)
|
||||||
|
app.register_blueprint(shelf)
|
||||||
|
app.register_blueprint(admi)
|
||||||
|
app.register_blueprint(remotelogin)
|
||||||
|
app.register_blueprint(meta)
|
||||||
|
app.register_blueprint(gdrive)
|
||||||
|
app.register_blueprint(editbook)
|
||||||
|
if kobo_available:
|
||||||
|
app.register_blueprint(kobo)
|
||||||
|
app.register_blueprint(kobo_auth)
|
||||||
|
limiter.limit("3/minute", key_func=get_remote_address)(kobo)
|
||||||
|
if oauth_available:
|
||||||
|
app.register_blueprint(oauth)
|
||||||
|
success = web_server.start()
|
||||||
|
sys.exit(0 if success else 1)
|
141
cps/metadata_provider/amazon.py
Normal file
141
cps/metadata_provider/amazon.py
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2022 quarz12
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import concurrent.futures
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup as BS # requirement
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cchardet #optional for better speed
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
from cps import logger
|
||||||
|
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
||||||
|
import cps.logger as logger
|
||||||
|
|
||||||
|
#from time import time
|
||||||
|
from operator import itemgetter
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
|
class Amazon(Metadata):
|
||||||
|
__name__ = "Amazon"
|
||||||
|
__id__ = "amazon"
|
||||||
|
headers = {'upgrade-insecure-requests': '1',
|
||||||
|
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36',
|
||||||
|
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
|
||||||
|
'sec-gpc': '1',
|
||||||
|
'sec-fetch-site': 'none',
|
||||||
|
'sec-fetch-mode': 'navigate',
|
||||||
|
'sec-fetch-user': '?1',
|
||||||
|
'sec-fetch-dest': 'document',
|
||||||
|
'accept-encoding': 'gzip, deflate, br',
|
||||||
|
'accept-language': 'en-US,en;q=0.9'}
|
||||||
|
session = requests.Session()
|
||||||
|
session.headers=headers
|
||||||
|
|
||||||
|
def search(
|
||||||
|
self, query: str, generic_cover: str = "", locale: str = "en"
|
||||||
|
) -> Optional[List[MetaRecord]]:
|
||||||
|
#timer=time()
|
||||||
|
def inner(link, index) -> [dict, int]:
|
||||||
|
with self.session as session:
|
||||||
|
try:
|
||||||
|
r = session.get(f"https://www.amazon.com/{link}")
|
||||||
|
r.raise_for_status()
|
||||||
|
except Exception as ex:
|
||||||
|
log.warning(ex)
|
||||||
|
return None
|
||||||
|
long_soup = BS(r.text, "lxml") #~4sec :/
|
||||||
|
soup2 = long_soup.find("div", attrs={"cel_widget_id": "dpx-books-ppd_csm_instrumentation_wrapper"})
|
||||||
|
if soup2 is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
match = MetaRecord(
|
||||||
|
title = "",
|
||||||
|
authors = "",
|
||||||
|
source=MetaSourceInfo(
|
||||||
|
id=self.__id__,
|
||||||
|
description="Amazon Books",
|
||||||
|
link="https://amazon.com/"
|
||||||
|
),
|
||||||
|
url = f"https://www.amazon.com{link}",
|
||||||
|
#the more searches the slower, these are too hard to find in reasonable time or might not even exist
|
||||||
|
publisher= "", # very unreliable
|
||||||
|
publishedDate= "", # very unreliable
|
||||||
|
id = None, # ?
|
||||||
|
tags = [] # dont exist on amazon
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
match.description = "\n".join(
|
||||||
|
soup2.find("div", attrs={"data-feature-name": "bookDescription"}).stripped_strings)\
|
||||||
|
.replace("\xa0"," ")[:-9].strip().strip("\n")
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
return None # if there is no description it is not a book and therefore should be ignored
|
||||||
|
try:
|
||||||
|
match.title = soup2.find("span", attrs={"id": "productTitle"}).text
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
match.title = ""
|
||||||
|
try:
|
||||||
|
match.authors = [next(
|
||||||
|
filter(lambda i: i != " " and i != "\n" and not i.startswith("{"),
|
||||||
|
x.findAll(string=True))).strip()
|
||||||
|
for x in soup2.findAll("span", attrs={"class": "author"})]
|
||||||
|
except (AttributeError, TypeError, StopIteration):
|
||||||
|
match.authors = ""
|
||||||
|
try:
|
||||||
|
match.rating = int(
|
||||||
|
soup2.find("span", class_="a-icon-alt").text.split(" ")[0].split(".")[
|
||||||
|
0]) # first number in string
|
||||||
|
except (AttributeError, ValueError):
|
||||||
|
match.rating = 0
|
||||||
|
try:
|
||||||
|
match.cover = soup2.find("img", attrs={"class": "a-dynamic-image frontImage"})["src"]
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
match.cover = ""
|
||||||
|
return match, index
|
||||||
|
except Exception as e:
|
||||||
|
log.error_or_exception(e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
val = list()
|
||||||
|
if self.active:
|
||||||
|
try:
|
||||||
|
results = self.session.get(
|
||||||
|
f"https://www.amazon.com/s?k={query.replace(' ', '+')}&i=digital-text&sprefix={query.replace(' ', '+')}"
|
||||||
|
f"%2Cdigital-text&ref=nb_sb_noss",
|
||||||
|
headers=self.headers)
|
||||||
|
results.raise_for_status()
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
log.error_or_exception(e)
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(e)
|
||||||
|
return []
|
||||||
|
soup = BS(results.text, 'html.parser')
|
||||||
|
links_list = [next(filter(lambda i: "digital-text" in i["href"], x.findAll("a")))["href"] for x in
|
||||||
|
soup.findAll("div", attrs={"data-component-type": "s-search-result"})]
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||||
|
fut = {executor.submit(inner, link, index) for index, link in enumerate(links_list[:5])}
|
||||||
|
val = list(map(lambda x : x.result() ,concurrent.futures.as_completed(fut)))
|
||||||
|
result = list(filter(lambda x: x, val))
|
||||||
|
return [x[0] for x in sorted(result, key=itemgetter(1))] #sort by amazons listing order for best relevance
|
92
cps/metadata_provider/comicvine.py
Normal file
92
cps/metadata_provider/comicvine.py
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2021 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# ComicVine api document: https://comicvine.gamespot.com/api/documentation
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from cps import logger
|
||||||
|
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
|
class ComicVine(Metadata):
|
||||||
|
__name__ = "ComicVine"
|
||||||
|
__id__ = "comicvine"
|
||||||
|
DESCRIPTION = "ComicVine Books"
|
||||||
|
META_URL = "https://comicvine.gamespot.com/"
|
||||||
|
API_KEY = "57558043c53943d5d1e96a9ad425b0eb85532ee6"
|
||||||
|
BASE_URL = (
|
||||||
|
f"https://comicvine.gamespot.com/api/search?api_key={API_KEY}"
|
||||||
|
f"&resources=issue&query="
|
||||||
|
)
|
||||||
|
QUERY_PARAMS = "&sort=name:desc&format=json"
|
||||||
|
HEADERS = {"User-Agent": "Not Evil Browser"}
|
||||||
|
|
||||||
|
def search(
|
||||||
|
self, query: str, generic_cover: str = "", locale: str = "en"
|
||||||
|
) -> Optional[List[MetaRecord]]:
|
||||||
|
val = list()
|
||||||
|
if self.active:
|
||||||
|
title_tokens = list(self.get_title_tokens(query, strip_joiners=False))
|
||||||
|
if title_tokens:
|
||||||
|
tokens = [quote(t.encode("utf-8")) for t in title_tokens]
|
||||||
|
query = "%20".join(tokens)
|
||||||
|
try:
|
||||||
|
result = requests.get(
|
||||||
|
f"{ComicVine.BASE_URL}{query}{ComicVine.QUERY_PARAMS}",
|
||||||
|
headers=ComicVine.HEADERS,
|
||||||
|
)
|
||||||
|
result.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(e)
|
||||||
|
return None
|
||||||
|
for result in result.json()["results"]:
|
||||||
|
match = self._parse_search_result(
|
||||||
|
result=result, generic_cover=generic_cover, locale=locale
|
||||||
|
)
|
||||||
|
val.append(match)
|
||||||
|
return val
|
||||||
|
|
||||||
|
def _parse_search_result(
|
||||||
|
self, result: Dict, generic_cover: str, locale: str
|
||||||
|
) -> MetaRecord:
|
||||||
|
series = result["volume"].get("name", "")
|
||||||
|
series_index = result.get("issue_number", 0)
|
||||||
|
issue_name = result.get("name", "")
|
||||||
|
match = MetaRecord(
|
||||||
|
id=result["id"],
|
||||||
|
title=f"{series}#{series_index} - {issue_name}",
|
||||||
|
authors=result.get("authors", []),
|
||||||
|
url=result.get("site_detail_url", ""),
|
||||||
|
source=MetaSourceInfo(
|
||||||
|
id=self.__id__,
|
||||||
|
description=ComicVine.DESCRIPTION,
|
||||||
|
link=ComicVine.META_URL,
|
||||||
|
),
|
||||||
|
series=series,
|
||||||
|
)
|
||||||
|
match.cover = result["image"].get("original_url", generic_cover)
|
||||||
|
match.description = result.get("description", "")
|
||||||
|
match.publishedDate = result.get("store_date", result.get("date_added"))
|
||||||
|
match.series_index = series_index
|
||||||
|
match.tags = ["Comics", series]
|
||||||
|
match.identifiers = {"comicvine": match.id}
|
||||||
|
return match
|
259
cps/metadata_provider/douban.py
Normal file
259
cps/metadata_provider/douban.py
Normal file
|
@ -0,0 +1,259 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2022 xlivevil
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
import re
|
||||||
|
from concurrent import futures
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from html2text import HTML2Text
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
from cps import logger
|
||||||
|
from cps.services.Metadata import Metadata, MetaRecord, MetaSourceInfo
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
|
def html2text(html: str) -> str:
|
||||||
|
|
||||||
|
h2t = HTML2Text()
|
||||||
|
h2t.body_width = 0
|
||||||
|
h2t.single_line_break = True
|
||||||
|
h2t.emphasis_mark = "*"
|
||||||
|
return h2t.handle(html)
|
||||||
|
|
||||||
|
|
||||||
|
class Douban(Metadata):
|
||||||
|
__name__ = "豆瓣"
|
||||||
|
__id__ = "douban"
|
||||||
|
DESCRIPTION = "豆瓣"
|
||||||
|
META_URL = "https://book.douban.com/"
|
||||||
|
SEARCH_JSON_URL = "https://www.douban.com/j/search"
|
||||||
|
SEARCH_URL = "https://www.douban.com/search"
|
||||||
|
|
||||||
|
ID_PATTERN = re.compile(r"sid: (?P<id>\d+),")
|
||||||
|
AUTHORS_PATTERN = re.compile(r"作者|译者")
|
||||||
|
PUBLISHER_PATTERN = re.compile(r"出版社")
|
||||||
|
SUBTITLE_PATTERN = re.compile(r"副标题")
|
||||||
|
PUBLISHED_DATE_PATTERN = re.compile(r"出版年")
|
||||||
|
SERIES_PATTERN = re.compile(r"丛书")
|
||||||
|
IDENTIFIERS_PATTERN = re.compile(r"ISBN|统一书号")
|
||||||
|
CRITERIA_PATTERN = re.compile("criteria = '(.+)'")
|
||||||
|
|
||||||
|
TITTLE_XPATH = "//span[@property='v:itemreviewed']"
|
||||||
|
COVER_XPATH = "//a[@class='nbg']"
|
||||||
|
INFO_XPATH = "//*[@id='info']//span[@class='pl']"
|
||||||
|
TAGS_XPATH = "//a[contains(@class, 'tag')]"
|
||||||
|
DESCRIPTION_XPATH = "//div[@id='link-report']//div[@class='intro']"
|
||||||
|
RATING_XPATH = "//div[@class='rating_self clearfix']/strong"
|
||||||
|
|
||||||
|
session = requests.Session()
|
||||||
|
session.headers = {
|
||||||
|
'user-agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36 Edg/98.0.1108.56',
|
||||||
|
}
|
||||||
|
|
||||||
|
def search(self,
|
||||||
|
query: str,
|
||||||
|
generic_cover: str = "",
|
||||||
|
locale: str = "en") -> List[MetaRecord]:
|
||||||
|
val = []
|
||||||
|
if self.active:
|
||||||
|
log.debug(f"start searching {query} on douban")
|
||||||
|
if title_tokens := list(
|
||||||
|
self.get_title_tokens(query, strip_joiners=False)):
|
||||||
|
query = "+".join(title_tokens)
|
||||||
|
|
||||||
|
book_id_list = self._get_book_id_list_from_html(query)
|
||||||
|
|
||||||
|
if not book_id_list:
|
||||||
|
log.debug("No search results in Douban")
|
||||||
|
return []
|
||||||
|
|
||||||
|
with futures.ThreadPoolExecutor(
|
||||||
|
max_workers=5, thread_name_prefix='douban') as executor:
|
||||||
|
|
||||||
|
fut = [
|
||||||
|
executor.submit(self._parse_single_book, book_id,
|
||||||
|
generic_cover) for book_id in book_id_list
|
||||||
|
]
|
||||||
|
|
||||||
|
val = [
|
||||||
|
future.result() for future in futures.as_completed(fut)
|
||||||
|
if future.result()
|
||||||
|
]
|
||||||
|
|
||||||
|
return val
|
||||||
|
|
||||||
|
def _get_book_id_list_from_html(self, query: str) -> List[str]:
|
||||||
|
try:
|
||||||
|
r = self.session.get(self.SEARCH_URL,
|
||||||
|
params={
|
||||||
|
"cat": 1001,
|
||||||
|
"q": query
|
||||||
|
})
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
html = etree.HTML(r.content.decode("utf8"))
|
||||||
|
result_list = html.xpath(self.COVER_XPATH)
|
||||||
|
|
||||||
|
return [
|
||||||
|
self.ID_PATTERN.search(item.get("onclick")).group("id")
|
||||||
|
for item in result_list[:10]
|
||||||
|
if self.ID_PATTERN.search(item.get("onclick"))
|
||||||
|
]
|
||||||
|
|
||||||
|
def _get_book_id_list_from_json(self, query: str) -> List[str]:
|
||||||
|
try:
|
||||||
|
r = self.session.get(self.SEARCH_JSON_URL,
|
||||||
|
params={
|
||||||
|
"cat": 1001,
|
||||||
|
"q": query
|
||||||
|
})
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
results = r.json()
|
||||||
|
if results["total"] == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
self.ID_PATTERN.search(item).group("id")
|
||||||
|
for item in results["items"][:10] if self.ID_PATTERN.search(item)
|
||||||
|
]
|
||||||
|
|
||||||
|
def _parse_single_book(self,
|
||||||
|
id: str,
|
||||||
|
generic_cover: str = "") -> Optional[MetaRecord]:
|
||||||
|
url = f"https://book.douban.com/subject/{id}/"
|
||||||
|
log.debug(f"start parsing {url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = self.session.get(url)
|
||||||
|
r.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
match = MetaRecord(
|
||||||
|
id=id,
|
||||||
|
title="",
|
||||||
|
authors=[],
|
||||||
|
url=url,
|
||||||
|
source=MetaSourceInfo(
|
||||||
|
id=self.__id__,
|
||||||
|
description=self.DESCRIPTION,
|
||||||
|
link=self.META_URL,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
decode_content = r.content.decode("utf8")
|
||||||
|
html = etree.HTML(decode_content)
|
||||||
|
|
||||||
|
match.title = html.xpath(self.TITTLE_XPATH)[0].text
|
||||||
|
match.cover = html.xpath(
|
||||||
|
self.COVER_XPATH)[0].attrib["href"] or generic_cover
|
||||||
|
try:
|
||||||
|
rating_num = float(html.xpath(self.RATING_XPATH)[0].text.strip())
|
||||||
|
except Exception:
|
||||||
|
rating_num = 0
|
||||||
|
match.rating = int(-1 * rating_num // 2 * -1) if rating_num else 0
|
||||||
|
|
||||||
|
tag_elements = html.xpath(self.TAGS_XPATH)
|
||||||
|
if len(tag_elements):
|
||||||
|
match.tags = [tag_element.text for tag_element in tag_elements]
|
||||||
|
else:
|
||||||
|
match.tags = self._get_tags(decode_content)
|
||||||
|
|
||||||
|
description_element = html.xpath(self.DESCRIPTION_XPATH)
|
||||||
|
if len(description_element):
|
||||||
|
match.description = html2text(
|
||||||
|
etree.tostring(description_element[-1]).decode("utf8"))
|
||||||
|
|
||||||
|
info = html.xpath(self.INFO_XPATH)
|
||||||
|
|
||||||
|
for element in info:
|
||||||
|
text = element.text
|
||||||
|
if self.AUTHORS_PATTERN.search(text):
|
||||||
|
next_element = element.getnext()
|
||||||
|
while next_element is not None and next_element.tag != "br":
|
||||||
|
match.authors.append(next_element.text)
|
||||||
|
next_element = next_element.getnext()
|
||||||
|
elif self.PUBLISHER_PATTERN.search(text):
|
||||||
|
if publisher := element.tail.strip():
|
||||||
|
match.publisher = publisher
|
||||||
|
else:
|
||||||
|
match.publisher = element.getnext().text
|
||||||
|
elif self.SUBTITLE_PATTERN.search(text):
|
||||||
|
match.title = f'{match.title}:{element.tail.strip()}'
|
||||||
|
elif self.PUBLISHED_DATE_PATTERN.search(text):
|
||||||
|
match.publishedDate = self._clean_date(element.tail.strip())
|
||||||
|
elif self.SERIES_PATTERN.search(text):
|
||||||
|
match.series = element.getnext().text
|
||||||
|
elif i_type := self.IDENTIFIERS_PATTERN.search(text):
|
||||||
|
match.identifiers[i_type.group()] = element.tail.strip()
|
||||||
|
|
||||||
|
return match
|
||||||
|
|
||||||
|
def _clean_date(self, date: str) -> str:
|
||||||
|
"""
|
||||||
|
Clean up the date string to be in the format YYYY-MM-DD
|
||||||
|
|
||||||
|
Examples of possible patterns:
|
||||||
|
'2014-7-16', '1988年4月', '1995-04', '2021-8', '2020-12-1', '1996年',
|
||||||
|
'1972', '2004/11/01', '1959年3月北京第1版第1印'
|
||||||
|
"""
|
||||||
|
year = date[:4]
|
||||||
|
moon = "01"
|
||||||
|
day = "01"
|
||||||
|
|
||||||
|
if len(date) > 5:
|
||||||
|
digit = []
|
||||||
|
ls = []
|
||||||
|
for i in range(5, len(date)):
|
||||||
|
if date[i].isdigit():
|
||||||
|
digit.append(date[i])
|
||||||
|
elif digit:
|
||||||
|
ls.append("".join(digit) if len(digit) ==
|
||||||
|
2 else f"0{digit[0]}")
|
||||||
|
digit = []
|
||||||
|
if digit:
|
||||||
|
ls.append("".join(digit) if len(digit) ==
|
||||||
|
2 else f"0{digit[0]}")
|
||||||
|
|
||||||
|
moon = ls[0]
|
||||||
|
if len(ls) > 1:
|
||||||
|
day = ls[1]
|
||||||
|
|
||||||
|
return f"{year}-{moon}-{day}"
|
||||||
|
|
||||||
|
def _get_tags(self, text: str) -> List[str]:
|
||||||
|
tags = []
|
||||||
|
if criteria := self.CRITERIA_PATTERN.search(text):
|
||||||
|
tags.extend(
|
||||||
|
item.replace('7:', '') for item in criteria.group().split('|')
|
||||||
|
if item.startswith('7:'))
|
||||||
|
|
||||||
|
return tags
|
129
cps/metadata_provider/google.py
Normal file
129
cps/metadata_provider/google.py
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2021 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# Google Books api document: https://developers.google.com/books/docs/v1/using
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from cps import logger
|
||||||
|
from cps.isoLanguages import get_lang3, get_language_name
|
||||||
|
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
|
class Google(Metadata):
|
||||||
|
__name__ = "Google"
|
||||||
|
__id__ = "google"
|
||||||
|
DESCRIPTION = "Google Books"
|
||||||
|
META_URL = "https://books.google.com/"
|
||||||
|
BOOK_URL = "https://books.google.com/books?id="
|
||||||
|
SEARCH_URL = "https://www.googleapis.com/books/v1/volumes?q="
|
||||||
|
ISBN_TYPE = "ISBN_13"
|
||||||
|
|
||||||
|
def search(
|
||||||
|
self, query: str, generic_cover: str = "", locale: str = "en"
|
||||||
|
) -> Optional[List[MetaRecord]]:
|
||||||
|
val = list()
|
||||||
|
if self.active:
|
||||||
|
|
||||||
|
title_tokens = list(self.get_title_tokens(query, strip_joiners=False))
|
||||||
|
if title_tokens:
|
||||||
|
tokens = [quote(t.encode("utf-8")) for t in title_tokens]
|
||||||
|
query = "+".join(tokens)
|
||||||
|
try:
|
||||||
|
results = requests.get(Google.SEARCH_URL + query)
|
||||||
|
results.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(e)
|
||||||
|
return None
|
||||||
|
for result in results.json().get("items", []):
|
||||||
|
val.append(
|
||||||
|
self._parse_search_result(
|
||||||
|
result=result, generic_cover=generic_cover, locale=locale
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return val
|
||||||
|
|
||||||
|
def _parse_search_result(
|
||||||
|
self, result: Dict, generic_cover: str, locale: str
|
||||||
|
) -> MetaRecord:
|
||||||
|
match = MetaRecord(
|
||||||
|
id=result["id"],
|
||||||
|
title=result["volumeInfo"]["title"],
|
||||||
|
authors=result["volumeInfo"].get("authors", []),
|
||||||
|
url=Google.BOOK_URL + result["id"],
|
||||||
|
source=MetaSourceInfo(
|
||||||
|
id=self.__id__,
|
||||||
|
description=Google.DESCRIPTION,
|
||||||
|
link=Google.META_URL,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
match.cover = self._parse_cover(result=result, generic_cover=generic_cover)
|
||||||
|
match.description = result["volumeInfo"].get("description", "")
|
||||||
|
match.languages = self._parse_languages(result=result, locale=locale)
|
||||||
|
match.publisher = result["volumeInfo"].get("publisher", "")
|
||||||
|
try:
|
||||||
|
datetime.strptime(result["volumeInfo"].get("publishedDate", ""), "%Y-%m-%d")
|
||||||
|
match.publishedDate = result["volumeInfo"].get("publishedDate", "")
|
||||||
|
except ValueError:
|
||||||
|
match.publishedDate = ""
|
||||||
|
match.rating = result["volumeInfo"].get("averageRating", 0)
|
||||||
|
match.series, match.series_index = "", 1
|
||||||
|
match.tags = result["volumeInfo"].get("categories", [])
|
||||||
|
|
||||||
|
match.identifiers = {"google": match.id}
|
||||||
|
match = self._parse_isbn(result=result, match=match)
|
||||||
|
return match
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_isbn(result: Dict, match: MetaRecord) -> MetaRecord:
|
||||||
|
identifiers = result["volumeInfo"].get("industryIdentifiers", [])
|
||||||
|
for identifier in identifiers:
|
||||||
|
if identifier.get("type") == Google.ISBN_TYPE:
|
||||||
|
match.identifiers["isbn"] = identifier.get("identifier")
|
||||||
|
break
|
||||||
|
return match
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_cover(result: Dict, generic_cover: str) -> str:
|
||||||
|
if result["volumeInfo"].get("imageLinks"):
|
||||||
|
cover_url = result["volumeInfo"]["imageLinks"]["thumbnail"]
|
||||||
|
|
||||||
|
# strip curl in cover
|
||||||
|
cover_url = cover_url.replace("&edge=curl", "")
|
||||||
|
|
||||||
|
# request 800x900 cover image (higher resolution)
|
||||||
|
cover_url += "&fife=w800-h900"
|
||||||
|
|
||||||
|
return cover_url.replace("http://", "https://")
|
||||||
|
return generic_cover
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_languages(result: Dict, locale: str) -> List[str]:
|
||||||
|
language_iso2 = result["volumeInfo"].get("language", "")
|
||||||
|
languages = (
|
||||||
|
[get_language_name(locale, get_lang3(language_iso2))]
|
||||||
|
if language_iso2
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
return languages
|
357
cps/metadata_provider/lubimyczytac.py
Normal file
357
cps/metadata_provider/lubimyczytac.py
Normal file
|
@ -0,0 +1,357 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2021 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from multiprocessing.pool import ThreadPool
|
||||||
|
from typing import List, Optional, Tuple, Union
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from dateutil import parser
|
||||||
|
from html2text import HTML2Text
|
||||||
|
from lxml.html import HtmlElement, fromstring, tostring
|
||||||
|
from markdown2 import Markdown
|
||||||
|
|
||||||
|
from cps import logger
|
||||||
|
from cps.isoLanguages import get_language_name
|
||||||
|
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
SYMBOLS_TO_TRANSLATE = (
|
||||||
|
"öÖüÜóÓőŐúÚéÉáÁűŰíÍąĄćĆęĘłŁńŃóÓśŚźŹżŻ",
|
||||||
|
"oOuUoOoOuUeEaAuUiIaAcCeElLnNoOsSzZzZ",
|
||||||
|
)
|
||||||
|
SYMBOL_TRANSLATION_MAP = dict(
|
||||||
|
[(ord(a), ord(b)) for (a, b) in zip(*SYMBOLS_TO_TRANSLATE)]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_int_or_float(value: str) -> Union[int, float]:
|
||||||
|
number_as_float = float(value)
|
||||||
|
number_as_int = int(number_as_float)
|
||||||
|
return number_as_int if number_as_float == number_as_int else number_as_float
|
||||||
|
|
||||||
|
|
||||||
|
def strip_accents(s: Optional[str]) -> Optional[str]:
|
||||||
|
return s.translate(SYMBOL_TRANSLATION_MAP) if s is not None else s
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_comments_html(html: str) -> str:
|
||||||
|
text = html2text(html)
|
||||||
|
md = Markdown()
|
||||||
|
html = md.convert(text)
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
def html2text(html: str) -> str:
|
||||||
|
# replace <u> tags with <span> as <u> becomes emphasis in html2text
|
||||||
|
if isinstance(html, bytes):
|
||||||
|
html = html.decode("utf-8")
|
||||||
|
html = re.sub(
|
||||||
|
r"<\s*(?P<solidus>/?)\s*[uU]\b(?P<rest>[^>]*)>",
|
||||||
|
r"<\g<solidus>span\g<rest>>",
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
h2t = HTML2Text()
|
||||||
|
h2t.body_width = 0
|
||||||
|
h2t.single_line_break = True
|
||||||
|
h2t.emphasis_mark = "*"
|
||||||
|
return h2t.handle(html)
|
||||||
|
|
||||||
|
|
||||||
|
class LubimyCzytac(Metadata):
|
||||||
|
__name__ = "LubimyCzytac.pl"
|
||||||
|
__id__ = "lubimyczytac"
|
||||||
|
|
||||||
|
BASE_URL = "https://lubimyczytac.pl"
|
||||||
|
|
||||||
|
BOOK_SEARCH_RESULT_XPATH = (
|
||||||
|
"*//div[@class='listSearch']//div[@class='authorAllBooks__single']"
|
||||||
|
)
|
||||||
|
SINGLE_BOOK_RESULT_XPATH = ".//div[contains(@class,'authorAllBooks__singleText')]"
|
||||||
|
TITLE_PATH = "/div/a[contains(@class,'authorAllBooks__singleTextTitle')]"
|
||||||
|
TITLE_TEXT_PATH = f"{TITLE_PATH}//text()"
|
||||||
|
URL_PATH = f"{TITLE_PATH}/@href"
|
||||||
|
AUTHORS_PATH = "/div/a[contains(@href,'autor')]//text()"
|
||||||
|
|
||||||
|
SIBLINGS = "/following-sibling::dd"
|
||||||
|
|
||||||
|
CONTAINER = "//section[@class='container book']"
|
||||||
|
PUBLISHER = f"{CONTAINER}//dt[contains(text(),'Wydawnictwo:')]{SIBLINGS}/a/text()"
|
||||||
|
LANGUAGES = f"{CONTAINER}//dt[contains(text(),'Język:')]{SIBLINGS}/text()"
|
||||||
|
DESCRIPTION = f"{CONTAINER}//div[@class='collapse-content']"
|
||||||
|
SERIES = f"{CONTAINER}//span/a[contains(@href,'/cykl/')]/text()"
|
||||||
|
TRANSLATOR = f"{CONTAINER}//dt[contains(text(),'Tłumacz:')]{SIBLINGS}/a/text()"
|
||||||
|
|
||||||
|
DETAILS = "//div[@id='book-details']"
|
||||||
|
PUBLISH_DATE = "//dt[contains(@title,'Data pierwszego wydania"
|
||||||
|
FIRST_PUBLISH_DATE = f"{DETAILS}{PUBLISH_DATE} oryginalnego')]{SIBLINGS}[1]/text()"
|
||||||
|
FIRST_PUBLISH_DATE_PL = f"{DETAILS}{PUBLISH_DATE} polskiego')]{SIBLINGS}[1]/text()"
|
||||||
|
TAGS = "//a[contains(@href,'/ksiazki/t/')]/text()" # "//nav[@aria-label='breadcrumbs']//a[contains(@href,'/ksiazki/k/')]/span/text()"
|
||||||
|
|
||||||
|
|
||||||
|
RATING = "//meta[@property='books:rating:value']/@content"
|
||||||
|
COVER = "//meta[@property='og:image']/@content"
|
||||||
|
ISBN = "//meta[@property='books:isbn']/@content"
|
||||||
|
META_TITLE = "//meta[@property='og:description']/@content"
|
||||||
|
|
||||||
|
SUMMARY = "//script[@type='application/ld+json']//text()"
|
||||||
|
|
||||||
|
def search(
|
||||||
|
self, query: str, generic_cover: str = "", locale: str = "en"
|
||||||
|
) -> Optional[List[MetaRecord]]:
|
||||||
|
if self.active:
|
||||||
|
try:
|
||||||
|
result = requests.get(self._prepare_query(title=query))
|
||||||
|
result.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(e)
|
||||||
|
return None
|
||||||
|
root = fromstring(result.text)
|
||||||
|
lc_parser = LubimyCzytacParser(root=root, metadata=self)
|
||||||
|
matches = lc_parser.parse_search_results()
|
||||||
|
if matches:
|
||||||
|
with ThreadPool(processes=10) as pool:
|
||||||
|
final_matches = pool.starmap(
|
||||||
|
lc_parser.parse_single_book,
|
||||||
|
[(match, generic_cover, locale) for match in matches],
|
||||||
|
)
|
||||||
|
return final_matches
|
||||||
|
return matches
|
||||||
|
|
||||||
|
def _prepare_query(self, title: str) -> str:
|
||||||
|
query = ""
|
||||||
|
characters_to_remove = "\?()\/"
|
||||||
|
pattern = "[" + characters_to_remove + "]"
|
||||||
|
title = re.sub(pattern, "", title)
|
||||||
|
title = title.replace("_", " ")
|
||||||
|
if '"' in title or ",," in title:
|
||||||
|
title = title.split('"')[0].split(",,")[0]
|
||||||
|
|
||||||
|
if "/" in title:
|
||||||
|
title_tokens = [
|
||||||
|
token for token in title.lower().split(" ") if len(token) > 1
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
title_tokens = list(self.get_title_tokens(title, strip_joiners=False))
|
||||||
|
if title_tokens:
|
||||||
|
tokens = [quote(t.encode("utf-8")) for t in title_tokens]
|
||||||
|
query = query + "%20".join(tokens)
|
||||||
|
if not query:
|
||||||
|
return ""
|
||||||
|
return f"{LubimyCzytac.BASE_URL}/szukaj/ksiazki?phrase={query}"
|
||||||
|
|
||||||
|
|
||||||
|
class LubimyCzytacParser:
|
||||||
|
PAGES_TEMPLATE = "<p id='strony'>Książka ma {0} stron(y).</p>"
|
||||||
|
TRANSLATOR_TEMPLATE = "<p id='translator'>Tłumacz: {0}</p>"
|
||||||
|
PUBLISH_DATE_TEMPLATE = "<p id='pierwsze_wydanie'>Data pierwszego wydania: {0}</p>"
|
||||||
|
PUBLISH_DATE_PL_TEMPLATE = (
|
||||||
|
"<p id='pierwsze_wydanie'>Data pierwszego wydania w Polsce: {0}</p>"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, root: HtmlElement, metadata: Metadata) -> None:
|
||||||
|
self.root = root
|
||||||
|
self.metadata = metadata
|
||||||
|
|
||||||
|
def parse_search_results(self) -> List[MetaRecord]:
|
||||||
|
matches = []
|
||||||
|
results = self.root.xpath(LubimyCzytac.BOOK_SEARCH_RESULT_XPATH)
|
||||||
|
for result in results:
|
||||||
|
title = self._parse_xpath_node(
|
||||||
|
root=result,
|
||||||
|
xpath=f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}"
|
||||||
|
f"{LubimyCzytac.TITLE_TEXT_PATH}",
|
||||||
|
)
|
||||||
|
|
||||||
|
book_url = self._parse_xpath_node(
|
||||||
|
root=result,
|
||||||
|
xpath=f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}"
|
||||||
|
f"{LubimyCzytac.URL_PATH}",
|
||||||
|
)
|
||||||
|
authors = self._parse_xpath_node(
|
||||||
|
root=result,
|
||||||
|
xpath=f"{LubimyCzytac.SINGLE_BOOK_RESULT_XPATH}"
|
||||||
|
f"{LubimyCzytac.AUTHORS_PATH}",
|
||||||
|
take_first=False,
|
||||||
|
)
|
||||||
|
if not all([title, book_url, authors]):
|
||||||
|
continue
|
||||||
|
matches.append(
|
||||||
|
MetaRecord(
|
||||||
|
id=book_url.replace(f"/ksiazka/", "").split("/")[0],
|
||||||
|
title=title,
|
||||||
|
authors=[strip_accents(author) for author in authors],
|
||||||
|
url=LubimyCzytac.BASE_URL + book_url,
|
||||||
|
source=MetaSourceInfo(
|
||||||
|
id=self.metadata.__id__,
|
||||||
|
description=self.metadata.__name__,
|
||||||
|
link=LubimyCzytac.BASE_URL,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return matches
|
||||||
|
|
||||||
|
def parse_single_book(
|
||||||
|
self, match: MetaRecord, generic_cover: str, locale: str
|
||||||
|
) -> MetaRecord:
|
||||||
|
try:
|
||||||
|
response = requests.get(match.url)
|
||||||
|
response.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(e)
|
||||||
|
return None
|
||||||
|
self.root = fromstring(response.text)
|
||||||
|
match.cover = self._parse_cover(generic_cover=generic_cover)
|
||||||
|
match.description = self._parse_description()
|
||||||
|
match.languages = self._parse_languages(locale=locale)
|
||||||
|
match.publisher = self._parse_publisher()
|
||||||
|
match.publishedDate = self._parse_from_summary(attribute_name="datePublished")
|
||||||
|
match.rating = self._parse_rating()
|
||||||
|
match.series, match.series_index = self._parse_series()
|
||||||
|
match.tags = self._parse_tags()
|
||||||
|
match.identifiers = {
|
||||||
|
"isbn": self._parse_isbn(),
|
||||||
|
"lubimyczytac": match.id,
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
|
||||||
|
def _parse_xpath_node(
|
||||||
|
self,
|
||||||
|
xpath: str,
|
||||||
|
root: HtmlElement = None,
|
||||||
|
take_first: bool = True,
|
||||||
|
strip_element: bool = True,
|
||||||
|
) -> Optional[Union[str, List[str]]]:
|
||||||
|
root = root if root is not None else self.root
|
||||||
|
node = root.xpath(xpath)
|
||||||
|
if not node:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
(node[0].strip() if strip_element else node[0])
|
||||||
|
if take_first
|
||||||
|
else [x.strip() for x in node]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_cover(self, generic_cover) -> Optional[str]:
|
||||||
|
return (
|
||||||
|
self._parse_xpath_node(xpath=LubimyCzytac.COVER, take_first=True)
|
||||||
|
or generic_cover
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_publisher(self) -> Optional[str]:
|
||||||
|
return self._parse_xpath_node(xpath=LubimyCzytac.PUBLISHER, take_first=True)
|
||||||
|
|
||||||
|
def _parse_languages(self, locale: str) -> List[str]:
|
||||||
|
languages = list()
|
||||||
|
lang = self._parse_xpath_node(xpath=LubimyCzytac.LANGUAGES, take_first=True)
|
||||||
|
if lang:
|
||||||
|
if "polski" in lang:
|
||||||
|
languages.append("pol")
|
||||||
|
if "angielski" in lang:
|
||||||
|
languages.append("eng")
|
||||||
|
return [get_language_name(locale, language) for language in languages]
|
||||||
|
|
||||||
|
def _parse_series(self) -> Tuple[Optional[str], Optional[Union[float, int]]]:
|
||||||
|
series_index = 0
|
||||||
|
series = self._parse_xpath_node(xpath=LubimyCzytac.SERIES, take_first=True)
|
||||||
|
if series:
|
||||||
|
if "tom " in series:
|
||||||
|
series_name, series_info = series.split(" (tom ", 1)
|
||||||
|
series_info = series_info.replace(" ", "").replace(")", "")
|
||||||
|
# Check if book is not a bundle, i.e. chapter 1-3
|
||||||
|
if "-" in series_info:
|
||||||
|
series_info = series_info.split("-", 1)[0]
|
||||||
|
if series_info.replace(".", "").isdigit() is True:
|
||||||
|
series_index = get_int_or_float(series_info)
|
||||||
|
return series_name, series_index
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _parse_tags(self) -> List[str]:
|
||||||
|
tags = self._parse_xpath_node(xpath=LubimyCzytac.TAGS, take_first=False)
|
||||||
|
return [
|
||||||
|
strip_accents(w.replace(", itd.", " itd."))
|
||||||
|
for w in tags
|
||||||
|
if isinstance(w, str)
|
||||||
|
]
|
||||||
|
|
||||||
|
def _parse_from_summary(self, attribute_name: str) -> Optional[str]:
|
||||||
|
value = None
|
||||||
|
summary_text = self._parse_xpath_node(xpath=LubimyCzytac.SUMMARY)
|
||||||
|
if summary_text:
|
||||||
|
data = json.loads(summary_text)
|
||||||
|
value = data.get(attribute_name)
|
||||||
|
return value.strip() if value is not None else value
|
||||||
|
|
||||||
|
def _parse_rating(self) -> Optional[str]:
|
||||||
|
rating = self._parse_xpath_node(xpath=LubimyCzytac.RATING)
|
||||||
|
return round(float(rating.replace(",", ".")) / 2) if rating else rating
|
||||||
|
|
||||||
|
def _parse_date(self, xpath="first_publish") -> Optional[datetime.datetime]:
|
||||||
|
options = {
|
||||||
|
"first_publish": LubimyCzytac.FIRST_PUBLISH_DATE,
|
||||||
|
"first_publish_pl": LubimyCzytac.FIRST_PUBLISH_DATE_PL,
|
||||||
|
}
|
||||||
|
date = self._parse_xpath_node(xpath=options.get(xpath))
|
||||||
|
return parser.parse(date) if date else None
|
||||||
|
|
||||||
|
def _parse_isbn(self) -> Optional[str]:
|
||||||
|
return self._parse_xpath_node(xpath=LubimyCzytac.ISBN)
|
||||||
|
|
||||||
|
def _parse_description(self) -> str:
|
||||||
|
description = ""
|
||||||
|
description_node = self._parse_xpath_node(
|
||||||
|
xpath=LubimyCzytac.DESCRIPTION, strip_element=False
|
||||||
|
)
|
||||||
|
if description_node is not None:
|
||||||
|
for source in self.root.xpath('//p[@class="source"]'):
|
||||||
|
source.getparent().remove(source)
|
||||||
|
description = tostring(description_node, method="html")
|
||||||
|
description = sanitize_comments_html(description)
|
||||||
|
|
||||||
|
else:
|
||||||
|
description_node = self._parse_xpath_node(xpath=LubimyCzytac.META_TITLE)
|
||||||
|
if description_node is not None:
|
||||||
|
description = description_node
|
||||||
|
description = sanitize_comments_html(description)
|
||||||
|
description = self._add_extra_info_to_description(description=description)
|
||||||
|
return description
|
||||||
|
|
||||||
|
def _add_extra_info_to_description(self, description: str) -> str:
|
||||||
|
pages = self._parse_from_summary(attribute_name="numberOfPages")
|
||||||
|
if pages:
|
||||||
|
description += LubimyCzytacParser.PAGES_TEMPLATE.format(pages)
|
||||||
|
|
||||||
|
first_publish_date = self._parse_date()
|
||||||
|
if first_publish_date:
|
||||||
|
description += LubimyCzytacParser.PUBLISH_DATE_TEMPLATE.format(
|
||||||
|
first_publish_date.strftime("%d.%m.%Y")
|
||||||
|
)
|
||||||
|
|
||||||
|
first_publish_date_pl = self._parse_date(xpath="first_publish_pl")
|
||||||
|
if first_publish_date_pl:
|
||||||
|
description += LubimyCzytacParser.PUBLISH_DATE_PL_TEMPLATE.format(
|
||||||
|
first_publish_date_pl.strftime("%d.%m.%Y")
|
||||||
|
)
|
||||||
|
translator = self._parse_xpath_node(xpath=LubimyCzytac.TRANSLATOR)
|
||||||
|
if translator:
|
||||||
|
description += LubimyCzytacParser.TRANSLATOR_TEMPLATE.format(translator)
|
||||||
|
|
||||||
|
|
||||||
|
return description
|
83
cps/metadata_provider/scholar.py
Normal file
83
cps/metadata_provider/scholar.py
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2021 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
import itertools
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from urllib.parse import quote, unquote
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fake_useragent.errors import FakeUserAgentError
|
||||||
|
except (ImportError):
|
||||||
|
FakeUserAgentError = BaseException
|
||||||
|
try:
|
||||||
|
from scholarly import scholarly
|
||||||
|
except FakeUserAgentError:
|
||||||
|
raise ImportError("No module named 'scholarly'")
|
||||||
|
|
||||||
|
from cps import logger
|
||||||
|
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
|
class scholar(Metadata):
|
||||||
|
__name__ = "Google Scholar"
|
||||||
|
__id__ = "googlescholar"
|
||||||
|
META_URL = "https://scholar.google.com/"
|
||||||
|
|
||||||
|
def search(
|
||||||
|
self, query: str, generic_cover: str = "", locale: str = "en"
|
||||||
|
) -> Optional[List[MetaRecord]]:
|
||||||
|
val = list()
|
||||||
|
if self.active:
|
||||||
|
title_tokens = list(self.get_title_tokens(query, strip_joiners=False))
|
||||||
|
if title_tokens:
|
||||||
|
tokens = [quote(t.encode("utf-8")) for t in title_tokens]
|
||||||
|
query = " ".join(tokens)
|
||||||
|
try:
|
||||||
|
scholarly.set_timeout(20)
|
||||||
|
scholarly.set_retries(2)
|
||||||
|
scholar_gen = itertools.islice(scholarly.search_pubs(query), 10)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(e)
|
||||||
|
return list()
|
||||||
|
for result in scholar_gen:
|
||||||
|
match = self._parse_search_result(
|
||||||
|
result=result, generic_cover="", locale=locale
|
||||||
|
)
|
||||||
|
val.append(match)
|
||||||
|
return val
|
||||||
|
|
||||||
|
def _parse_search_result(
|
||||||
|
self, result: Dict, generic_cover: str, locale: str
|
||||||
|
) -> MetaRecord:
|
||||||
|
match = MetaRecord(
|
||||||
|
id=result.get("pub_url", result.get("eprint_url", "")),
|
||||||
|
title=result["bib"].get("title"),
|
||||||
|
authors=result["bib"].get("author", []),
|
||||||
|
url=result.get("pub_url", result.get("eprint_url", "")),
|
||||||
|
source=MetaSourceInfo(
|
||||||
|
id=self.__id__, description=self.__name__, link=scholar.META_URL
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
match.cover = result.get("image", {}).get("original_url", generic_cover)
|
||||||
|
match.description = unquote(result["bib"].get("abstract", ""))
|
||||||
|
match.publisher = result["bib"].get("venue", "")
|
||||||
|
match.publishedDate = result["bib"].get("pub_year") + "-01-01"
|
||||||
|
match.identifiers = {"scholar": match.id}
|
||||||
|
return match
|
253
cps/oauth.py
253
cps/oauth.py
|
@ -16,152 +16,141 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
from flask import session
|
from flask import session
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from flask_dance.consumer.backend.sqla import SQLAlchemyBackend, first, _get_real_user
|
from flask_dance.consumer.storage.sqla import SQLAlchemyStorage as SQLAlchemyBackend
|
||||||
|
from flask_dance.consumer.storage.sqla import first, _get_real_user
|
||||||
from sqlalchemy.orm.exc import NoResultFound
|
from sqlalchemy.orm.exc import NoResultFound
|
||||||
backend_resultcode = False # prevent storing values with this resultcode
|
backend_resultcode = True # prevent storing values with this resultcode
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# fails on flask-dance >1.3, due to renaming
|
pass
|
||||||
try:
|
|
||||||
from flask_dance.consumer.storage.sqla import SQLAlchemyStorage as SQLAlchemyBackend
|
|
||||||
from flask_dance.consumer.storage.sqla import first, _get_real_user
|
|
||||||
from sqlalchemy.orm.exc import NoResultFound
|
|
||||||
backend_resultcode = True # prevent storing values with this resultcode
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
class OAuthBackend(SQLAlchemyBackend):
|
|
||||||
"""
|
|
||||||
Stores and retrieves OAuth tokens using a relational database through
|
|
||||||
the `SQLAlchemy`_ ORM.
|
|
||||||
|
|
||||||
.. _SQLAlchemy: http://www.sqlalchemy.org/
|
class OAuthBackend(SQLAlchemyBackend):
|
||||||
"""
|
"""
|
||||||
def __init__(self, model, session, provider_id,
|
Stores and retrieves OAuth tokens using a relational database through
|
||||||
user=None, user_id=None, user_required=None, anon_user=None,
|
the `SQLAlchemy`_ ORM.
|
||||||
cache=None):
|
|
||||||
self.provider_id = provider_id
|
|
||||||
super(OAuthBackend, self).__init__(model, session, user, user_id, user_required, anon_user, cache)
|
|
||||||
|
|
||||||
def get(self, blueprint, user=None, user_id=None):
|
.. _SQLAlchemy: https://www.sqlalchemy.org/
|
||||||
if self.provider_id + '_oauth_token' in session and session[self.provider_id + '_oauth_token'] != '':
|
"""
|
||||||
return session[self.provider_id + '_oauth_token']
|
def __init__(self, model, session, provider_id,
|
||||||
# check cache
|
user=None, user_id=None, user_required=None, anon_user=None,
|
||||||
cache_key = self.make_cache_key(blueprint=blueprint, user=user, user_id=user_id)
|
cache=None):
|
||||||
token = self.cache.get(cache_key)
|
self.provider_id = provider_id
|
||||||
if token:
|
super(OAuthBackend, self).__init__(model, session, user, user_id, user_required, anon_user, cache)
|
||||||
return token
|
|
||||||
|
|
||||||
# if not cached, make database queries
|
|
||||||
query = (
|
|
||||||
self.session.query(self.model)
|
|
||||||
.filter_by(provider=self.provider_id)
|
|
||||||
)
|
|
||||||
uid = first([user_id, self.user_id, blueprint.config.get("user_id")])
|
|
||||||
u = first(_get_real_user(ref, self.anon_user)
|
|
||||||
for ref in (user, self.user, blueprint.config.get("user")))
|
|
||||||
|
|
||||||
use_provider_user_id = False
|
|
||||||
if self.provider_id + '_oauth_user_id' in session and session[self.provider_id + '_oauth_user_id'] != '':
|
|
||||||
query = query.filter_by(provider_user_id=session[self.provider_id + '_oauth_user_id'])
|
|
||||||
use_provider_user_id = True
|
|
||||||
|
|
||||||
if self.user_required and not u and not uid and not use_provider_user_id:
|
|
||||||
# raise ValueError("Cannot get OAuth token without an associated user")
|
|
||||||
return None
|
|
||||||
# check for user ID
|
|
||||||
if hasattr(self.model, "user_id") and uid:
|
|
||||||
query = query.filter_by(user_id=uid)
|
|
||||||
# check for user (relationship property)
|
|
||||||
elif hasattr(self.model, "user") and u:
|
|
||||||
query = query.filter_by(user=u)
|
|
||||||
# if we have the property, but not value, filter by None
|
|
||||||
elif hasattr(self.model, "user_id"):
|
|
||||||
query = query.filter_by(user_id=None)
|
|
||||||
# run query
|
|
||||||
try:
|
|
||||||
token = query.one().token
|
|
||||||
except NoResultFound:
|
|
||||||
token = None
|
|
||||||
|
|
||||||
# cache the result
|
|
||||||
self.cache.set(cache_key, token)
|
|
||||||
|
|
||||||
|
def get(self, blueprint, user=None, user_id=None):
|
||||||
|
if self.provider_id + '_oauth_token' in session and session[self.provider_id + '_oauth_token'] != '':
|
||||||
|
return session[self.provider_id + '_oauth_token']
|
||||||
|
# check cache
|
||||||
|
cache_key = self.make_cache_key(blueprint=blueprint, user=user, user_id=user_id)
|
||||||
|
token = self.cache.get(cache_key)
|
||||||
|
if token:
|
||||||
return token
|
return token
|
||||||
|
|
||||||
def set(self, blueprint, token, user=None, user_id=None):
|
# if not cached, make database queries
|
||||||
uid = first([user_id, self.user_id, blueprint.config.get("user_id")])
|
query = (
|
||||||
u = first(_get_real_user(ref, self.anon_user)
|
self.session.query(self.model)
|
||||||
for ref in (user, self.user, blueprint.config.get("user")))
|
.filter_by(provider=self.provider_id)
|
||||||
|
)
|
||||||
|
uid = first([user_id, self.user_id, blueprint.config.get("user_id")])
|
||||||
|
u = first(_get_real_user(ref, self.anon_user)
|
||||||
|
for ref in (user, self.user, blueprint.config.get("user")))
|
||||||
|
|
||||||
if self.user_required and not u and not uid:
|
use_provider_user_id = False
|
||||||
raise ValueError("Cannot set OAuth token without an associated user")
|
if self.provider_id + '_oauth_user_id' in session and session[self.provider_id + '_oauth_user_id'] != '':
|
||||||
|
query = query.filter_by(provider_user_id=session[self.provider_id + '_oauth_user_id'])
|
||||||
|
use_provider_user_id = True
|
||||||
|
|
||||||
# if there was an existing model, delete it
|
if self.user_required and not u and not uid and not use_provider_user_id:
|
||||||
existing_query = (
|
# raise ValueError("Cannot get OAuth token without an associated user")
|
||||||
self.session.query(self.model)
|
return None
|
||||||
.filter_by(provider=self.provider_id)
|
# check for user ID
|
||||||
)
|
if hasattr(self.model, "user_id") and uid:
|
||||||
# check for user ID
|
query = query.filter_by(user_id=uid)
|
||||||
has_user_id = hasattr(self.model, "user_id")
|
# check for user (relationship property)
|
||||||
if has_user_id and uid:
|
elif hasattr(self.model, "user") and u:
|
||||||
existing_query = existing_query.filter_by(user_id=uid)
|
query = query.filter_by(user=u)
|
||||||
# check for user (relationship property)
|
# if we have the property, but not value, filter by None
|
||||||
has_user = hasattr(self.model, "user")
|
elif hasattr(self.model, "user_id"):
|
||||||
if has_user and u:
|
query = query.filter_by(user_id=None)
|
||||||
existing_query = existing_query.filter_by(user=u)
|
# run query
|
||||||
# queue up delete query -- won't be run until commit()
|
try:
|
||||||
existing_query.delete()
|
token = query.one().token
|
||||||
# create a new model for this token
|
except NoResultFound:
|
||||||
kwargs = {
|
token = None
|
||||||
"provider": self.provider_id,
|
|
||||||
"token": token,
|
|
||||||
}
|
|
||||||
if has_user_id and uid:
|
|
||||||
kwargs["user_id"] = uid
|
|
||||||
if has_user and u:
|
|
||||||
kwargs["user"] = u
|
|
||||||
self.session.add(self.model(**kwargs))
|
|
||||||
# commit to delete and add simultaneously
|
|
||||||
self.session.commit()
|
|
||||||
# invalidate cache
|
|
||||||
self.cache.delete(self.make_cache_key(
|
|
||||||
blueprint=blueprint, user=user, user_id=user_id
|
|
||||||
))
|
|
||||||
|
|
||||||
def delete(self, blueprint, user=None, user_id=None):
|
# cache the result
|
||||||
query = (
|
self.cache.set(cache_key, token)
|
||||||
self.session.query(self.model)
|
|
||||||
.filter_by(provider=self.provider_id)
|
|
||||||
)
|
|
||||||
uid = first([user_id, self.user_id, blueprint.config.get("user_id")])
|
|
||||||
u = first(_get_real_user(ref, self.anon_user)
|
|
||||||
for ref in (user, self.user, blueprint.config.get("user")))
|
|
||||||
|
|
||||||
if self.user_required and not u and not uid:
|
return token
|
||||||
raise ValueError("Cannot delete OAuth token without an associated user")
|
|
||||||
|
|
||||||
# check for user ID
|
def set(self, blueprint, token, user=None, user_id=None):
|
||||||
if hasattr(self.model, "user_id") and uid:
|
uid = first([user_id, self.user_id, blueprint.config.get("user_id")])
|
||||||
query = query.filter_by(user_id=uid)
|
u = first(_get_real_user(ref, self.anon_user)
|
||||||
# check for user (relationship property)
|
for ref in (user, self.user, blueprint.config.get("user")))
|
||||||
elif hasattr(self.model, "user") and u:
|
|
||||||
query = query.filter_by(user=u)
|
|
||||||
# if we have the property, but not value, filter by None
|
|
||||||
elif hasattr(self.model, "user_id"):
|
|
||||||
query = query.filter_by(user_id=None)
|
|
||||||
# run query
|
|
||||||
query.delete()
|
|
||||||
self.session.commit()
|
|
||||||
# invalidate cache
|
|
||||||
self.cache.delete(self.make_cache_key(
|
|
||||||
blueprint=blueprint, user=user, user_id=user_id,
|
|
||||||
))
|
|
||||||
|
|
||||||
except Exception:
|
if self.user_required and not u and not uid:
|
||||||
pass
|
raise ValueError("Cannot set OAuth token without an associated user")
|
||||||
|
|
||||||
|
# if there was an existing model, delete it
|
||||||
|
existing_query = (
|
||||||
|
self.session.query(self.model)
|
||||||
|
.filter_by(provider=self.provider_id)
|
||||||
|
)
|
||||||
|
# check for user ID
|
||||||
|
has_user_id = hasattr(self.model, "user_id")
|
||||||
|
if has_user_id and uid:
|
||||||
|
existing_query = existing_query.filter_by(user_id=uid)
|
||||||
|
# check for user (relationship property)
|
||||||
|
has_user = hasattr(self.model, "user")
|
||||||
|
if has_user and u:
|
||||||
|
existing_query = existing_query.filter_by(user=u)
|
||||||
|
# queue up delete query -- won't be run until commit()
|
||||||
|
existing_query.delete()
|
||||||
|
# create a new model for this token
|
||||||
|
kwargs = {
|
||||||
|
"provider": self.provider_id,
|
||||||
|
"token": token,
|
||||||
|
}
|
||||||
|
if has_user_id and uid:
|
||||||
|
kwargs["user_id"] = uid
|
||||||
|
if has_user and u:
|
||||||
|
kwargs["user"] = u
|
||||||
|
self.session.add(self.model(**kwargs))
|
||||||
|
# commit to delete and add simultaneously
|
||||||
|
self.session.commit()
|
||||||
|
# invalidate cache
|
||||||
|
self.cache.delete(self.make_cache_key(
|
||||||
|
blueprint=blueprint, user=user, user_id=user_id
|
||||||
|
))
|
||||||
|
|
||||||
|
def delete(self, blueprint, user=None, user_id=None):
|
||||||
|
query = (
|
||||||
|
self.session.query(self.model)
|
||||||
|
.filter_by(provider=self.provider_id)
|
||||||
|
)
|
||||||
|
uid = first([user_id, self.user_id, blueprint.config.get("user_id")])
|
||||||
|
u = first(_get_real_user(ref, self.anon_user)
|
||||||
|
for ref in (user, self.user, blueprint.config.get("user")))
|
||||||
|
|
||||||
|
if self.user_required and not u and not uid:
|
||||||
|
raise ValueError("Cannot delete OAuth token without an associated user")
|
||||||
|
|
||||||
|
# check for user ID
|
||||||
|
if hasattr(self.model, "user_id") and uid:
|
||||||
|
query = query.filter_by(user_id=uid)
|
||||||
|
# check for user (relationship property)
|
||||||
|
elif hasattr(self.model, "user") and u:
|
||||||
|
query = query.filter_by(user=u)
|
||||||
|
# if we have the property, but not value, filter by None
|
||||||
|
elif hasattr(self.model, "user_id"):
|
||||||
|
query = query.filter_by(user_id=None)
|
||||||
|
# run query
|
||||||
|
query.delete()
|
||||||
|
self.session.commit()
|
||||||
|
# invalidate cache
|
||||||
|
self.cache.delete(self.make_cache_key(
|
||||||
|
blueprint=blueprint, user=user, user_id=user_id,
|
||||||
|
))
|
||||||
|
|
369
cps/oauth_bb.py
369
cps/oauth_bb.py
|
@ -20,7 +20,6 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
import json
|
import json
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
|
@ -30,15 +29,20 @@ from flask_babel import gettext as _
|
||||||
from flask_dance.consumer import oauth_authorized, oauth_error
|
from flask_dance.consumer import oauth_authorized, oauth_error
|
||||||
from flask_dance.contrib.github import make_github_blueprint, github
|
from flask_dance.contrib.github import make_github_blueprint, github
|
||||||
from flask_dance.contrib.google import make_google_blueprint, google
|
from flask_dance.contrib.google import make_google_blueprint, google
|
||||||
from flask_login import login_user, current_user
|
from oauthlib.oauth2 import TokenExpiredError, InvalidGrantError
|
||||||
|
from flask_login import login_user, current_user, login_required
|
||||||
from sqlalchemy.orm.exc import NoResultFound
|
from sqlalchemy.orm.exc import NoResultFound
|
||||||
|
|
||||||
from . import constants, logger, config, app, ub
|
from . import constants, logger, config, app, ub
|
||||||
from .web import login_required
|
|
||||||
from .oauth import OAuthBackend, backend_resultcode
|
try:
|
||||||
|
from .oauth import OAuthBackend, backend_resultcode
|
||||||
|
except NameError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
oauth_check = {}
|
oauth_check = {}
|
||||||
|
oauthblueprints = []
|
||||||
oauth = Blueprint('oauth', __name__)
|
oauth = Blueprint('oauth', __name__)
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
@ -70,7 +74,7 @@ def register_user_with_oauth(user=None):
|
||||||
if len(all_oauth.keys()) == 0:
|
if len(all_oauth.keys()) == 0:
|
||||||
return
|
return
|
||||||
if user is None:
|
if user is None:
|
||||||
flash(_(u"Register with %(provider)s", provider=", ".join(list(all_oauth.values()))), category="success")
|
flash(_("Register with %(provider)s", provider=", ".join(list(all_oauth.values()))), category="success")
|
||||||
else:
|
else:
|
||||||
for oauth_key in all_oauth.keys():
|
for oauth_key in all_oauth.keys():
|
||||||
# Find this OAuth token in the database, or create it
|
# Find this OAuth token in the database, or create it
|
||||||
|
@ -84,11 +88,7 @@ def register_user_with_oauth(user=None):
|
||||||
except NoResultFound:
|
except NoResultFound:
|
||||||
# no found, return error
|
# no found, return error
|
||||||
return
|
return
|
||||||
try:
|
ub.session_commit("User {} with OAuth for provider {} registered".format(user.name, oauth_key))
|
||||||
ub.session.commit()
|
|
||||||
except Exception as e:
|
|
||||||
log.exception(e)
|
|
||||||
ub.session.rollback()
|
|
||||||
|
|
||||||
|
|
||||||
def logout_oauth_user():
|
def logout_oauth_user():
|
||||||
|
@ -97,19 +97,122 @@ def logout_oauth_user():
|
||||||
session.pop(str(oauth_key) + '_oauth_user_id')
|
session.pop(str(oauth_key) + '_oauth_user_id')
|
||||||
|
|
||||||
|
|
||||||
if ub.oauth_support:
|
def oauth_update_token(provider_id, token, provider_user_id):
|
||||||
oauthblueprints = []
|
session[provider_id + "_oauth_user_id"] = provider_user_id
|
||||||
|
session[provider_id + "_oauth_token"] = token
|
||||||
|
|
||||||
|
# Find this OAuth token in the database, or create it
|
||||||
|
query = ub.session.query(ub.OAuth).filter_by(
|
||||||
|
provider=provider_id,
|
||||||
|
provider_user_id=provider_user_id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
oauth_entry = query.one()
|
||||||
|
# update token
|
||||||
|
oauth_entry.token = token
|
||||||
|
except NoResultFound:
|
||||||
|
oauth_entry = ub.OAuth(
|
||||||
|
provider=provider_id,
|
||||||
|
provider_user_id=provider_user_id,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
ub.session.add(oauth_entry)
|
||||||
|
ub.session_commit()
|
||||||
|
|
||||||
|
# Disable Flask-Dance's default behavior for saving the OAuth token
|
||||||
|
# Value differrs depending on flask-dance version
|
||||||
|
return backend_resultcode
|
||||||
|
|
||||||
|
|
||||||
|
def bind_oauth_or_register(provider_id, provider_user_id, redirect_url, provider_name):
|
||||||
|
query = ub.session.query(ub.OAuth).filter_by(
|
||||||
|
provider=provider_id,
|
||||||
|
provider_user_id=provider_user_id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
oauth_entry = query.first()
|
||||||
|
# already bind with user, just login
|
||||||
|
if oauth_entry.user:
|
||||||
|
login_user(oauth_entry.user)
|
||||||
|
log.debug("You are now logged in as: '%s'", oauth_entry.user.name)
|
||||||
|
flash(_("Success! You are now logged in as: %(nickname)s", nickname= oauth_entry.user.name),
|
||||||
|
category="success")
|
||||||
|
return redirect(url_for('web.index'))
|
||||||
|
else:
|
||||||
|
# bind to current user
|
||||||
|
if current_user and current_user.is_authenticated:
|
||||||
|
oauth_entry.user = current_user
|
||||||
|
try:
|
||||||
|
ub.session.add(oauth_entry)
|
||||||
|
ub.session.commit()
|
||||||
|
flash(_("Link to %(oauth)s Succeeded", oauth=provider_name), category="success")
|
||||||
|
log.info("Link to {} Succeeded".format(provider_name))
|
||||||
|
return redirect(url_for('web.profile'))
|
||||||
|
except Exception as ex:
|
||||||
|
log.error_or_exception(ex)
|
||||||
|
ub.session.rollback()
|
||||||
|
else:
|
||||||
|
flash(_("Login failed, No User Linked With OAuth Account"), category="error")
|
||||||
|
log.info('Login failed, No User Linked With OAuth Account')
|
||||||
|
return redirect(url_for('web.login'))
|
||||||
|
# return redirect(url_for('web.login'))
|
||||||
|
# if config.config_public_reg:
|
||||||
|
# return redirect(url_for('web.register'))
|
||||||
|
# else:
|
||||||
|
# flash(_("Public registration is not enabled"), category="error")
|
||||||
|
# return redirect(url_for(redirect_url))
|
||||||
|
except (NoResultFound, AttributeError):
|
||||||
|
return redirect(url_for(redirect_url))
|
||||||
|
|
||||||
|
|
||||||
|
def get_oauth_status():
|
||||||
|
status = []
|
||||||
|
query = ub.session.query(ub.OAuth).filter_by(
|
||||||
|
user_id=current_user.id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
oauths = query.all()
|
||||||
|
for oauth_entry in oauths:
|
||||||
|
status.append(int(oauth_entry.provider))
|
||||||
|
return status
|
||||||
|
except NoResultFound:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def unlink_oauth(provider):
|
||||||
|
if request.host_url + 'me' != request.referrer:
|
||||||
|
pass
|
||||||
|
query = ub.session.query(ub.OAuth).filter_by(
|
||||||
|
provider=provider,
|
||||||
|
user_id=current_user.id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
oauth_entry = query.one()
|
||||||
|
if current_user and current_user.is_authenticated:
|
||||||
|
oauth_entry.user = current_user
|
||||||
|
try:
|
||||||
|
ub.session.delete(oauth_entry)
|
||||||
|
ub.session.commit()
|
||||||
|
logout_oauth_user()
|
||||||
|
flash(_("Unlink to %(oauth)s Succeeded", oauth=oauth_check[provider]), category="success")
|
||||||
|
log.info("Unlink to {} Succeeded".format(oauth_check[provider]))
|
||||||
|
except Exception as ex:
|
||||||
|
log.error_or_exception(ex)
|
||||||
|
ub.session.rollback()
|
||||||
|
flash(_("Unlink to %(oauth)s Failed", oauth=oauth_check[provider]), category="error")
|
||||||
|
except NoResultFound:
|
||||||
|
log.warning("oauth %s for user %d not found", provider, current_user.id)
|
||||||
|
flash(_("Not Linked to %(oauth)s", oauth=provider), category="error")
|
||||||
|
return redirect(url_for('web.profile'))
|
||||||
|
|
||||||
|
def generate_oauth_blueprints():
|
||||||
if not ub.session.query(ub.OAuthProvider).count():
|
if not ub.session.query(ub.OAuthProvider).count():
|
||||||
oauthProvider = ub.OAuthProvider()
|
for provider in ("github", "google"):
|
||||||
oauthProvider.provider_name = "github"
|
oauthProvider = ub.OAuthProvider()
|
||||||
oauthProvider.active = False
|
oauthProvider.provider_name = provider
|
||||||
ub.session.add(oauthProvider)
|
oauthProvider.active = False
|
||||||
ub.session.commit()
|
ub.session.add(oauthProvider)
|
||||||
oauthProvider = ub.OAuthProvider()
|
ub.session_commit("{} Blueprint Created".format(provider))
|
||||||
oauthProvider.provider_name = "google"
|
|
||||||
oauthProvider.active = False
|
|
||||||
ub.session.add(oauthProvider)
|
|
||||||
ub.session.commit()
|
|
||||||
|
|
||||||
oauth_ids = ub.session.query(ub.OAuthProvider).all()
|
oauth_ids = ub.session.query(ub.OAuthProvider).all()
|
||||||
ele1 = dict(provider_name='github',
|
ele1 = dict(provider_name='github',
|
||||||
|
@ -122,10 +225,10 @@ if ub.oauth_support:
|
||||||
ele2 = dict(provider_name='google',
|
ele2 = dict(provider_name='google',
|
||||||
id=oauth_ids[1].id,
|
id=oauth_ids[1].id,
|
||||||
active=oauth_ids[1].active,
|
active=oauth_ids[1].active,
|
||||||
scope=["https://www.googleapis.com/auth/plus.me", "https://www.googleapis.com/auth/userinfo.email"],
|
scope=["https://www.googleapis.com/auth/userinfo.email"],
|
||||||
oauth_client_id=oauth_ids[1].oauth_client_id,
|
oauth_client_id=oauth_ids[1].oauth_client_id,
|
||||||
oauth_client_secret=oauth_ids[1].oauth_client_secret,
|
oauth_client_secret=oauth_ids[1].oauth_client_secret,
|
||||||
obtain_link='https://github.com/settings/developers')
|
obtain_link='https://console.developers.google.com/apis/credentials')
|
||||||
oauthblueprints.append(ele1)
|
oauthblueprints.append(ele1)
|
||||||
oauthblueprints.append(ele2)
|
oauthblueprints.append(ele2)
|
||||||
|
|
||||||
|
@ -146,17 +249,23 @@ if ub.oauth_support:
|
||||||
app.register_blueprint(blueprint, url_prefix="/login")
|
app.register_blueprint(blueprint, url_prefix="/login")
|
||||||
if element['active']:
|
if element['active']:
|
||||||
register_oauth_blueprint(element['id'], element['provider_name'])
|
register_oauth_blueprint(element['id'], element['provider_name'])
|
||||||
|
return oauthblueprints
|
||||||
|
|
||||||
|
|
||||||
|
if ub.oauth_support:
|
||||||
|
oauthblueprints = generate_oauth_blueprints()
|
||||||
|
|
||||||
@oauth_authorized.connect_via(oauthblueprints[0]['blueprint'])
|
@oauth_authorized.connect_via(oauthblueprints[0]['blueprint'])
|
||||||
def github_logged_in(blueprint, token):
|
def github_logged_in(blueprint, token):
|
||||||
if not token:
|
if not token:
|
||||||
flash(_(u"Failed to log in with GitHub."), category="error")
|
flash(_("Failed to log in with GitHub."), category="error")
|
||||||
|
log.error("Failed to log in with GitHub")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
resp = blueprint.session.get("/user")
|
resp = blueprint.session.get("/user")
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
flash(_(u"Failed to fetch user info from GitHub."), category="error")
|
flash(_("Failed to fetch user info from GitHub."), category="error")
|
||||||
|
log.error("Failed to fetch user info from GitHub")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
github_info = resp.json()
|
github_info = resp.json()
|
||||||
|
@ -167,12 +276,14 @@ if ub.oauth_support:
|
||||||
@oauth_authorized.connect_via(oauthblueprints[1]['blueprint'])
|
@oauth_authorized.connect_via(oauthblueprints[1]['blueprint'])
|
||||||
def google_logged_in(blueprint, token):
|
def google_logged_in(blueprint, token):
|
||||||
if not token:
|
if not token:
|
||||||
flash(_(u"Failed to log in with Google."), category="error")
|
flash(_("Failed to log in with Google."), category="error")
|
||||||
|
log.error("Failed to log in with Google")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
resp = blueprint.session.get("/oauth2/v2/userinfo")
|
resp = blueprint.session.get("/oauth2/v2/userinfo")
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
flash(_(u"Failed to fetch user info from Google."), category="error")
|
flash(_("Failed to fetch user info from Google."), category="error")
|
||||||
|
log.error("Failed to fetch user info from Google")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
google_info = resp.json()
|
google_info = resp.json()
|
||||||
|
@ -180,123 +291,12 @@ if ub.oauth_support:
|
||||||
return oauth_update_token(str(oauthblueprints[1]['id']), token, google_user_id)
|
return oauth_update_token(str(oauthblueprints[1]['id']), token, google_user_id)
|
||||||
|
|
||||||
|
|
||||||
def oauth_update_token(provider_id, token, provider_user_id):
|
|
||||||
session[provider_id + "_oauth_user_id"] = provider_user_id
|
|
||||||
session[provider_id + "_oauth_token"] = token
|
|
||||||
|
|
||||||
# Find this OAuth token in the database, or create it
|
|
||||||
query = ub.session.query(ub.OAuth).filter_by(
|
|
||||||
provider=provider_id,
|
|
||||||
provider_user_id=provider_user_id,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
oauth_entry = query.one()
|
|
||||||
# update token
|
|
||||||
oauth_entry.token = token
|
|
||||||
except NoResultFound:
|
|
||||||
oauth_entry = ub.OAuth(
|
|
||||||
provider=provider_id,
|
|
||||||
provider_user_id=provider_user_id,
|
|
||||||
token=token,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
ub.session.add(oauth_entry)
|
|
||||||
ub.session.commit()
|
|
||||||
except Exception as e:
|
|
||||||
log.exception(e)
|
|
||||||
ub.session.rollback()
|
|
||||||
|
|
||||||
# Disable Flask-Dance's default behavior for saving the OAuth token
|
|
||||||
# Value differrs depending on flask-dance version
|
|
||||||
return backend_resultcode
|
|
||||||
|
|
||||||
|
|
||||||
def bind_oauth_or_register(provider_id, provider_user_id, redirect_url, provider_name):
|
|
||||||
query = ub.session.query(ub.OAuth).filter_by(
|
|
||||||
provider=provider_id,
|
|
||||||
provider_user_id=provider_user_id,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
oauth_entry = query.first()
|
|
||||||
# already bind with user, just login
|
|
||||||
if oauth_entry.user:
|
|
||||||
login_user(oauth_entry.user)
|
|
||||||
log.debug(u"You are now logged in as: '%s'", oauth_entry.user.nickname)
|
|
||||||
flash(_(u"you are now logged in as: '%(nickname)s'", nickname= oauth_entry.user.nickname),
|
|
||||||
category="success")
|
|
||||||
return redirect(url_for('web.index'))
|
|
||||||
else:
|
|
||||||
# bind to current user
|
|
||||||
if current_user and current_user.is_authenticated:
|
|
||||||
oauth_entry.user = current_user
|
|
||||||
try:
|
|
||||||
ub.session.add(oauth_entry)
|
|
||||||
ub.session.commit()
|
|
||||||
flash(_(u"Link to %(oauth)s Succeeded", oauth=provider_name), category="success")
|
|
||||||
return redirect(url_for('web.profile'))
|
|
||||||
except Exception as e:
|
|
||||||
log.exception(e)
|
|
||||||
ub.session.rollback()
|
|
||||||
else:
|
|
||||||
flash(_(u"Login failed, No User Linked With OAuth Account"), category="error")
|
|
||||||
log.info('Login failed, No User Linked With OAuth Account')
|
|
||||||
return redirect(url_for('web.login'))
|
|
||||||
# return redirect(url_for('web.login'))
|
|
||||||
# if config.config_public_reg:
|
|
||||||
# return redirect(url_for('web.register'))
|
|
||||||
# else:
|
|
||||||
# flash(_(u"Public registration is not enabled"), category="error")
|
|
||||||
# return redirect(url_for(redirect_url))
|
|
||||||
except (NoResultFound, AttributeError):
|
|
||||||
return redirect(url_for(redirect_url))
|
|
||||||
|
|
||||||
|
|
||||||
def get_oauth_status():
|
|
||||||
status = []
|
|
||||||
query = ub.session.query(ub.OAuth).filter_by(
|
|
||||||
user_id=current_user.id,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
oauths = query.all()
|
|
||||||
for oauth_entry in oauths:
|
|
||||||
status.append(int(oauth_entry.provider))
|
|
||||||
return status
|
|
||||||
except NoResultFound:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def unlink_oauth(provider):
|
|
||||||
if request.host_url + 'me' != request.referrer:
|
|
||||||
pass
|
|
||||||
query = ub.session.query(ub.OAuth).filter_by(
|
|
||||||
provider=provider,
|
|
||||||
user_id=current_user.id,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
oauth_entry = query.one()
|
|
||||||
if current_user and current_user.is_authenticated:
|
|
||||||
oauth_entry.user = current_user
|
|
||||||
try:
|
|
||||||
ub.session.delete(oauth_entry)
|
|
||||||
ub.session.commit()
|
|
||||||
logout_oauth_user()
|
|
||||||
flash(_(u"Unlink to %(oauth)s Succeeded", oauth=oauth_check[provider]), category="success")
|
|
||||||
except Exception as e:
|
|
||||||
log.exception(e)
|
|
||||||
ub.session.rollback()
|
|
||||||
flash(_(u"Unlink to %(oauth)s Failed", oauth=oauth_check[provider]), category="error")
|
|
||||||
except NoResultFound:
|
|
||||||
log.warning("oauth %s for user %d not found", provider, current_user.id)
|
|
||||||
flash(_(u"Not Linked to %(oauth)s.", oauth=oauth_check[provider]), category="error")
|
|
||||||
return redirect(url_for('web.profile'))
|
|
||||||
|
|
||||||
|
|
||||||
# notify on OAuth provider error
|
# notify on OAuth provider error
|
||||||
@oauth_error.connect_via(oauthblueprints[0]['blueprint'])
|
@oauth_error.connect_via(oauthblueprints[0]['blueprint'])
|
||||||
def github_error(blueprint, error, error_description=None, error_uri=None):
|
def github_error(blueprint, error, error_description=None, error_uri=None):
|
||||||
msg = (
|
msg = (
|
||||||
u"OAuth error from {name}! "
|
"OAuth error from {name}! "
|
||||||
u"error={error} description={description} uri={uri}"
|
"error={error} description={description} uri={uri}"
|
||||||
).format(
|
).format(
|
||||||
name=blueprint.name,
|
name=blueprint.name,
|
||||||
error=error,
|
error=error,
|
||||||
|
@ -305,44 +305,11 @@ if ub.oauth_support:
|
||||||
) # ToDo: Translate
|
) # ToDo: Translate
|
||||||
flash(msg, category="error")
|
flash(msg, category="error")
|
||||||
|
|
||||||
|
|
||||||
@oauth.route('/link/github')
|
|
||||||
@oauth_required
|
|
||||||
def github_login():
|
|
||||||
if not github.authorized:
|
|
||||||
return redirect(url_for('github.login'))
|
|
||||||
account_info = github.get('/user')
|
|
||||||
if account_info.ok:
|
|
||||||
account_info_json = account_info.json()
|
|
||||||
return bind_oauth_or_register(oauthblueprints[0]['id'], account_info_json['id'], 'github.login', 'github')
|
|
||||||
flash(_(u"GitHub Oauth error, please retry later."), category="error")
|
|
||||||
return redirect(url_for('web.login'))
|
|
||||||
|
|
||||||
|
|
||||||
@oauth.route('/unlink/github', methods=["GET"])
|
|
||||||
@login_required
|
|
||||||
def github_login_unlink():
|
|
||||||
return unlink_oauth(oauthblueprints[0]['id'])
|
|
||||||
|
|
||||||
|
|
||||||
@oauth.route('/link/google')
|
|
||||||
@oauth_required
|
|
||||||
def google_login():
|
|
||||||
if not google.authorized:
|
|
||||||
return redirect(url_for("google.login"))
|
|
||||||
resp = google.get("/oauth2/v2/userinfo")
|
|
||||||
if resp.ok:
|
|
||||||
account_info_json = resp.json()
|
|
||||||
return bind_oauth_or_register(oauthblueprints[1]['id'], account_info_json['id'], 'google.login', 'google')
|
|
||||||
flash(_(u"Google Oauth error, please retry later."), category="error")
|
|
||||||
return redirect(url_for('web.login'))
|
|
||||||
|
|
||||||
|
|
||||||
@oauth_error.connect_via(oauthblueprints[1]['blueprint'])
|
@oauth_error.connect_via(oauthblueprints[1]['blueprint'])
|
||||||
def google_error(blueprint, error, error_description=None, error_uri=None):
|
def google_error(blueprint, error, error_description=None, error_uri=None):
|
||||||
msg = (
|
msg = (
|
||||||
u"OAuth error from {name}! "
|
"OAuth error from {name}! "
|
||||||
u"error={error} description={description} uri={uri}"
|
"error={error} description={description} uri={uri}"
|
||||||
).format(
|
).format(
|
||||||
name=blueprint.name,
|
name=blueprint.name,
|
||||||
error=error,
|
error=error,
|
||||||
|
@ -352,7 +319,49 @@ if ub.oauth_support:
|
||||||
flash(msg, category="error")
|
flash(msg, category="error")
|
||||||
|
|
||||||
|
|
||||||
@oauth.route('/unlink/google', methods=["GET"])
|
@oauth.route('/link/github')
|
||||||
@login_required
|
@oauth_required
|
||||||
def google_login_unlink():
|
def github_login():
|
||||||
return unlink_oauth(oauthblueprints[1]['blueprint'].name)
|
if not github.authorized:
|
||||||
|
return redirect(url_for('github.login'))
|
||||||
|
try:
|
||||||
|
account_info = github.get('/user')
|
||||||
|
if account_info.ok:
|
||||||
|
account_info_json = account_info.json()
|
||||||
|
return bind_oauth_or_register(oauthblueprints[0]['id'], account_info_json['id'], 'github.login', 'github')
|
||||||
|
flash(_("GitHub Oauth error, please retry later."), category="error")
|
||||||
|
log.error("GitHub Oauth error, please retry later")
|
||||||
|
except (InvalidGrantError, TokenExpiredError) as e:
|
||||||
|
flash(_("GitHub Oauth error: {}").format(e), category="error")
|
||||||
|
log.error(e)
|
||||||
|
return redirect(url_for('web.login'))
|
||||||
|
|
||||||
|
|
||||||
|
@oauth.route('/unlink/github', methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def github_login_unlink():
|
||||||
|
return unlink_oauth(oauthblueprints[0]['id'])
|
||||||
|
|
||||||
|
|
||||||
|
@oauth.route('/link/google')
|
||||||
|
@oauth_required
|
||||||
|
def google_login():
|
||||||
|
if not google.authorized:
|
||||||
|
return redirect(url_for("google.login"))
|
||||||
|
try:
|
||||||
|
resp = google.get("/oauth2/v2/userinfo")
|
||||||
|
if resp.ok:
|
||||||
|
account_info_json = resp.json()
|
||||||
|
return bind_oauth_or_register(oauthblueprints[1]['id'], account_info_json['id'], 'google.login', 'google')
|
||||||
|
flash(_("Google Oauth error, please retry later."), category="error")
|
||||||
|
log.error("Google Oauth error, please retry later")
|
||||||
|
except (InvalidGrantError, TokenExpiredError) as e:
|
||||||
|
flash(_("Google Oauth error: {}").format(e), category="error")
|
||||||
|
log.error(e)
|
||||||
|
return redirect(url_for('web.login'))
|
||||||
|
|
||||||
|
|
||||||
|
@oauth.route('/unlink/google', methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def google_login_unlink():
|
||||||
|
return unlink_oauth(oauthblueprints[1]['id'])
|
||||||
|
|
862
cps/opds.py
862
cps/opds.py
|
@ -20,40 +20,468 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
import sys
|
|
||||||
import datetime
|
import datetime
|
||||||
from functools import wraps
|
import json
|
||||||
|
from urllib.parse import unquote_plus
|
||||||
|
|
||||||
from flask import Blueprint, request, render_template, Response, g, make_response
|
from flask import Blueprint, request, render_template, make_response, abort, Response, g
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
from sqlalchemy.sql.expression import func, text, or_, and_
|
from flask_babel import get_locale
|
||||||
from werkzeug.security import check_password_hash
|
|
||||||
|
|
||||||
from . import constants, logger, config, db, ub, services, get_locale, isoLanguages
|
|
||||||
from .helper import fill_indexpage, get_download_link, get_book_cover, speaking_language
|
|
||||||
from .pagination import Pagination
|
|
||||||
from .web import common_filters, get_search_results, render_read_books, download_required
|
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from babel import Locale as LC
|
from sqlalchemy.sql.expression import func, text, or_, and_, true
|
||||||
from babel.core import UnknownLocaleError
|
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||||
|
|
||||||
|
from . import logger, config, db, calibre_db, ub, isoLanguages, constants
|
||||||
|
from .usermanagement import requires_basic_auth_if_no_ano
|
||||||
|
from .helper import get_download_link, get_book_cover
|
||||||
|
from .pagination import Pagination
|
||||||
|
from .web import render_read_books
|
||||||
|
|
||||||
|
|
||||||
opds = Blueprint('opds', __name__)
|
opds = Blueprint('opds', __name__)
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
def requires_basic_auth_if_no_ano(f):
|
@opds.route("/opds/")
|
||||||
@wraps(f)
|
@opds.route("/opds")
|
||||||
def decorated(*args, **kwargs):
|
@requires_basic_auth_if_no_ano
|
||||||
auth = request.authorization
|
def feed_index():
|
||||||
if config.config_anonbrowse != 1:
|
return render_xml_template('index.xml')
|
||||||
if not auth or auth.type != 'basic' or not check_auth(auth.username, auth.password):
|
|
||||||
return authenticate()
|
|
||||||
return f(*args, **kwargs)
|
@opds.route("/opds/osd")
|
||||||
if config.config_login_type == constants.LOGIN_LDAP and services.ldap:
|
@requires_basic_auth_if_no_ano
|
||||||
return services.ldap.basic_auth_required(f)
|
def feed_osd():
|
||||||
return decorated
|
return render_xml_template('osd.xml', lang='en-EN')
|
||||||
|
|
||||||
|
|
||||||
|
# @opds.route("/opds/search", defaults={'query': ""})
|
||||||
|
@opds.route("/opds/search/<path:query>")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_cc_search(query):
|
||||||
|
# Handle strange query from Libera Reader with + instead of spaces
|
||||||
|
plus_query = unquote_plus(request.environ['RAW_URI'].split('/opds/search/')[1]).strip()
|
||||||
|
return feed_search(plus_query)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/search", methods=["GET"])
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_normal_search():
|
||||||
|
return feed_search(request.args.get("query", "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/books")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_booksindex():
|
||||||
|
return render_element_index(db.Books.sort, None, 'opds.feed_letter_books')
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/books/letter/<book_id>")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_letter_books(book_id):
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
letter = true() if book_id == "00" else func.upper(db.Books.sort).startswith(book_id)
|
||||||
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
|
db.Books,
|
||||||
|
letter,
|
||||||
|
[db.Books.sort],
|
||||||
|
True, config.config_read_column)
|
||||||
|
|
||||||
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/new")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_new():
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_RECENT):
|
||||||
|
abort(404)
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
|
db.Books, True, [db.Books.timestamp.desc()],
|
||||||
|
True, config.config_read_column)
|
||||||
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/discover")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_discover():
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_RANDOM):
|
||||||
|
abort(404)
|
||||||
|
query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
|
||||||
|
entries = query.filter(calibre_db.common_filters()).order_by(func.random()).limit(config.config_books_per_page)
|
||||||
|
pagination = Pagination(1, config.config_books_per_page, int(config.config_books_per_page))
|
||||||
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/rated")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_best_rated():
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_BEST_RATED):
|
||||||
|
abort(404)
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
|
db.Books, db.Books.ratings.any(db.Ratings.rating > 9),
|
||||||
|
[db.Books.timestamp.desc()],
|
||||||
|
True, config.config_read_column)
|
||||||
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/hot")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_hot():
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_HOT):
|
||||||
|
abort(404)
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
all_books = ub.session.query(ub.Downloads, func.count(ub.Downloads.book_id)).order_by(
|
||||||
|
func.count(ub.Downloads.book_id).desc()).group_by(ub.Downloads.book_id)
|
||||||
|
hot_books = all_books.offset(off).limit(config.config_books_per_page)
|
||||||
|
entries = list()
|
||||||
|
for book in hot_books:
|
||||||
|
query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
|
||||||
|
download_book = query.filter(calibre_db.common_filters()).filter(
|
||||||
|
book.Downloads.book_id == db.Books.id).first()
|
||||||
|
if download_book:
|
||||||
|
entries.append(download_book)
|
||||||
|
else:
|
||||||
|
ub.delete_download(book.Downloads.book_id)
|
||||||
|
num_books = entries.__len__()
|
||||||
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1),
|
||||||
|
config.config_books_per_page, num_books)
|
||||||
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/author")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_authorindex():
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_AUTHOR):
|
||||||
|
abort(404)
|
||||||
|
return render_element_index(db.Authors.sort, db.books_authors_link, 'opds.feed_letter_author')
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/author/letter/<book_id>")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_letter_author(book_id):
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_AUTHOR):
|
||||||
|
abort(404)
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
letter = true() if book_id == "00" else func.upper(db.Authors.sort).startswith(book_id)
|
||||||
|
entries = calibre_db.session.query(db.Authors).join(db.books_authors_link).join(db.Books)\
|
||||||
|
.filter(calibre_db.common_filters()).filter(letter)\
|
||||||
|
.group_by(text('books_authors_link.author'))\
|
||||||
|
.order_by(db.Authors.sort)
|
||||||
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
|
entries.count())
|
||||||
|
entries = entries.limit(config.config_books_per_page).offset(off).all()
|
||||||
|
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_author', pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/author/<int:book_id>")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_author(book_id):
|
||||||
|
return render_xml_dataset(db.Authors, book_id)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/publisher")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_publisherindex():
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_PUBLISHER):
|
||||||
|
abort(404)
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
entries = calibre_db.session.query(db.Publishers)\
|
||||||
|
.join(db.books_publishers_link)\
|
||||||
|
.join(db.Books).filter(calibre_db.common_filters())\
|
||||||
|
.group_by(text('books_publishers_link.publisher'))\
|
||||||
|
.order_by(db.Publishers.sort)\
|
||||||
|
.limit(config.config_books_per_page).offset(off)
|
||||||
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
|
len(calibre_db.session.query(db.Publishers).all()))
|
||||||
|
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_publisher', pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/publisher/<int:book_id>")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_publisher(book_id):
|
||||||
|
return render_xml_dataset(db.Publishers, book_id)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/category")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_categoryindex():
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_CATEGORY):
|
||||||
|
abort(404)
|
||||||
|
return render_element_index(db.Tags.name, db.books_tags_link, 'opds.feed_letter_category')
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/category/letter/<book_id>")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_letter_category(book_id):
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_CATEGORY):
|
||||||
|
abort(404)
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
letter = true() if book_id == "00" else func.upper(db.Tags.name).startswith(book_id)
|
||||||
|
entries = calibre_db.session.query(db.Tags)\
|
||||||
|
.join(db.books_tags_link)\
|
||||||
|
.join(db.Books)\
|
||||||
|
.filter(calibre_db.common_filters()).filter(letter)\
|
||||||
|
.group_by(text('books_tags_link.tag'))\
|
||||||
|
.order_by(db.Tags.name)
|
||||||
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
|
entries.count())
|
||||||
|
entries = entries.offset(off).limit(config.config_books_per_page).all()
|
||||||
|
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_category', pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/category/<int:book_id>")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_category(book_id):
|
||||||
|
return render_xml_dataset(db.Tags, book_id)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/series")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_seriesindex():
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_SERIES):
|
||||||
|
abort(404)
|
||||||
|
return render_element_index(db.Series.sort, db.books_series_link, 'opds.feed_letter_series')
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/series/letter/<book_id>")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_letter_series(book_id):
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_SERIES):
|
||||||
|
abort(404)
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
letter = true() if book_id == "00" else func.upper(db.Series.sort).startswith(book_id)
|
||||||
|
entries = calibre_db.session.query(db.Series)\
|
||||||
|
.join(db.books_series_link)\
|
||||||
|
.join(db.Books)\
|
||||||
|
.filter(calibre_db.common_filters()).filter(letter)\
|
||||||
|
.group_by(text('books_series_link.series'))\
|
||||||
|
.order_by(db.Series.sort)
|
||||||
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
|
entries.count())
|
||||||
|
entries = entries.offset(off).limit(config.config_books_per_page).all()
|
||||||
|
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_series', pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/series/<int:book_id>")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_series(book_id):
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
|
db.Books,
|
||||||
|
db.Books.series.any(db.Series.id == book_id),
|
||||||
|
[db.Books.series_index],
|
||||||
|
True, config.config_read_column)
|
||||||
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/ratings")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_ratingindex():
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_RATING):
|
||||||
|
abort(404)
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
entries = calibre_db.session.query(db.Ratings, func.count('books_ratings_link.book').label('count'),
|
||||||
|
(db.Ratings.rating / 2).label('name')) \
|
||||||
|
.join(db.books_ratings_link)\
|
||||||
|
.join(db.Books)\
|
||||||
|
.filter(calibre_db.common_filters()) \
|
||||||
|
.group_by(text('books_ratings_link.rating'))\
|
||||||
|
.order_by(db.Ratings.rating).all()
|
||||||
|
|
||||||
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
|
len(entries))
|
||||||
|
element = list()
|
||||||
|
for entry in entries:
|
||||||
|
element.append(FeedObject(entry[0].id, _("{} Stars").format(entry.name)))
|
||||||
|
return render_xml_template('feed.xml', listelements=element, folder='opds.feed_ratings', pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/ratings/<book_id>")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_ratings(book_id):
|
||||||
|
return render_xml_dataset(db.Ratings, book_id)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/formats")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_formatindex():
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_FORMAT):
|
||||||
|
abort(404)
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
entries = calibre_db.session.query(db.Data).join(db.Books)\
|
||||||
|
.filter(calibre_db.common_filters()) \
|
||||||
|
.group_by(db.Data.format)\
|
||||||
|
.order_by(db.Data.format).all()
|
||||||
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
|
len(entries))
|
||||||
|
element = list()
|
||||||
|
for entry in entries:
|
||||||
|
element.append(FeedObject(entry.format, entry.format))
|
||||||
|
return render_xml_template('feed.xml', listelements=element, folder='opds.feed_format', pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/formats/<book_id>")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_format(book_id):
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
|
db.Books,
|
||||||
|
db.Books.data.any(db.Data.format == book_id.upper()),
|
||||||
|
[db.Books.timestamp.desc()],
|
||||||
|
True, config.config_read_column)
|
||||||
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/language")
|
||||||
|
@opds.route("/opds/language/")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_languagesindex():
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_LANGUAGE):
|
||||||
|
abort(404)
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
if current_user.filter_language() == "all":
|
||||||
|
languages = calibre_db.speaking_language()
|
||||||
|
else:
|
||||||
|
languages = calibre_db.session.query(db.Languages).filter(
|
||||||
|
db.Languages.lang_code == current_user.filter_language()).all()
|
||||||
|
languages[0].name = isoLanguages.get_language_name(get_locale(), languages[0].lang_code)
|
||||||
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
|
len(languages))
|
||||||
|
return render_xml_template('feed.xml', listelements=languages, folder='opds.feed_languages', pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/language/<int:book_id>")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_languages(book_id):
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
|
db.Books,
|
||||||
|
db.Books.languages.any(db.Languages.id == book_id),
|
||||||
|
[db.Books.timestamp.desc()],
|
||||||
|
True, config.config_read_column)
|
||||||
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/shelfindex")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_shelfindex():
|
||||||
|
if not (current_user.is_authenticated or g.allow_anonymous):
|
||||||
|
abort(404)
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
shelf = ub.session.query(ub.Shelf).filter(
|
||||||
|
or_(ub.Shelf.is_public == 1, ub.Shelf.user_id == current_user.id)).order_by(ub.Shelf.name).all()
|
||||||
|
number = len(shelf)
|
||||||
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
|
number)
|
||||||
|
return render_xml_template('feed.xml', listelements=shelf, folder='opds.feed_shelf', pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/shelf/<int:book_id>")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_shelf(book_id):
|
||||||
|
if not (current_user.is_authenticated or g.allow_anonymous):
|
||||||
|
abort(404)
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
if current_user.is_anonymous:
|
||||||
|
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1,
|
||||||
|
ub.Shelf.id == book_id).first()
|
||||||
|
else:
|
||||||
|
shelf = ub.session.query(ub.Shelf).filter(or_(and_(ub.Shelf.user_id == int(current_user.id),
|
||||||
|
ub.Shelf.id == book_id),
|
||||||
|
and_(ub.Shelf.is_public == 1,
|
||||||
|
ub.Shelf.id == book_id))).first()
|
||||||
|
result = list()
|
||||||
|
# user is allowed to access shelf
|
||||||
|
if shelf:
|
||||||
|
result, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
||||||
|
config.config_books_per_page,
|
||||||
|
db.Books,
|
||||||
|
ub.BookShelf.shelf == shelf.id,
|
||||||
|
[ub.BookShelf.order.asc()],
|
||||||
|
True, config.config_read_column,
|
||||||
|
ub.BookShelf, ub.BookShelf.book_id == db.Books.id)
|
||||||
|
# delete shelf entries where book is not existent anymore, can happen if book is deleted outside calibre-web
|
||||||
|
wrong_entries = calibre_db.session.query(ub.BookShelf) \
|
||||||
|
.join(db.Books, ub.BookShelf.book_id == db.Books.id, isouter=True) \
|
||||||
|
.filter(db.Books.id == None).all()
|
||||||
|
for entry in wrong_entries:
|
||||||
|
log.info('Not existing book {} in {} deleted'.format(entry.book_id, shelf))
|
||||||
|
try:
|
||||||
|
ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == entry.book_id).delete()
|
||||||
|
ub.session.commit()
|
||||||
|
except (OperationalError, InvalidRequestError) as e:
|
||||||
|
ub.session.rollback()
|
||||||
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
|
return render_xml_template('feed.xml', entries=result, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/download/<book_id>/<book_format>/")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def opds_download_link(book_id, book_format):
|
||||||
|
if not current_user.role_download():
|
||||||
|
return abort(403)
|
||||||
|
if "Kobo" in request.headers.get('User-Agent'):
|
||||||
|
client = "kobo"
|
||||||
|
else:
|
||||||
|
client = ""
|
||||||
|
return get_download_link(book_id, book_format.lower(), client)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/ajax/book/<string:uuid>/<library>")
|
||||||
|
@opds.route("/ajax/book/<string:uuid>", defaults={'library': ""})
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def get_metadata_calibre_companion(uuid, library):
|
||||||
|
entry = calibre_db.session.query(db.Books).filter(db.Books.uuid.like("%" + uuid + "%")).first()
|
||||||
|
if entry is not None:
|
||||||
|
js = render_template('json.txt', entry=entry)
|
||||||
|
response = make_response(js)
|
||||||
|
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/stats")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def get_database_stats():
|
||||||
|
stat = dict()
|
||||||
|
stat['books'] = calibre_db.session.query(db.Books).count()
|
||||||
|
stat['authors'] = calibre_db.session.query(db.Authors).count()
|
||||||
|
stat['categories'] = calibre_db.session.query(db.Tags).count()
|
||||||
|
stat['series'] = calibre_db.session.query(db.Series).count()
|
||||||
|
return Response(json.dumps(stat), mimetype="application/json")
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/thumb_240_240/<book_id>")
|
||||||
|
@opds.route("/opds/cover_240_240/<book_id>")
|
||||||
|
@opds.route("/opds/cover_90_90/<book_id>")
|
||||||
|
@opds.route("/opds/cover/<book_id>")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_get_cover(book_id):
|
||||||
|
return get_book_cover(book_id)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/readbooks")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_read_books():
|
||||||
|
if not (current_user.check_visibility(constants.SIDEBAR_READ_AND_UNREAD) and not current_user.is_anonymous):
|
||||||
|
return abort(403)
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, True, True)
|
||||||
|
return render_xml_template('feed.xml', entries=result, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/unreadbooks")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_unread_books():
|
||||||
|
if not (current_user.check_visibility(constants.SIDEBAR_READ_AND_UNREAD) and not current_user.is_anonymous):
|
||||||
|
return abort(403)
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, False, True)
|
||||||
|
return render_xml_template('feed.xml', entries=result, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
class FeedObject:
|
class FeedObject:
|
||||||
|
@ -70,375 +498,55 @@ class FeedObject:
|
||||||
return self.rating_name
|
return self.rating_name
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/")
|
|
||||||
@opds.route("/opds")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_index():
|
|
||||||
return render_xml_template('index.xml')
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/osd")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_osd():
|
|
||||||
return render_xml_template('osd.xml', lang='en-EN')
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/search", defaults={'query': ""})
|
|
||||||
@opds.route("/opds/search/<query>")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_cc_search(query):
|
|
||||||
return feed_search(query.strip())
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/search", methods=["GET"])
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_normal_search():
|
|
||||||
return feed_search(request.args.get("query").strip())
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/new")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_new():
|
|
||||||
off = request.args.get("offset") or 0
|
|
||||||
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
|
||||||
db.Books, True, [db.Books.timestamp.desc()])
|
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/discover")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_discover():
|
|
||||||
entries = db.session.query(db.Books).filter(common_filters()).order_by(func.random())\
|
|
||||||
.limit(config.config_books_per_page)
|
|
||||||
pagination = Pagination(1, config.config_books_per_page, int(config.config_books_per_page))
|
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/rated")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_best_rated():
|
|
||||||
off = request.args.get("offset") or 0
|
|
||||||
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
|
||||||
db.Books, db.Books.ratings.any(db.Ratings.rating > 9),
|
|
||||||
[db.Books.timestamp.desc()])
|
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/hot")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_hot():
|
|
||||||
off = request.args.get("offset") or 0
|
|
||||||
all_books = ub.session.query(ub.Downloads, func.count(ub.Downloads.book_id)).order_by(
|
|
||||||
func.count(ub.Downloads.book_id).desc()).group_by(ub.Downloads.book_id)
|
|
||||||
hot_books = all_books.offset(off).limit(config.config_books_per_page)
|
|
||||||
entries = list()
|
|
||||||
for book in hot_books:
|
|
||||||
downloadBook = db.session.query(db.Books).filter(db.Books.id == book.Downloads.book_id).first()
|
|
||||||
if downloadBook:
|
|
||||||
entries.append(
|
|
||||||
db.session.query(db.Books).filter(common_filters())
|
|
||||||
.filter(db.Books.id == book.Downloads.book_id).first()
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
ub.delete_download(book.Downloads.book_id)
|
|
||||||
# ub.session.query(ub.Downloads).filter(book.Downloads.book_id == ub.Downloads.book_id).delete()
|
|
||||||
# ub.session.commit()
|
|
||||||
numBooks = entries.__len__()
|
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1),
|
|
||||||
config.config_books_per_page, numBooks)
|
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/author")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_authorindex():
|
|
||||||
off = request.args.get("offset") or 0
|
|
||||||
entries = db.session.query(db.Authors).join(db.books_authors_link).join(db.Books).filter(common_filters())\
|
|
||||||
.group_by(text('books_authors_link.author')).order_by(db.Authors.sort).limit(config.config_books_per_page)\
|
|
||||||
.offset(off)
|
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
|
||||||
len(db.session.query(db.Authors).all()))
|
|
||||||
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_author', pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/author/<int:book_id>")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_author(book_id):
|
|
||||||
off = request.args.get("offset") or 0
|
|
||||||
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
|
||||||
db.Books,
|
|
||||||
db.Books.authors.any(db.Authors.id == book_id),
|
|
||||||
[db.Books.timestamp.desc()])
|
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/publisher")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_publisherindex():
|
|
||||||
off = request.args.get("offset") or 0
|
|
||||||
entries = db.session.query(db.Publishers).join(db.books_publishers_link).join(db.Books).filter(common_filters())\
|
|
||||||
.group_by(text('books_publishers_link.publisher')).order_by(db.Publishers.sort)\
|
|
||||||
.limit(config.config_books_per_page).offset(off)
|
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
|
||||||
len(db.session.query(db.Publishers).all()))
|
|
||||||
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_publisher', pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/publisher/<int:book_id>")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_publisher(book_id):
|
|
||||||
off = request.args.get("offset") or 0
|
|
||||||
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
|
||||||
db.Books,
|
|
||||||
db.Books.publishers.any(db.Publishers.id == book_id),
|
|
||||||
[db.Books.timestamp.desc()])
|
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/category")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_categoryindex():
|
|
||||||
off = request.args.get("offset") or 0
|
|
||||||
entries = db.session.query(db.Tags).join(db.books_tags_link).join(db.Books).filter(common_filters())\
|
|
||||||
.group_by(text('books_tags_link.tag')).order_by(db.Tags.name).offset(off).limit(config.config_books_per_page)
|
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
|
||||||
len(db.session.query(db.Tags).all()))
|
|
||||||
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_category', pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/category/<int:book_id>")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_category(book_id):
|
|
||||||
off = request.args.get("offset") or 0
|
|
||||||
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
|
||||||
db.Books,
|
|
||||||
db.Books.tags.any(db.Tags.id == book_id),
|
|
||||||
[db.Books.timestamp.desc()])
|
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/series")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_seriesindex():
|
|
||||||
off = request.args.get("offset") or 0
|
|
||||||
entries = db.session.query(db.Series).join(db.books_series_link).join(db.Books).filter(common_filters())\
|
|
||||||
.group_by(text('books_series_link.series')).order_by(db.Series.sort).offset(off).all()
|
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
|
||||||
len(db.session.query(db.Series).all()))
|
|
||||||
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_series', pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/series/<int:book_id>")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_series(book_id):
|
|
||||||
off = request.args.get("offset") or 0
|
|
||||||
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
|
||||||
db.Books,
|
|
||||||
db.Books.series.any(db.Series.id == book_id),
|
|
||||||
[db.Books.series_index])
|
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/ratings")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_ratingindex():
|
|
||||||
off = request.args.get("offset") or 0
|
|
||||||
entries = db.session.query(db.Ratings, func.count('books_ratings_link.book').label('count'),
|
|
||||||
(db.Ratings.rating / 2).label('name')) \
|
|
||||||
.join(db.books_ratings_link).join(db.Books).filter(common_filters()) \
|
|
||||||
.group_by(text('books_ratings_link.rating')).order_by(db.Ratings.rating).all()
|
|
||||||
|
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
|
||||||
len(entries))
|
|
||||||
element = list()
|
|
||||||
for entry in entries:
|
|
||||||
element.append(FeedObject(entry[0].id, "{} Stars".format(entry.name)))
|
|
||||||
return render_xml_template('feed.xml', listelements=element, folder='opds.feed_ratings', pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/ratings/<book_id>")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_ratings(book_id):
|
|
||||||
off = request.args.get("offset") or 0
|
|
||||||
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
|
||||||
db.Books,
|
|
||||||
db.Books.ratings.any(db.Ratings.id == book_id),
|
|
||||||
[db.Books.timestamp.desc()])
|
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/formats")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_formatindex():
|
|
||||||
off = request.args.get("offset") or 0
|
|
||||||
entries = db.session.query(db.Data).join(db.Books).filter(common_filters()) \
|
|
||||||
.group_by(db.Data.format).order_by(db.Data.format).all()
|
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
|
||||||
len(entries))
|
|
||||||
|
|
||||||
element = list()
|
|
||||||
for entry in entries:
|
|
||||||
element.append(FeedObject(entry.format, entry.format))
|
|
||||||
return render_xml_template('feed.xml', listelements=element, folder='opds.feed_format', pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/formats/<book_id>")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_format(book_id):
|
|
||||||
off = request.args.get("offset") or 0
|
|
||||||
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
|
||||||
db.Books,
|
|
||||||
db.Books.data.any(db.Data.format == book_id.upper()),
|
|
||||||
[db.Books.timestamp.desc()])
|
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/language")
|
|
||||||
@opds.route("/opds/language/")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_languagesindex():
|
|
||||||
off = request.args.get("offset") or 0
|
|
||||||
if current_user.filter_language() == u"all":
|
|
||||||
languages = speaking_language()
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
cur_l = LC.parse(current_user.filter_language())
|
|
||||||
except UnknownLocaleError:
|
|
||||||
cur_l = None
|
|
||||||
languages = db.session.query(db.Languages).filter(
|
|
||||||
db.Languages.lang_code == current_user.filter_language()).all()
|
|
||||||
if cur_l:
|
|
||||||
languages[0].name = cur_l.get_language_name(get_locale())
|
|
||||||
else:
|
|
||||||
languages[0].name = _(isoLanguages.get(part3=languages[0].lang_code).name)
|
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
|
||||||
len(languages))
|
|
||||||
return render_xml_template('feed.xml', listelements=languages, folder='opds.feed_languages', pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/language/<int:book_id>")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_languages(book_id):
|
|
||||||
off = request.args.get("offset") or 0
|
|
||||||
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
|
||||||
db.Books,
|
|
||||||
db.Books.languages.any(db.Languages.id == book_id),
|
|
||||||
[db.Books.timestamp.desc()])
|
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/shelfindex")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_shelfindex():
|
|
||||||
off = request.args.get("offset") or 0
|
|
||||||
shelf = g.shelves_access
|
|
||||||
number = len(shelf)
|
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
|
||||||
number)
|
|
||||||
return render_xml_template('feed.xml', listelements=shelf, folder='opds.feed_shelf', pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/shelf/<int:book_id>")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_shelf(book_id):
|
|
||||||
off = request.args.get("offset") or 0
|
|
||||||
if current_user.is_anonymous:
|
|
||||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1,
|
|
||||||
ub.Shelf.id == book_id).first()
|
|
||||||
else:
|
|
||||||
shelf = ub.session.query(ub.Shelf).filter(or_(and_(ub.Shelf.user_id == int(current_user.id),
|
|
||||||
ub.Shelf.id == book_id),
|
|
||||||
and_(ub.Shelf.is_public == 1,
|
|
||||||
ub.Shelf.id == book_id))).first()
|
|
||||||
result = list()
|
|
||||||
# user is allowed to access shelf
|
|
||||||
if shelf:
|
|
||||||
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == book_id).order_by(
|
|
||||||
ub.BookShelf.order.asc()).all()
|
|
||||||
for book in books_in_shelf:
|
|
||||||
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).first()
|
|
||||||
result.append(cur_book)
|
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
|
||||||
len(result))
|
|
||||||
return render_xml_template('feed.xml', entries=result, pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/download/<book_id>/<book_format>/")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
@download_required
|
|
||||||
def opds_download_link(book_id, book_format):
|
|
||||||
return get_download_link(book_id, book_format.lower())
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/ajax/book/<string:uuid>/<library>")
|
|
||||||
@opds.route("/ajax/book/<string:uuid>", defaults={'library': ""})
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def get_metadata_calibre_companion(uuid, library):
|
|
||||||
entry = db.session.query(db.Books).filter(db.Books.uuid.like("%" + uuid + "%")).first()
|
|
||||||
if entry is not None:
|
|
||||||
js = render_template('json.txt', entry=entry)
|
|
||||||
response = make_response(js)
|
|
||||||
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
|
||||||
return response
|
|
||||||
else:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def feed_search(term):
|
def feed_search(term):
|
||||||
if term:
|
if term:
|
||||||
term = term.strip().lower()
|
entries, __, ___ = calibre_db.get_search_results(term, config=config)
|
||||||
entries = get_search_results(term)
|
entries_count = len(entries) if len(entries) > 0 else 1
|
||||||
entriescount = len(entries) if len(entries) > 0 else 1
|
pagination = Pagination(1, entries_count, entries_count)
|
||||||
pagination = Pagination(1, entriescount, entriescount)
|
|
||||||
return render_xml_template('feed.xml', searchterm=term, entries=entries, pagination=pagination)
|
return render_xml_template('feed.xml', searchterm=term, entries=entries, pagination=pagination)
|
||||||
else:
|
else:
|
||||||
return render_xml_template('feed.xml', searchterm="")
|
return render_xml_template('feed.xml', searchterm="")
|
||||||
|
|
||||||
|
|
||||||
def check_auth(username, password):
|
|
||||||
if sys.version_info.major == 3:
|
|
||||||
username = username.encode('windows-1252')
|
|
||||||
user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) ==
|
|
||||||
username.decode('utf-8').lower()).first()
|
|
||||||
return bool(user and check_password_hash(str(user.password), password))
|
|
||||||
|
|
||||||
|
|
||||||
def authenticate():
|
|
||||||
return Response(
|
|
||||||
'Could not verify your access level for that URL.\n'
|
|
||||||
'You have to login with proper credentials', 401,
|
|
||||||
{'WWW-Authenticate': 'Basic realm="Login Required"'})
|
|
||||||
|
|
||||||
|
|
||||||
def render_xml_template(*args, **kwargs):
|
def render_xml_template(*args, **kwargs):
|
||||||
# ToDo: return time in current timezone similar to %z
|
# ToDo: return time in current timezone similar to %z
|
||||||
currtime = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S+00:00")
|
currtime = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S+00:00")
|
||||||
xml = render_template(current_time=currtime, instance=config.config_calibre_web_title, *args, **kwargs)
|
xml = render_template(current_time=currtime, instance=config.config_calibre_web_title, constants=constants.sidebar_settings, *args, **kwargs)
|
||||||
response = make_response(xml)
|
response = make_response(xml)
|
||||||
response.headers["Content-Type"] = "application/atom+xml; charset=utf-8"
|
response.headers["Content-Type"] = "application/atom+xml; charset=utf-8"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/thumb_240_240/<book_id>")
|
def render_xml_dataset(data_table, book_id):
|
||||||
@opds.route("/opds/cover_240_240/<book_id>")
|
|
||||||
@opds.route("/opds/cover_90_90/<book_id>")
|
|
||||||
@opds.route("/opds/cover/<book_id>")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_get_cover(book_id):
|
|
||||||
return get_book_cover(book_id)
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/readbooks")
|
|
||||||
@requires_basic_auth_if_no_ano
|
|
||||||
def feed_read_books():
|
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, True, True)
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
return render_xml_template('feed.xml', entries=result, pagination=pagination)
|
db.Books,
|
||||||
|
getattr(db.Books, data_table.__tablename__).any(data_table.id == book_id),
|
||||||
|
[db.Books.timestamp.desc()],
|
||||||
|
True, config.config_read_column)
|
||||||
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/unreadbooks")
|
def render_element_index(database_column, linked_table, folder):
|
||||||
@requires_basic_auth_if_no_ano
|
shift = 0
|
||||||
def feed_unread_books():
|
off = int(request.args.get("offset") or 0)
|
||||||
off = request.args.get("offset") or 0
|
entries = calibre_db.session.query(func.upper(func.substr(database_column, 1, 1)).label('id'), None, None)
|
||||||
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, False, True)
|
# query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
|
||||||
return render_xml_template('feed.xml', entries=result, pagination=pagination)
|
if linked_table is not None:
|
||||||
|
entries = entries.join(linked_table).join(db.Books)
|
||||||
|
entries = entries.filter(calibre_db.common_filters()).group_by(func.upper(func.substr(database_column, 1, 1))).all()
|
||||||
|
elements = []
|
||||||
|
if off == 0 and entries:
|
||||||
|
elements.append({'id': "00", 'name': _("All")})
|
||||||
|
shift = 1
|
||||||
|
for entry in entries[
|
||||||
|
off + shift - 1:
|
||||||
|
int(off + int(config.config_books_per_page) - shift)]:
|
||||||
|
elements.append({'id': entry.id, 'name': entry.id})
|
||||||
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
|
len(entries) + 1)
|
||||||
|
return render_xml_template('feed.xml',
|
||||||
|
letterelements=elements,
|
||||||
|
folder=folder,
|
||||||
|
pagination=pagination)
|
||||||
|
|
|
@ -20,7 +20,6 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
from math import ceil
|
from math import ceil
|
||||||
|
|
||||||
|
|
||||||
|
@ -58,10 +57,10 @@ class Pagination(object):
|
||||||
def has_next(self):
|
def has_next(self):
|
||||||
return self.page < self.pages
|
return self.page < self.pages
|
||||||
|
|
||||||
# right_edge: last right_edges count of all pages are shown as number, means, if 10 pages are paginated -> 9,10 shwn
|
# right_edge: last right_edges count of all pages are shown as number, means, if 10 pages are paginated -> 9,10 shown
|
||||||
# left_edge: first left_edges count of all pages are shown as number -> 1,2 shwn
|
# left_edge: first left_edges count of all pages are shown as number -> 1,2 shown
|
||||||
# left_current: left_current count below current page are shown as number, means if current page 5 -> 3,4 shwn
|
# left_current: left_current count below current page are shown as number, means if current page 5 -> 3,4 shown
|
||||||
# left_current: right_current count above current page are shown as number, means if current page 5 -> 6,7 shwn
|
# left_current: right_current count above current page are shown as number, means if current page 5 -> 6,7 shown
|
||||||
def iter_pages(self, left_edge=2, left_current=2,
|
def iter_pages(self, left_edge=2, left_current=2,
|
||||||
right_current=4, right_edge=2):
|
right_current=4, right_edge=2):
|
||||||
last = 0
|
last = 0
|
||||||
|
|
29
cps/redirect.py
Normal file → Executable file
29
cps/redirect.py
Normal file → Executable file
|
@ -25,15 +25,11 @@
|
||||||
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING
|
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING
|
||||||
# IN ANY WAY OUT OF THE USE OF THIS SOFTWARE AND DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
# IN ANY WAY OUT OF THE USE OF THIS SOFTWARE AND DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
# http://flask.pocoo.org/snippets/62/
|
# https://web.archive.org/web/20120517003641/http://flask.pocoo.org/snippets/62/
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
from urllib.parse import urlparse, urljoin
|
||||||
try:
|
|
||||||
from urllib.parse import urlparse, urljoin
|
|
||||||
except ImportError:
|
|
||||||
from urlparse import urlparse, urljoin
|
|
||||||
|
|
||||||
from flask import request, url_for, redirect
|
from flask import request, url_for, redirect, current_app
|
||||||
|
|
||||||
|
|
||||||
def is_safe_url(target):
|
def is_safe_url(target):
|
||||||
|
@ -42,16 +38,15 @@ def is_safe_url(target):
|
||||||
return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc
|
return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc
|
||||||
|
|
||||||
|
|
||||||
def get_redirect_target():
|
def remove_prefix(text, prefix):
|
||||||
for target in request.values.get('next'), request.referrer:
|
if text.startswith(prefix):
|
||||||
if not target:
|
return text[len(prefix):]
|
||||||
continue
|
return ""
|
||||||
if is_safe_url(target):
|
|
||||||
return target
|
|
||||||
|
|
||||||
|
|
||||||
def redirect_back(endpoint, **values):
|
def get_redirect_location(next, endpoint, **values):
|
||||||
target = request.form['next']
|
target = next or url_for(endpoint, **values)
|
||||||
if not target or not is_safe_url(target):
|
adapter = current_app.url_map.bind(urlparse(request.host_url).netloc)
|
||||||
|
if not len(adapter.allowed_methods(remove_prefix(target, request.environ.get('HTTP_X_SCRIPT_NAME',"")))):
|
||||||
target = url_for(endpoint, **values)
|
target = url_for(endpoint, **values)
|
||||||
return redirect(target)
|
return target
|
||||||
|
|
135
cps/remotelogin.py
Normal file
135
cps/remotelogin.py
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2018-2019 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11,
|
||||||
|
# andy29485, idalin, Kyosfonica, wuqi, Kennyl, lemmsh,
|
||||||
|
# falgh1, grunjol, csitko, ytils, xybydy, trasba, vrabe,
|
||||||
|
# ruben-herold, marblepebble, JackED42, SiphonSquirrel,
|
||||||
|
# apetresc, nanu-c, mutschler
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import Blueprint, request, make_response, abort, url_for, flash, redirect
|
||||||
|
from flask_login import login_required, current_user, login_user
|
||||||
|
from flask_babel import gettext as _
|
||||||
|
from sqlalchemy.sql.expression import true
|
||||||
|
|
||||||
|
from . import config, logger, ub
|
||||||
|
from .render_template import render_title_template
|
||||||
|
|
||||||
|
|
||||||
|
remotelogin = Blueprint('remotelogin', __name__)
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
|
def remote_login_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def inner(*args, **kwargs):
|
||||||
|
if config.config_remote_login:
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
data = {'status': 'error', 'message': 'Forbidden'}
|
||||||
|
response = make_response(json.dumps(data, ensure_ascii=False))
|
||||||
|
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||||
|
return response, 403
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
return inner
|
||||||
|
|
||||||
|
@remotelogin.route('/remote/login')
|
||||||
|
@remote_login_required
|
||||||
|
def remote_login():
|
||||||
|
auth_token = ub.RemoteAuthToken()
|
||||||
|
ub.session.add(auth_token)
|
||||||
|
ub.session_commit()
|
||||||
|
verify_url = url_for('remotelogin.verify_token', token=auth_token.auth_token, _external=true)
|
||||||
|
log.debug("Remot Login request with token: %s", auth_token.auth_token)
|
||||||
|
return render_title_template('remote_login.html', title=_("Login"), token=auth_token.auth_token,
|
||||||
|
verify_url=verify_url, page="remotelogin")
|
||||||
|
|
||||||
|
|
||||||
|
@remotelogin.route('/verify/<token>')
|
||||||
|
@remote_login_required
|
||||||
|
@login_required
|
||||||
|
def verify_token(token):
|
||||||
|
auth_token = ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == token).first()
|
||||||
|
|
||||||
|
# Token not found
|
||||||
|
if auth_token is None:
|
||||||
|
flash(_("Token not found"), category="error")
|
||||||
|
log.error("Remote Login token not found")
|
||||||
|
return redirect(url_for('web.index'))
|
||||||
|
|
||||||
|
# Token expired
|
||||||
|
elif datetime.now() > auth_token.expiration:
|
||||||
|
ub.session.delete(auth_token)
|
||||||
|
ub.session_commit()
|
||||||
|
|
||||||
|
flash(_("Token has expired"), category="error")
|
||||||
|
log.error("Remote Login token expired")
|
||||||
|
return redirect(url_for('web.index'))
|
||||||
|
|
||||||
|
# Update token with user information
|
||||||
|
auth_token.user_id = current_user.id
|
||||||
|
auth_token.verified = True
|
||||||
|
ub.session_commit()
|
||||||
|
|
||||||
|
flash(_("Success! Please return to your device"), category="success")
|
||||||
|
log.debug("Remote Login token for userid %s verified", auth_token.user_id)
|
||||||
|
return redirect(url_for('web.index'))
|
||||||
|
|
||||||
|
|
||||||
|
@remotelogin.route('/ajax/verify_token', methods=['POST'])
|
||||||
|
@remote_login_required
|
||||||
|
def token_verified():
|
||||||
|
token = request.form['token']
|
||||||
|
auth_token = ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == token).first()
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
# Token not found
|
||||||
|
if auth_token is None:
|
||||||
|
data['status'] = 'error'
|
||||||
|
data['message'] = _("Token not found")
|
||||||
|
|
||||||
|
# Token expired
|
||||||
|
elif datetime.now() > auth_token.expiration:
|
||||||
|
ub.session.delete(auth_token)
|
||||||
|
ub.session_commit()
|
||||||
|
|
||||||
|
data['status'] = 'error'
|
||||||
|
data['message'] = _("Token has expired")
|
||||||
|
|
||||||
|
elif not auth_token.verified:
|
||||||
|
data['status'] = 'not_verified'
|
||||||
|
|
||||||
|
else:
|
||||||
|
user = ub.session.query(ub.User).filter(ub.User.id == auth_token.user_id).first()
|
||||||
|
login_user(user)
|
||||||
|
|
||||||
|
ub.session.delete(auth_token)
|
||||||
|
ub.session_commit("User {} logged in via remotelogin, token deleted".format(user.name))
|
||||||
|
|
||||||
|
data['status'] = 'success'
|
||||||
|
log.debug("Remote Login for userid %s succeeded", user.id)
|
||||||
|
flash(_("Success! You are now logged in as: %(nickname)s", nickname=user.name), category="success")
|
||||||
|
|
||||||
|
response = make_response(json.dumps(data, ensure_ascii=False))
|
||||||
|
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||||
|
|
||||||
|
return response
|
119
cps/render_template.py
Normal file
119
cps/render_template.py
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2018-2020 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from flask import render_template, g, abort, request
|
||||||
|
from flask_babel import gettext as _
|
||||||
|
from werkzeug.local import LocalProxy
|
||||||
|
from flask_login import current_user
|
||||||
|
from sqlalchemy.sql.expression import or_
|
||||||
|
|
||||||
|
from . import config, constants, logger, ub
|
||||||
|
from .ub import User
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
def get_sidebar_config(kwargs=None):
|
||||||
|
kwargs = kwargs or []
|
||||||
|
simple = bool([e for e in ['kindle', 'tolino', "kobo", "bookeen"]
|
||||||
|
if (e in request.headers.get('User-Agent', "").lower())])
|
||||||
|
if 'content' in kwargs:
|
||||||
|
content = kwargs['content']
|
||||||
|
content = isinstance(content, (User, LocalProxy)) and not content.role_anonymous()
|
||||||
|
else:
|
||||||
|
content = 'conf' in kwargs
|
||||||
|
sidebar = list()
|
||||||
|
sidebar.append({"glyph": "glyphicon-book", "text": _('Books'), "link": 'web.index', "id": "new",
|
||||||
|
"visibility": constants.SIDEBAR_RECENT, 'public': True, "page": "root",
|
||||||
|
"show_text": _('Show recent books'), "config_show":False})
|
||||||
|
sidebar.append({"glyph": "glyphicon-fire", "text": _('Hot Books'), "link": 'web.books_list', "id": "hot",
|
||||||
|
"visibility": constants.SIDEBAR_HOT, 'public': True, "page": "hot",
|
||||||
|
"show_text": _('Show Hot Books'), "config_show": True})
|
||||||
|
if current_user.role_admin():
|
||||||
|
sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.download_list',
|
||||||
|
"id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not current_user.is_anonymous),
|
||||||
|
"page": "download", "show_text": _('Show Downloaded Books'),
|
||||||
|
"config_show": content})
|
||||||
|
else:
|
||||||
|
sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.books_list',
|
||||||
|
"id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not current_user.is_anonymous),
|
||||||
|
"page": "download", "show_text": _('Show Downloaded Books'),
|
||||||
|
"config_show": content})
|
||||||
|
sidebar.append(
|
||||||
|
{"glyph": "glyphicon-star", "text": _('Top Rated Books'), "link": 'web.books_list', "id": "rated",
|
||||||
|
"visibility": constants.SIDEBAR_BEST_RATED, 'public': True, "page": "rated",
|
||||||
|
"show_text": _('Show Top Rated Books'), "config_show": True})
|
||||||
|
sidebar.append({"glyph": "glyphicon-eye-open", "text": _('Read Books'), "link": 'web.books_list', "id": "read",
|
||||||
|
"visibility": constants.SIDEBAR_READ_AND_UNREAD, 'public': (not current_user.is_anonymous),
|
||||||
|
"page": "read", "show_text": _('Show Read and Unread'), "config_show": content})
|
||||||
|
sidebar.append(
|
||||||
|
{"glyph": "glyphicon-eye-close", "text": _('Unread Books'), "link": 'web.books_list', "id": "unread",
|
||||||
|
"visibility": constants.SIDEBAR_READ_AND_UNREAD, 'public': (not current_user.is_anonymous), "page": "unread",
|
||||||
|
"show_text": _('Show unread'), "config_show": False})
|
||||||
|
sidebar.append({"glyph": "glyphicon-random", "text": _('Discover'), "link": 'web.books_list', "id": "rand",
|
||||||
|
"visibility": constants.SIDEBAR_RANDOM, 'public': True, "page": "discover",
|
||||||
|
"show_text": _('Show Random Books'), "config_show": True})
|
||||||
|
sidebar.append({"glyph": "glyphicon-inbox", "text": _('Categories'), "link": 'web.category_list', "id": "cat",
|
||||||
|
"visibility": constants.SIDEBAR_CATEGORY, 'public': True, "page": "category",
|
||||||
|
"show_text": _('Show Category Section'), "config_show": True})
|
||||||
|
sidebar.append({"glyph": "glyphicon-bookmark", "text": _('Series'), "link": 'web.series_list', "id": "serie",
|
||||||
|
"visibility": constants.SIDEBAR_SERIES, 'public': True, "page": "series",
|
||||||
|
"show_text": _('Show Series Section'), "config_show": True})
|
||||||
|
sidebar.append({"glyph": "glyphicon-user", "text": _('Authors'), "link": 'web.author_list', "id": "author",
|
||||||
|
"visibility": constants.SIDEBAR_AUTHOR, 'public': True, "page": "author",
|
||||||
|
"show_text": _('Show Author Section'), "config_show": True})
|
||||||
|
sidebar.append(
|
||||||
|
{"glyph": "glyphicon-text-size", "text": _('Publishers'), "link": 'web.publisher_list', "id": "publisher",
|
||||||
|
"visibility": constants.SIDEBAR_PUBLISHER, 'public': True, "page": "publisher",
|
||||||
|
"show_text": _('Show Publisher Section'), "config_show":True})
|
||||||
|
sidebar.append({"glyph": "glyphicon-flag", "text": _('Languages'), "link": 'web.language_overview', "id": "lang",
|
||||||
|
"visibility": constants.SIDEBAR_LANGUAGE, 'public': (current_user.filter_language() == 'all'),
|
||||||
|
"page": "language",
|
||||||
|
"show_text": _('Show Language Section'), "config_show": True})
|
||||||
|
sidebar.append({"glyph": "glyphicon-star-empty", "text": _('Ratings'), "link": 'web.ratings_list', "id": "rate",
|
||||||
|
"visibility": constants.SIDEBAR_RATING, 'public': True,
|
||||||
|
"page": "rating", "show_text": _('Show Ratings Section'), "config_show": True})
|
||||||
|
sidebar.append({"glyph": "glyphicon-file", "text": _('File formats'), "link": 'web.formats_list', "id": "format",
|
||||||
|
"visibility": constants.SIDEBAR_FORMAT, 'public': True,
|
||||||
|
"page": "format", "show_text": _('Show File Formats Section'), "config_show": True})
|
||||||
|
sidebar.append(
|
||||||
|
{"glyph": "glyphicon-trash", "text": _('Archived Books'), "link": 'web.books_list', "id": "archived",
|
||||||
|
"visibility": constants.SIDEBAR_ARCHIVED, 'public': (not current_user.is_anonymous), "page": "archived",
|
||||||
|
"show_text": _('Show Archived Books'), "config_show": content})
|
||||||
|
if not simple:
|
||||||
|
sidebar.append(
|
||||||
|
{"glyph": "glyphicon-th-list", "text": _('Books List'), "link": 'web.books_table', "id": "list",
|
||||||
|
"visibility": constants.SIDEBAR_LIST, 'public': (not current_user.is_anonymous), "page": "list",
|
||||||
|
"show_text": _('Show Books List'), "config_show": content})
|
||||||
|
g.shelves_access = ub.session.query(ub.Shelf).filter(
|
||||||
|
or_(ub.Shelf.is_public == 1, ub.Shelf.user_id == current_user.id)).order_by(ub.Shelf.name).all()
|
||||||
|
|
||||||
|
return sidebar, simple
|
||||||
|
|
||||||
|
|
||||||
|
# Returns the template for rendering and includes the instance name
|
||||||
|
def render_title_template(*args, **kwargs):
|
||||||
|
sidebar, simple = get_sidebar_config(kwargs)
|
||||||
|
try:
|
||||||
|
return render_template(instance=config.config_calibre_web_title, sidebar=sidebar, simple=simple,
|
||||||
|
accept=constants.EXTENSIONS_UPLOAD,
|
||||||
|
*args, **kwargs)
|
||||||
|
except PermissionError:
|
||||||
|
log.error("No permission to access {} file.".format(args[0]))
|
||||||
|
abort(403)
|
|
@ -36,8 +36,6 @@
|
||||||
#
|
#
|
||||||
# Inspired by http://flask.pocoo.org/snippets/35/
|
# Inspired by http://flask.pocoo.org/snippets/35/
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
|
|
||||||
|
|
||||||
class ReverseProxied(object):
|
class ReverseProxied(object):
|
||||||
"""Wrap the application in this middleware and configure the
|
"""Wrap the application in this middleware and configure the
|
||||||
|
@ -77,6 +75,7 @@ class ReverseProxied(object):
|
||||||
servr = environ.get('HTTP_X_FORWARDED_HOST', '')
|
servr = environ.get('HTTP_X_FORWARDED_HOST', '')
|
||||||
if servr:
|
if servr:
|
||||||
environ['HTTP_HOST'] = servr
|
environ['HTTP_HOST'] = servr
|
||||||
|
self.proxied = True
|
||||||
return self.app(environ, start_response)
|
return self.app(environ, start_response)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
110
cps/schedule.py
Normal file
110
cps/schedule.py
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2020 mmonkey
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from . import config, constants
|
||||||
|
from .services.background_scheduler import BackgroundScheduler, CronTrigger, use_APScheduler
|
||||||
|
from .tasks.database import TaskReconnectDatabase
|
||||||
|
from .tasks.tempFolder import TaskDeleteTempFolder
|
||||||
|
from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails, TaskClearCoverThumbnailCache
|
||||||
|
from .services.worker import WorkerThread
|
||||||
|
from .tasks.metadata_backup import TaskBackupMetadata
|
||||||
|
|
||||||
|
def get_scheduled_tasks(reconnect=True):
|
||||||
|
tasks = list()
|
||||||
|
# Reconnect Calibre database (metadata.db) based on config.schedule_reconnect
|
||||||
|
if reconnect:
|
||||||
|
tasks.append([lambda: TaskReconnectDatabase(), 'reconnect', False])
|
||||||
|
|
||||||
|
# Delete temp folder
|
||||||
|
tasks.append([lambda: TaskDeleteTempFolder(), 'delete temp', True])
|
||||||
|
|
||||||
|
# Generate metadata.opf file for each changed book
|
||||||
|
if config.schedule_metadata_backup:
|
||||||
|
tasks.append([lambda: TaskBackupMetadata("en"), 'backup metadata', False])
|
||||||
|
|
||||||
|
# Generate all missing book cover thumbnails
|
||||||
|
if config.schedule_generate_book_covers:
|
||||||
|
tasks.append([lambda: TaskClearCoverThumbnailCache(0), 'delete superfluous book covers', True])
|
||||||
|
tasks.append([lambda: TaskGenerateCoverThumbnails(), 'generate book covers', False])
|
||||||
|
|
||||||
|
# Generate all missing series thumbnails
|
||||||
|
if config.schedule_generate_series_covers:
|
||||||
|
tasks.append([lambda: TaskGenerateSeriesThumbnails(), 'generate book covers', False])
|
||||||
|
|
||||||
|
return tasks
|
||||||
|
|
||||||
|
|
||||||
|
def end_scheduled_tasks():
|
||||||
|
worker = WorkerThread.get_instance()
|
||||||
|
for __, __, __, task, __ in worker.tasks:
|
||||||
|
if task.scheduled and task.is_cancellable:
|
||||||
|
worker.end_task(task.id)
|
||||||
|
|
||||||
|
|
||||||
|
def register_scheduled_tasks(reconnect=True):
|
||||||
|
scheduler = BackgroundScheduler()
|
||||||
|
|
||||||
|
if scheduler:
|
||||||
|
# Remove all existing jobs
|
||||||
|
scheduler.remove_all_jobs()
|
||||||
|
|
||||||
|
start = config.schedule_start_time
|
||||||
|
duration = config.schedule_duration
|
||||||
|
|
||||||
|
# Register scheduled tasks
|
||||||
|
timezone_info = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo
|
||||||
|
scheduler.schedule_tasks(tasks=get_scheduled_tasks(reconnect), trigger=CronTrigger(hour=start,
|
||||||
|
timezone=timezone_info))
|
||||||
|
end_time = calclulate_end_time(start, duration)
|
||||||
|
scheduler.schedule(func=end_scheduled_tasks, trigger=CronTrigger(hour=end_time.hour, minute=end_time.minute,
|
||||||
|
timezone=timezone_info),
|
||||||
|
name="end scheduled task")
|
||||||
|
|
||||||
|
# Kick-off tasks, if they should currently be running
|
||||||
|
if should_task_be_running(start, duration):
|
||||||
|
scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(reconnect))
|
||||||
|
|
||||||
|
|
||||||
|
def register_startup_tasks():
|
||||||
|
scheduler = BackgroundScheduler()
|
||||||
|
|
||||||
|
if scheduler:
|
||||||
|
start = config.schedule_start_time
|
||||||
|
duration = config.schedule_duration
|
||||||
|
|
||||||
|
# Run scheduled tasks immediately for development and testing
|
||||||
|
# Ignore tasks that should currently be running, as these will be added when registering scheduled tasks
|
||||||
|
if constants.APP_MODE in ['development', 'test'] and not should_task_be_running(start, duration):
|
||||||
|
scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(False))
|
||||||
|
else:
|
||||||
|
scheduler.schedule_tasks_immediately(tasks=[[lambda: TaskDeleteTempFolder(), 'delete temp', True]])
|
||||||
|
|
||||||
|
|
||||||
|
def should_task_be_running(start, duration):
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
start_time = datetime.datetime.now().replace(hour=start, minute=0, second=0, microsecond=0)
|
||||||
|
end_time = start_time + datetime.timedelta(hours=duration // 60, minutes=duration % 60)
|
||||||
|
return start_time < now < end_time
|
||||||
|
|
||||||
|
|
||||||
|
def calclulate_end_time(start, duration):
|
||||||
|
start_time = datetime.datetime.now().replace(hour=start, minute=0)
|
||||||
|
return start_time + datetime.timedelta(hours=duration // 60, minutes=duration % 60)
|
||||||
|
|
403
cps/search.py
Normal file
403
cps/search.py
Normal file
|
@ -0,0 +1,403 @@
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2022 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask import Blueprint, request, redirect, url_for, flash
|
||||||
|
from flask import session as flask_session
|
||||||
|
from flask_login import current_user
|
||||||
|
from flask_babel import format_date
|
||||||
|
from flask_babel import gettext as _
|
||||||
|
from sqlalchemy.sql.expression import func, not_, and_, or_, text, true
|
||||||
|
from sqlalchemy.sql.functions import coalesce
|
||||||
|
|
||||||
|
from . import logger, db, calibre_db, config, ub
|
||||||
|
from .usermanagement import login_required_if_no_ano
|
||||||
|
from .render_template import render_title_template
|
||||||
|
from .pagination import Pagination
|
||||||
|
|
||||||
|
search = Blueprint('search', __name__)
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
|
@search.route("/search", methods=["GET"])
|
||||||
|
@login_required_if_no_ano
|
||||||
|
def simple_search():
|
||||||
|
term = request.args.get("query")
|
||||||
|
if term:
|
||||||
|
return redirect(url_for('web.books_list', data="search", sort_param='stored', query=term.strip()))
|
||||||
|
else:
|
||||||
|
return render_title_template('search.html',
|
||||||
|
searchterm="",
|
||||||
|
result_count=0,
|
||||||
|
title=_("Search"),
|
||||||
|
page="search")
|
||||||
|
|
||||||
|
|
||||||
|
@search.route("/advsearch", methods=['POST'])
|
||||||
|
@login_required_if_no_ano
|
||||||
|
def advanced_search():
|
||||||
|
values = dict(request.form)
|
||||||
|
params = ['include_tag', 'exclude_tag', 'include_serie', 'exclude_serie', 'include_shelf', 'exclude_shelf',
|
||||||
|
'include_language', 'exclude_language', 'include_extension', 'exclude_extension']
|
||||||
|
for param in params:
|
||||||
|
values[param] = list(request.form.getlist(param))
|
||||||
|
flask_session['query'] = json.dumps(values)
|
||||||
|
return redirect(url_for('web.books_list', data="advsearch", sort_param='stored', query=""))
|
||||||
|
|
||||||
|
|
||||||
|
@search.route("/advsearch", methods=['GET'])
|
||||||
|
@login_required_if_no_ano
|
||||||
|
def advanced_search_form():
|
||||||
|
# Build custom columns names
|
||||||
|
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
||||||
|
return render_prepare_search_form(cc)
|
||||||
|
|
||||||
|
|
||||||
|
def adv_search_custom_columns(cc, term, q):
|
||||||
|
for c in cc:
|
||||||
|
if c.datatype == "datetime":
|
||||||
|
custom_start = term.get('custom_column_' + str(c.id) + '_start')
|
||||||
|
custom_end = term.get('custom_column_' + str(c.id) + '_end')
|
||||||
|
if custom_start:
|
||||||
|
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||||
|
func.datetime(db.cc_classes[c.id].value) >= func.datetime(custom_start)))
|
||||||
|
if custom_end:
|
||||||
|
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||||
|
func.datetime(db.cc_classes[c.id].value) <= func.datetime(custom_end)))
|
||||||
|
else:
|
||||||
|
custom_query = term.get('custom_column_' + str(c.id))
|
||||||
|
if custom_query != '' and custom_query is not None:
|
||||||
|
if c.datatype == 'bool':
|
||||||
|
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||||
|
db.cc_classes[c.id].value == (custom_query == "True")))
|
||||||
|
elif c.datatype == 'int' or c.datatype == 'float':
|
||||||
|
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||||
|
db.cc_classes[c.id].value == custom_query))
|
||||||
|
elif c.datatype == 'rating':
|
||||||
|
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||||
|
db.cc_classes[c.id].value == int(float(custom_query) * 2)))
|
||||||
|
else:
|
||||||
|
q = q.filter(getattr(db.Books, 'custom_column_' + str(c.id)).any(
|
||||||
|
func.lower(db.cc_classes[c.id].value).ilike("%" + custom_query + "%")))
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def adv_search_language(q, include_languages_inputs, exclude_languages_inputs):
|
||||||
|
if current_user.filter_language() != "all":
|
||||||
|
q = q.filter(db.Books.languages.any(db.Languages.lang_code == current_user.filter_language()))
|
||||||
|
else:
|
||||||
|
for language in include_languages_inputs:
|
||||||
|
q = q.filter(db.Books.languages.any(db.Languages.id == language))
|
||||||
|
for language in exclude_languages_inputs:
|
||||||
|
q = q.filter(not_(db.Books.series.any(db.Languages.id == language)))
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def adv_search_ratings(q, rating_high, rating_low):
|
||||||
|
if rating_high:
|
||||||
|
rating_high = int(rating_high) * 2
|
||||||
|
q = q.filter(db.Books.ratings.any(db.Ratings.rating <= rating_high))
|
||||||
|
if rating_low:
|
||||||
|
rating_low = int(rating_low) * 2
|
||||||
|
q = q.filter(db.Books.ratings.any(db.Ratings.rating >= rating_low))
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def adv_search_read_status(read_status):
|
||||||
|
if not config.config_read_column:
|
||||||
|
if read_status == "True":
|
||||||
|
db_filter = and_(ub.ReadBook.user_id == int(current_user.id),
|
||||||
|
ub.ReadBook.read_status == ub.ReadBook.STATUS_FINISHED)
|
||||||
|
else:
|
||||||
|
db_filter = coalesce(ub.ReadBook.read_status, 0) != ub.ReadBook.STATUS_FINISHED
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
if read_status == "True":
|
||||||
|
db_filter = db.cc_classes[config.config_read_column].value == True
|
||||||
|
else:
|
||||||
|
db_filter = coalesce(db.cc_classes[config.config_read_column].value, False) != True
|
||||||
|
except (KeyError, AttributeError, IndexError):
|
||||||
|
log.error("Custom Column No.{} does not exist in calibre database".format(config.config_read_column))
|
||||||
|
flash(_("Custom Column No.%(column)d does not exist in calibre database",
|
||||||
|
column=config.config_read_column),
|
||||||
|
category="error")
|
||||||
|
return true()
|
||||||
|
return db_filter
|
||||||
|
|
||||||
|
|
||||||
|
def adv_search_extension(q, include_extension_inputs, exclude_extension_inputs):
|
||||||
|
for extension in include_extension_inputs:
|
||||||
|
q = q.filter(db.Books.data.any(db.Data.format == extension))
|
||||||
|
for extension in exclude_extension_inputs:
|
||||||
|
q = q.filter(not_(db.Books.data.any(db.Data.format == extension)))
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def adv_search_tag(q, include_tag_inputs, exclude_tag_inputs):
|
||||||
|
for tag in include_tag_inputs:
|
||||||
|
q = q.filter(db.Books.tags.any(db.Tags.id == tag))
|
||||||
|
for tag in exclude_tag_inputs:
|
||||||
|
q = q.filter(not_(db.Books.tags.any(db.Tags.id == tag)))
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def adv_search_serie(q, include_series_inputs, exclude_series_inputs):
|
||||||
|
for serie in include_series_inputs:
|
||||||
|
q = q.filter(db.Books.series.any(db.Series.id == serie))
|
||||||
|
for serie in exclude_series_inputs:
|
||||||
|
q = q.filter(not_(db.Books.series.any(db.Series.id == serie)))
|
||||||
|
return q
|
||||||
|
|
||||||
|
def adv_search_shelf(q, include_shelf_inputs, exclude_shelf_inputs):
|
||||||
|
q = q.outerjoin(ub.BookShelf, db.Books.id == ub.BookShelf.book_id)\
|
||||||
|
.filter(or_(ub.BookShelf.shelf == None, ub.BookShelf.shelf.notin_(exclude_shelf_inputs)))
|
||||||
|
if len(include_shelf_inputs) > 0:
|
||||||
|
q = q.filter(ub.BookShelf.shelf.in_(include_shelf_inputs))
|
||||||
|
return q
|
||||||
|
|
||||||
|
def extend_search_term(searchterm,
|
||||||
|
author_name,
|
||||||
|
book_title,
|
||||||
|
publisher,
|
||||||
|
pub_start,
|
||||||
|
pub_end,
|
||||||
|
tags,
|
||||||
|
rating_high,
|
||||||
|
rating_low,
|
||||||
|
read_status,
|
||||||
|
):
|
||||||
|
searchterm.extend((author_name.replace('|', ','), book_title, publisher))
|
||||||
|
if pub_start:
|
||||||
|
try:
|
||||||
|
searchterm.extend([_("Published after ") +
|
||||||
|
format_date(datetime.strptime(pub_start, "%Y-%m-%d"),
|
||||||
|
format='medium')])
|
||||||
|
except ValueError:
|
||||||
|
pub_start = ""
|
||||||
|
if pub_end:
|
||||||
|
try:
|
||||||
|
searchterm.extend([_("Published before ") +
|
||||||
|
format_date(datetime.strptime(pub_end, "%Y-%m-%d"),
|
||||||
|
format='medium')])
|
||||||
|
except ValueError:
|
||||||
|
pub_end = ""
|
||||||
|
elements = {'tag': db.Tags, 'serie':db.Series, 'shelf':ub.Shelf}
|
||||||
|
for key, db_element in elements.items():
|
||||||
|
tag_names = calibre_db.session.query(db_element).filter(db_element.id.in_(tags['include_' + key])).all()
|
||||||
|
searchterm.extend(tag.name for tag in tag_names)
|
||||||
|
tag_names = calibre_db.session.query(db_element).filter(db_element.id.in_(tags['exclude_' + key])).all()
|
||||||
|
searchterm.extend(tag.name for tag in tag_names)
|
||||||
|
language_names = calibre_db.session.query(db.Languages). \
|
||||||
|
filter(db.Languages.id.in_(tags['include_language'])).all()
|
||||||
|
if language_names:
|
||||||
|
language_names = calibre_db.speaking_language(language_names)
|
||||||
|
searchterm.extend(language.name for language in language_names)
|
||||||
|
language_names = calibre_db.session.query(db.Languages). \
|
||||||
|
filter(db.Languages.id.in_(tags['exclude_language'])).all()
|
||||||
|
if language_names:
|
||||||
|
language_names = calibre_db.speaking_language(language_names)
|
||||||
|
searchterm.extend(language.name for language in language_names)
|
||||||
|
if rating_high:
|
||||||
|
searchterm.extend([_("Rating <= %(rating)s", rating=rating_high)])
|
||||||
|
if rating_low:
|
||||||
|
searchterm.extend([_("Rating >= %(rating)s", rating=rating_low)])
|
||||||
|
if read_status != "Any":
|
||||||
|
searchterm.extend([_("Read Status = '%(status)s'", status=read_status)])
|
||||||
|
searchterm.extend(ext for ext in tags['include_extension'])
|
||||||
|
searchterm.extend(ext for ext in tags['exclude_extension'])
|
||||||
|
# handle custom columns
|
||||||
|
searchterm = " + ".join(filter(None, searchterm))
|
||||||
|
return searchterm, pub_start, pub_end
|
||||||
|
|
||||||
|
|
||||||
|
def render_adv_search_results(term, offset=None, order=None, limit=None):
|
||||||
|
sort = order[0] if order else [db.Books.sort]
|
||||||
|
pagination = None
|
||||||
|
|
||||||
|
cc = calibre_db.get_cc_columns(config, filter_config_custom_read=True)
|
||||||
|
calibre_db.session.connection().connection.connection.create_function("lower", 1, db.lcase)
|
||||||
|
query = calibre_db.generate_linked_query(config.config_read_column, db.Books)
|
||||||
|
q = query.outerjoin(db.books_series_link, db.Books.id == db.books_series_link.c.book)\
|
||||||
|
.outerjoin(db.Series)\
|
||||||
|
.filter(calibre_db.common_filters(True))
|
||||||
|
|
||||||
|
# parse multi selects to a complete dict
|
||||||
|
tags = dict()
|
||||||
|
elements = ['tag', 'serie', 'shelf', 'language', 'extension']
|
||||||
|
for element in elements:
|
||||||
|
tags['include_' + element] = term.get('include_' + element)
|
||||||
|
tags['exclude_' + element] = term.get('exclude_' + element)
|
||||||
|
|
||||||
|
author_name = term.get("author_name")
|
||||||
|
book_title = term.get("book_title")
|
||||||
|
publisher = term.get("publisher")
|
||||||
|
pub_start = term.get("publishstart")
|
||||||
|
pub_end = term.get("publishend")
|
||||||
|
rating_low = term.get("ratinghigh")
|
||||||
|
rating_high = term.get("ratinglow")
|
||||||
|
description = term.get("comment")
|
||||||
|
read_status = term.get("read_status")
|
||||||
|
if author_name:
|
||||||
|
author_name = author_name.strip().lower().replace(',', '|')
|
||||||
|
if book_title:
|
||||||
|
book_title = book_title.strip().lower()
|
||||||
|
if publisher:
|
||||||
|
publisher = publisher.strip().lower()
|
||||||
|
|
||||||
|
search_term = []
|
||||||
|
cc_present = False
|
||||||
|
for c in cc:
|
||||||
|
if c.datatype == "datetime":
|
||||||
|
column_start = term.get('custom_column_' + str(c.id) + '_start')
|
||||||
|
column_end = term.get('custom_column_' + str(c.id) + '_end')
|
||||||
|
if column_start:
|
||||||
|
search_term.extend(["{} >= {}".format(c.name,
|
||||||
|
format_date(datetime.strptime(column_start, "%Y-%m-%d").date(),
|
||||||
|
format='medium')
|
||||||
|
)])
|
||||||
|
cc_present = True
|
||||||
|
if column_end:
|
||||||
|
search_term.extend(["{} <= {}".format(c.name,
|
||||||
|
format_date(datetime.strptime(column_end, "%Y-%m-%d").date(),
|
||||||
|
format='medium')
|
||||||
|
)])
|
||||||
|
cc_present = True
|
||||||
|
elif term.get('custom_column_' + str(c.id)):
|
||||||
|
search_term.extend([("{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))])
|
||||||
|
cc_present = True
|
||||||
|
|
||||||
|
if any(tags.values()) or author_name or book_title or publisher or pub_start or pub_end or rating_low \
|
||||||
|
or rating_high or description or cc_present or read_status != "Any":
|
||||||
|
search_term, pub_start, pub_end = extend_search_term(search_term,
|
||||||
|
author_name,
|
||||||
|
book_title,
|
||||||
|
publisher,
|
||||||
|
pub_start,
|
||||||
|
pub_end,
|
||||||
|
tags,
|
||||||
|
rating_high,
|
||||||
|
rating_low,
|
||||||
|
read_status)
|
||||||
|
if author_name:
|
||||||
|
q = q.filter(db.Books.authors.any(func.lower(db.Authors.name).ilike("%" + author_name + "%")))
|
||||||
|
if book_title:
|
||||||
|
q = q.filter(func.lower(db.Books.title).ilike("%" + book_title + "%"))
|
||||||
|
if pub_start:
|
||||||
|
q = q.filter(func.datetime(db.Books.pubdate) > func.datetime(pub_start))
|
||||||
|
if pub_end:
|
||||||
|
q = q.filter(func.datetime(db.Books.pubdate) < func.datetime(pub_end))
|
||||||
|
if read_status != "Any":
|
||||||
|
q = q.filter(adv_search_read_status(read_status))
|
||||||
|
if publisher:
|
||||||
|
q = q.filter(db.Books.publishers.any(func.lower(db.Publishers.name).ilike("%" + publisher + "%")))
|
||||||
|
q = adv_search_tag(q, tags['include_tag'], tags['exclude_tag'])
|
||||||
|
q = adv_search_serie(q, tags['include_serie'], tags['exclude_serie'])
|
||||||
|
q = adv_search_shelf(q, tags['include_shelf'], tags['exclude_shelf'])
|
||||||
|
q = adv_search_extension(q, tags['include_extension'], tags['exclude_extension'])
|
||||||
|
q = adv_search_language(q, tags['include_language'], tags['exclude_language'])
|
||||||
|
q = adv_search_ratings(q, rating_high, rating_low)
|
||||||
|
|
||||||
|
if description:
|
||||||
|
q = q.filter(db.Books.comments.any(func.lower(db.Comments.text).ilike("%" + description + "%")))
|
||||||
|
|
||||||
|
# search custom columns
|
||||||
|
try:
|
||||||
|
q = adv_search_custom_columns(cc, term, q)
|
||||||
|
except AttributeError as ex:
|
||||||
|
log.debug_or_exception(ex)
|
||||||
|
flash(_("Error on search for custom columns, please restart Calibre-Web"), category="error")
|
||||||
|
|
||||||
|
q = q.order_by(*sort).all()
|
||||||
|
flask_session['query'] = json.dumps(term)
|
||||||
|
ub.store_combo_ids(q)
|
||||||
|
result_count = len(q)
|
||||||
|
if offset is not None and limit is not None:
|
||||||
|
offset = int(offset)
|
||||||
|
limit_all = offset + int(limit)
|
||||||
|
pagination = Pagination((offset / (int(limit)) + 1), limit, result_count)
|
||||||
|
else:
|
||||||
|
offset = 0
|
||||||
|
limit_all = result_count
|
||||||
|
entries = calibre_db.order_authors(q[offset:limit_all], list_return=True, combined=True)
|
||||||
|
return render_title_template('search.html',
|
||||||
|
adv_searchterm=search_term,
|
||||||
|
pagination=pagination,
|
||||||
|
entries=entries,
|
||||||
|
result_count=result_count,
|
||||||
|
title=_("Advanced Search"), page="advsearch",
|
||||||
|
order=order[1])
|
||||||
|
|
||||||
|
|
||||||
|
def render_prepare_search_form(cc):
|
||||||
|
# prepare data for search-form
|
||||||
|
tags = calibre_db.session.query(db.Tags)\
|
||||||
|
.join(db.books_tags_link)\
|
||||||
|
.join(db.Books)\
|
||||||
|
.filter(calibre_db.common_filters()) \
|
||||||
|
.group_by(text('books_tags_link.tag'))\
|
||||||
|
.order_by(db.Tags.name).all()
|
||||||
|
series = calibre_db.session.query(db.Series)\
|
||||||
|
.join(db.books_series_link)\
|
||||||
|
.join(db.Books)\
|
||||||
|
.filter(calibre_db.common_filters()) \
|
||||||
|
.group_by(text('books_series_link.series'))\
|
||||||
|
.order_by(db.Series.name)\
|
||||||
|
.filter(calibre_db.common_filters()).all()
|
||||||
|
shelves = ub.session.query(ub.Shelf)\
|
||||||
|
.filter(or_(ub.Shelf.is_public == 1, ub.Shelf.user_id == int(current_user.id)))\
|
||||||
|
.order_by(ub.Shelf.name).all()
|
||||||
|
extensions = calibre_db.session.query(db.Data)\
|
||||||
|
.join(db.Books)\
|
||||||
|
.filter(calibre_db.common_filters()) \
|
||||||
|
.group_by(db.Data.format)\
|
||||||
|
.order_by(db.Data.format).all()
|
||||||
|
if current_user.filter_language() == "all":
|
||||||
|
languages = calibre_db.speaking_language()
|
||||||
|
else:
|
||||||
|
languages = None
|
||||||
|
return render_title_template('search_form.html', tags=tags, languages=languages, extensions=extensions,
|
||||||
|
series=series,shelves=shelves, title=_("Advanced Search"), cc=cc, page="advsearch")
|
||||||
|
|
||||||
|
|
||||||
|
def render_search_results(term, offset=None, order=None, limit=None):
|
||||||
|
if term:
|
||||||
|
join = db.books_series_link, db.Books.id == db.books_series_link.c.book, db.Series
|
||||||
|
entries, result_count, pagination = calibre_db.get_search_results(term,
|
||||||
|
config,
|
||||||
|
offset,
|
||||||
|
order,
|
||||||
|
limit,
|
||||||
|
*join)
|
||||||
|
else:
|
||||||
|
entries = list()
|
||||||
|
order = [None, None]
|
||||||
|
pagination = result_count = None
|
||||||
|
|
||||||
|
return render_title_template('search.html',
|
||||||
|
searchterm=term,
|
||||||
|
pagination=pagination,
|
||||||
|
query=term,
|
||||||
|
adv_searchterm=term,
|
||||||
|
entries=entries,
|
||||||
|
result_count=result_count,
|
||||||
|
title=_("Search"),
|
||||||
|
page="search",
|
||||||
|
order=order[1])
|
||||||
|
|
||||||
|
|
143
cps/search_metadata.py
Normal file
143
cps/search_metadata.py
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2021 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import concurrent.futures
|
||||||
|
import importlib
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, request, url_for
|
||||||
|
from flask_login import current_user
|
||||||
|
from flask_login import login_required
|
||||||
|
from flask_babel import get_locale
|
||||||
|
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
|
from cps.services.Metadata import Metadata
|
||||||
|
from . import constants, logger, ub, web_server
|
||||||
|
|
||||||
|
# current_milli_time = lambda: int(round(time() * 1000))
|
||||||
|
|
||||||
|
meta = Blueprint("metadata", __name__)
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from dataclasses import asdict
|
||||||
|
except ImportError:
|
||||||
|
log.info('*** "dataclasses" is needed for calibre-web to run. Please install it using pip: "pip install dataclasses" ***')
|
||||||
|
print('*** "dataclasses" is needed for calibre-web to run. Please install it using pip: "pip install dataclasses" ***')
|
||||||
|
web_server.stop(True)
|
||||||
|
sys.exit(6)
|
||||||
|
|
||||||
|
new_list = list()
|
||||||
|
meta_dir = os.path.join(constants.BASE_DIR, "cps", "metadata_provider")
|
||||||
|
modules = os.listdir(os.path.join(constants.BASE_DIR, "cps", "metadata_provider"))
|
||||||
|
for f in modules:
|
||||||
|
if os.path.isfile(os.path.join(meta_dir, f)) and not f.endswith("__init__.py"):
|
||||||
|
a = os.path.basename(f)[:-3]
|
||||||
|
try:
|
||||||
|
importlib.import_module("cps.metadata_provider." + a)
|
||||||
|
new_list.append(a)
|
||||||
|
except (IndentationError, SyntaxError) as e:
|
||||||
|
log.error("Syntax error for metadata source: {} - {}".format(a, e))
|
||||||
|
except ImportError as e:
|
||||||
|
log.debug("Import error for metadata source: {} - {}".format(a, e))
|
||||||
|
|
||||||
|
|
||||||
|
def list_classes(provider_list):
|
||||||
|
classes = list()
|
||||||
|
for element in provider_list:
|
||||||
|
for name, obj in inspect.getmembers(
|
||||||
|
sys.modules["cps.metadata_provider." + element]
|
||||||
|
):
|
||||||
|
if (
|
||||||
|
inspect.isclass(obj)
|
||||||
|
and name != "Metadata"
|
||||||
|
and issubclass(obj, Metadata)
|
||||||
|
):
|
||||||
|
classes.append(obj())
|
||||||
|
return classes
|
||||||
|
|
||||||
|
|
||||||
|
cl = list_classes(new_list)
|
||||||
|
|
||||||
|
|
||||||
|
@meta.route("/metadata/provider")
|
||||||
|
@login_required
|
||||||
|
def metadata_provider():
|
||||||
|
active = current_user.view_settings.get("metadata", {})
|
||||||
|
provider = list()
|
||||||
|
for c in cl:
|
||||||
|
ac = active.get(c.__id__, True)
|
||||||
|
provider.append(
|
||||||
|
{"name": c.__name__, "active": ac, "initial": ac, "id": c.__id__}
|
||||||
|
)
|
||||||
|
return Response(json.dumps(provider), mimetype="application/json")
|
||||||
|
|
||||||
|
|
||||||
|
@meta.route("/metadata/provider", methods=["POST"])
|
||||||
|
@meta.route("/metadata/provider/<prov_name>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def metadata_change_active_provider(prov_name):
|
||||||
|
new_state = request.get_json()
|
||||||
|
active = current_user.view_settings.get("metadata", {})
|
||||||
|
active[new_state["id"]] = new_state["value"]
|
||||||
|
current_user.view_settings["metadata"] = active
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
flag_modified(current_user, "view_settings")
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
ub.session.commit()
|
||||||
|
except (InvalidRequestError, OperationalError):
|
||||||
|
log.error("Invalid request received: {}".format(request))
|
||||||
|
return "Invalid request", 400
|
||||||
|
if "initial" in new_state and prov_name:
|
||||||
|
data = []
|
||||||
|
provider = next((c for c in cl if c.__id__ == prov_name), None)
|
||||||
|
if provider is not None:
|
||||||
|
data = provider.search(new_state.get("query", ""))
|
||||||
|
return Response(
|
||||||
|
json.dumps([asdict(x) for x in data]), mimetype="application/json"
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@meta.route("/metadata/search", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def metadata_search():
|
||||||
|
query = request.form.to_dict().get("query")
|
||||||
|
data = list()
|
||||||
|
active = current_user.view_settings.get("metadata", {})
|
||||||
|
locale = get_locale()
|
||||||
|
if query:
|
||||||
|
static_cover = url_for("static", filename="generic_cover.jpg")
|
||||||
|
# start = current_milli_time()
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||||
|
meta = {
|
||||||
|
executor.submit(c.search, query, static_cover, locale): c
|
||||||
|
for c in cl
|
||||||
|
if active.get(c.__id__, True)
|
||||||
|
}
|
||||||
|
for future in concurrent.futures.as_completed(meta):
|
||||||
|
data.extend([asdict(x) for x in future.result() if x])
|
||||||
|
# log.info({'Time elapsed {}'.format(current_milli_time()-start)})
|
||||||
|
return Response(json.dumps(data), mimetype="application/json")
|
205
cps/server.py
205
cps/server.py
|
@ -16,23 +16,28 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import errno
|
import errno
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
|
import asyncio
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from gevent.pywsgi import WSGIServer
|
from gevent.pywsgi import WSGIServer
|
||||||
|
from .gevent_wsgi import MyWSGIHandler
|
||||||
from gevent.pool import Pool
|
from gevent.pool import Pool
|
||||||
|
from gevent.socket import socket as GeventSocket
|
||||||
from gevent import __version__ as _version
|
from gevent import __version__ as _version
|
||||||
|
from greenlet import GreenletExit
|
||||||
|
import ssl
|
||||||
VERSION = 'Gevent ' + _version
|
VERSION = 'Gevent ' + _version
|
||||||
_GEVENT = True
|
_GEVENT = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from tornado.wsgi import WSGIContainer
|
from .tornado_wsgi import MyWSGIContainer
|
||||||
from tornado.httpserver import HTTPServer
|
from tornado.httpserver import HTTPServer
|
||||||
from tornado.ioloop import IOLoop
|
from tornado.ioloop import IOLoop
|
||||||
|
from tornado import netutil
|
||||||
from tornado import version as _version
|
from tornado import version as _version
|
||||||
VERSION = 'Tornado ' + _version
|
VERSION = 'Tornado ' + _version
|
||||||
_GEVENT = False
|
_GEVENT = False
|
||||||
|
@ -72,7 +77,11 @@ class WebServer(object):
|
||||||
if config.config_access_log:
|
if config.config_access_log:
|
||||||
log_name = "gevent.access" if _GEVENT else "tornado.access"
|
log_name = "gevent.access" if _GEVENT else "tornado.access"
|
||||||
formatter = logger.ACCESS_FORMATTER_GEVENT if _GEVENT else logger.ACCESS_FORMATTER_TORNADO
|
formatter = logger.ACCESS_FORMATTER_GEVENT if _GEVENT else logger.ACCESS_FORMATTER_TORNADO
|
||||||
self.access_logger = logger.create_access_log(config.config_access_logfile, log_name, formatter)
|
self.access_logger, logfile = logger.create_access_log(config.config_access_logfile, log_name, formatter)
|
||||||
|
if logfile != config.config_access_logfile:
|
||||||
|
log.warning("Accesslog path %s not valid, falling back to default", config.config_access_logfile)
|
||||||
|
config.config_access_logfile = logfile
|
||||||
|
config.save()
|
||||||
else:
|
else:
|
||||||
if not _GEVENT:
|
if not _GEVENT:
|
||||||
logger.get('tornado.access').disabled = True
|
logger.get('tornado.access').disabled = True
|
||||||
|
@ -88,7 +97,12 @@ class WebServer(object):
|
||||||
log.warning('Cert path: %s', certfile_path)
|
log.warning('Cert path: %s', certfile_path)
|
||||||
log.warning('Key path: %s', keyfile_path)
|
log.warning('Key path: %s', keyfile_path)
|
||||||
|
|
||||||
def _make_gevent_unix_socket(self, socket_file):
|
def _make_gevent_socket_activated(self):
|
||||||
|
# Reuse an already open socket on fd=SD_LISTEN_FDS_START
|
||||||
|
SD_LISTEN_FDS_START = 3
|
||||||
|
return GeventSocket(fileno=SD_LISTEN_FDS_START)
|
||||||
|
|
||||||
|
def _prepare_unix_socket(self, socket_file):
|
||||||
# the socket file must not exist prior to bind()
|
# the socket file must not exist prior to bind()
|
||||||
if os.path.exists(socket_file):
|
if os.path.exists(socket_file):
|
||||||
# avoid nuking regular files and symbolic links (could be a mistype or security issue)
|
# avoid nuking regular files and symbolic links (could be a mistype or security issue)
|
||||||
|
@ -96,49 +110,125 @@ class WebServer(object):
|
||||||
raise OSError(errno.EEXIST, os.strerror(errno.EEXIST), socket_file)
|
raise OSError(errno.EEXIST, os.strerror(errno.EEXIST), socket_file)
|
||||||
os.remove(socket_file)
|
os.remove(socket_file)
|
||||||
|
|
||||||
unix_sock = WSGIServer.get_listener(socket_file, family=socket.AF_UNIX)
|
|
||||||
self.unix_socket_file = socket_file
|
self.unix_socket_file = socket_file
|
||||||
|
|
||||||
# ensure current user and group have r/w permissions, no permissions for other users
|
def _make_gevent_listener(self):
|
||||||
# this way the socket can be shared in a semi-secure manner
|
|
||||||
# between the user running calibre-web and the user running the fronting webserver
|
|
||||||
os.chmod(socket_file, 0o660)
|
|
||||||
|
|
||||||
return unix_sock
|
|
||||||
|
|
||||||
def _make_gevent_socket(self):
|
|
||||||
if os.name != 'nt':
|
if os.name != 'nt':
|
||||||
|
socket_activated = os.environ.get("LISTEN_FDS")
|
||||||
|
if socket_activated:
|
||||||
|
sock = self._make_gevent_socket_activated()
|
||||||
|
sock_info = sock.getsockname()
|
||||||
|
return sock, "systemd-socket:" + _readable_listen_address(sock_info[0], sock_info[1])
|
||||||
unix_socket_file = os.environ.get("CALIBRE_UNIX_SOCKET")
|
unix_socket_file = os.environ.get("CALIBRE_UNIX_SOCKET")
|
||||||
if unix_socket_file:
|
if unix_socket_file:
|
||||||
return self._make_gevent_unix_socket(unix_socket_file), "unix:" + unix_socket_file
|
self._prepare_unix_socket(unix_socket_file)
|
||||||
|
unix_sock = WSGIServer.get_listener(unix_socket_file, family=socket.AF_UNIX)
|
||||||
|
# ensure current user and group have r/w permissions, no permissions for other users
|
||||||
|
# this way the socket can be shared in a semi-secure manner
|
||||||
|
# between the user running calibre-web and the user running the fronting webserver
|
||||||
|
os.chmod(unix_socket_file, 0o660)
|
||||||
|
|
||||||
|
return unix_sock, "unix:" + unix_socket_file
|
||||||
|
|
||||||
if self.listen_address:
|
if self.listen_address:
|
||||||
return (self.listen_address, self.listen_port), None
|
return ((self.listen_address, self.listen_port),
|
||||||
|
_readable_listen_address(self.listen_address, self.listen_port))
|
||||||
|
|
||||||
if os.name == 'nt':
|
if os.name == 'nt':
|
||||||
self.listen_address = '0.0.0.0'
|
self.listen_address = '0.0.0.0'
|
||||||
return (self.listen_address, self.listen_port), None
|
return ((self.listen_address, self.listen_port),
|
||||||
|
_readable_listen_address(self.listen_address, self.listen_port))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
address = ('::', self.listen_port)
|
address = ('::', self.listen_port)
|
||||||
sock = WSGIServer.get_listener(address, family=socket.AF_INET6)
|
sock = WSGIServer.get_listener(address, family=socket.AF_INET6)
|
||||||
except socket.error as ex:
|
except socket.error as ex:
|
||||||
log.error('%s', ex)
|
log.error('%s', ex)
|
||||||
log.warning('Unable to listen on "", trying on IPv4 only...')
|
log.warning('Unable to listen on {}, trying on IPv4 only...'.format(address))
|
||||||
address = ('', self.listen_port)
|
address = ('', self.listen_port)
|
||||||
sock = WSGIServer.get_listener(address, family=socket.AF_INET)
|
sock = WSGIServer.get_listener(address, family=socket.AF_INET)
|
||||||
|
|
||||||
return sock, _readable_listen_address(*address)
|
return sock, _readable_listen_address(*address)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_args_for_reloading():
|
||||||
|
"""Determine how the script was executed, and return the args needed
|
||||||
|
to execute it again in a new process.
|
||||||
|
Code from https://github.com/pyload/pyload. Author GammaC0de, voulter
|
||||||
|
"""
|
||||||
|
rv = [sys.executable]
|
||||||
|
py_script = sys.argv[0]
|
||||||
|
args = sys.argv[1:]
|
||||||
|
# Need to look at main module to determine how it was executed.
|
||||||
|
__main__ = sys.modules["__main__"]
|
||||||
|
|
||||||
|
# The value of __package__ indicates how Python was called. It may
|
||||||
|
# not exist if a setuptools script is installed as an egg. It may be
|
||||||
|
# set incorrectly for entry points created with pip on Windows.
|
||||||
|
if getattr(__main__, "__package__", "") in ["", None] or (
|
||||||
|
os.name == "nt"
|
||||||
|
and __main__.__package__ == ""
|
||||||
|
and not os.path.exists(py_script)
|
||||||
|
and os.path.exists("{}.exe".format(py_script))
|
||||||
|
):
|
||||||
|
# Executed a file, like "python app.py".
|
||||||
|
py_script = os.path.abspath(py_script)
|
||||||
|
|
||||||
|
if os.name == "nt":
|
||||||
|
# Windows entry points have ".exe" extension and should be
|
||||||
|
# called directly.
|
||||||
|
if not os.path.exists(py_script) and os.path.exists("{}.exe".format(py_script)):
|
||||||
|
py_script += ".exe"
|
||||||
|
|
||||||
|
if (
|
||||||
|
os.path.splitext(sys.executable)[1] == ".exe"
|
||||||
|
and os.path.splitext(py_script)[1] == ".exe"
|
||||||
|
):
|
||||||
|
rv.pop(0)
|
||||||
|
|
||||||
|
rv.append(py_script)
|
||||||
|
else:
|
||||||
|
# Executed a module, like "python -m module".
|
||||||
|
if sys.argv[0] == "-m":
|
||||||
|
args = sys.argv
|
||||||
|
else:
|
||||||
|
if os.path.isfile(py_script):
|
||||||
|
# Rewritten by Python from "-m script" to "/path/to/script.py".
|
||||||
|
py_module = __main__.__package__
|
||||||
|
name = os.path.splitext(os.path.basename(py_script))[0]
|
||||||
|
|
||||||
|
if name != "__main__":
|
||||||
|
py_module += ".{}".format(name)
|
||||||
|
else:
|
||||||
|
# Incorrectly rewritten by pydevd debugger from "-m script" to "script".
|
||||||
|
py_module = py_script
|
||||||
|
|
||||||
|
rv.extend(("-m", py_module.lstrip(".")))
|
||||||
|
|
||||||
|
rv.extend(args)
|
||||||
|
if os.name == 'nt':
|
||||||
|
rv = ['"{}"'.format(a) for a in rv]
|
||||||
|
return rv
|
||||||
|
|
||||||
def _start_gevent(self):
|
def _start_gevent(self):
|
||||||
ssl_args = self.ssl_args or {}
|
ssl_args = self.ssl_args or {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sock, output = self._make_gevent_socket()
|
sock, output = self._make_gevent_listener()
|
||||||
if output is None:
|
|
||||||
output = _readable_listen_address(self.listen_address, self.listen_port)
|
|
||||||
log.info('Starting Gevent server on %s', output)
|
log.info('Starting Gevent server on %s', output)
|
||||||
self.wsgiserver = WSGIServer(sock, self.app, log=self.access_logger, spawn=Pool(), **ssl_args)
|
self.wsgiserver = WSGIServer(sock, self.app, log=self.access_logger, handler_class=MyWSGIHandler,
|
||||||
|
error_log=log,
|
||||||
|
spawn=Pool(), **ssl_args)
|
||||||
|
if ssl_args:
|
||||||
|
wrap_socket = self.wsgiserver.wrap_socket
|
||||||
|
def my_wrap_socket(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return wrap_socket(*args, **kwargs)
|
||||||
|
except (ssl.SSLError, OSError) as ex:
|
||||||
|
log.warning('Gevent SSL Error: %s', ex)
|
||||||
|
raise GreenletExit
|
||||||
|
|
||||||
|
self.wsgiserver.wrap_socket = my_wrap_socket
|
||||||
self.wsgiserver.serve_forever()
|
self.wsgiserver.serve_forever()
|
||||||
finally:
|
finally:
|
||||||
if self.unix_socket_file:
|
if self.unix_socket_file:
|
||||||
|
@ -149,17 +239,42 @@ class WebServer(object):
|
||||||
if os.name == 'nt' and sys.version_info > (3, 7):
|
if os.name == 'nt' and sys.version_info > (3, 7):
|
||||||
import asyncio
|
import asyncio
|
||||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||||
log.info('Starting Tornado server on %s', _readable_listen_address(self.listen_address, self.listen_port))
|
try:
|
||||||
|
# Max Buffersize set to 200MB
|
||||||
|
http_server = HTTPServer(MyWSGIContainer(self.app),
|
||||||
|
max_buffer_size=209700000,
|
||||||
|
ssl_options=self.ssl_args)
|
||||||
|
|
||||||
# Max Buffersize set to 200MB )
|
unix_socket_file = os.environ.get("CALIBRE_UNIX_SOCKET")
|
||||||
http_server = HTTPServer(WSGIContainer(self.app),
|
if os.environ.get("LISTEN_FDS") and os.name != 'nt':
|
||||||
max_buffer_size=209700000,
|
SD_LISTEN_FDS_START = 3
|
||||||
ssl_options=self.ssl_args)
|
sock = socket.socket(fileno=SD_LISTEN_FDS_START)
|
||||||
http_server.listen(self.listen_port, self.listen_address)
|
http_server.add_socket(sock)
|
||||||
self.wsgiserver = IOLoop.current()
|
sock.setblocking(0)
|
||||||
self.wsgiserver.start()
|
socket_name =sock.getsockname()
|
||||||
# wait for stop signal
|
output = "systemd-socket:" + _readable_listen_address(socket_name[0], socket_name[1])
|
||||||
self.wsgiserver.close(True)
|
elif unix_socket_file and os.name != 'nt':
|
||||||
|
self._prepare_unix_socket(unix_socket_file)
|
||||||
|
output = "unix:" + unix_socket_file
|
||||||
|
unix_socket = netutil.bind_unix_socket(self.unix_socket_file)
|
||||||
|
http_server.add_socket(unix_socket)
|
||||||
|
# ensure current user and group have r/w permissions, no permissions for other users
|
||||||
|
# this way the socket can be shared in a semi-secure manner
|
||||||
|
# between the user running calibre-web and the user running the fronting webserver
|
||||||
|
os.chmod(self.unix_socket_file, 0o660)
|
||||||
|
else:
|
||||||
|
output = _readable_listen_address(self.listen_address, self.listen_port)
|
||||||
|
http_server.listen(self.listen_port, self.listen_address)
|
||||||
|
log.info('Starting Tornado server on %s', output)
|
||||||
|
|
||||||
|
self.wsgiserver = IOLoop.current()
|
||||||
|
self.wsgiserver.start()
|
||||||
|
# wait for stop signal
|
||||||
|
self.wsgiserver.close(True)
|
||||||
|
finally:
|
||||||
|
if self.unix_socket_file:
|
||||||
|
os.remove(self.unix_socket_file)
|
||||||
|
self.unix_socket_file = None
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
try:
|
try:
|
||||||
|
@ -176,21 +291,26 @@ class WebServer(object):
|
||||||
finally:
|
finally:
|
||||||
self.wsgiserver = None
|
self.wsgiserver = None
|
||||||
|
|
||||||
|
# prevent irritating log of pending tasks message from asyncio
|
||||||
|
logger.get('asyncio').setLevel(logger.logging.CRITICAL)
|
||||||
|
|
||||||
if not self.restart:
|
if not self.restart:
|
||||||
log.info("Performing shutdown of Calibre-Web")
|
log.info("Performing shutdown of Calibre-Web")
|
||||||
# prevent irritiating log of pending tasks message from asyncio
|
|
||||||
logger.get('asyncio').setLevel(logger.logging.CRITICAL)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
log.info("Performing restart of Calibre-Web")
|
log.info("Performing restart of Calibre-Web")
|
||||||
arguments = list(sys.argv)
|
args = self._get_args_for_reloading()
|
||||||
arguments.insert(0, sys.executable)
|
os.execv(args[0].lstrip('"').rstrip('"'), args)
|
||||||
if os.name == 'nt':
|
|
||||||
arguments = ["\"%s\"" % a for a in arguments]
|
|
||||||
os.execv(sys.executable, arguments)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _killServer(self, ignored_signum, ignored_frame):
|
@staticmethod
|
||||||
|
def shutdown_scheduler():
|
||||||
|
from .services.background_scheduler import BackgroundScheduler
|
||||||
|
scheduler = BackgroundScheduler()
|
||||||
|
if scheduler:
|
||||||
|
scheduler.scheduler.shutdown()
|
||||||
|
|
||||||
|
def _killServer(self, __, ___):
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
def stop(self, restart=False):
|
def stop(self, restart=False):
|
||||||
|
@ -198,9 +318,14 @@ class WebServer(object):
|
||||||
updater_thread.stop()
|
updater_thread.stop()
|
||||||
|
|
||||||
log.info("webserver stop (restart=%s)", restart)
|
log.info("webserver stop (restart=%s)", restart)
|
||||||
|
self.shutdown_scheduler()
|
||||||
self.restart = restart
|
self.restart = restart
|
||||||
if self.wsgiserver:
|
if self.wsgiserver:
|
||||||
if _GEVENT:
|
if _GEVENT:
|
||||||
self.wsgiserver.close()
|
self.wsgiserver.close()
|
||||||
else:
|
else:
|
||||||
self.wsgiserver.add_callback_from_signal(self.wsgiserver.stop)
|
if restart:
|
||||||
|
self.wsgiserver.call_later(1.0, self.wsgiserver.stop)
|
||||||
|
else:
|
||||||
|
self.wsgiserver.asyncio_loop.call_soon_threadsafe(self.wsgiserver.stop)
|
||||||
|
|
||||||
|
|
111
cps/services/Metadata.py
Normal file
111
cps/services/Metadata.py
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2021 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
import abc
|
||||||
|
import dataclasses
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Dict, Generator, List, Optional, Union
|
||||||
|
|
||||||
|
from cps import constants
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class MetaSourceInfo:
|
||||||
|
id: str
|
||||||
|
description: str
|
||||||
|
link: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class MetaRecord:
|
||||||
|
id: Union[str, int]
|
||||||
|
title: str
|
||||||
|
authors: List[str]
|
||||||
|
url: str
|
||||||
|
source: MetaSourceInfo
|
||||||
|
cover: str = os.path.join(constants.STATIC_DIR, 'generic_cover.jpg')
|
||||||
|
description: Optional[str] = ""
|
||||||
|
series: Optional[str] = None
|
||||||
|
series_index: Optional[Union[int, float]] = 0
|
||||||
|
identifiers: Dict[str, Union[str, int]] = dataclasses.field(default_factory=dict)
|
||||||
|
publisher: Optional[str] = None
|
||||||
|
publishedDate: Optional[str] = None
|
||||||
|
rating: Optional[int] = 0
|
||||||
|
languages: Optional[List[str]] = dataclasses.field(default_factory=list)
|
||||||
|
tags: Optional[List[str]] = dataclasses.field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class Metadata:
|
||||||
|
__name__ = "Generic"
|
||||||
|
__id__ = "generic"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.active = True
|
||||||
|
|
||||||
|
def set_status(self, state):
|
||||||
|
self.active = state
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def search(
|
||||||
|
self, query: str, generic_cover: str = "", locale: str = "en"
|
||||||
|
) -> Optional[List[MetaRecord]]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_title_tokens(
|
||||||
|
title: str, strip_joiners: bool = True
|
||||||
|
) -> Generator[str, None, None]:
|
||||||
|
"""
|
||||||
|
Taken from calibre source code
|
||||||
|
It's a simplified (cut out what is unnecessary) version of
|
||||||
|
https://github.com/kovidgoyal/calibre/blob/99d85b97918625d172227c8ffb7e0c71794966c0/
|
||||||
|
src/calibre/ebooks/metadata/sources/base.py#L363-L367
|
||||||
|
(src/calibre/ebooks/metadata/sources/base.py - lines 363-398)
|
||||||
|
"""
|
||||||
|
title_patterns = [
|
||||||
|
(re.compile(pat, re.IGNORECASE), repl)
|
||||||
|
for pat, repl in [
|
||||||
|
# Remove things like: (2010) (Omnibus) etc.
|
||||||
|
(
|
||||||
|
r"(?i)[({\[](\d{4}|omnibus|anthology|hardcover|"
|
||||||
|
r"audiobook|audio\scd|paperback|turtleback|"
|
||||||
|
r"mass\s*market|edition|ed\.)[\])}]",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
# Remove any strings that contain the substring edition inside
|
||||||
|
# parentheses
|
||||||
|
(r"(?i)[({\[].*?(edition|ed.).*?[\]})]", ""),
|
||||||
|
# Remove commas used a separators in numbers
|
||||||
|
(r"(\d+),(\d+)", r"\1\2"),
|
||||||
|
# Remove hyphens only if they have whitespace before them
|
||||||
|
(r"(\s-)", " "),
|
||||||
|
# Replace other special chars with a space
|
||||||
|
(r"""[:,;!@$%^&*(){}.`~"\s\[\]/]《》「」“”""", " "),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
for pat, repl in title_patterns:
|
||||||
|
title = pat.sub(repl, title)
|
||||||
|
|
||||||
|
tokens = title.split()
|
||||||
|
for token in tokens:
|
||||||
|
token = token.strip().strip('"').strip("'")
|
||||||
|
if token and (
|
||||||
|
not strip_joiners or token.lower() not in ("a", "and", "the", "&")
|
||||||
|
):
|
||||||
|
yield token
|
|
@ -19,25 +19,21 @@
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from base64 import b64decode, b64encode
|
from base64 import b64decode, b64encode
|
||||||
from jsonschema import validate, exceptions, __version__
|
from jsonschema import validate, exceptions
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
try:
|
|
||||||
from urllib import unquote
|
|
||||||
except ImportError:
|
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
from flask import json
|
from flask import json
|
||||||
from .. import logger as log
|
from .. import logger
|
||||||
|
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
def b64encode_json(json_data):
|
def b64encode_json(json_data):
|
||||||
if sys.version_info < (3, 0):
|
return b64encode(json.dumps(json_data).encode()).decode("utf-8")
|
||||||
return b64encode(json.dumps(json_data))
|
|
||||||
else:
|
|
||||||
return b64encode(json.dumps(json_data).encode())
|
|
||||||
|
|
||||||
|
|
||||||
# Python3 has a timestamp() method we could be calling, however it's not avaiable in python2.
|
# Python3 has a timestamp() method we could be calling, however it's not available in python2.
|
||||||
def to_epoch_timestamp(datetime_object):
|
def to_epoch_timestamp(datetime_object):
|
||||||
return (datetime_object - datetime(1970, 1, 1)).total_seconds()
|
return (datetime_object - datetime(1970, 1, 1)).total_seconds()
|
||||||
|
|
||||||
|
@ -45,12 +41,13 @@ def to_epoch_timestamp(datetime_object):
|
||||||
def get_datetime_from_json(json_object, field_name):
|
def get_datetime_from_json(json_object, field_name):
|
||||||
try:
|
try:
|
||||||
return datetime.utcfromtimestamp(json_object[field_name])
|
return datetime.utcfromtimestamp(json_object[field_name])
|
||||||
except KeyError:
|
except (KeyError, OSError, OverflowError):
|
||||||
|
# OSError is thrown on Windows if timestamp is <1970 or >2038
|
||||||
return datetime.min
|
return datetime.min
|
||||||
|
|
||||||
|
|
||||||
class SyncToken:
|
class SyncToken:
|
||||||
""" The SyncToken is used to persist state accross requests.
|
""" The SyncToken is used to persist state across requests.
|
||||||
When serialized over the response headers, the Kobo device will propagate the token onto following
|
When serialized over the response headers, the Kobo device will propagate the token onto following
|
||||||
requests to the service. As an example use-case, the SyncToken is used to detect books that have been added
|
requests to the service. As an example use-case, the SyncToken is used to detect books that have been added
|
||||||
to the library since the last time the device synced to the server.
|
to the library since the last time the device synced to the server.
|
||||||
|
@ -60,7 +57,7 @@ class SyncToken:
|
||||||
books_last_modified: Datetime representing the last modified book that the device knows about.
|
books_last_modified: Datetime representing the last modified book that the device knows about.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
SYNC_TOKEN_HEADER = "x-kobo-synctoken"
|
SYNC_TOKEN_HEADER = "x-kobo-synctoken" # nosec
|
||||||
VERSION = "1-1-0"
|
VERSION = "1-1-0"
|
||||||
LAST_MODIFIED_ADDED_VERSION = "1-1-0"
|
LAST_MODIFIED_ADDED_VERSION = "1-1-0"
|
||||||
MIN_VERSION = "1-0-0"
|
MIN_VERSION = "1-0-0"
|
||||||
|
@ -80,7 +77,8 @@ class SyncToken:
|
||||||
"books_last_created": {"type": "string"},
|
"books_last_created": {"type": "string"},
|
||||||
"archive_last_modified": {"type": "string"},
|
"archive_last_modified": {"type": "string"},
|
||||||
"reading_state_last_modified": {"type": "string"},
|
"reading_state_last_modified": {"type": "string"},
|
||||||
"tags_last_modified": {"type": "string"},
|
"tags_last_modified": {"type": "string"}
|
||||||
|
# "books_last_id": {"type": "integer", "optional": True}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,19 +89,21 @@ class SyncToken:
|
||||||
books_last_modified=datetime.min,
|
books_last_modified=datetime.min,
|
||||||
archive_last_modified=datetime.min,
|
archive_last_modified=datetime.min,
|
||||||
reading_state_last_modified=datetime.min,
|
reading_state_last_modified=datetime.min,
|
||||||
tags_last_modified=datetime.min,
|
tags_last_modified=datetime.min
|
||||||
):
|
# books_last_id=-1
|
||||||
|
): # nosec
|
||||||
self.raw_kobo_store_token = raw_kobo_store_token
|
self.raw_kobo_store_token = raw_kobo_store_token
|
||||||
self.books_last_created = books_last_created
|
self.books_last_created = books_last_created
|
||||||
self.books_last_modified = books_last_modified
|
self.books_last_modified = books_last_modified
|
||||||
self.archive_last_modified = archive_last_modified
|
self.archive_last_modified = archive_last_modified
|
||||||
self.reading_state_last_modified = reading_state_last_modified
|
self.reading_state_last_modified = reading_state_last_modified
|
||||||
self.tags_last_modified = tags_last_modified
|
self.tags_last_modified = tags_last_modified
|
||||||
|
# self.books_last_id = books_last_id
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_headers(headers):
|
def from_headers(headers):
|
||||||
sync_token_header = headers.get(SyncToken.SYNC_TOKEN_HEADER, "")
|
sync_token_header = headers.get(SyncToken.SYNC_TOKEN_HEADER, "")
|
||||||
if sync_token_header == "":
|
if sync_token_header == "": # nosec
|
||||||
return SyncToken()
|
return SyncToken()
|
||||||
|
|
||||||
# On the first sync from a Kobo device, we may receive the SyncToken
|
# On the first sync from a Kobo device, we may receive the SyncToken
|
||||||
|
@ -143,7 +143,7 @@ class SyncToken:
|
||||||
books_last_modified=books_last_modified,
|
books_last_modified=books_last_modified,
|
||||||
archive_last_modified=archive_last_modified,
|
archive_last_modified=archive_last_modified,
|
||||||
reading_state_last_modified=reading_state_last_modified,
|
reading_state_last_modified=reading_state_last_modified,
|
||||||
tags_last_modified=tags_last_modified
|
tags_last_modified=tags_last_modified,
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_kobo_store_header(self, store_headers):
|
def set_kobo_store_header(self, store_headers):
|
||||||
|
@ -166,7 +166,15 @@ class SyncToken:
|
||||||
"books_last_created": to_epoch_timestamp(self.books_last_created),
|
"books_last_created": to_epoch_timestamp(self.books_last_created),
|
||||||
"archive_last_modified": to_epoch_timestamp(self.archive_last_modified),
|
"archive_last_modified": to_epoch_timestamp(self.archive_last_modified),
|
||||||
"reading_state_last_modified": to_epoch_timestamp(self.reading_state_last_modified),
|
"reading_state_last_modified": to_epoch_timestamp(self.reading_state_last_modified),
|
||||||
"tags_last_modified": to_epoch_timestamp(self.tags_last_modified)
|
"tags_last_modified": to_epoch_timestamp(self.tags_last_modified),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return b64encode_json(token)
|
return b64encode_json(token)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "{},{},{},{},{},{}".format(self.books_last_created,
|
||||||
|
self.books_last_modified,
|
||||||
|
self.archive_last_modified,
|
||||||
|
self.reading_state_last_modified,
|
||||||
|
self.tags_last_modified,
|
||||||
|
self.raw_kobo_store_token)
|
||||||
|
|
|
@ -16,15 +16,12 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
|
|
||||||
from .. import logger
|
from .. import logger
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
try:
|
||||||
try: from . import goodreads_support
|
from . import goodreads_support
|
||||||
except ImportError as err:
|
except ImportError as err:
|
||||||
log.debug("Cannot import goodreads, showing authors-metadata will not work: %s", err)
|
log.debug("Cannot import goodreads, showing authors-metadata will not work: %s", err)
|
||||||
goodreads_support = None
|
goodreads_support = None
|
||||||
|
@ -45,3 +42,9 @@ except ImportError as err:
|
||||||
log.debug("Cannot import SyncToken, syncing books with Kobo Devices will not work: %s", err)
|
log.debug("Cannot import SyncToken, syncing books with Kobo Devices will not work: %s", err)
|
||||||
kobo = None
|
kobo = None
|
||||||
SyncToken = None
|
SyncToken = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from . import gmail
|
||||||
|
except ImportError as err:
|
||||||
|
log.debug("Cannot import gmail, sending books via Gmail Oauth2 Verification will not work: %s", err)
|
||||||
|
gmail = None
|
||||||
|
|
84
cps/services/background_scheduler.py
Normal file
84
cps/services/background_scheduler.py
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2020 mmonkey
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import atexit
|
||||||
|
|
||||||
|
from .. import logger
|
||||||
|
from .worker import WorkerThread
|
||||||
|
|
||||||
|
try:
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler as BScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
from apscheduler.triggers.date import DateTrigger
|
||||||
|
use_APScheduler = True
|
||||||
|
except (ImportError, RuntimeError) as e:
|
||||||
|
use_APScheduler = False
|
||||||
|
log = logger.create()
|
||||||
|
log.info('APScheduler not found. Unable to schedule tasks.')
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundScheduler:
|
||||||
|
_instance = None
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if not use_APScheduler:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super(BackgroundScheduler, cls).__new__(cls)
|
||||||
|
cls.log = logger.create()
|
||||||
|
cls.scheduler = BScheduler()
|
||||||
|
cls.scheduler.start()
|
||||||
|
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def schedule(self, func, trigger, name=None):
|
||||||
|
if use_APScheduler:
|
||||||
|
return self.scheduler.add_job(func=func, trigger=trigger, name=name)
|
||||||
|
|
||||||
|
# Expects a lambda expression for the task
|
||||||
|
def schedule_task(self, task, user=None, name=None, hidden=False, trigger=None):
|
||||||
|
if use_APScheduler:
|
||||||
|
def scheduled_task():
|
||||||
|
worker_task = task()
|
||||||
|
worker_task.scheduled = True
|
||||||
|
WorkerThread.add(user, worker_task, hidden=hidden)
|
||||||
|
return self.schedule(func=scheduled_task, trigger=trigger, name=name)
|
||||||
|
|
||||||
|
# Expects a list of lambda expressions for the tasks
|
||||||
|
def schedule_tasks(self, tasks, user=None, trigger=None):
|
||||||
|
if use_APScheduler:
|
||||||
|
for task in tasks:
|
||||||
|
self.schedule_task(task[0], user=user, trigger=trigger, name=task[1], hidden=task[2])
|
||||||
|
|
||||||
|
# Expects a lambda expression for the task
|
||||||
|
def schedule_task_immediately(self, task, user=None, name=None, hidden=False):
|
||||||
|
if use_APScheduler:
|
||||||
|
def immediate_task():
|
||||||
|
WorkerThread.add(user, task(), hidden)
|
||||||
|
return self.schedule(func=immediate_task, trigger=DateTrigger(), name=name)
|
||||||
|
|
||||||
|
# Expects a list of lambda expressions for the tasks
|
||||||
|
def schedule_tasks_immediately(self, tasks, user=None):
|
||||||
|
if use_APScheduler:
|
||||||
|
for task in tasks:
|
||||||
|
self.schedule_task_immediately(task[0], user, name="immediately " + task[1], hidden=task[2])
|
||||||
|
|
||||||
|
# Remove all jobs
|
||||||
|
def remove_all_jobs(self):
|
||||||
|
self.scheduler.remove_all_jobs()
|
100
cps/services/gmail.py
Normal file
100
cps/services/gmail.py
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2021 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import os.path
|
||||||
|
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||||
|
from google.auth.transport.requests import Request
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
from google.oauth2.credentials import Credentials
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
import base64
|
||||||
|
from flask_babel import gettext as _
|
||||||
|
from ..constants import CONFIG_DIR
|
||||||
|
from .. import logger
|
||||||
|
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
SCOPES = ['openid', 'https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/userinfo.email']
|
||||||
|
|
||||||
|
def setup_gmail(token):
|
||||||
|
# If there are no (valid) credentials available, let the user log in.
|
||||||
|
creds = None
|
||||||
|
if "token" in token:
|
||||||
|
creds = Credentials(
|
||||||
|
token=token['token'],
|
||||||
|
refresh_token=token['refresh_token'],
|
||||||
|
token_uri=token['token_uri'],
|
||||||
|
client_id=token['client_id'],
|
||||||
|
client_secret=token['client_secret'],
|
||||||
|
scopes=token['scopes'],
|
||||||
|
)
|
||||||
|
creds.expiry = datetime.fromisoformat(token['expiry'])
|
||||||
|
|
||||||
|
if not creds or not creds.valid:
|
||||||
|
# don't forget to dump one more time after the refresh
|
||||||
|
# also, some file-locking routines wouldn't be needless
|
||||||
|
if creds and creds.expired and creds.refresh_token:
|
||||||
|
creds.refresh(Request())
|
||||||
|
else:
|
||||||
|
cred_file = os.path.join(CONFIG_DIR, 'gmail.json')
|
||||||
|
if not os.path.exists(cred_file):
|
||||||
|
raise Exception(_("Found no valid gmail.json file with OAuth information"))
|
||||||
|
flow = InstalledAppFlow.from_client_secrets_file(
|
||||||
|
os.path.join(CONFIG_DIR, 'gmail.json'), SCOPES)
|
||||||
|
creds = flow.run_local_server(port=0)
|
||||||
|
user_info = get_user_info(creds)
|
||||||
|
return {
|
||||||
|
'token': creds.token,
|
||||||
|
'refresh_token': creds.refresh_token,
|
||||||
|
'token_uri': creds.token_uri,
|
||||||
|
'client_id': creds.client_id,
|
||||||
|
'client_secret': creds.client_secret,
|
||||||
|
'scopes': creds.scopes,
|
||||||
|
'expiry': creds.expiry.isoformat(),
|
||||||
|
'email': user_info
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_user_info(credentials):
|
||||||
|
user_info_service = build(serviceName='oauth2', version='v2',credentials=credentials)
|
||||||
|
user_info = user_info_service.userinfo().get().execute()
|
||||||
|
return user_info.get('email', "")
|
||||||
|
|
||||||
|
def send_messsage(token, msg):
|
||||||
|
log.debug("Start sending e-mail via Gmail")
|
||||||
|
creds = Credentials(
|
||||||
|
token=token['token'],
|
||||||
|
refresh_token=token['refresh_token'],
|
||||||
|
token_uri=token['token_uri'],
|
||||||
|
client_id=token['client_id'],
|
||||||
|
client_secret=token['client_secret'],
|
||||||
|
scopes=token['scopes'],
|
||||||
|
)
|
||||||
|
creds.expiry = datetime.fromisoformat(token['expiry'])
|
||||||
|
if creds and creds.expired and creds.refresh_token:
|
||||||
|
creds.refresh(Request())
|
||||||
|
service = build('gmail', 'v1', credentials=creds)
|
||||||
|
message_as_bytes = msg.as_bytes() # the message should converted from string to bytes.
|
||||||
|
message_as_base64 = base64.urlsafe_b64encode(message_as_bytes) # encode in base64 (printable letters coding)
|
||||||
|
raw = message_as_base64.decode() # convert to something JSON serializable
|
||||||
|
body = {'raw': raw}
|
||||||
|
|
||||||
|
(service.users().messages().send(userId='me', body=body).execute())
|
||||||
|
log.debug("E-mail send successfully via Gmail")
|
|
@ -16,16 +16,51 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
import time
|
import time
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
import requests
|
||||||
|
|
||||||
from goodreads.client import GoodreadsClient
|
from goodreads.client import GoodreadsClient
|
||||||
|
from goodreads.request import GoodreadsRequest
|
||||||
|
import xmltodict
|
||||||
|
|
||||||
try: import Levenshtein
|
try:
|
||||||
except ImportError: Levenshtein = False
|
import Levenshtein
|
||||||
|
except ImportError:
|
||||||
|
Levenshtein = False
|
||||||
|
|
||||||
from .. import logger
|
from .. import logger
|
||||||
|
from ..clean_html import clean_string
|
||||||
|
|
||||||
|
class my_GoodreadsClient(GoodreadsClient):
|
||||||
|
|
||||||
|
def request(self, *args, **kwargs):
|
||||||
|
"""Create a GoodreadsRequest object and make that request"""
|
||||||
|
req = my_GoodreadsRequest(self, *args, **kwargs)
|
||||||
|
return req.request()
|
||||||
|
|
||||||
|
class GoodreadsRequestException(Exception):
|
||||||
|
def __init__(self, error_msg, url):
|
||||||
|
self.error_msg = error_msg
|
||||||
|
self.url = url
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.url, ':', self.error_msg
|
||||||
|
|
||||||
|
|
||||||
|
class my_GoodreadsRequest(GoodreadsRequest):
|
||||||
|
|
||||||
|
def request(self):
|
||||||
|
resp = requests.get(self.host+self.path, params=self.params,
|
||||||
|
headers={"User-Agent":"Mozilla/5.0 (X11; Linux x86_64; rv:125.0) "
|
||||||
|
"Gecko/20100101 Firefox/125.0"})
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise GoodreadsRequestException(resp.reason, self.path)
|
||||||
|
if self.req_format == 'xml':
|
||||||
|
data_dict = xmltodict.parse(resp.content)
|
||||||
|
return data_dict['GoodreadsResponse']
|
||||||
|
else:
|
||||||
|
raise Exception("Invalid format")
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
@ -36,20 +71,20 @@ _CACHE_TIMEOUT = 23 * 60 * 60 # 23 hours (in seconds)
|
||||||
_AUTHORS_CACHE = {}
|
_AUTHORS_CACHE = {}
|
||||||
|
|
||||||
|
|
||||||
def connect(key=None, secret=None, enabled=True):
|
def connect(key=None, enabled=True):
|
||||||
global _client
|
global _client
|
||||||
|
|
||||||
if not enabled or not key or not secret:
|
if not enabled or not key:
|
||||||
_client = None
|
_client = None
|
||||||
return
|
return
|
||||||
|
|
||||||
if _client:
|
if _client:
|
||||||
# make sure the configuration has not changed since last we used the client
|
# make sure the configuration has not changed since last we used the client
|
||||||
if _client.client_key != key or _client.client_secret != secret:
|
if _client.client_key != key:
|
||||||
_client = None
|
_client = None
|
||||||
|
|
||||||
if not _client:
|
if not _client:
|
||||||
_client = GoodreadsClient(key, secret)
|
_client = my_GoodreadsClient(key, None)
|
||||||
|
|
||||||
|
|
||||||
def get_author_info(author_name):
|
def get_author_info(author_name):
|
||||||
|
@ -69,11 +104,12 @@ def get_author_info(author_name):
|
||||||
author_info = _client.find_author(author_name=author_name)
|
author_info = _client.find_author(author_name=author_name)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
# Skip goodreads, if site is down/inaccessible
|
# Skip goodreads, if site is down/inaccessible
|
||||||
log.warning('Goodreads website is down/inaccessible? %s', ex)
|
log.warning('Goodreads website is down/inaccessible? %s', ex.__str__())
|
||||||
return
|
return
|
||||||
|
|
||||||
if author_info:
|
if author_info:
|
||||||
author_info._timestamp = now
|
author_info._timestamp = now
|
||||||
|
author_info.safe_about = clean_string(author_info.about)
|
||||||
_AUTHORS_CACHE[author_name] = author_info
|
_AUTHORS_CACHE[author_name] = author_info
|
||||||
return author_info
|
return author_info
|
||||||
|
|
||||||
|
@ -95,8 +131,12 @@ def get_other_books(author_info, library_books=None):
|
||||||
for book in author_info.books:
|
for book in author_info.books:
|
||||||
if book.isbn in identifiers:
|
if book.isbn in identifiers:
|
||||||
continue
|
continue
|
||||||
if book.gid["#text"] in identifiers:
|
if isinstance(book.gid, int):
|
||||||
continue
|
if book.gid in identifiers:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if book.gid["#text"] in identifiers:
|
||||||
|
continue
|
||||||
|
|
||||||
if Levenshtein and library_titles:
|
if Levenshtein and library_titles:
|
||||||
goodreads_title = book._book_dict['title_without_series']
|
goodreads_title = book._book_dict['title_without_series']
|
||||||
|
|
|
@ -16,11 +16,11 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
from flask_simpleldap import LDAP, LDAPException
|
from flask_simpleldap import LDAP, LDAPException
|
||||||
|
from flask_simpleldap import ldap as pyLDAP
|
||||||
|
from flask import current_app
|
||||||
from .. import constants, logger
|
from .. import constants, logger
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -29,8 +29,47 @@ except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
_ldap = LDAP()
|
|
||||||
|
|
||||||
|
class LDAPLogger(object):
|
||||||
|
|
||||||
|
def write(self, message):
|
||||||
|
try:
|
||||||
|
log.debug(message.strip("\n").replace("\n", ""))
|
||||||
|
except Exception:
|
||||||
|
log.debug("Logging Error")
|
||||||
|
|
||||||
|
|
||||||
|
class mySimpleLDap(LDAP):
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def init_app(app):
|
||||||
|
super(mySimpleLDap, mySimpleLDap).init_app(app)
|
||||||
|
app.config.setdefault('LDAP_LOGLEVEL', 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def initialize(self):
|
||||||
|
"""Initialize a connection to the LDAP server.
|
||||||
|
|
||||||
|
:return: LDAP connection object.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
log_level = 2 if current_app.config['LDAP_LOGLEVEL'] == logger.logging.DEBUG else 0
|
||||||
|
conn = pyLDAP.initialize('{0}://{1}:{2}'.format(
|
||||||
|
current_app.config['LDAP_SCHEMA'],
|
||||||
|
current_app.config['LDAP_HOST'],
|
||||||
|
current_app.config['LDAP_PORT']), trace_level=log_level, trace_file=LDAPLogger())
|
||||||
|
conn.set_option(pyLDAP.OPT_NETWORK_TIMEOUT,
|
||||||
|
current_app.config['LDAP_TIMEOUT'])
|
||||||
|
conn = self._set_custom_options(conn)
|
||||||
|
conn.protocol_version = pyLDAP.VERSION3
|
||||||
|
if current_app.config['LDAP_USE_TLS']:
|
||||||
|
conn.start_tls_s()
|
||||||
|
return conn
|
||||||
|
except pyLDAP.LDAPError as e:
|
||||||
|
raise LDAPException(self.error(e.args))
|
||||||
|
|
||||||
|
|
||||||
|
_ldap = mySimpleLDap()
|
||||||
|
|
||||||
def init_app(app, config):
|
def init_app(app, config):
|
||||||
if config.config_login_type != constants.LOGIN_LDAP:
|
if config.config_login_type != constants.LOGIN_LDAP:
|
||||||
|
@ -38,24 +77,31 @@ def init_app(app, config):
|
||||||
|
|
||||||
app.config['LDAP_HOST'] = config.config_ldap_provider_url
|
app.config['LDAP_HOST'] = config.config_ldap_provider_url
|
||||||
app.config['LDAP_PORT'] = config.config_ldap_port
|
app.config['LDAP_PORT'] = config.config_ldap_port
|
||||||
|
app.config['LDAP_CUSTOM_OPTIONS'] = {pyLDAP.OPT_REFERRALS: 0}
|
||||||
if config.config_ldap_encryption == 2:
|
if config.config_ldap_encryption == 2:
|
||||||
app.config['LDAP_SCHEMA'] = 'ldaps'
|
app.config['LDAP_SCHEMA'] = 'ldaps'
|
||||||
else:
|
else:
|
||||||
app.config['LDAP_SCHEMA'] = 'ldap'
|
app.config['LDAP_SCHEMA'] = 'ldap'
|
||||||
if config.config_ldap_authentication > constants.LDAP_AUTH_ANONYMOUS:
|
if config.config_ldap_authentication > constants.LDAP_AUTH_ANONYMOUS:
|
||||||
if config.config_ldap_authentication > constants.LDAP_AUTH_UNAUTHENTICATE:
|
if config.config_ldap_authentication > constants.LDAP_AUTH_UNAUTHENTICATE:
|
||||||
if config.config_ldap_serv_password is None:
|
if config.config_ldap_serv_password_e is None:
|
||||||
config.config_ldap_serv_password = ''
|
config.config_ldap_serv_password_e = ''
|
||||||
app.config['LDAP_PASSWORD'] = base64.b64decode(config.config_ldap_serv_password)
|
app.config['LDAP_PASSWORD'] = config.config_ldap_serv_password_e
|
||||||
else:
|
else:
|
||||||
app.config['LDAP_PASSWORD'] = base64.b64decode("")
|
app.config['LDAP_PASSWORD'] = ""
|
||||||
app.config['LDAP_USERNAME'] = config.config_ldap_serv_username
|
app.config['LDAP_USERNAME'] = config.config_ldap_serv_username
|
||||||
else:
|
else:
|
||||||
app.config['LDAP_USERNAME'] = ""
|
app.config['LDAP_USERNAME'] = ""
|
||||||
app.config['LDAP_PASSWORD'] = base64.b64decode("")
|
app.config['LDAP_PASSWORD'] = ""
|
||||||
if bool(config.config_ldap_cert_path):
|
if bool(config.config_ldap_cert_path):
|
||||||
app.config['LDAP_REQUIRE_CERT'] = True
|
app.config['LDAP_CUSTOM_OPTIONS'].update({
|
||||||
app.config['LDAP_CERT_PATH'] = config.config_ldap_cert_path
|
pyLDAP.OPT_X_TLS_REQUIRE_CERT: pyLDAP.OPT_X_TLS_DEMAND,
|
||||||
|
pyLDAP.OPT_X_TLS_CACERTFILE: config.config_ldap_cacert_path,
|
||||||
|
pyLDAP.OPT_X_TLS_CERTFILE: config.config_ldap_cert_path,
|
||||||
|
pyLDAP.OPT_X_TLS_KEYFILE: config.config_ldap_key_path,
|
||||||
|
pyLDAP.OPT_X_TLS_NEWCTX: 0
|
||||||
|
})
|
||||||
|
|
||||||
app.config['LDAP_BASE_DN'] = config.config_ldap_dn
|
app.config['LDAP_BASE_DN'] = config.config_ldap_dn
|
||||||
app.config['LDAP_USER_OBJECT_FILTER'] = config.config_ldap_user_object
|
app.config['LDAP_USER_OBJECT_FILTER'] = config.config_ldap_user_object
|
||||||
|
|
||||||
|
@ -64,13 +110,22 @@ def init_app(app, config):
|
||||||
app.config['LDAP_OPENLDAP'] = bool(config.config_ldap_openldap)
|
app.config['LDAP_OPENLDAP'] = bool(config.config_ldap_openldap)
|
||||||
app.config['LDAP_GROUP_OBJECT_FILTER'] = config.config_ldap_group_object_filter
|
app.config['LDAP_GROUP_OBJECT_FILTER'] = config.config_ldap_group_object_filter
|
||||||
app.config['LDAP_GROUP_MEMBERS_FIELD'] = config.config_ldap_group_members_field
|
app.config['LDAP_GROUP_MEMBERS_FIELD'] = config.config_ldap_group_members_field
|
||||||
# app.config['LDAP_CUSTOM_OPTIONS'] = {'OPT_NETWORK_TIMEOUT': 10}
|
app.config['LDAP_LOGLEVEL'] = config.config_log_level
|
||||||
|
try:
|
||||||
_ldap.init_app(app)
|
_ldap.init_app(app)
|
||||||
|
except ValueError:
|
||||||
|
if bool(config.config_ldap_cert_path):
|
||||||
|
app.config['LDAP_CUSTOM_OPTIONS'].pop(pyLDAP.OPT_X_TLS_NEWCTX)
|
||||||
|
try:
|
||||||
|
_ldap.init_app(app)
|
||||||
|
except RuntimeError as e:
|
||||||
|
log.error(e)
|
||||||
|
except RuntimeError as e:
|
||||||
|
log.error(e)
|
||||||
|
|
||||||
|
|
||||||
def get_object_details(user=None, group=None, query_filter=None, dn_only=False):
|
def get_object_details(user=None,query_filter=None):
|
||||||
return _ldap.get_object_details(user, group, query_filter, dn_only)
|
return _ldap.get_object_details(user, query_filter=query_filter)
|
||||||
|
|
||||||
|
|
||||||
def bind():
|
def bind():
|
||||||
|
@ -101,7 +156,7 @@ def bind_user(username, password):
|
||||||
return None, error
|
return None, error
|
||||||
except LDAPException as ex:
|
except LDAPException as ex:
|
||||||
if ex.message == 'Invalid credentials':
|
if ex.message == 'Invalid credentials':
|
||||||
error = ("LDAP admin login failed")
|
error = "LDAP admin login failed"
|
||||||
return None, error
|
return None, error
|
||||||
if ex.message == "Can't contact LDAP server":
|
if ex.message == "Can't contact LDAP server":
|
||||||
# log.warning('LDAP Server down: %s', ex)
|
# log.warning('LDAP Server down: %s', ex)
|
||||||
|
|
271
cps/services/worker.py
Normal file
271
cps/services/worker.py
Normal file
|
@ -0,0 +1,271 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2020 pwr
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import abc
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
|
||||||
|
try:
|
||||||
|
import queue
|
||||||
|
except ImportError:
|
||||||
|
import Queue as queue
|
||||||
|
from datetime import datetime
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
|
from cps import logger
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
# task 'status' consts
|
||||||
|
STAT_WAITING = 0
|
||||||
|
STAT_FAIL = 1
|
||||||
|
STAT_STARTED = 2
|
||||||
|
STAT_FINISH_SUCCESS = 3
|
||||||
|
STAT_ENDED = 4
|
||||||
|
STAT_CANCELLED = 5
|
||||||
|
|
||||||
|
# Only retain this many tasks in dequeued list
|
||||||
|
TASK_CLEANUP_TRIGGER = 20
|
||||||
|
|
||||||
|
QueuedTask = namedtuple('QueuedTask', 'num, user, added, task, hidden')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_main_thread():
|
||||||
|
for t in threading.enumerate():
|
||||||
|
if t.__class__.__name__ == '_MainThread':
|
||||||
|
return t
|
||||||
|
raise Exception("main thread not found?!")
|
||||||
|
|
||||||
|
|
||||||
|
class ImprovedQueue(queue.Queue):
|
||||||
|
def to_list(self):
|
||||||
|
"""
|
||||||
|
Returns a copy of all items in the queue without removing them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
with self.mutex:
|
||||||
|
return list(self.queue)
|
||||||
|
|
||||||
|
|
||||||
|
# Class for all worker tasks in the background
|
||||||
|
class WorkerThread(threading.Thread):
|
||||||
|
_instance = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_instance(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = WorkerThread()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
threading.Thread.__init__(self)
|
||||||
|
|
||||||
|
self.dequeued = list()
|
||||||
|
|
||||||
|
self.doLock = threading.Lock()
|
||||||
|
self.queue = ImprovedQueue()
|
||||||
|
self.num = 0
|
||||||
|
self.start()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add(cls, user, task, hidden=False):
|
||||||
|
ins = cls.get_instance()
|
||||||
|
ins.num += 1
|
||||||
|
username = user if user is not None else 'System'
|
||||||
|
log.debug("Add Task for user: {} - {}".format(username, task))
|
||||||
|
ins.queue.put(QueuedTask(
|
||||||
|
num=ins.num,
|
||||||
|
user=username,
|
||||||
|
added=datetime.now(),
|
||||||
|
task=task,
|
||||||
|
hidden=hidden
|
||||||
|
))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tasks(self):
|
||||||
|
with self.doLock:
|
||||||
|
tasks = self.queue.to_list() + self.dequeued
|
||||||
|
return sorted(tasks, key=lambda x: x.num)
|
||||||
|
|
||||||
|
def cleanup_tasks(self):
|
||||||
|
with self.doLock:
|
||||||
|
dead = []
|
||||||
|
alive = []
|
||||||
|
for x in self.dequeued:
|
||||||
|
(dead if x.task.dead else alive).append(x)
|
||||||
|
|
||||||
|
# if the ones that we need to keep are within the trigger, do nothing else
|
||||||
|
delta = len(self.dequeued) - len(dead)
|
||||||
|
if delta > TASK_CLEANUP_TRIGGER:
|
||||||
|
ret = alive
|
||||||
|
else:
|
||||||
|
# otherwise, loop off the oldest dead tasks until we hit the target trigger
|
||||||
|
ret = sorted(dead, key=lambda y: y.task.end_time)[-TASK_CLEANUP_TRIGGER:] + alive
|
||||||
|
|
||||||
|
self.dequeued = sorted(ret, key=lambda y: y.num)
|
||||||
|
|
||||||
|
# Main thread loop starting the different tasks
|
||||||
|
def run(self):
|
||||||
|
main_thread = _get_main_thread()
|
||||||
|
while main_thread.is_alive():
|
||||||
|
try:
|
||||||
|
# this blocks until something is available. This can cause issues when the main thread dies - this
|
||||||
|
# thread will remain alive. We implement a timeout to unblock every second which allows us to check if
|
||||||
|
# the main thread is still alive.
|
||||||
|
# We don't use a daemon here because we don't want the tasks to just be abruptly halted, leading to
|
||||||
|
# possible file / database corruption
|
||||||
|
item = self.queue.get(timeout=1)
|
||||||
|
except queue.Empty:
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
with self.doLock:
|
||||||
|
# add to list so that in-progress tasks show up
|
||||||
|
self.dequeued.append(item)
|
||||||
|
|
||||||
|
# once we hit our trigger, start cleaning up dead tasks
|
||||||
|
if len(self.dequeued) > TASK_CLEANUP_TRIGGER:
|
||||||
|
self.cleanup_tasks()
|
||||||
|
|
||||||
|
# sometimes tasks (like Upload) don't actually have work to do and are created as already finished
|
||||||
|
if item.task.stat is STAT_WAITING:
|
||||||
|
# CalibreTask.start() should wrap all exceptions in its own error handling
|
||||||
|
item.task.start(self)
|
||||||
|
|
||||||
|
# remove self_cleanup tasks and hidden "System Tasks" from list
|
||||||
|
if item.task.self_cleanup or item.hidden:
|
||||||
|
self.dequeued.remove(item)
|
||||||
|
|
||||||
|
self.queue.task_done()
|
||||||
|
|
||||||
|
def end_task(self, task_id):
|
||||||
|
ins = self.get_instance()
|
||||||
|
for __, __, __, task, __ in ins.tasks:
|
||||||
|
if str(task.id) == str(task_id) and task.is_cancellable:
|
||||||
|
task.stat = STAT_CANCELLED if task.stat == STAT_WAITING else STAT_ENDED
|
||||||
|
|
||||||
|
|
||||||
|
class CalibreTask:
|
||||||
|
__metaclass__ = abc.ABCMeta
|
||||||
|
|
||||||
|
def __init__(self, message):
|
||||||
|
self._progress = 0
|
||||||
|
self.stat = STAT_WAITING
|
||||||
|
self.error = None
|
||||||
|
self.start_time = None
|
||||||
|
self.end_time = None
|
||||||
|
self.message = message
|
||||||
|
self.id = uuid.uuid4()
|
||||||
|
self.self_cleanup = False
|
||||||
|
self._scheduled = False
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def run(self, worker_thread):
|
||||||
|
"""The main entry-point for this task"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def name(self):
|
||||||
|
"""Provides the caller some human-readable name for this class"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def is_cancellable(self):
|
||||||
|
"""Does this task gracefully handle being cancelled (STAT_ENDED, STAT_CANCELLED)?"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def start(self, *args):
|
||||||
|
self.start_time = datetime.now()
|
||||||
|
self.stat = STAT_STARTED
|
||||||
|
|
||||||
|
# catch any unhandled exceptions in a task and automatically fail it
|
||||||
|
try:
|
||||||
|
self.run(*args)
|
||||||
|
except Exception as ex:
|
||||||
|
self._handleError(str(ex))
|
||||||
|
log.error_or_exception(ex)
|
||||||
|
|
||||||
|
self.end_time = datetime.now()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stat(self):
|
||||||
|
return self._stat
|
||||||
|
|
||||||
|
@stat.setter
|
||||||
|
def stat(self, x):
|
||||||
|
self._stat = x
|
||||||
|
|
||||||
|
@property
|
||||||
|
def progress(self):
|
||||||
|
return self._progress
|
||||||
|
|
||||||
|
@progress.setter
|
||||||
|
def progress(self, x):
|
||||||
|
if not 0 <= x <= 1:
|
||||||
|
raise ValueError("Task progress should within [0, 1] range")
|
||||||
|
self._progress = x
|
||||||
|
|
||||||
|
@property
|
||||||
|
def error(self):
|
||||||
|
return self._error
|
||||||
|
|
||||||
|
@error.setter
|
||||||
|
def error(self, x):
|
||||||
|
self._error = x
|
||||||
|
|
||||||
|
@property
|
||||||
|
def runtime(self):
|
||||||
|
return (self.end_time or datetime.now()) - self.start_time
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dead(self):
|
||||||
|
"""Determines whether or not this task can be garbage collected
|
||||||
|
|
||||||
|
We have a separate dictating this because there may be certain tasks that want to override this
|
||||||
|
"""
|
||||||
|
# By default, we're good to clean a task if it's "Done"
|
||||||
|
return self.stat in (STAT_FINISH_SUCCESS, STAT_FAIL, STAT_ENDED, STAT_CANCELLED)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def self_cleanup(self):
|
||||||
|
return self._self_cleanup
|
||||||
|
|
||||||
|
@self_cleanup.setter
|
||||||
|
def self_cleanup(self, is_self_cleanup):
|
||||||
|
self._self_cleanup = is_self_cleanup
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scheduled(self):
|
||||||
|
return self._scheduled
|
||||||
|
|
||||||
|
@scheduled.setter
|
||||||
|
def scheduled(self, is_scheduled):
|
||||||
|
self._scheduled = is_scheduled
|
||||||
|
|
||||||
|
def _handleError(self, error_message):
|
||||||
|
self.stat = STAT_FAIL
|
||||||
|
self.progress = 1
|
||||||
|
self.error = error_message
|
||||||
|
|
||||||
|
def _handleSuccess(self):
|
||||||
|
self.stat = STAT_FINISH_SUCCESS
|
||||||
|
self.progress = 1
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
520
cps/shelf.py
520
cps/shelf.py
|
@ -20,43 +20,25 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from flask import Blueprint, request, flash, redirect, url_for
|
from flask import Blueprint, flash, redirect, request, url_for, abort
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_login import login_required, current_user
|
from flask_login import current_user, login_required
|
||||||
from sqlalchemy.sql.expression import func
|
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||||
|
from sqlalchemy.sql.expression import func, true
|
||||||
|
|
||||||
from . import logger, ub, searched_ids, db
|
from . import calibre_db, config, db, logger, ub
|
||||||
from .web import render_title_template
|
from .render_template import render_title_template
|
||||||
from .helper import common_filters
|
from .usermanagement import login_required_if_no_ano
|
||||||
|
|
||||||
|
|
||||||
shelf = Blueprint('shelf', __name__)
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
shelf = Blueprint('shelf', __name__)
|
||||||
def check_shelf_edit_permissions(cur_shelf):
|
|
||||||
if not cur_shelf.is_public and not cur_shelf.user_id == int(current_user.id):
|
|
||||||
log.error("User %s not allowed to edit shelf %s", current_user, cur_shelf)
|
|
||||||
return False
|
|
||||||
if cur_shelf.is_public and not current_user.role_edit_shelfs():
|
|
||||||
log.info("User %s not allowed to edit public shelves", current_user)
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def check_shelf_view_permissions(cur_shelf):
|
@shelf.route("/shelf/add/<int:shelf_id>/<int:book_id>", methods=["POST"])
|
||||||
if cur_shelf.is_public:
|
|
||||||
return True
|
|
||||||
if current_user.is_anonymous or cur_shelf.user_id != current_user.id:
|
|
||||||
log.error("User is unauthorized to view non-public shelf: %s", cur_shelf)
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/add/<int:shelf_id>/<int:book_id>")
|
|
||||||
@login_required
|
@login_required
|
||||||
def add_to_shelf(shelf_id, book_id):
|
def add_to_shelf(shelf_id, book_id):
|
||||||
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||||
|
@ -64,23 +46,22 @@ def add_to_shelf(shelf_id, book_id):
|
||||||
if shelf is None:
|
if shelf is None:
|
||||||
log.error("Invalid shelf specified: %s", shelf_id)
|
log.error("Invalid shelf specified: %s", shelf_id)
|
||||||
if not xhr:
|
if not xhr:
|
||||||
flash(_(u"Invalid shelf specified"), category="error")
|
flash(_("Invalid shelf specified"), category="error")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
return "Invalid shelf specified", 400
|
return "Invalid shelf specified", 400
|
||||||
|
|
||||||
if not check_shelf_edit_permissions(shelf):
|
if not check_shelf_edit_permissions(shelf):
|
||||||
if not xhr:
|
if not xhr:
|
||||||
flash(_(u"Sorry you are not allowed to add a book to the the shelf: %(shelfname)s", shelfname=shelf.name),
|
flash(_("Sorry you are not allowed to add a book to that shelf"), category="error")
|
||||||
category="error")
|
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
return "Sorry you are not allowed to add a book to the the shelf: %s" % shelf.name, 403
|
return "Sorry you are not allowed to add a book to the that shelf", 403
|
||||||
|
|
||||||
book_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id,
|
book_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id,
|
||||||
ub.BookShelf.book_id == book_id).first()
|
ub.BookShelf.book_id == book_id).first()
|
||||||
if book_in_shelf:
|
if book_in_shelf:
|
||||||
log.error("Book %s is already part of %s", book_id, shelf)
|
log.error("Book %s is already part of %s", book_id, shelf)
|
||||||
if not xhr:
|
if not xhr:
|
||||||
flash(_(u"Book is already part of the shelf: %(shelfname)s", shelfname=shelf.name), category="error")
|
flash(_("Book is already part of the shelf: %(shelfname)s", shelfname=shelf.name), category="error")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
return "Book is already part of the shelf: %s" % shelf.name, 400
|
return "Book is already part of the shelf: %s" % shelf.name, 400
|
||||||
|
|
||||||
|
@ -90,12 +71,30 @@ def add_to_shelf(shelf_id, book_id):
|
||||||
else:
|
else:
|
||||||
maxOrder = maxOrder[0]
|
maxOrder = maxOrder[0]
|
||||||
|
|
||||||
|
if not calibre_db.session.query(db.Books).filter(db.Books.id == book_id).one_or_none():
|
||||||
|
log.error("Invalid Book Id: %s. Could not be added to shelf %s", book_id, shelf.name)
|
||||||
|
if not xhr:
|
||||||
|
flash(_("%(book_id)s is a invalid Book Id. Could not be added to Shelf", book_id=book_id),
|
||||||
|
category="error")
|
||||||
|
return redirect(url_for('web.index'))
|
||||||
|
return "%s is a invalid Book Id. Could not be added to Shelf" % book_id, 400
|
||||||
|
|
||||||
shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book_id, order=maxOrder + 1))
|
shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book_id, order=maxOrder + 1))
|
||||||
shelf.last_modified = datetime.utcnow()
|
shelf.last_modified = datetime.utcnow()
|
||||||
ub.session.merge(shelf)
|
try:
|
||||||
ub.session.commit()
|
ub.session.merge(shelf)
|
||||||
|
ub.session.commit()
|
||||||
|
except (OperationalError, InvalidRequestError) as e:
|
||||||
|
ub.session.rollback()
|
||||||
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||||
|
if "HTTP_REFERER" in request.environ:
|
||||||
|
return redirect(request.environ["HTTP_REFERER"])
|
||||||
|
else:
|
||||||
|
return redirect(url_for('web.index'))
|
||||||
if not xhr:
|
if not xhr:
|
||||||
flash(_(u"Book has been added to shelf: %(sname)s", sname=shelf.name), category="success")
|
log.debug("Book has been added to shelf: {}".format(shelf.name))
|
||||||
|
flash(_("Book has been added to shelf: %(sname)s", sname=shelf.name), category="success")
|
||||||
if "HTTP_REFERER" in request.environ:
|
if "HTTP_REFERER" in request.environ:
|
||||||
return redirect(request.environ["HTTP_REFERER"])
|
return redirect(request.environ["HTTP_REFERER"])
|
||||||
else:
|
else:
|
||||||
|
@ -103,62 +102,65 @@ def add_to_shelf(shelf_id, book_id):
|
||||||
return "", 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/massadd/<int:shelf_id>")
|
@shelf.route("/shelf/massadd/<int:shelf_id>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def search_to_shelf(shelf_id):
|
def search_to_shelf(shelf_id):
|
||||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||||
if shelf is None:
|
if shelf is None:
|
||||||
log.error("Invalid shelf specified: %s", shelf_id)
|
log.error("Invalid shelf specified: {}".format(shelf_id))
|
||||||
flash(_(u"Invalid shelf specified"), category="error")
|
flash(_("Invalid shelf specified"), category="error")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
|
|
||||||
if not check_shelf_edit_permissions(shelf):
|
if not check_shelf_edit_permissions(shelf):
|
||||||
flash(_(u"You are not allowed to add a book to the the shelf: %(name)s", name=shelf.name), category="error")
|
log.warning("You are not allowed to add a book to the shelf".format(shelf.name))
|
||||||
|
flash(_("You are not allowed to add a book to the shelf"), category="error")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
|
|
||||||
if current_user.id in searched_ids and searched_ids[current_user.id]:
|
if current_user.id in ub.searched_ids and ub.searched_ids[current_user.id]:
|
||||||
books_for_shelf = list()
|
books_for_shelf = list()
|
||||||
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).all()
|
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).all()
|
||||||
if books_in_shelf:
|
if books_in_shelf:
|
||||||
book_ids = list()
|
book_ids = list()
|
||||||
for book_id in books_in_shelf:
|
for book_id in books_in_shelf:
|
||||||
book_ids.append(book_id.book_id)
|
book_ids.append(book_id.book_id)
|
||||||
for searchid in searched_ids[current_user.id]:
|
for searchid in ub.searched_ids[current_user.id]:
|
||||||
if searchid not in book_ids:
|
if searchid not in book_ids:
|
||||||
books_for_shelf.append(searchid)
|
books_for_shelf.append(searchid)
|
||||||
else:
|
else:
|
||||||
books_for_shelf = searched_ids[current_user.id]
|
books_for_shelf = ub.searched_ids[current_user.id]
|
||||||
|
|
||||||
if not books_for_shelf:
|
if not books_for_shelf:
|
||||||
log.error("Books are already part of %s", shelf)
|
log.error("Books are already part of {}".format(shelf.name))
|
||||||
flash(_(u"Books are already part of the shelf: %(name)s", name=shelf.name), category="error")
|
flash(_("Books are already part of the shelf: %(name)s", name=shelf.name), category="error")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
|
|
||||||
maxOrder = ub.session.query(func.max(ub.BookShelf.order)).filter(ub.BookShelf.shelf == shelf_id).first()
|
maxOrder = ub.session.query(func.max(ub.BookShelf.order)).filter(ub.BookShelf.shelf == shelf_id).first()[0] or 0
|
||||||
if maxOrder[0] is None:
|
|
||||||
maxOrder = 0
|
|
||||||
else:
|
|
||||||
maxOrder = maxOrder[0]
|
|
||||||
|
|
||||||
for book in books_for_shelf:
|
for book in books_for_shelf:
|
||||||
maxOrder = maxOrder + 1
|
maxOrder += 1
|
||||||
shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book, order=maxOrder))
|
shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book, order=maxOrder))
|
||||||
shelf.last_modified = datetime.utcnow()
|
shelf.last_modified = datetime.utcnow()
|
||||||
ub.session.merge(shelf)
|
try:
|
||||||
ub.session.commit()
|
ub.session.merge(shelf)
|
||||||
flash(_(u"Books have been added to shelf: %(sname)s", sname=shelf.name), category="success")
|
ub.session.commit()
|
||||||
|
flash(_("Books have been added to shelf: %(sname)s", sname=shelf.name), category="success")
|
||||||
|
except (OperationalError, InvalidRequestError) as e:
|
||||||
|
ub.session.rollback()
|
||||||
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||||
else:
|
else:
|
||||||
flash(_(u"Could not add books to shelf: %(sname)s", sname=shelf.name), category="error")
|
log.error("Could not add books to shelf: {}".format(shelf.name))
|
||||||
|
flash(_("Could not add books to shelf: %(sname)s", sname=shelf.name), category="error")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/remove/<int:shelf_id>/<int:book_id>")
|
@shelf.route("/shelf/remove/<int:shelf_id>/<int:book_id>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def remove_from_shelf(shelf_id, book_id):
|
def remove_from_shelf(shelf_id, book_id):
|
||||||
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||||
if shelf is None:
|
if shelf is None:
|
||||||
log.error("Invalid shelf specified: %s", shelf_id)
|
log.error("Invalid shelf specified: {}".format(shelf_id))
|
||||||
if not xhr:
|
if not xhr:
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
return "Invalid shelf specified", 400
|
return "Invalid shelf specified", 400
|
||||||
|
@ -181,12 +183,20 @@ def remove_from_shelf(shelf_id, book_id):
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
return "Book already removed from shelf", 410
|
return "Book already removed from shelf", 410
|
||||||
|
|
||||||
ub.session.delete(book_shelf)
|
try:
|
||||||
shelf.last_modified = datetime.utcnow()
|
ub.session.delete(book_shelf)
|
||||||
ub.session.commit()
|
shelf.last_modified = datetime.utcnow()
|
||||||
|
ub.session.commit()
|
||||||
|
except (OperationalError, InvalidRequestError) as e:
|
||||||
|
ub.session.rollback()
|
||||||
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||||
|
if "HTTP_REFERER" in request.environ:
|
||||||
|
return redirect(request.environ["HTTP_REFERER"])
|
||||||
|
else:
|
||||||
|
return redirect(url_for('web.index'))
|
||||||
if not xhr:
|
if not xhr:
|
||||||
flash(_(u"Book has been removed from shelf: %(sname)s", sname=shelf.name), category="success")
|
flash(_("Book has been removed from shelf: %(sname)s", sname=shelf.name), category="success")
|
||||||
if "HTTP_REFERER" in request.environ:
|
if "HTTP_REFERER" in request.environ:
|
||||||
return redirect(request.environ["HTTP_REFERER"])
|
return redirect(request.environ["HTTP_REFERER"])
|
||||||
else:
|
else:
|
||||||
|
@ -194,181 +204,275 @@ def remove_from_shelf(shelf_id, book_id):
|
||||||
return "", 204
|
return "", 204
|
||||||
else:
|
else:
|
||||||
if not xhr:
|
if not xhr:
|
||||||
flash(_(u"Sorry you are not allowed to remove a book from this shelf: %(sname)s", sname=shelf.name),
|
log.warning("You are not allowed to remove a book from shelf: {}".format(shelf.name))
|
||||||
|
flash(_("Sorry you are not allowed to remove a book from this shelf"),
|
||||||
category="error")
|
category="error")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
return "Sorry you are not allowed to remove a book from this shelf: %s" % shelf.name, 403
|
return "Sorry you are not allowed to remove a book from this shelf", 403
|
||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/create", methods=["GET", "POST"])
|
@shelf.route("/shelf/create", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def create_shelf():
|
def create_shelf():
|
||||||
shelf = ub.Shelf()
|
shelf = ub.Shelf()
|
||||||
if request.method == "POST":
|
return create_edit_shelf(shelf, page_title=_("Create a Shelf"), page="shelfcreate")
|
||||||
to_save = request.form.to_dict()
|
|
||||||
if "is_public" in to_save:
|
|
||||||
shelf.is_public = 1
|
|
||||||
shelf.name = to_save["title"]
|
|
||||||
shelf.user_id = int(current_user.id)
|
|
||||||
|
|
||||||
is_shelf_name_unique = False
|
|
||||||
if shelf.is_public == 1:
|
|
||||||
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
|
||||||
.filter((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 1)) \
|
|
||||||
.first() is None
|
|
||||||
|
|
||||||
if not is_shelf_name_unique:
|
|
||||||
flash(_(u"A public shelf with the name '%(title)s' already exists.", title=to_save["title"]),
|
|
||||||
category="error")
|
|
||||||
else:
|
|
||||||
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
|
||||||
.filter((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 0) &
|
|
||||||
(ub.Shelf.user_id == int(current_user.id)))\
|
|
||||||
.first() is None
|
|
||||||
|
|
||||||
if not is_shelf_name_unique:
|
|
||||||
flash(_(u"A private shelf with the name '%(title)s' already exists.", title=to_save["title"]),
|
|
||||||
category="error")
|
|
||||||
|
|
||||||
if is_shelf_name_unique:
|
|
||||||
try:
|
|
||||||
ub.session.add(shelf)
|
|
||||||
ub.session.commit()
|
|
||||||
flash(_(u"Shelf %(title)s created", title=to_save["title"]), category="success")
|
|
||||||
return redirect(url_for('shelf.show_shelf', shelf_id=shelf.id))
|
|
||||||
except Exception:
|
|
||||||
flash(_(u"There was an error"), category="error")
|
|
||||||
return render_title_template('shelf_edit.html', shelf=shelf, title=_(u"Create a Shelf"), page="shelfcreate")
|
|
||||||
else:
|
|
||||||
return render_title_template('shelf_edit.html', shelf=shelf, title=_(u"Create a Shelf"), page="shelfcreate")
|
|
||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/edit/<int:shelf_id>", methods=["GET", "POST"])
|
@shelf.route("/shelf/edit/<int:shelf_id>", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def edit_shelf(shelf_id):
|
def edit_shelf(shelf_id):
|
||||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||||
if request.method == "POST":
|
if not check_shelf_edit_permissions(shelf):
|
||||||
to_save = request.form.to_dict()
|
flash(_("Sorry you are not allowed to edit this shelf"), category="error")
|
||||||
|
return redirect(url_for('web.index'))
|
||||||
is_shelf_name_unique = False
|
return create_edit_shelf(shelf, page_title=_("Edit a shelf"), page="shelfedit", shelf_id=shelf_id)
|
||||||
if shelf.is_public == 1:
|
|
||||||
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
|
||||||
.filter((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 1)) \
|
|
||||||
.filter(ub.Shelf.id != shelf_id) \
|
|
||||||
.first() is None
|
|
||||||
|
|
||||||
if not is_shelf_name_unique:
|
|
||||||
flash(_(u"A public shelf with the name '%(title)s' already exists.", title=to_save["title"]),
|
|
||||||
category="error")
|
|
||||||
else:
|
|
||||||
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
|
||||||
.filter((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 0) &
|
|
||||||
(ub.Shelf.user_id == int(current_user.id)))\
|
|
||||||
.filter(ub.Shelf.id != shelf_id)\
|
|
||||||
.first() is None
|
|
||||||
|
|
||||||
if not is_shelf_name_unique:
|
|
||||||
flash(_(u"A private shelf with the name '%(title)s' already exists.", title=to_save["title"]),
|
|
||||||
category="error")
|
|
||||||
|
|
||||||
if is_shelf_name_unique:
|
|
||||||
shelf.name = to_save["title"]
|
|
||||||
shelf.last_modified = datetime.utcnow()
|
|
||||||
if "is_public" in to_save:
|
|
||||||
shelf.is_public = 1
|
|
||||||
else:
|
|
||||||
shelf.is_public = 0
|
|
||||||
try:
|
|
||||||
ub.session.commit()
|
|
||||||
flash(_(u"Shelf %(title)s changed", title=to_save["title"]), category="success")
|
|
||||||
except Exception:
|
|
||||||
flash(_(u"There was an error"), category="error")
|
|
||||||
return render_title_template('shelf_edit.html', shelf=shelf, title=_(u"Edit a shelf"), page="shelfedit")
|
|
||||||
else:
|
|
||||||
return render_title_template('shelf_edit.html', shelf=shelf, title=_(u"Edit a shelf"), page="shelfedit")
|
|
||||||
|
|
||||||
|
|
||||||
def delete_shelf_helper(cur_shelf):
|
@shelf.route("/shelf/delete/<int:shelf_id>", methods=["POST"])
|
||||||
if not cur_shelf or not check_shelf_edit_permissions(cur_shelf):
|
|
||||||
return
|
|
||||||
shelf_id = cur_shelf.id
|
|
||||||
ub.session.delete(cur_shelf)
|
|
||||||
ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).delete()
|
|
||||||
ub.session.add(ub.ShelfArchive(uuid=cur_shelf.uuid, user_id=cur_shelf.user_id))
|
|
||||||
ub.session.commit()
|
|
||||||
log.info("successfully deleted %s", cur_shelf)
|
|
||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/delete/<int:shelf_id>")
|
|
||||||
@login_required
|
@login_required
|
||||||
def delete_shelf(shelf_id):
|
def delete_shelf(shelf_id):
|
||||||
cur_shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
cur_shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||||
delete_shelf_helper(cur_shelf)
|
try:
|
||||||
|
if not delete_shelf_helper(cur_shelf):
|
||||||
|
flash(_("Error deleting Shelf"), category="error")
|
||||||
|
else:
|
||||||
|
flash(_("Shelf successfully deleted"), category="success")
|
||||||
|
except InvalidRequestError as e:
|
||||||
|
ub.session.rollback()
|
||||||
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/<int:shelf_id>", defaults={'shelf_type': 1})
|
@shelf.route("/simpleshelf/<int:shelf_id>")
|
||||||
@shelf.route("/shelf/<int:shelf_id>/<int:shelf_type>")
|
@login_required_if_no_ano
|
||||||
def show_shelf(shelf_type, shelf_id):
|
def show_simpleshelf(shelf_id):
|
||||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
return render_show_shelf(2, shelf_id, 1, None)
|
||||||
|
|
||||||
result = list()
|
|
||||||
# user is allowed to access shelf
|
|
||||||
if shelf and check_shelf_view_permissions(shelf):
|
|
||||||
page = "shelf.html" if shelf_type == 1 else 'shelfdown.html'
|
|
||||||
|
|
||||||
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id)\
|
@shelf.route("/shelf/<int:shelf_id>", defaults={"sort_param": "order", 'page': 1})
|
||||||
.order_by(ub.BookShelf.order.asc()).all()
|
@shelf.route("/shelf/<int:shelf_id>/<sort_param>", defaults={'page': 1})
|
||||||
for book in books_in_shelf:
|
@shelf.route("/shelf/<int:shelf_id>/<sort_param>/<int:page>")
|
||||||
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).filter(common_filters()).first()
|
@login_required_if_no_ano
|
||||||
if cur_book:
|
def show_shelf(shelf_id, sort_param, page):
|
||||||
result.append(cur_book)
|
return render_show_shelf(1, shelf_id, page, sort_param)
|
||||||
else:
|
|
||||||
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).first()
|
|
||||||
if not cur_book:
|
|
||||||
log.info('Not existing book %s in %s deleted', book.book_id, shelf)
|
|
||||||
ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == book.book_id).delete()
|
|
||||||
ub.session.commit()
|
|
||||||
return render_title_template(page, entries=result, title=_(u"Shelf: '%(name)s'", name=shelf.name),
|
|
||||||
shelf=shelf, page="shelf")
|
|
||||||
else:
|
|
||||||
flash(_(u"Error opening shelf. Shelf does not exist or is not accessible"), category="error")
|
|
||||||
return redirect(url_for("web.index"))
|
|
||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/order/<int:shelf_id>", methods=["GET", "POST"])
|
@shelf.route("/shelf/order/<int:shelf_id>", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def order_shelf(shelf_id):
|
def order_shelf(shelf_id):
|
||||||
|
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||||
|
if shelf and check_shelf_view_permissions(shelf):
|
||||||
|
if request.method == "POST":
|
||||||
|
to_save = request.form.to_dict()
|
||||||
|
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).order_by(
|
||||||
|
ub.BookShelf.order.asc()).all()
|
||||||
|
counter = 0
|
||||||
|
for book in books_in_shelf:
|
||||||
|
setattr(book, 'order', to_save[str(book.book_id)])
|
||||||
|
counter += 1
|
||||||
|
# if order different from before -> shelf.last_modified = datetime.utcnow()
|
||||||
|
try:
|
||||||
|
ub.session.commit()
|
||||||
|
except (OperationalError, InvalidRequestError) as e:
|
||||||
|
ub.session.rollback()
|
||||||
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||||
|
|
||||||
|
result = list()
|
||||||
|
if shelf:
|
||||||
|
result = calibre_db.session.query(db.Books) \
|
||||||
|
.join(ub.BookShelf, ub.BookShelf.book_id == db.Books.id, isouter=True) \
|
||||||
|
.add_columns(calibre_db.common_filters().label("visible")) \
|
||||||
|
.filter(ub.BookShelf.shelf == shelf_id).order_by(ub.BookShelf.order.asc()).all()
|
||||||
|
return render_title_template('shelf_order.html', entries=result,
|
||||||
|
title=_("Change order of Shelf: '%(name)s'", name=shelf.name),
|
||||||
|
shelf=shelf, page="shelforder")
|
||||||
|
else:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
|
||||||
|
def check_shelf_edit_permissions(cur_shelf):
|
||||||
|
if not cur_shelf.is_public and not cur_shelf.user_id == int(current_user.id):
|
||||||
|
log.error("User {} not allowed to edit shelf: {}".format(current_user.id, cur_shelf.name))
|
||||||
|
return False
|
||||||
|
if cur_shelf.is_public and not current_user.role_edit_shelfs():
|
||||||
|
log.info("User {} not allowed to edit public shelves".format(current_user.id))
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def check_shelf_view_permissions(cur_shelf):
|
||||||
|
try:
|
||||||
|
if cur_shelf.is_public:
|
||||||
|
return True
|
||||||
|
if current_user.is_anonymous or cur_shelf.user_id != current_user.id:
|
||||||
|
log.error("User is unauthorized to view non-public shelf: {}".format(cur_shelf.name))
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
log.error(e)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# if shelf ID is set, we are editing a shelf
|
||||||
|
def create_edit_shelf(shelf, page_title, page, shelf_id=False):
|
||||||
|
sync_only_selected_shelves = current_user.kobo_only_shelves_sync
|
||||||
|
# calibre_db.session.query(ub.Shelf).filter(ub.Shelf.user_id == current_user.id).filter(ub.Shelf.kobo_sync).count()
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
to_save = request.form.to_dict()
|
to_save = request.form.to_dict()
|
||||||
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).order_by(
|
if not current_user.role_edit_shelfs() and to_save.get("is_public") == "on":
|
||||||
ub.BookShelf.order.asc()).all()
|
flash(_("Sorry you are not allowed to create a public shelf"), category="error")
|
||||||
counter = 0
|
return redirect(url_for('web.index'))
|
||||||
for book in books_in_shelf:
|
is_public = 1 if to_save.get("is_public") == "on" else 0
|
||||||
setattr(book, 'order', to_save[str(book.book_id)])
|
if config.config_kobo_sync:
|
||||||
counter += 1
|
shelf.kobo_sync = True if to_save.get("kobo_sync") else False
|
||||||
# if order diffrent from before -> shelf.last_modified = datetime.utcnow()
|
if shelf.kobo_sync:
|
||||||
ub.session.commit()
|
ub.session.query(ub.ShelfArchive).filter(ub.ShelfArchive.user_id == current_user.id).filter(
|
||||||
|
ub.ShelfArchive.uuid == shelf.uuid).delete()
|
||||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
ub.session_commit()
|
||||||
result = list()
|
shelf_title = to_save.get("title", "")
|
||||||
if shelf and check_shelf_view_permissions(shelf):
|
if check_shelf_is_unique(shelf_title, is_public, shelf_id):
|
||||||
books_in_shelf2 = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id) \
|
shelf.name = shelf_title
|
||||||
.order_by(ub.BookShelf.order.asc()).all()
|
shelf.is_public = is_public
|
||||||
for book in books_in_shelf2:
|
if not shelf_id:
|
||||||
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).filter(common_filters()).first()
|
shelf.user_id = int(current_user.id)
|
||||||
if cur_book:
|
ub.session.add(shelf)
|
||||||
result.append({'title': cur_book.title,
|
shelf_action = "created"
|
||||||
'id': cur_book.id,
|
flash_text = _("Shelf %(title)s created", title=shelf_title)
|
||||||
'author': cur_book.authors,
|
|
||||||
'series': cur_book.series,
|
|
||||||
'series_index': cur_book.series_index})
|
|
||||||
else:
|
else:
|
||||||
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).first()
|
shelf_action = "changed"
|
||||||
result.append({'title': _('Hidden Book'),
|
flash_text = _("Shelf %(title)s changed", title=shelf_title)
|
||||||
'id': cur_book.id,
|
try:
|
||||||
'author': [],
|
ub.session.commit()
|
||||||
'series': []})
|
log.info("Shelf {} {}".format(shelf_title, shelf_action))
|
||||||
return render_title_template('shelf_order.html', entries=result,
|
flash(flash_text, category="success")
|
||||||
title=_(u"Change order of Shelf: '%(name)s'", name=shelf.name),
|
return redirect(url_for('shelf.show_shelf', shelf_id=shelf.id))
|
||||||
shelf=shelf, page="shelforder")
|
except (OperationalError, InvalidRequestError) as ex:
|
||||||
|
ub.session.rollback()
|
||||||
|
log.error_or_exception(ex)
|
||||||
|
log.error_or_exception("Settings Database error: {}".format(ex))
|
||||||
|
flash(_("Oops! Database Error: %(error)s.", error=ex.orig), category="error")
|
||||||
|
except Exception as ex:
|
||||||
|
ub.session.rollback()
|
||||||
|
log.error_or_exception(ex)
|
||||||
|
flash(_("There was an error"), category="error")
|
||||||
|
return render_title_template('shelf_edit.html',
|
||||||
|
shelf=shelf,
|
||||||
|
title=page_title,
|
||||||
|
page=page,
|
||||||
|
kobo_sync_enabled=config.config_kobo_sync,
|
||||||
|
sync_only_selected_shelves=sync_only_selected_shelves)
|
||||||
|
|
||||||
|
|
||||||
|
def check_shelf_is_unique(title, is_public, shelf_id=False):
|
||||||
|
if shelf_id:
|
||||||
|
ident = ub.Shelf.id != shelf_id
|
||||||
|
else:
|
||||||
|
ident = true()
|
||||||
|
if is_public == 1:
|
||||||
|
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
||||||
|
.filter((ub.Shelf.name == title) & (ub.Shelf.is_public == 1)) \
|
||||||
|
.filter(ident) \
|
||||||
|
.first() is None
|
||||||
|
|
||||||
|
if not is_shelf_name_unique:
|
||||||
|
log.error("A public shelf with the name '{}' already exists.".format(title))
|
||||||
|
flash(_("A public shelf with the name '%(title)s' already exists.", title=title),
|
||||||
|
category="error")
|
||||||
|
else:
|
||||||
|
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
||||||
|
.filter((ub.Shelf.name == title) & (ub.Shelf.is_public == 0) &
|
||||||
|
(ub.Shelf.user_id == int(current_user.id))) \
|
||||||
|
.filter(ident) \
|
||||||
|
.first() is None
|
||||||
|
|
||||||
|
if not is_shelf_name_unique:
|
||||||
|
log.error("A private shelf with the name '{}' already exists.".format(title))
|
||||||
|
flash(_("A private shelf with the name '%(title)s' already exists.", title=title),
|
||||||
|
category="error")
|
||||||
|
return is_shelf_name_unique
|
||||||
|
|
||||||
|
|
||||||
|
def delete_shelf_helper(cur_shelf):
|
||||||
|
if not cur_shelf or not check_shelf_edit_permissions(cur_shelf):
|
||||||
|
return False
|
||||||
|
shelf_id = cur_shelf.id
|
||||||
|
ub.session.delete(cur_shelf)
|
||||||
|
ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).delete()
|
||||||
|
ub.session.add(ub.ShelfArchive(uuid=cur_shelf.uuid, user_id=cur_shelf.user_id))
|
||||||
|
ub.session_commit("successfully deleted Shelf {}".format(cur_shelf.name))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def change_shelf_order(shelf_id, order):
|
||||||
|
result = calibre_db.session.query(db.Books).outerjoin(db.books_series_link,
|
||||||
|
db.Books.id == db.books_series_link.c.book)\
|
||||||
|
.outerjoin(db.Series).join(ub.BookShelf, ub.BookShelf.book_id == db.Books.id) \
|
||||||
|
.filter(ub.BookShelf.shelf == shelf_id).order_by(*order).all()
|
||||||
|
for index, entry in enumerate(result):
|
||||||
|
book = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id) \
|
||||||
|
.filter(ub.BookShelf.book_id == entry.id).first()
|
||||||
|
book.order = index
|
||||||
|
ub.session_commit("Shelf-id:{} - Order changed".format(shelf_id))
|
||||||
|
|
||||||
|
|
||||||
|
def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
|
||||||
|
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||||
|
|
||||||
|
# check user is allowed to access shelf
|
||||||
|
if shelf and check_shelf_view_permissions(shelf):
|
||||||
|
if shelf_type == 1:
|
||||||
|
# order = [ub.BookShelf.order.asc()]
|
||||||
|
if sort_param == 'pubnew':
|
||||||
|
change_shelf_order(shelf_id, [db.Books.pubdate.desc()])
|
||||||
|
if sort_param == 'pubold':
|
||||||
|
change_shelf_order(shelf_id, [db.Books.pubdate])
|
||||||
|
if sort_param == 'abc':
|
||||||
|
change_shelf_order(shelf_id, [db.Books.sort])
|
||||||
|
if sort_param == 'zyx':
|
||||||
|
change_shelf_order(shelf_id, [db.Books.sort.desc()])
|
||||||
|
if sort_param == 'new':
|
||||||
|
change_shelf_order(shelf_id, [db.Books.timestamp.desc()])
|
||||||
|
if sort_param == 'old':
|
||||||
|
change_shelf_order(shelf_id, [db.Books.timestamp])
|
||||||
|
if sort_param == 'authaz':
|
||||||
|
change_shelf_order(shelf_id, [db.Books.author_sort.asc(), db.Series.name, db.Books.series_index])
|
||||||
|
if sort_param == 'authza':
|
||||||
|
change_shelf_order(shelf_id, [db.Books.author_sort.desc(),
|
||||||
|
db.Series.name.desc(),
|
||||||
|
db.Books.series_index.desc()])
|
||||||
|
page = "shelf.html"
|
||||||
|
pagesize = 0
|
||||||
|
else:
|
||||||
|
pagesize = sys.maxsize
|
||||||
|
page = 'shelfdown.html'
|
||||||
|
|
||||||
|
result, __, pagination = calibre_db.fill_indexpage(page_no, pagesize,
|
||||||
|
db.Books,
|
||||||
|
ub.BookShelf.shelf == shelf_id,
|
||||||
|
[ub.BookShelf.order.asc()],
|
||||||
|
True, config.config_read_column,
|
||||||
|
ub.BookShelf, ub.BookShelf.book_id == db.Books.id)
|
||||||
|
# delete chelf entries where book is not existent anymore, can happen if book is deleted outside calibre-web
|
||||||
|
wrong_entries = calibre_db.session.query(ub.BookShelf) \
|
||||||
|
.join(db.Books, ub.BookShelf.book_id == db.Books.id, isouter=True) \
|
||||||
|
.filter(db.Books.id == None).all()
|
||||||
|
for entry in wrong_entries:
|
||||||
|
log.info('Not existing book {} in {} deleted'.format(entry.book_id, shelf))
|
||||||
|
try:
|
||||||
|
ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == entry.book_id).delete()
|
||||||
|
ub.session.commit()
|
||||||
|
except (OperationalError, InvalidRequestError) as e:
|
||||||
|
ub.session.rollback()
|
||||||
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||||
|
|
||||||
|
return render_title_template(page,
|
||||||
|
entries=result,
|
||||||
|
pagination=pagination,
|
||||||
|
title=_("Shelf: '%(name)s'", name=shelf.name),
|
||||||
|
shelf=shelf,
|
||||||
|
page="shelf")
|
||||||
|
else:
|
||||||
|
flash(_("Error opening shelf. Shelf does not exist or is not accessible"), category="error")
|
||||||
|
return redirect(url_for("web.index"))
|
||||||
|
|
BIN
cps/static/cmaps/78-EUC-H.bcmap
Normal file
BIN
cps/static/cmaps/78-EUC-H.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/78-EUC-V.bcmap
Normal file
BIN
cps/static/cmaps/78-EUC-V.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/78-H.bcmap
Normal file
BIN
cps/static/cmaps/78-H.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/78-RKSJ-H.bcmap
Normal file
BIN
cps/static/cmaps/78-RKSJ-H.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/78-RKSJ-V.bcmap
Normal file
BIN
cps/static/cmaps/78-RKSJ-V.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/78-V.bcmap
Normal file
BIN
cps/static/cmaps/78-V.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/78ms-RKSJ-H.bcmap
Normal file
BIN
cps/static/cmaps/78ms-RKSJ-H.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/78ms-RKSJ-V.bcmap
Normal file
BIN
cps/static/cmaps/78ms-RKSJ-V.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/83pv-RKSJ-H.bcmap
Normal file
BIN
cps/static/cmaps/83pv-RKSJ-H.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/90ms-RKSJ-H.bcmap
Normal file
BIN
cps/static/cmaps/90ms-RKSJ-H.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/90ms-RKSJ-V.bcmap
Normal file
BIN
cps/static/cmaps/90ms-RKSJ-V.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/90msp-RKSJ-H.bcmap
Normal file
BIN
cps/static/cmaps/90msp-RKSJ-H.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/90msp-RKSJ-V.bcmap
Normal file
BIN
cps/static/cmaps/90msp-RKSJ-V.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/90pv-RKSJ-H.bcmap
Normal file
BIN
cps/static/cmaps/90pv-RKSJ-H.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/90pv-RKSJ-V.bcmap
Normal file
BIN
cps/static/cmaps/90pv-RKSJ-V.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/Add-H.bcmap
Normal file
BIN
cps/static/cmaps/Add-H.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/Add-RKSJ-H.bcmap
Normal file
BIN
cps/static/cmaps/Add-RKSJ-H.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/Add-RKSJ-V.bcmap
Normal file
BIN
cps/static/cmaps/Add-RKSJ-V.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/Add-V.bcmap
Normal file
BIN
cps/static/cmaps/Add-V.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/Adobe-CNS1-0.bcmap
Normal file
BIN
cps/static/cmaps/Adobe-CNS1-0.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/Adobe-CNS1-1.bcmap
Normal file
BIN
cps/static/cmaps/Adobe-CNS1-1.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/Adobe-CNS1-2.bcmap
Normal file
BIN
cps/static/cmaps/Adobe-CNS1-2.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/Adobe-CNS1-3.bcmap
Normal file
BIN
cps/static/cmaps/Adobe-CNS1-3.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/Adobe-CNS1-4.bcmap
Normal file
BIN
cps/static/cmaps/Adobe-CNS1-4.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/Adobe-CNS1-5.bcmap
Normal file
BIN
cps/static/cmaps/Adobe-CNS1-5.bcmap
Normal file
Binary file not shown.
BIN
cps/static/cmaps/Adobe-CNS1-6.bcmap
Normal file
BIN
cps/static/cmaps/Adobe-CNS1-6.bcmap
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user