Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9bccc83c5 | ||
|
|
53ba352ed9 | ||
|
|
bbc3219d31 | ||
|
|
ab2620a265 | ||
|
|
a20a4a201e | ||
|
|
ba0718f3d0 | ||
|
|
60dd3df861 | ||
|
|
762074596d | ||
|
|
789912d0d4 | ||
|
|
daf8dd6f90 | ||
|
|
e02610e2f0 | ||
|
|
897188ff18 | ||
|
|
09c0794f1a | ||
|
|
b28a3635f8 | ||
|
|
1a1f1498a5 | ||
|
|
92ffce1dc8 | ||
|
|
6441f5f96b | ||
|
|
d5a57e3b07 | ||
|
|
fef20c831a | ||
|
|
e52e8c6fdf | ||
|
|
89ac9ff498 | ||
|
|
059bdf1649 | ||
|
|
266ef11940 | ||
|
|
64e4a1155c | ||
|
|
f7d5480811 | ||
|
|
7aa122e6cd | ||
|
|
cfdc07e6b2 | ||
|
|
1ca1ac973a | ||
|
|
ba4235a39e | ||
|
|
7295ebaee1 | ||
|
|
d74ea7dcf2 | ||
|
|
ee23ee97d9 | ||
|
|
c73ec52842 | ||
|
|
0e3d3876f3 | ||
|
|
76d086f536 | ||
|
|
6022f48040 | ||
|
|
e0be168779 | ||
|
|
d3118c0aa9 | ||
|
|
04370944b9 | ||
|
|
af52748aca | ||
|
|
7eece7603b | ||
|
|
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 | ||
|
|
46e5305f23 | ||
|
|
9bcbe523d7 | ||
|
|
ae3e3559b8 | ||
|
|
e176d63ca6 | ||
|
|
80b0e88650 | ||
|
|
0b4731913e | ||
|
|
fc7ce8da2d | ||
|
|
c89bc12c9b | ||
|
|
4913673e8f | ||
|
|
fc004f4f0c | ||
|
|
7344ef353c | ||
|
|
3bde8a5d95 | ||
|
|
c5c3874243 | ||
|
|
0d34f41a48 | ||
|
|
a77aef83c6 | ||
|
|
e39c6130c3 | ||
|
|
03359599ed | ||
|
|
3c4330ba51 | ||
|
|
8c781ad4a4 | ||
|
|
5e9ec706c5 | ||
|
|
b1c70d5b4a | ||
|
|
c5fc30a1be | ||
|
|
29fd4ae4a2 | ||
|
|
4ef8c35fb7 | ||
|
|
04326af2da | ||
|
|
d6a31e5db8 | ||
|
|
73d48e4ac1 | ||
|
|
b206b7a5d8 | ||
|
|
2816a75c3e | ||
|
|
bf12542df5 | ||
|
|
0f3f918153 | ||
|
|
7ae9f89bbf | ||
|
|
4eaa9413f9 | ||
|
|
e2eab808c0 | ||
|
|
3ac08a8c0d | ||
|
|
3f56f0dca7 | ||
|
|
7fc04b353b | ||
|
|
a8689ae26b |
@@ -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
|
||||||
|
|||||||
@@ -6,12 +6,23 @@ labels: ''
|
|||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
<!-- Please have a look at our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md) -->
|
|
||||||
|
## 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**
|
**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 '....'
|
||||||
@@ -19,15 +30,19 @@ 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/Raspberry Pi OS]
|
- OS: [e.g. Windows 10/Raspberry Pi OS]
|
||||||
- Python version: [e.g. python2.7]
|
- Python version: [e.g. python2.7]
|
||||||
- Calibre-Web version: [e.g. 0.6.8 or 087c4c59 (git rev-parse --short HEAD)]:
|
- Calibre-Web version: [e.g. 0.6.8 or 087c4c59 (git rev-parse --short HEAD)]:
|
||||||
@@ -37,3 +52,4 @@ If applicable, add screenshots to help explain your problem.
|
|||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
Add any other context about the problem here. [e.g. access via reverse proxy, database background sync, special database location]
|
Add any other context about the problem here. [e.g. access via reverse proxy, database background sync, special database location]
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ assignees: ''
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- Please have a look at our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md) -->
|
# 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 [...]
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ 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
|
gmail.json
|
||||||
|
/.key
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ The Calibre-Web documentation is hosted in the Github [Wiki](https://github.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".
|
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**
|
||||||
|
|
||||||
|
|||||||
@@ -1,99 +1,125 @@
|
|||||||
# About
|
# Short Notice from the maintainer
|
||||||
|
|
||||||
Calibre-Web is a web app providing a clean interface for browsing, reading and downloading eBooks using a valid [Calibre](https://calibre-ebook.com) database.
|
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.
|
||||||
|
|
||||||
[](https://github.com/janeczku/calibre-web/blob/master/LICENSE)
|
# Calibre-Web
|
||||||
[]()
|
|
||||||
[](https://github.com/janeczku/calibre-web/releases)
|
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.
|
||||||
|
|
||||||
|
[](https://github.com/janeczku/calibre-web/blob/master/LICENSE)
|
||||||
|

|
||||||
|
[](https://github.com/janeczku/calibre-web/releases)
|
||||||
[](https://pypi.org/project/calibreweb/)
|
[](https://pypi.org/project/calibreweb/)
|
||||||
[](https://pypi.org/project/calibreweb/)
|
[](https://pypi.org/project/calibreweb/)
|
||||||
[](https://discord.gg/h2VsJ2NEfB)
|
[](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>
|
||||||
|
|
||||||
|
|
||||||
*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.*
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 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 brazilian, czech, dutch, english, finnish, french, german, greek, hungarian, italian, japanese, khmer, korean, polish, russian, simplified and traditional 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, book format 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 downloading eBook metadata from various sources, sources can be extended via external plugins
|
- Metadata download from various sources (extensible via plugins)
|
||||||
- Support for converting eBooks through Calibre binaries
|
- eBook conversion through Calibre binaries
|
||||||
- Restrict eBook download to logged-in users
|
- eBook download restriction to logged-in users
|
||||||
- Support for public user registration
|
- Public user registration support
|
||||||
- Send eBooks to E-Readers with the click of a button
|
- Send eBooks to E-Readers with a single click
|
||||||
- Sync your Kobo devices through Calibre-Web with your Calibre library
|
- Sync Kobo devices with your Calibre library
|
||||||
- Support for reading eBooks directly in the browser (.txt, .epub, .pdf, .cbr, .cbt, .cbz, .djvu)
|
- In-browser eBook reading support for multiple formats
|
||||||
- Upload new books in many formats, including audio formats (.mp3, .m4a, .m4b)
|
- Upload new books in various formats, including audio formats
|
||||||
- Support for Calibre Custom Columns
|
- Calibre Custom Columns support
|
||||||
- Ability to hide content based on categories and Custom Column content per user
|
- 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
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
#### Installation via pip (recommended)
|
#### Installation via pip (recommended)
|
||||||
1. To avoid problems with already installed python dependencies, it's recommended to create a virtual environment for Calibre-Web
|
1. Create a virtual environment for Calibre-Web to avoid conflicts with existing Python dependencies
|
||||||
2. Install Calibre-Web via pip with the command `pip install calibreweb` (Depending on your OS and or distro the command could also be `pip3`).
|
2. Install Calibre-Web via pip: `pip install calibreweb` (or `pip3` depending on your OS/distro)
|
||||||
3. Optional features can also be installed via pip, please refer to [this page](https://github.com/janeczku/calibre-web/wiki/Dependencies-in-Calibre-Web-Linux-and-Windows) for details
|
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
|
||||||
4. Calibre-Web can be started afterwards by typing `cps`
|
4. Start Calibre-Web by typing `cps`
|
||||||
|
|
||||||
In the Wiki there are also examples for: a [manual installation](https://github.com/janeczku/calibre-web/wiki/Manual-installation), [installation on Linux Mint](https://github.com/janeczku/calibre-web/wiki/How-To:Install-Calibre-Web-in-Linux-Mint-19-or-20), [installation on a Cloud Provider](https://github.com/janeczku/calibre-web/wiki/How-To:-Install-Calibre-Web-on-a-Cloud-Provider).
|
*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.*
|
||||||
|
|
||||||
## Quick start
|
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).
|
||||||
|
|
||||||
Point your browser to `http://localhost:8083` or `http://localhost:8083/opds` for the OPDS catalog \
|
## Quick Start
|
||||||
Login with default admin login \
|
|
||||||
Set `Location of Calibre database` to the path of the folder where your Calibre library (metadata.db) lives, push "submit" button \
|
|
||||||
Optionally a Google Drive can be used to host the calibre library [-> Using Google Drive integration](https://github.com/janeczku/calibre-web/wiki/G-Drive-Setup#using-google-drive-integration) \
|
|
||||||
Afterwards you can configure your Calibre-Web instance ([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) on admin page)
|
|
||||||
|
|
||||||
#### Default admin login:
|
1. Open your browser and navigate to `http://localhost:8083` or `http://localhost:8083/opds` for the OPDS catalog
|
||||||
*Username:* admin\
|
2. Log in with the default admin credentials
|
||||||
*Password:* admin123
|
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.5+
|
- 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-ereader 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.
|
|
||||||
|
|
||||||
[Download](https://github.com/pgaskin/kepubify/releases/latest) Kepubify tool for your platform and place the binary starting with `kepubify` in Linux: `/opt/kepubify` Windows: `C:\Program Files\kepubify`.
|
|
||||||
|
|
||||||
## Docker Images
|
## Docker Images
|
||||||
|
|
||||||
A pre-built Docker image is available in these Docker Hub repository (maintained by the LinuxServer team):
|
Pre-built Docker images are available in the following Docker Hub repositories (maintained by the LinuxServer team):
|
||||||
|
|
||||||
#### **LinuxServer - x64, armhf, aarch64**
|
#### **LinuxServer - x64, aarch64**
|
||||||
+ Docker Hub - [https://hub.docker.com/r/linuxserver/calibre-web](https://hub.docker.com/r/linuxserver/calibre-web)
|
- [Docker Hub](https://hub.docker.com/r/linuxserver/calibre-web)
|
||||||
+ Github - [https://github.com/linuxserver/docker-calibre-web](https://github.com/linuxserver/docker-calibre-web)
|
- [GitHub](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)
|
- [GitHub - Optional Calibre layer](https://github.com/linuxserver/docker-mods/tree/universal-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)**
|
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.
|
||||||
|
|
||||||
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 automatically rebuilt on new releases and updates.
|
||||||
|
|
||||||
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.
|
- Set "path to convertertool" to `/usr/bin/ebook-convert`
|
||||||
+ The "path to convertertool" should be set to `/usr/bin/ebook-convert`
|
- Set "path to unrar" to `/usr/bin/unrar`
|
||||||
+ The "path to unrar" should be set to `/usr/bin/unrar`
|
|
||||||
|
|
||||||
# Contact
|
## Contributor Recognition
|
||||||
|
|
||||||
Just reach us out on [Discord](https://discord.gg/h2VsJ2NEfB)
|
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.
|
||||||
|
|
||||||
For further information, How To's and FAQ please check the [Wiki](https://github.com/janeczku/calibre-web/wiki)
|
## Contact
|
||||||
|
|
||||||
# Contributing to Calibre-Web
|
Join us on [Discord](https://discord.gg/h2VsJ2NEfB)
|
||||||
|
|
||||||
Please have a look at our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md)
|
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)
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ To receive fixes for security vulnerabilities it is required to always upgrade t
|
|||||||
| 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 | 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 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.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)
|
## Statement regarding Log4j (CVE-2021-44228 and related)
|
||||||
|
|||||||
@@ -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_
|
|
||||||
@@ -21,13 +21,33 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
# Add local path to sys.path so we can import cps
|
# Add local path to sys.path, so we can import cps
|
||||||
path = os.path.dirname(os.path.abspath(__file__))
|
path = os.path.dirname(os.path.abspath(__file__))
|
||||||
sys.path.insert(0, path)
|
sys.path.insert(0, path)
|
||||||
|
|
||||||
from cps.main import main
|
from cps.main import main
|
||||||
|
|
||||||
|
|
||||||
|
def hide_console_windows():
|
||||||
|
import ctypes
|
||||||
|
import os
|
||||||
|
|
||||||
|
hwnd = ctypes.windll.kernel32.GetConsoleWindow()
|
||||||
|
if hwnd != 0:
|
||||||
|
try:
|
||||||
|
import win32process
|
||||||
|
except ImportError:
|
||||||
|
print("To hide console window install 'pywin32' using 'pip install pywin32'")
|
||||||
|
return
|
||||||
|
ctypes.windll.user32.ShowWindow(hwnd, 0)
|
||||||
|
ctypes.windll.kernel32.CloseHandle(hwnd)
|
||||||
|
_, pid = win32process.GetWindowThreadProcessId(hwnd)
|
||||||
|
os.system('taskkill /PID ' + str(pid) + ' /f')
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
if os.name == "nt":
|
||||||
|
hide_console_windows()
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,15 +21,33 @@
|
|||||||
# 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 flask_login import LoginManager
|
from flask_login import LoginManager, confirm_login
|
||||||
from flask import session
|
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):
|
class MyLoginManager(LoginManager):
|
||||||
def _session_protection_failed(self):
|
def _session_protection_failed(self):
|
||||||
_session = session._get_current_object()
|
sess = session._get_current_object()
|
||||||
ident = self._session_identifier_generator()
|
ident = self._session_identifier_generator()
|
||||||
if(_session and not (len(_session) == 1
|
if(sess and not (len(sess) == 1
|
||||||
and _session.get('csrf_token', None))) and ident != _session.get('_id', None):
|
and sess.get('csrf_token', None))) and ident != sess.get('_id', None):
|
||||||
return super(). _session_protection_failed()
|
return super(). _session_protection_failed()
|
||||||
return False
|
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
|
||||||
|
|||||||
@@ -36,11 +36,16 @@ from .reverseproxy import ReverseProxied
|
|||||||
from .server import WebServer
|
from .server import WebServer
|
||||||
from .dep_check import dependency_check
|
from .dep_check import dependency_check
|
||||||
from .updater import Updater
|
from .updater import Updater
|
||||||
from .babel import babel
|
from .babel import babel, get_locale
|
||||||
from . import config_sql
|
from . import config_sql
|
||||||
from . import cache_buster
|
from . import cache_buster
|
||||||
from . import ub, db
|
from . import ub, db
|
||||||
|
|
||||||
|
try:
|
||||||
|
from flask_limiter import Limiter
|
||||||
|
limiter_present = True
|
||||||
|
except ImportError:
|
||||||
|
limiter_present = False
|
||||||
try:
|
try:
|
||||||
from flask_wtf.csrf import CSRFProtect
|
from flask_wtf.csrf import CSRFProtect
|
||||||
wtf_present = True
|
wtf_present = True
|
||||||
@@ -51,22 +56,26 @@ except ImportError:
|
|||||||
mimetypes.init()
|
mimetypes.init()
|
||||||
mimetypes.add_type('application/xhtml+xml', '.xhtml')
|
mimetypes.add_type('application/xhtml+xml', '.xhtml')
|
||||||
mimetypes.add_type('application/epub+zip', '.epub')
|
mimetypes.add_type('application/epub+zip', '.epub')
|
||||||
mimetypes.add_type('application/fb2+zip', '.fb2')
|
mimetypes.add_type('application/epub+zip', '.kepub')
|
||||||
mimetypes.add_type('application/x-mobipocket-ebook', '.mobi')
|
mimetypes.add_type('text/xml', '.fb2')
|
||||||
mimetypes.add_type('application/x-mobipocket-ebook', '.prc')
|
mimetypes.add_type('application/octet-stream', '.mobi')
|
||||||
|
mimetypes.add_type('application/octet-stream', '.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-mobi8-ebook', '.azw3')
|
||||||
mimetypes.add_type('application/x-cbr', '.cbr')
|
mimetypes.add_type('application/x-rar', '.cbr')
|
||||||
mimetypes.add_type('application/x-cbz', '.cbz')
|
mimetypes.add_type('application/zip', '.cbz')
|
||||||
mimetypes.add_type('application/x-cbt', '.cbt')
|
mimetypes.add_type('application/x-tar', '.cbt')
|
||||||
mimetypes.add_type('image/vnd.djvu', '.djvu')
|
mimetypes.add_type('application/x-7z-compressed', '.cb7')
|
||||||
|
mimetypes.add_type('image/vnd.djv', '.djv')
|
||||||
|
mimetypes.add_type('image/vnd.djv', '.djvu')
|
||||||
mimetypes.add_type('application/mpeg', '.mpeg')
|
mimetypes.add_type('application/mpeg', '.mpeg')
|
||||||
mimetypes.add_type('application/mpeg', '.mp3')
|
mimetypes.add_type('audio/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('audio/ogg', '.ogg')
|
||||||
mimetypes.add_type('application/ogg', '.oga')
|
mimetypes.add_type('application/ogg', '.oga')
|
||||||
mimetypes.add_type('text/css', '.css')
|
mimetypes.add_type('text/css', '.css')
|
||||||
|
mimetypes.add_type('application/x-ms-reader', '.lit')
|
||||||
mimetypes.add_type('text/javascript; charset=UTF-8', '.js')
|
mimetypes.add_type('text/javascript; charset=UTF-8', '.js')
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
@@ -81,10 +90,10 @@ app.config.update(
|
|||||||
|
|
||||||
lm = MyLoginManager()
|
lm = MyLoginManager()
|
||||||
|
|
||||||
config = config_sql._ConfigSQL()
|
|
||||||
|
|
||||||
cli_param = CliParameter()
|
cli_param = CliParameter()
|
||||||
|
|
||||||
|
config = config_sql.ConfigSQL()
|
||||||
|
|
||||||
if wtf_present:
|
if wtf_present:
|
||||||
csrf = CSRFProtect()
|
csrf = CSRFProtect()
|
||||||
else:
|
else:
|
||||||
@@ -96,32 +105,29 @@ web_server = WebServer()
|
|||||||
|
|
||||||
updater_thread = Updater()
|
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():
|
||||||
lm.login_view = 'web.login'
|
|
||||||
lm.anonymous_user = ub.Anonymous
|
|
||||||
lm.session_protection = 'strong'
|
|
||||||
|
|
||||||
if csrf:
|
if csrf:
|
||||||
csrf.init_app(app)
|
csrf.init_app(app)
|
||||||
|
|
||||||
cli_param.init()
|
cli_param.init()
|
||||||
|
|
||||||
ub.init_db(cli_param.settings_path, cli_param.user_credentials)
|
ub.init_db(cli_param.settings_path)
|
||||||
|
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
config_sql.load_configuration(config, ub.session, cli_param)
|
encrypt_key, error = config_sql.get_encryption_key(os.path.dirname(cli_param.settings_path))
|
||||||
|
|
||||||
db.CalibreDB.update_config(config)
|
config_sql.load_configuration(ub.session, encrypt_key)
|
||||||
db.CalibreDB.setup_db(config.config_calibre_dir, cli_param.settings_path)
|
config.init_config(ub.session, encrypt_key, cli_param)
|
||||||
calibre_db.init_db()
|
|
||||||
|
|
||||||
updater_thread.init_updater(config, web_server)
|
if error:
|
||||||
# Perform dry run of updater and exit afterwards
|
log.error(error)
|
||||||
if cli_param.dry_run:
|
|
||||||
updater_thread.dry_run()
|
ub.password_change(cli_param.user_credentials)
|
||||||
sys.exit(0)
|
|
||||||
updater_thread.start()
|
|
||||||
|
|
||||||
if sys.version_info < (3, 0):
|
if sys.version_info < (3, 0):
|
||||||
log.info(
|
log.info(
|
||||||
@@ -132,15 +138,32 @@ def create_app():
|
|||||||
'please update your installation to Python3 ***')
|
'please update your installation to Python3 ***')
|
||||||
web_server.stop(True)
|
web_server.stop(True)
|
||||||
sys.exit(5)
|
sys.exit(5)
|
||||||
if not wtf_present:
|
|
||||||
log.info('*** "flask-WTF" is needed for calibre-web to run. '
|
lm.login_view = 'web.login'
|
||||||
'Please install it using pip: "pip install flask-WTF" ***')
|
lm.anonymous_user = ub.Anonymous
|
||||||
print('*** "flask-WTF" is needed for calibre-web to run. '
|
lm.session_protection = 'strong' if config.config_session == 1 else "basic"
|
||||||
'Please install it using pip: "pip install flask-WTF" ***')
|
|
||||||
web_server.stop(True)
|
db.CalibreDB.update_config(config)
|
||||||
sys.exit(7)
|
db.CalibreDB.setup_db(config.config_calibre_dir, cli_param.settings_path)
|
||||||
for res in dependency_check() + dependency_check(True):
|
calibre_db.init_db()
|
||||||
log.info('*** "{}" version does not fit the requirements. '
|
|
||||||
|
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 ***'
|
'Should: {}, Found: {}, please consider installing required version ***'
|
||||||
.format(res['name'],
|
.format(res['name'],
|
||||||
res['target'],
|
res['target'],
|
||||||
@@ -150,14 +173,16 @@ def create_app():
|
|||||||
if os.environ.get('FLASK_DEBUG'):
|
if os.environ.get('FLASK_DEBUG'):
|
||||||
cache_buster.init_cache_busting(app)
|
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)
|
||||||
|
if hasattr(babel, "localeselector"):
|
||||||
babel.init_app(app)
|
babel.init_app(app)
|
||||||
|
babel.localeselector(get_locale)
|
||||||
|
else:
|
||||||
|
babel.init_app(app, locale_selector=get_locale)
|
||||||
|
|
||||||
from . import services
|
from . import services
|
||||||
|
|
||||||
@@ -165,9 +190,22 @@ def create_app():
|
|||||||
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)
|
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
|
# Register scheduled tasks
|
||||||
from .schedule import register_scheduled_tasks, register_startup_tasks
|
from .schedule import register_scheduled_tasks, register_startup_tasks
|
||||||
register_scheduled_tasks(config.schedule_reconnect)
|
register_scheduled_tasks(config.schedule_reconnect)
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ sorted_modules = OrderedDict((sorted(modules.items(), key=lambda x: x[0].casefol
|
|||||||
|
|
||||||
def collect_stats():
|
def collect_stats():
|
||||||
if constants.NIGHTLY_VERSION[0] == "$Format:%H$":
|
if constants.NIGHTLY_VERSION[0] == "$Format:%H$":
|
||||||
calibre_web_version = constants.STABLE_VERSION['version']
|
calibre_web_version = constants.STABLE_VERSION['version'].replace("b", " Beta")
|
||||||
else:
|
else:
|
||||||
calibre_web_version = (constants.STABLE_VERSION['version'] + ' - '
|
calibre_web_version = (constants.STABLE_VERSION['version'].replace("b", " Beta") + ' - '
|
||||||
+ constants.NIGHTLY_VERSION[0].replace('%', '%%') + ' - '
|
+ constants.NIGHTLY_VERSION[0].replace('%', '%%') + ' - '
|
||||||
+ constants.NIGHTLY_VERSION[1].replace('%', '%%'))
|
+ constants.NIGHTLY_VERSION[1].replace('%', '%%'))
|
||||||
|
|
||||||
@@ -81,4 +81,4 @@ def stats():
|
|||||||
categories = calibre_db.session.query(db.Tags).count()
|
categories = calibre_db.session.query(db.Tags).count()
|
||||||
series = calibre_db.session.query(db.Series).count()
|
series = calibre_db.session.query(db.Series).count()
|
||||||
return render_title_template('stats.html', bookcounter=counter, authorcounter=authors, versions=collect_stats(),
|
return render_title_template('stats.html', bookcounter=counter, authorcounter=authors, versions=collect_stats(),
|
||||||
categorycounter=categories, seriecounter=series, title=_(u"Statistics"), page="stat")
|
categorycounter=categories, seriecounter=series, title=_("Statistics"), page="stat")
|
||||||
|
|||||||
@@ -22,19 +22,21 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import base64
|
|
||||||
import json
|
import json
|
||||||
import operator
|
import operator
|
||||||
import time
|
import time
|
||||||
|
import sys
|
||||||
|
import string
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from datetime import time as datetime_time
|
from datetime import time as datetime_time
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory, g, Response
|
from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory, g, Response
|
||||||
from flask_login import login_required, current_user, logout_user, confirm_login
|
from markupsafe import Markup
|
||||||
|
from flask_login import login_required, current_user, logout_user
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_babel import get_locale, format_time, format_datetime, format_timedelta
|
from flask_babel import get_locale, format_time, format_datetime, format_timedelta
|
||||||
from flask import session as flask_session
|
from flask import session as flask_session
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
@@ -46,33 +48,35 @@ from . import db, calibre_db, ub, web_server, config, updater_thread, gdriveutil
|
|||||||
kobo_sync_status, schedule
|
kobo_sync_status, schedule
|
||||||
from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash, check_email, \
|
from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash, check_email, \
|
||||||
valid_email, check_username
|
valid_email, check_username
|
||||||
|
from .embed_helper import get_calibre_binarypath
|
||||||
from .gdriveutils import is_gdrive_ready, gdrive_support
|
from .gdriveutils import is_gdrive_ready, gdrive_support
|
||||||
from .render_template import render_title_template, get_sidebar_config
|
from .render_template import render_title_template, get_sidebar_config
|
||||||
from .services.worker import WorkerThread
|
from .services.worker import WorkerThread
|
||||||
from .babel import get_available_translations, get_available_locale, get_user_locale_language
|
from .babel import get_available_translations, get_available_locale, get_user_locale_language
|
||||||
from . import debug_info
|
from . import debug_info
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
feature_support = {
|
feature_support = {
|
||||||
'ldap': bool(services.ldap),
|
'ldap': bool(services.ldap),
|
||||||
'goodreads': bool(services.goodreads_support),
|
'goodreads': bool(services.goodreads_support),
|
||||||
'kobo': bool(services.kobo),
|
'kobo': bool(services.kobo),
|
||||||
'updater': constants.UPDATER_AVAILABLE,
|
'updater': constants.UPDATER_AVAILABLE,
|
||||||
'gmail': bool(services.gmail),
|
'gmail': bool(services.gmail),
|
||||||
'scheduler': schedule.use_APScheduler,
|
'scheduler': schedule.use_APScheduler,
|
||||||
'gdrive': gdrive_support
|
'gdrive': gdrive_support
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import rarfile # pylint: disable=unused-import
|
import rarfile # pylint: disable=unused-import
|
||||||
|
|
||||||
feature_support['rar'] = True
|
feature_support['rar'] = True
|
||||||
except (ImportError, SyntaxError):
|
except (ImportError, SyntaxError):
|
||||||
feature_support['rar'] = False
|
feature_support['rar'] = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .oauth_bb import oauth_check, oauthblueprints
|
from .oauth_bb import oauth_check, oauthblueprints
|
||||||
|
|
||||||
feature_support['oauth'] = True
|
feature_support['oauth'] = True
|
||||||
except ImportError as err:
|
except ImportError as err:
|
||||||
log.debug('Cannot import Flask-Dance, login with Oauth will not work: %s', err)
|
log.debug('Cannot import Flask-Dance, login with Oauth will not work: %s', err)
|
||||||
@@ -80,7 +84,6 @@ except ImportError as err:
|
|||||||
oauthblueprints = []
|
oauthblueprints = []
|
||||||
oauth_check = {}
|
oauth_check = {}
|
||||||
|
|
||||||
|
|
||||||
admi = Blueprint('admin', __name__)
|
admi = Blueprint('admin', __name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -100,25 +103,26 @@ def admin_required(f):
|
|||||||
|
|
||||||
@admi.before_app_request
|
@admi.before_app_request
|
||||||
def before_request():
|
def before_request():
|
||||||
# make remember me function work
|
try:
|
||||||
if current_user.is_authenticated:
|
if not ub.check_user_session(current_user.id,
|
||||||
confirm_login()
|
flask_session.get('_id')) and 'opds' not in request.path \
|
||||||
if not ub.check_user_session(current_user.id, flask_session.get('_id')) and 'opds' not in request.path:
|
and config.config_session == 1:
|
||||||
logout_user()
|
logout_user()
|
||||||
|
except AttributeError:
|
||||||
|
pass # ? fails on requesting /ajax/emailstat during restart ?
|
||||||
g.constants = constants
|
g.constants = constants
|
||||||
g.user = current_user
|
g.google_site_verification = os.getenv('GOOGLE_SITE_VERIFICATION', '')
|
||||||
g.allow_registration = config.config_public_reg
|
g.allow_registration = config.config_public_reg
|
||||||
g.allow_anonymous = config.config_anonbrowse
|
g.allow_anonymous = config.config_anonbrowse
|
||||||
g.allow_upload = config.config_uploading
|
g.allow_upload = config.config_uploading
|
||||||
g.current_theme = config.config_theme
|
g.current_theme = config.config_theme
|
||||||
g.config_authors_max = config.config_authors_max
|
g.config_authors_max = config.config_authors_max
|
||||||
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()
|
|
||||||
if '/static/' not in request.path and not config.db_configured and \
|
if '/static/' not in request.path and not config.db_configured and \
|
||||||
request.endpoint not in ('admin.ajax_db_config',
|
request.endpoint not in ('admin.ajax_db_config',
|
||||||
'admin.simulatedbchange',
|
'admin.simulatedbchange',
|
||||||
'admin.db_configuration',
|
'admin.db_configuration',
|
||||||
'web.login',
|
'web.login',
|
||||||
|
'web.login_post',
|
||||||
'web.logout',
|
'web.logout',
|
||||||
'admin.load_dialogtexts',
|
'admin.load_dialogtexts',
|
||||||
'admin.ajax_pathchooser'):
|
'admin.ajax_pathchooser'):
|
||||||
@@ -136,28 +140,39 @@ def admin_forbidden():
|
|||||||
@admin_required
|
@admin_required
|
||||||
def shutdown():
|
def shutdown():
|
||||||
task = request.get_json().get('parameter', -1)
|
task = request.get_json().get('parameter', -1)
|
||||||
showtext = {}
|
show_text = {}
|
||||||
if task in (0, 1): # valid commandos received
|
if task in (0, 1): # valid commandos received
|
||||||
# close all database connections
|
# close all database connections
|
||||||
calibre_db.dispose()
|
calibre_db.dispose()
|
||||||
ub.dispose()
|
ub.dispose()
|
||||||
|
|
||||||
if task == 0:
|
if task == 0:
|
||||||
showtext['text'] = _(u'Server restarted, please reload page')
|
show_text['text'] = _('Server restarted, please reload page.')
|
||||||
else:
|
else:
|
||||||
showtext['text'] = _(u'Performing shutdown of server, please close window')
|
show_text['text'] = _('Performing Server shutdown, please close window.')
|
||||||
# stop gevent/tornado server
|
# stop gevent/tornado server
|
||||||
web_server.stop(task == 0)
|
web_server.stop(task == 0)
|
||||||
return json.dumps(showtext)
|
return json.dumps(show_text)
|
||||||
|
|
||||||
if task == 2:
|
if task == 2:
|
||||||
log.warning("reconnecting to calibre database")
|
log.warning("reconnecting to calibre database")
|
||||||
calibre_db.reconnect_db(config, ub.app_DB_path)
|
calibre_db.reconnect_db(config, ub.app_DB_path)
|
||||||
showtext['text'] = _(u'Reconnect successful')
|
show_text['text'] = _('Success! Database Reconnected')
|
||||||
return json.dumps(showtext)
|
return json.dumps(show_text)
|
||||||
|
|
||||||
showtext['text'] = _(u'Unknown command')
|
show_text['text'] = _('Unknown command')
|
||||||
return json.dumps(showtext), 400
|
return json.dumps(show_text), 400
|
||||||
|
|
||||||
|
|
||||||
|
@admi.route("/metadata_backup", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def queue_metadata_backup():
|
||||||
|
show_text = {}
|
||||||
|
log.warning("Queuing all books for metadata backup")
|
||||||
|
helper.set_all_metadata_dirty()
|
||||||
|
show_text['text'] = _('Success! Books queued for Metadata Backup, please check Tasks for result')
|
||||||
|
return json.dumps(show_text)
|
||||||
|
|
||||||
|
|
||||||
# method is available without login and not protected by CSRF to make it easy reachable, is per default switched off
|
# method is available without login and not protected by CSRF to make it easy reachable, is per default switched off
|
||||||
@@ -189,32 +204,32 @@ def update_thumbnails():
|
|||||||
def admin():
|
def admin():
|
||||||
version = updater_thread.get_current_version_info()
|
version = updater_thread.get_current_version_info()
|
||||||
if version is False:
|
if version is False:
|
||||||
commit = _(u'Unknown')
|
commit = _('Unknown')
|
||||||
else:
|
else:
|
||||||
if 'datetime' in version:
|
if 'datetime' in version:
|
||||||
commit = version['datetime']
|
commit = version['datetime']
|
||||||
|
|
||||||
tz = timedelta(seconds=time.timezone if (time.localtime().tm_isdst == 0) else time.altzone)
|
tz = timedelta(seconds=time.timezone if (time.localtime().tm_isdst == 0) else time.altzone)
|
||||||
form_date = datetime.strptime(commit[:19], "%Y-%m-%dT%H:%M:%S")
|
form_date = datetime.strptime(commit[:19], "%Y-%m-%dT%H:%M:%S")
|
||||||
if len(commit) > 19: # check if string has timezone
|
if len(commit) > 19: # check if string has timezone
|
||||||
if commit[19] == '+':
|
if commit[19] == '+':
|
||||||
form_date -= timedelta(hours=int(commit[20:22]), minutes=int(commit[23:]))
|
form_date -= timedelta(hours=int(commit[20:22]), minutes=int(commit[23:]))
|
||||||
elif commit[19] == '-':
|
elif commit[19] == '-':
|
||||||
form_date += timedelta(hours=int(commit[20:22]), minutes=int(commit[23:]))
|
form_date += timedelta(hours=int(commit[20:22]), minutes=int(commit[23:]))
|
||||||
commit = format_datetime(form_date - tz, format='short')
|
commit = format_datetime(form_date - tz, format='short')
|
||||||
else:
|
else:
|
||||||
commit = version['version']
|
commit = version['version'].replace("b", " Beta")
|
||||||
|
|
||||||
all_user = ub.session.query(ub.User).all()
|
all_user = ub.session.query(ub.User).all()
|
||||||
email_settings = config.get_mail_settings()
|
# email_settings = mail_config.get_mail_settings()
|
||||||
schedule_time = format_time(datetime_time(hour=config.schedule_start_time), format="short")
|
schedule_time = format_time(datetime_time(hour=config.schedule_start_time), format="short")
|
||||||
t = timedelta(hours=config.schedule_duration // 60, minutes=config.schedule_duration % 60)
|
t = timedelta(hours=config.schedule_duration // 60, minutes=config.schedule_duration % 60)
|
||||||
schedule_duration = format_timedelta(t, threshold=.99)
|
schedule_duration = format_timedelta(t, threshold=.99)
|
||||||
|
|
||||||
return render_title_template("admin.html", allUser=all_user, email=email_settings, config=config, commit=commit,
|
return render_title_template("admin.html", allUser=all_user, config=config, commit=commit,
|
||||||
feature_support=feature_support, schedule_time=schedule_time,
|
feature_support=feature_support, schedule_time=schedule_time,
|
||||||
schedule_duration=schedule_duration,
|
schedule_duration=schedule_duration,
|
||||||
title=_(u"Admin page"), page="admin")
|
title=_("Admin page"), page="admin")
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/dbconfig", methods=["GET", "POST"])
|
@admi.route("/admin/dbconfig", methods=["GET", "POST"])
|
||||||
@@ -234,7 +249,7 @@ def configuration():
|
|||||||
config=config,
|
config=config,
|
||||||
provider=oauthblueprints,
|
provider=oauthblueprints,
|
||||||
feature_support=feature_support,
|
feature_support=feature_support,
|
||||||
title=_(u"Basic Configuration"), page="config")
|
title=_("Basic Configuration"), page="config")
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/ajaxconfig", methods=["POST"])
|
@admi.route("/admin/ajaxconfig", methods=["POST"])
|
||||||
@@ -262,9 +277,9 @@ def calibreweb_alive():
|
|||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def view_configuration():
|
def view_configuration():
|
||||||
read_column = calibre_db.session.query(db.CustomColumns)\
|
read_column = calibre_db.session.query(db.CustomColumns) \
|
||||||
.filter(and_(db.CustomColumns.datatype == 'bool', db.CustomColumns.mark_for_delete == 0)).all()
|
.filter(and_(db.CustomColumns.datatype == 'bool', db.CustomColumns.mark_for_delete == 0)).all()
|
||||||
restrict_columns = calibre_db.session.query(db.CustomColumns)\
|
restrict_columns = calibre_db.session.query(db.CustomColumns) \
|
||||||
.filter(and_(db.CustomColumns.datatype == 'text', db.CustomColumns.mark_for_delete == 0)).all()
|
.filter(and_(db.CustomColumns.datatype == 'text', db.CustomColumns.mark_for_delete == 0)).all()
|
||||||
languages = calibre_db.speaking_language()
|
languages = calibre_db.speaking_language()
|
||||||
translations = get_available_locale()
|
translations = get_available_locale()
|
||||||
@@ -272,7 +287,7 @@ def view_configuration():
|
|||||||
restrictColumns=restrict_columns,
|
restrictColumns=restrict_columns,
|
||||||
languages=languages,
|
languages=languages,
|
||||||
translations=translations,
|
translations=translations,
|
||||||
title=_(u"UI Configuration"), page="uiconfig")
|
title=_("UI Configuration"), page="uiconfig")
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/usertable")
|
@admi.route("/admin/usertable")
|
||||||
@@ -283,11 +298,11 @@ def edit_user_table():
|
|||||||
languages = calibre_db.speaking_language()
|
languages = calibre_db.speaking_language()
|
||||||
translations = get_available_locale()
|
translations = get_available_locale()
|
||||||
all_user = ub.session.query(ub.User)
|
all_user = ub.session.query(ub.User)
|
||||||
tags = calibre_db.session.query(db.Tags)\
|
tags = calibre_db.session.query(db.Tags) \
|
||||||
.join(db.books_tags_link)\
|
.join(db.books_tags_link) \
|
||||||
.join(db.Books)\
|
.join(db.Books) \
|
||||||
.filter(calibre_db.common_filters()) \
|
.filter(calibre_db.common_filters()) \
|
||||||
.group_by(text('books_tags_link.tag'))\
|
.group_by(text('books_tags_link.tag')) \
|
||||||
.order_by(db.Tags.name).all()
|
.order_by(db.Tags.name).all()
|
||||||
if config.config_restricted_column:
|
if config.config_restricted_column:
|
||||||
custom_values = calibre_db.session.query(db.cc_classes[config.config_restricted_column]).all()
|
custom_values = calibre_db.session.query(db.cc_classes[config.config_restricted_column]).all()
|
||||||
@@ -306,7 +321,7 @@ def edit_user_table():
|
|||||||
all_roles=constants.ALL_ROLES,
|
all_roles=constants.ALL_ROLES,
|
||||||
kobo_support=kobo_support,
|
kobo_support=kobo_support,
|
||||||
sidebar_settings=constants.sidebar_settings,
|
sidebar_settings=constants.sidebar_settings,
|
||||||
title=_(u"Edit Users"),
|
title=_("Edit Users"),
|
||||||
page="usertable")
|
page="usertable")
|
||||||
|
|
||||||
|
|
||||||
@@ -464,20 +479,20 @@ def edit_list_user(param):
|
|||||||
elif param.endswith('role'):
|
elif param.endswith('role'):
|
||||||
value = int(vals['field_index'])
|
value = int(vals['field_index'])
|
||||||
if user.name == "Guest" and value in \
|
if user.name == "Guest" and value in \
|
||||||
[constants.ROLE_ADMIN, constants.ROLE_PASSWD, constants.ROLE_EDIT_SHELFS]:
|
[constants.ROLE_ADMIN, constants.ROLE_PASSWD, constants.ROLE_EDIT_SHELFS]:
|
||||||
raise Exception(_("Guest can't have this role"))
|
raise Exception(_("Guest can't have this role"))
|
||||||
# check for valid value, last on checks for power of 2 value
|
# check for valid value, last on checks for power of 2 value
|
||||||
if value > 0 and value <= constants.ROLE_VIEWER and (value & value-1 == 0 or value == 1):
|
if value > 0 and value <= constants.ROLE_VIEWER and (value & value - 1 == 0 or value == 1):
|
||||||
if vals['value'] == 'true':
|
if vals['value'] == 'true':
|
||||||
user.role |= value
|
user.role |= value
|
||||||
elif vals['value'] == 'false':
|
elif vals['value'] == 'false':
|
||||||
if value == constants.ROLE_ADMIN:
|
if value == constants.ROLE_ADMIN:
|
||||||
if not ub.session.query(ub.User).\
|
if not ub.session.query(ub.User). \
|
||||||
filter(ub.User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN,
|
filter(ub.User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN,
|
||||||
ub.User.id != user.id).count():
|
ub.User.id != user.id).count():
|
||||||
return Response(
|
return Response(
|
||||||
json.dumps([{'type': "danger",
|
json.dumps([{'type': "danger",
|
||||||
'message': _(u"No admin user remaining, can't remove admin role",
|
'message': _("No admin user remaining, can't remove admin role",
|
||||||
nick=user.name)}]), mimetype='application/json')
|
nick=user.name)}]), mimetype='application/json')
|
||||||
user.role &= ~value
|
user.role &= ~value
|
||||||
else:
|
else:
|
||||||
@@ -489,7 +504,7 @@ def edit_list_user(param):
|
|||||||
if user.name == "Guest" and value == constants.SIDEBAR_READ_AND_UNREAD:
|
if user.name == "Guest" and value == constants.SIDEBAR_READ_AND_UNREAD:
|
||||||
raise Exception(_("Guest can't have this view"))
|
raise Exception(_("Guest can't have this view"))
|
||||||
# check for valid value, last on checks for power of 2 value
|
# check for valid value, last on checks for power of 2 value
|
||||||
if value > 0 and value <= constants.SIDEBAR_LIST and (value & value-1 == 0 or value == 1):
|
if value > 0 and value <= constants.SIDEBAR_LIST and (value & value - 1 == 0 or value == 1):
|
||||||
if vals['value'] == 'true':
|
if vals['value'] == 'true':
|
||||||
user.sidebar_view |= value
|
user.sidebar_view |= value
|
||||||
elif vals['value'] == 'false':
|
elif vals['value'] == 'false':
|
||||||
@@ -554,13 +569,13 @@ def update_view_configuration():
|
|||||||
calibre_db.update_title_sort(config)
|
calibre_db.update_title_sort(config)
|
||||||
|
|
||||||
if not check_valid_read_column(to_save.get("config_read_column", "0")):
|
if not check_valid_read_column(to_save.get("config_read_column", "0")):
|
||||||
flash(_(u"Invalid Read Column"), category="error")
|
flash(_("Invalid Read Column"), category="error")
|
||||||
log.debug("Invalid Read column")
|
log.debug("Invalid Read column")
|
||||||
return view_configuration()
|
return view_configuration()
|
||||||
_config_int(to_save, "config_read_column")
|
_config_int(to_save, "config_read_column")
|
||||||
|
|
||||||
if not check_valid_restricted_column(to_save.get("config_restricted_column", "0")):
|
if not check_valid_restricted_column(to_save.get("config_restricted_column", "0")):
|
||||||
flash(_(u"Invalid Restricted Column"), category="error")
|
flash(_("Invalid Restricted Column"), category="error")
|
||||||
log.debug("Invalid Restricted Column")
|
log.debug("Invalid Restricted Column")
|
||||||
return view_configuration()
|
return view_configuration()
|
||||||
_config_int(to_save, "config_restricted_column")
|
_config_int(to_save, "config_restricted_column")
|
||||||
@@ -580,7 +595,7 @@ def update_view_configuration():
|
|||||||
config.config_default_show |= constants.DETAIL_RANDOM
|
config.config_default_show |= constants.DETAIL_RANDOM
|
||||||
|
|
||||||
config.save()
|
config.save()
|
||||||
flash(_(u"Calibre-Web configuration updated"), category="success")
|
flash(_("Calibre-Web configuration updated"), category="success")
|
||||||
log.debug("Calibre-Web configuration updated")
|
log.debug("Calibre-Web configuration updated")
|
||||||
before_request()
|
before_request()
|
||||||
|
|
||||||
@@ -642,7 +657,7 @@ def edit_domain(allow):
|
|||||||
@admin_required
|
@admin_required
|
||||||
def add_domain(allow):
|
def add_domain(allow):
|
||||||
domain_name = request.form.to_dict()['domainname'].replace('*', '%').replace('?', '_').lower()
|
domain_name = request.form.to_dict()['domainname'].replace('*', '%').replace('?', '_').lower()
|
||||||
check = ub.session.query(ub.Registration).filter(ub.Registration.domain == domain_name)\
|
check = ub.session.query(ub.Registration).filter(ub.Registration.domain == domain_name) \
|
||||||
.filter(ub.Registration.allow == allow).first()
|
.filter(ub.Registration.allow == allow).first()
|
||||||
if not check:
|
if not check:
|
||||||
new_domain = ub.Registration(domain=domain_name, allow=allow)
|
new_domain = ub.Registration(domain=domain_name, allow=allow)
|
||||||
@@ -860,16 +875,16 @@ def delete_restriction(res_type, user_id):
|
|||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def list_restriction(res_type, user_id):
|
def list_restriction(res_type, user_id):
|
||||||
if res_type == 0: # Tags as template
|
if res_type == 0: # Tags as template
|
||||||
restrict = [{'Element': x, 'type': _('Deny'), 'id': 'd'+str(i)}
|
restrict = [{'Element': x, 'type': _('Deny'), 'id': 'd' + str(i)}
|
||||||
for i, x in enumerate(config.list_denied_tags()) if x != '']
|
for i, x in enumerate(config.list_denied_tags()) if x != '']
|
||||||
allow = [{'Element': x, 'type': _('Allow'), 'id': 'a'+str(i)}
|
allow = [{'Element': x, 'type': _('Allow'), 'id': 'a' + str(i)}
|
||||||
for i, x in enumerate(config.list_allowed_tags()) if x != '']
|
for i, x in enumerate(config.list_allowed_tags()) if x != '']
|
||||||
json_dumps = restrict + allow
|
json_dumps = restrict + allow
|
||||||
elif res_type == 1: # CustomC as template
|
elif res_type == 1: # CustomC as template
|
||||||
restrict = [{'Element': x, 'type': _('Deny'), 'id': 'd'+str(i)}
|
restrict = [{'Element': x, 'type': _('Deny'), 'id': 'd' + str(i)}
|
||||||
for i, x in enumerate(config.list_denied_column_values()) if x != '']
|
for i, x in enumerate(config.list_denied_column_values()) if x != '']
|
||||||
allow = [{'Element': x, 'type': _('Allow'), 'id': 'a'+str(i)}
|
allow = [{'Element': x, 'type': _('Allow'), 'id': 'a' + str(i)}
|
||||||
for i, x in enumerate(config.list_allowed_column_values()) if x != '']
|
for i, x in enumerate(config.list_allowed_column_values()) if x != '']
|
||||||
json_dumps = restrict + allow
|
json_dumps = restrict + allow
|
||||||
elif res_type == 2: # Tags per user
|
elif res_type == 2: # Tags per user
|
||||||
@@ -877,9 +892,9 @@ def list_restriction(res_type, user_id):
|
|||||||
usr = ub.session.query(ub.User).filter(ub.User.id == user_id).first()
|
usr = ub.session.query(ub.User).filter(ub.User.id == user_id).first()
|
||||||
else:
|
else:
|
||||||
usr = current_user
|
usr = current_user
|
||||||
restrict = [{'Element': x, 'type': _('Deny'), 'id': 'd'+str(i)}
|
restrict = [{'Element': x, 'type': _('Deny'), 'id': 'd' + str(i)}
|
||||||
for i, x in enumerate(usr.list_denied_tags()) if x != '']
|
for i, x in enumerate(usr.list_denied_tags()) if x != '']
|
||||||
allow = [{'Element': x, 'type': _('Allow'), 'id': 'a'+str(i)}
|
allow = [{'Element': x, 'type': _('Allow'), 'id': 'a' + str(i)}
|
||||||
for i, x in enumerate(usr.list_allowed_tags()) if x != '']
|
for i, x in enumerate(usr.list_allowed_tags()) if x != '']
|
||||||
json_dumps = restrict + allow
|
json_dumps = restrict + allow
|
||||||
elif res_type == 3: # CustomC per user
|
elif res_type == 3: # CustomC per user
|
||||||
@@ -887,9 +902,9 @@ def list_restriction(res_type, user_id):
|
|||||||
usr = ub.session.query(ub.User).filter(ub.User.id == user_id).first()
|
usr = ub.session.query(ub.User).filter(ub.User.id == user_id).first()
|
||||||
else:
|
else:
|
||||||
usr = current_user
|
usr = current_user
|
||||||
restrict = [{'Element': x, 'type': _('Deny'), 'id': 'd'+str(i)}
|
restrict = [{'Element': x, 'type': _('Deny'), 'id': 'd' + str(i)}
|
||||||
for i, x in enumerate(usr.list_denied_column_values()) if x != '']
|
for i, x in enumerate(usr.list_denied_column_values()) if x != '']
|
||||||
allow = [{'Element': x, 'type': _('Allow'), 'id': 'a'+str(i)}
|
allow = [{'Element': x, 'type': _('Allow'), 'id': 'a' + str(i)}
|
||||||
for i, x in enumerate(usr.list_allowed_column_values()) if x != '']
|
for i, x in enumerate(usr.list_allowed_column_values()) if x != '']
|
||||||
json_dumps = restrict + allow
|
json_dumps = restrict + allow
|
||||||
else:
|
else:
|
||||||
@@ -902,11 +917,15 @@ def list_restriction(res_type, user_id):
|
|||||||
|
|
||||||
@admi.route("/ajax/fullsync", methods=["POST"])
|
@admi.route("/ajax/fullsync", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def ajax_fullsync():
|
def ajax_self_fullsync():
|
||||||
count = ub.session.query(ub.KoboSyncedBooks).filter(current_user.id == ub.KoboSyncedBooks.user_id).delete()
|
return do_full_kobo_sync(current_user.id)
|
||||||
message = _("{} sync entries deleted").format(count)
|
|
||||||
ub.session_commit(message)
|
|
||||||
return Response(json.dumps([{"type": "success", "message": message}]), mimetype='application/json')
|
@admi.route("/ajax/fullsync/<int:userid>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def ajax_fullsync(userid):
|
||||||
|
return do_full_kobo_sync(userid)
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/ajax/pathchooser/")
|
@admi.route("/ajax/pathchooser/")
|
||||||
@@ -916,10 +935,17 @@ def ajax_pathchooser():
|
|||||||
return pathchooser()
|
return pathchooser()
|
||||||
|
|
||||||
|
|
||||||
|
def do_full_kobo_sync(userid):
|
||||||
|
count = ub.session.query(ub.KoboSyncedBooks).filter(userid == ub.KoboSyncedBooks.user_id).delete()
|
||||||
|
message = _("{} sync entries deleted").format(count)
|
||||||
|
ub.session_commit(message)
|
||||||
|
return Response(json.dumps([{"type": "success", "message": message}]), mimetype='application/json')
|
||||||
|
|
||||||
|
|
||||||
def check_valid_read_column(column):
|
def check_valid_read_column(column):
|
||||||
if column != "0":
|
if column != "0":
|
||||||
if not calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.id == column) \
|
if not calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.id == column) \
|
||||||
.filter(and_(db.CustomColumns.datatype == 'bool', db.CustomColumns.mark_for_delete == 0)).all():
|
.filter(and_(db.CustomColumns.datatype == 'bool', db.CustomColumns.mark_for_delete == 0)).all():
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -927,7 +953,7 @@ def check_valid_read_column(column):
|
|||||||
def check_valid_restricted_column(column):
|
def check_valid_restricted_column(column):
|
||||||
if column != "0":
|
if column != "0":
|
||||||
if not calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.id == column) \
|
if not calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.id == column) \
|
||||||
.filter(and_(db.CustomColumns.datatype == 'text', db.CustomColumns.mark_for_delete == 0)).all():
|
.filter(and_(db.CustomColumns.datatype == 'text', db.CustomColumns.mark_for_delete == 0)).all():
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -955,7 +981,7 @@ def prepare_tags(user, action, tags_name, id_list):
|
|||||||
raise Exception(_("Tag not found"))
|
raise Exception(_("Tag not found"))
|
||||||
new_tags_list = [x.name for x in tags]
|
new_tags_list = [x.name for x in tags]
|
||||||
else:
|
else:
|
||||||
tags = calibre_db.session.query(db.cc_classes[config.config_restricted_column])\
|
tags = calibre_db.session.query(db.cc_classes[config.config_restricted_column]) \
|
||||||
.filter(db.cc_classes[config.config_restricted_column].id.in_(id_list)).all()
|
.filter(db.cc_classes[config.config_restricted_column].id.in_(id_list)).all()
|
||||||
new_tags_list = [x.value for x in tags]
|
new_tags_list = [x.value for x in tags]
|
||||||
saved_tags_list = user.__dict__[tags_name].split(",") if len(user.__dict__[tags_name]) else []
|
saved_tags_list = user.__dict__[tags_name].split(",") if len(user.__dict__[tags_name]) else []
|
||||||
@@ -968,6 +994,16 @@ def prepare_tags(user, action, tags_name, id_list):
|
|||||||
return ",".join(saved_tags_list)
|
return ",".join(saved_tags_list)
|
||||||
|
|
||||||
|
|
||||||
|
def get_drives(current):
|
||||||
|
drive_letters = []
|
||||||
|
for d in string.ascii_uppercase:
|
||||||
|
if os.path.exists('{}:'.format(d)) and current[0].lower() != d.lower():
|
||||||
|
drive = "{}:\\".format(d)
|
||||||
|
data = {"name": drive, "fullpath": drive, "type": "dir", "size": "", "sort": "_" + drive.lower()}
|
||||||
|
drive_letters.append(data)
|
||||||
|
return drive_letters
|
||||||
|
|
||||||
|
|
||||||
def pathchooser():
|
def pathchooser():
|
||||||
browse_for = "folder"
|
browse_for = "folder"
|
||||||
folder_only = request.args.get('folder', False) == "true"
|
folder_only = request.args.get('folder', False) == "true"
|
||||||
@@ -975,43 +1011,45 @@ def pathchooser():
|
|||||||
path = os.path.normpath(request.args.get('path', ""))
|
path = os.path.normpath(request.args.get('path', ""))
|
||||||
|
|
||||||
if os.path.isfile(path):
|
if os.path.isfile(path):
|
||||||
oldfile = path
|
old_file = path
|
||||||
path = os.path.dirname(path)
|
path = os.path.dirname(path)
|
||||||
else:
|
else:
|
||||||
oldfile = ""
|
old_file = ""
|
||||||
|
|
||||||
absolute = False
|
absolute = False
|
||||||
|
|
||||||
if os.path.isdir(path):
|
if os.path.isdir(path):
|
||||||
# if os.path.isabs(path):
|
|
||||||
cwd = os.path.realpath(path)
|
cwd = os.path.realpath(path)
|
||||||
absolute = True
|
absolute = True
|
||||||
# else:
|
|
||||||
# cwd = os.path.relpath(path)
|
|
||||||
else:
|
else:
|
||||||
cwd = os.getcwd()
|
cwd = os.getcwd()
|
||||||
|
|
||||||
cwd = os.path.normpath(os.path.realpath(cwd))
|
cwd = os.path.normpath(os.path.realpath(cwd))
|
||||||
parentdir = os.path.dirname(cwd)
|
parent_dir = os.path.dirname(cwd)
|
||||||
if not absolute:
|
if not absolute:
|
||||||
if os.path.realpath(cwd) == os.path.realpath("/"):
|
if os.path.realpath(cwd) == os.path.realpath("/"):
|
||||||
cwd = os.path.relpath(cwd)
|
cwd = os.path.relpath(cwd)
|
||||||
else:
|
else:
|
||||||
cwd = os.path.relpath(cwd) + os.path.sep
|
cwd = os.path.relpath(cwd) + os.path.sep
|
||||||
parentdir = os.path.relpath(parentdir) + os.path.sep
|
parent_dir = os.path.relpath(parent_dir) + os.path.sep
|
||||||
|
|
||||||
if os.path.realpath(cwd) == os.path.realpath("/"):
|
files = []
|
||||||
parentdir = ""
|
if os.path.realpath(cwd) == os.path.realpath("/") \
|
||||||
|
or (sys.platform == "win32" and os.path.realpath(cwd)[1:] == os.path.realpath("/")[1:]):
|
||||||
|
# we are in root
|
||||||
|
parent_dir = ""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
files = get_drives(cwd)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
folders = os.listdir(cwd)
|
folders = os.listdir(cwd)
|
||||||
except Exception:
|
except Exception:
|
||||||
folders = []
|
folders = []
|
||||||
|
|
||||||
files = []
|
|
||||||
for f in folders:
|
for f in folders:
|
||||||
try:
|
try:
|
||||||
data = {"name": f, "fullpath": os.path.join(cwd, f)}
|
sanitized_f = str(Markup.escape(f))
|
||||||
|
data = {"name": sanitized_f, "fullpath": os.path.join(cwd, sanitized_f)}
|
||||||
data["sort"] = data["fullpath"].lower()
|
data["sort"] = data["fullpath"].lower()
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
@@ -1041,9 +1079,9 @@ def pathchooser():
|
|||||||
context = {
|
context = {
|
||||||
"cwd": cwd,
|
"cwd": cwd,
|
||||||
"files": files,
|
"files": files,
|
||||||
"parentdir": parentdir,
|
"parentdir": parent_dir,
|
||||||
"type": browse_for,
|
"type": browse_for,
|
||||||
"oldfile": oldfile,
|
"oldfile": old_file,
|
||||||
"absolute": absolute,
|
"absolute": absolute,
|
||||||
}
|
}
|
||||||
return json.dumps(context)
|
return json.dumps(context)
|
||||||
@@ -1062,7 +1100,7 @@ def _config_checkbox_int(to_save, x):
|
|||||||
|
|
||||||
|
|
||||||
def _config_string(to_save, x):
|
def _config_string(to_save, x):
|
||||||
return config.set_from_dictionary(to_save, x, lambda y: y.strip() if y else y)
|
return config.set_from_dictionary(to_save, x, lambda y: y.strip().strip(u'\u200B\u200C\u200D\ufeff') if y else y)
|
||||||
|
|
||||||
|
|
||||||
def _configuration_gdrive_helper(to_save):
|
def _configuration_gdrive_helper(to_save):
|
||||||
@@ -1081,10 +1119,10 @@ def _configuration_gdrive_helper(to_save):
|
|||||||
if not gdrive_secrets:
|
if not gdrive_secrets:
|
||||||
return _configuration_result(_('client_secrets.json Is Not Configured For Web Application'))
|
return _configuration_result(_('client_secrets.json Is Not Configured For Web Application'))
|
||||||
gdriveutils.update_settings(
|
gdriveutils.update_settings(
|
||||||
gdrive_secrets['client_id'],
|
gdrive_secrets['client_id'],
|
||||||
gdrive_secrets['client_secret'],
|
gdrive_secrets['client_secret'],
|
||||||
gdrive_secrets['redirect_uris'][0]
|
gdrive_secrets['redirect_uris'][0]
|
||||||
)
|
)
|
||||||
|
|
||||||
# always show Google Drive settings, but in case of error deny support
|
# always show Google Drive settings, but in case of error deny support
|
||||||
new_gdrive_value = (not gdrive_error) and ("config_use_google_drive" in to_save)
|
new_gdrive_value = (not gdrive_error) and ("config_use_google_drive" in to_save)
|
||||||
@@ -1101,12 +1139,12 @@ def _configuration_oauth_helper(to_save):
|
|||||||
reboot_required = False
|
reboot_required = False
|
||||||
for element in oauthblueprints:
|
for element in oauthblueprints:
|
||||||
if to_save["config_" + str(element['id']) + "_oauth_client_id"] != element['oauth_client_id'] \
|
if to_save["config_" + str(element['id']) + "_oauth_client_id"] != element['oauth_client_id'] \
|
||||||
or to_save["config_" + str(element['id']) + "_oauth_client_secret"] != element['oauth_client_secret']:
|
or to_save["config_" + str(element['id']) + "_oauth_client_secret"] != element['oauth_client_secret']:
|
||||||
reboot_required = True
|
reboot_required = True
|
||||||
element['oauth_client_id'] = to_save["config_" + str(element['id']) + "_oauth_client_id"]
|
element['oauth_client_id'] = to_save["config_" + str(element['id']) + "_oauth_client_id"]
|
||||||
element['oauth_client_secret'] = to_save["config_" + str(element['id']) + "_oauth_client_secret"]
|
element['oauth_client_secret'] = to_save["config_" + str(element['id']) + "_oauth_client_secret"]
|
||||||
if to_save["config_" + str(element['id']) + "_oauth_client_id"] \
|
if to_save["config_" + str(element['id']) + "_oauth_client_id"] \
|
||||||
and to_save["config_" + str(element['id']) + "_oauth_client_secret"]:
|
and to_save["config_" + str(element['id']) + "_oauth_client_secret"]:
|
||||||
active_oauths += 1
|
active_oauths += 1
|
||||||
element["active"] = 1
|
element["active"] = 1
|
||||||
else:
|
else:
|
||||||
@@ -1136,7 +1174,6 @@ def _configuration_logfile_helper(to_save):
|
|||||||
|
|
||||||
def _configuration_ldap_helper(to_save):
|
def _configuration_ldap_helper(to_save):
|
||||||
reboot_required = False
|
reboot_required = False
|
||||||
reboot_required |= _config_string(to_save, "config_ldap_provider_url")
|
|
||||||
reboot_required |= _config_int(to_save, "config_ldap_port")
|
reboot_required |= _config_int(to_save, "config_ldap_port")
|
||||||
reboot_required |= _config_int(to_save, "config_ldap_authentication")
|
reboot_required |= _config_int(to_save, "config_ldap_authentication")
|
||||||
reboot_required |= _config_string(to_save, "config_ldap_dn")
|
reboot_required |= _config_string(to_save, "config_ldap_dn")
|
||||||
@@ -1151,21 +1188,26 @@ def _configuration_ldap_helper(to_save):
|
|||||||
reboot_required |= _config_string(to_save, "config_ldap_cert_path")
|
reboot_required |= _config_string(to_save, "config_ldap_cert_path")
|
||||||
reboot_required |= _config_string(to_save, "config_ldap_key_path")
|
reboot_required |= _config_string(to_save, "config_ldap_key_path")
|
||||||
_config_string(to_save, "config_ldap_group_name")
|
_config_string(to_save, "config_ldap_group_name")
|
||||||
if to_save.get("config_ldap_serv_password", "") != "":
|
|
||||||
|
address = urlparse(to_save.get("config_ldap_provider_url", ""))
|
||||||
|
to_save["config_ldap_provider_url"] = (address.hostname or address.path).strip("/")
|
||||||
|
reboot_required |= _config_string(to_save, "config_ldap_provider_url")
|
||||||
|
|
||||||
|
if to_save.get("config_ldap_serv_password_e", "") != "":
|
||||||
reboot_required |= 1
|
reboot_required |= 1
|
||||||
config.set_from_dictionary(to_save, "config_ldap_serv_password", base64.b64encode, encode='UTF-8')
|
config.set_from_dictionary(to_save, "config_ldap_serv_password_e")
|
||||||
config.save()
|
config.save()
|
||||||
|
|
||||||
if not config.config_ldap_provider_url \
|
if not config.config_ldap_provider_url \
|
||||||
or not config.config_ldap_port \
|
or not config.config_ldap_port \
|
||||||
or not config.config_ldap_dn \
|
or not config.config_ldap_dn \
|
||||||
or not config.config_ldap_user_object:
|
or not config.config_ldap_user_object:
|
||||||
return reboot_required, _configuration_result(_('Please Enter a LDAP Provider, '
|
return reboot_required, _configuration_result(_('Please Enter a LDAP Provider, '
|
||||||
'Port, DN and User Object Identifier'))
|
'Port, DN and User Object Identifier'))
|
||||||
|
|
||||||
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 not config.config_ldap_serv_username or not bool(config.config_ldap_serv_password):
|
if not config.config_ldap_serv_username or not bool(config.config_ldap_serv_password_e):
|
||||||
return reboot_required, _configuration_result(_('Please Enter a LDAP Service Account and Password'))
|
return reboot_required, _configuration_result(_('Please Enter a LDAP Service Account and Password'))
|
||||||
else:
|
else:
|
||||||
if not config.config_ldap_serv_username:
|
if not config.config_ldap_serv_username:
|
||||||
@@ -1229,16 +1271,16 @@ def new_user():
|
|||||||
content.default_language = config.config_default_language
|
content.default_language = config.config_default_language
|
||||||
return render_title_template("user_edit.html", new_user=1, content=content,
|
return render_title_template("user_edit.html", new_user=1, content=content,
|
||||||
config=config, translations=translations,
|
config=config, translations=translations,
|
||||||
languages=languages, title=_(u"Add new user"), page="newuser",
|
languages=languages, title=_("Add New User"), page="newuser",
|
||||||
kobo_support=kobo_support, registered_oauth=oauth_check)
|
kobo_support=kobo_support, registered_oauth=oauth_check)
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/mailsettings")
|
@admi.route("/admin/mailsettings", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def edit_mailsettings():
|
def edit_mailsettings():
|
||||||
content = config.get_mail_settings()
|
content = config.get_mail_settings()
|
||||||
return render_title_template("email_edit.html", content=content, title=_(u"Edit E-mail Server Settings"),
|
return render_title_template("email_edit.html", content=content, title=_("Edit Email Server Settings"),
|
||||||
page="mailset", feature_support=feature_support)
|
page="mailset", feature_support=feature_support)
|
||||||
|
|
||||||
|
|
||||||
@@ -1257,7 +1299,7 @@ def update_mailsettings():
|
|||||||
elif to_save.get("gmail"):
|
elif to_save.get("gmail"):
|
||||||
try:
|
try:
|
||||||
config.mail_gmail_token = services.gmail.setup_gmail(config.mail_gmail_token)
|
config.mail_gmail_token = services.gmail.setup_gmail(config.mail_gmail_token)
|
||||||
flash(_(u"Gmail Account Verification Successful"), category="success")
|
flash(_("Success! Gmail Account Verified."), category="success")
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
flash(str(ex), category="error")
|
flash(str(ex), category="error")
|
||||||
log.error(ex)
|
log.error(ex)
|
||||||
@@ -1266,8 +1308,9 @@ def update_mailsettings():
|
|||||||
else:
|
else:
|
||||||
_config_int(to_save, "mail_port")
|
_config_int(to_save, "mail_port")
|
||||||
_config_int(to_save, "mail_use_ssl")
|
_config_int(to_save, "mail_use_ssl")
|
||||||
_config_string(to_save, "mail_password")
|
if to_save.get("mail_password_e", ""):
|
||||||
_config_int(to_save, "mail_size", lambda y: int(y)*1024*1024)
|
_config_string(to_save, "mail_password_e")
|
||||||
|
_config_int(to_save, "mail_size", lambda y: int(y) * 1024 * 1024)
|
||||||
config.mail_server = to_save.get('mail_server', "").strip()
|
config.mail_server = to_save.get('mail_server', "").strip()
|
||||||
config.mail_from = to_save.get('mail_from', "").strip()
|
config.mail_from = to_save.get('mail_from', "").strip()
|
||||||
config.mail_login = to_save.get('mail_login', "").strip()
|
config.mail_login = to_save.get('mail_login', "").strip()
|
||||||
@@ -1276,24 +1319,24 @@ def update_mailsettings():
|
|||||||
except (OperationalError, InvalidRequestError) as e:
|
except (OperationalError, InvalidRequestError) as e:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error_or_exception("Settings Database error: {}".format(e))
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||||
return edit_mailsettings()
|
return edit_mailsettings()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||||
return edit_mailsettings()
|
return edit_mailsettings()
|
||||||
|
|
||||||
if to_save.get("test"):
|
if to_save.get("test"):
|
||||||
if current_user.email:
|
if current_user.email:
|
||||||
result = send_test_mail(current_user.email, current_user.name)
|
result = send_test_mail(current_user.email, current_user.name)
|
||||||
if result is None:
|
if result is None:
|
||||||
flash(_(u"Test e-mail queued for sending to %(email)s, please check Tasks for result",
|
flash(_("Test e-mail queued for sending to %(email)s, please check Tasks for result",
|
||||||
email=current_user.email), category="info")
|
email=current_user.email), category="info")
|
||||||
else:
|
else:
|
||||||
flash(_(u"There was an error sending the Test e-mail: %(res)s", res=result), category="error")
|
flash(_("There was an error sending the Test e-mail: %(res)s", res=result), category="error")
|
||||||
else:
|
else:
|
||||||
flash(_(u"Please configure your e-mail address first..."), category="error")
|
flash(_("Please configure your e-mail address first..."), category="error")
|
||||||
else:
|
else:
|
||||||
flash(_(u"E-mail server settings updated"), category="success")
|
flash(_("Email Server Settings updated"), category="success")
|
||||||
|
|
||||||
return edit_mailsettings()
|
return edit_mailsettings()
|
||||||
|
|
||||||
@@ -1307,16 +1350,16 @@ def edit_scheduledtasks():
|
|||||||
duration_field = list()
|
duration_field = list()
|
||||||
|
|
||||||
for n in range(24):
|
for n in range(24):
|
||||||
time_field.append((n, format_time(datetime_time(hour=n), format="short",)))
|
time_field.append((n, format_time(datetime_time(hour=n), format="short", )))
|
||||||
for n in range(5, 65, 5):
|
for n in range(5, 65, 5):
|
||||||
t = timedelta(hours=n // 60, minutes=n % 60)
|
t = timedelta(hours=n // 60, minutes=n % 60)
|
||||||
duration_field.append((n, format_timedelta(t, threshold=.9)))
|
duration_field.append((n, format_timedelta(t, threshold=.97)))
|
||||||
|
|
||||||
return render_title_template("schedule_edit.html",
|
return render_title_template("schedule_edit.html",
|
||||||
config=content,
|
config=content,
|
||||||
starttime=time_field,
|
starttime=time_field,
|
||||||
duration=duration_field,
|
duration=duration_field,
|
||||||
title=_(u"Edit Scheduled Tasks Settings"))
|
title=_("Edit Scheduled Tasks Settings"))
|
||||||
|
|
||||||
|
|
||||||
@admi.route("/admin/scheduledtasks", methods=["POST"])
|
@admi.route("/admin/scheduledtasks", methods=["POST"])
|
||||||
@@ -1328,21 +1371,22 @@ def update_scheduledtasks():
|
|||||||
if 0 <= int(to_save.get("schedule_start_time")) <= 23:
|
if 0 <= int(to_save.get("schedule_start_time")) <= 23:
|
||||||
_config_int(to_save, "schedule_start_time")
|
_config_int(to_save, "schedule_start_time")
|
||||||
else:
|
else:
|
||||||
flash(_(u"Invalid start time for task specified"), category="error")
|
flash(_("Invalid start time for task specified"), category="error")
|
||||||
error = True
|
error = True
|
||||||
if 0 < int(to_save.get("schedule_duration")) <= 60:
|
if 0 < int(to_save.get("schedule_duration")) <= 60:
|
||||||
_config_int(to_save, "schedule_duration")
|
_config_int(to_save, "schedule_duration")
|
||||||
else:
|
else:
|
||||||
flash(_(u"Invalid duration for task specified"), category="error")
|
flash(_("Invalid duration for task specified"), category="error")
|
||||||
error = True
|
error = True
|
||||||
_config_checkbox(to_save, "schedule_generate_book_covers")
|
_config_checkbox(to_save, "schedule_generate_book_covers")
|
||||||
_config_checkbox(to_save, "schedule_generate_series_covers")
|
_config_checkbox(to_save, "schedule_generate_series_covers")
|
||||||
|
_config_checkbox(to_save, "schedule_metadata_backup")
|
||||||
_config_checkbox(to_save, "schedule_reconnect")
|
_config_checkbox(to_save, "schedule_reconnect")
|
||||||
|
|
||||||
if not error:
|
if not error:
|
||||||
try:
|
try:
|
||||||
config.save()
|
config.save()
|
||||||
flash(_(u"Scheduled tasks settings updated"), category="success")
|
flash(_("Scheduled tasks settings updated"), category="success")
|
||||||
|
|
||||||
# Cancel any running tasks
|
# Cancel any running tasks
|
||||||
schedule.end_scheduled_tasks()
|
schedule.end_scheduled_tasks()
|
||||||
@@ -1352,7 +1396,7 @@ def update_scheduledtasks():
|
|||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error("An unknown error occurred while saving scheduled tasks settings")
|
log.error("An unknown error occurred while saving scheduled tasks settings")
|
||||||
flash(_(u"An unknown error occurred. Please try again later."), category="error")
|
flash(_("Oops! An unknown error occurred. Please try again later."), category="error")
|
||||||
except OperationalError:
|
except OperationalError:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error("Settings DB is not Writeable")
|
log.error("Settings DB is not Writeable")
|
||||||
@@ -1367,7 +1411,7 @@ def update_scheduledtasks():
|
|||||||
def edit_user(user_id):
|
def edit_user(user_id):
|
||||||
content = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() # type: ub.User
|
content = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() # type: ub.User
|
||||||
if not content or (not config.config_anonbrowse and content.name == "Guest"):
|
if not content or (not config.config_anonbrowse and content.name == "Guest"):
|
||||||
flash(_(u"User not found"), category="error")
|
flash(_("User not found"), category="error")
|
||||||
return redirect(url_for('admin.admin'))
|
return redirect(url_for('admin.admin'))
|
||||||
languages = calibre_db.speaking_language(return_all_languages=True)
|
languages = calibre_db.speaking_language(return_all_languages=True)
|
||||||
translations = get_available_locale()
|
translations = get_available_locale()
|
||||||
@@ -1386,7 +1430,7 @@ def edit_user(user_id):
|
|||||||
registered_oauth=oauth_check,
|
registered_oauth=oauth_check,
|
||||||
mail_configured=config.get_mail_server_configured(),
|
mail_configured=config.get_mail_server_configured(),
|
||||||
kobo_support=kobo_support,
|
kobo_support=kobo_support,
|
||||||
title=_(u"Edit User %(nick)s", nick=content.name),
|
title=_("Edit User %(nick)s", nick=content.name),
|
||||||
page="edituser")
|
page="edituser")
|
||||||
|
|
||||||
|
|
||||||
@@ -1397,14 +1441,14 @@ def reset_user_password(user_id):
|
|||||||
if current_user is not None and current_user.is_authenticated:
|
if current_user is not None and current_user.is_authenticated:
|
||||||
ret, message = reset_password(user_id)
|
ret, message = reset_password(user_id)
|
||||||
if ret == 1:
|
if ret == 1:
|
||||||
log.debug(u"Password for user %s reset", message)
|
log.debug("Password for user %s reset", message)
|
||||||
flash(_(u"Password for user %(user)s reset", user=message), category="success")
|
flash(_("Success! Password for user %(user)s reset", user=message), category="success")
|
||||||
elif ret == 0:
|
elif ret == 0:
|
||||||
log.error(u"An unknown error occurred. Please try again later.")
|
log.error("An unknown error occurred. Please try again later.")
|
||||||
flash(_(u"An unknown error occurred. Please try again later."), category="error")
|
flash(_("Oops! An unknown error occurred. Please try again later."), category="error")
|
||||||
else:
|
else:
|
||||||
log.error(u"Please configure the SMTP mail settings first...")
|
log.error("Please configure the SMTP mail settings.")
|
||||||
flash(_(u"Please configure the SMTP mail settings first..."), category="error")
|
flash(_("Oops! Please configure the SMTP mail settings."), category="error")
|
||||||
return redirect(url_for('admin.admin'))
|
return redirect(url_for('admin.admin'))
|
||||||
|
|
||||||
|
|
||||||
@@ -1415,7 +1459,7 @@ def view_logfile():
|
|||||||
logfiles = {0: logger.get_logfile(config.config_logfile),
|
logfiles = {0: logger.get_logfile(config.config_logfile),
|
||||||
1: logger.get_accesslogfile(config.config_access_logfile)}
|
1: logger.get_accesslogfile(config.config_access_logfile)}
|
||||||
return render_title_template("logviewer.html",
|
return render_title_template("logviewer.html",
|
||||||
title=_(u"Logfile viewer"),
|
title=_("Logfile viewer"),
|
||||||
accesslog_enable=config.config_access_log,
|
accesslog_enable=config.config_access_log,
|
||||||
log_enable=bool(config.config_logfile != logger.LOG_TO_STDOUT),
|
log_enable=bool(config.config_logfile != logger.LOG_TO_STDOUT),
|
||||||
logfiles=logfiles,
|
logfiles=logfiles,
|
||||||
@@ -1465,7 +1509,7 @@ def download_debug():
|
|||||||
@admin_required
|
@admin_required
|
||||||
def get_update_status():
|
def get_update_status():
|
||||||
if feature_support['updater']:
|
if feature_support['updater']:
|
||||||
log.info(u"Update status requested")
|
log.info("Update status requested")
|
||||||
return updater_thread.get_available_updates(request.method)
|
return updater_thread.get_available_updates(request.method)
|
||||||
else:
|
else:
|
||||||
return ''
|
return ''
|
||||||
@@ -1558,7 +1602,7 @@ def ldap_import_create_user(user, user_data):
|
|||||||
ub.session.add(content)
|
ub.session.add(content)
|
||||||
try:
|
try:
|
||||||
ub.session.commit()
|
ub.session.commit()
|
||||||
return 1, None # increase no of users
|
return 1, None # increase no of users
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.warning("Failed to create LDAP user: %s - %s", user, ex)
|
log.warning("Failed to create LDAP user: %s - %s", user, ex)
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
@@ -1584,7 +1628,10 @@ def import_ldap_users():
|
|||||||
|
|
||||||
imported = 0
|
imported = 0
|
||||||
for username in new_users:
|
for username in new_users:
|
||||||
user = username.decode('utf-8')
|
if isinstance(username, bytes):
|
||||||
|
user = username.decode('utf-8')
|
||||||
|
else:
|
||||||
|
user = username
|
||||||
if '=' in user:
|
if '=' in user:
|
||||||
# if member object field is empty take user object as filter
|
# if member object field is empty take user object as filter
|
||||||
if config.config_ldap_member_user_object:
|
if config.config_ldap_member_user_object:
|
||||||
@@ -1660,7 +1707,7 @@ def _db_configuration_update_helper():
|
|||||||
except (OperationalError, InvalidRequestError) as e:
|
except (OperationalError, InvalidRequestError) as e:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error_or_exception("Settings Database error: {}".format(e))
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
_db_configuration_result(_(u"Database error: %(error)s.", error=e.orig), gdrive_error)
|
_db_configuration_result(_("Oops! Database Error: %(error)s.", error=e.orig), gdrive_error)
|
||||||
try:
|
try:
|
||||||
metadata_db = os.path.join(to_save['config_calibre_dir'], "metadata.db")
|
metadata_db = os.path.join(to_save['config_calibre_dir'], "metadata.db")
|
||||||
if config.config_use_google_drive and is_gdrive_ready() and not os.path.exists(metadata_db):
|
if config.config_use_google_drive and is_gdrive_ready() and not os.path.exists(metadata_db):
|
||||||
@@ -1670,7 +1717,7 @@ def _db_configuration_update_helper():
|
|||||||
return _db_configuration_result('{}'.format(ex), gdrive_error)
|
return _db_configuration_result('{}'.format(ex), gdrive_error)
|
||||||
|
|
||||||
if db_change or not db_valid or not config.db_configured \
|
if db_change or not db_valid or not config.db_configured \
|
||||||
or config.config_calibre_dir != to_save["config_calibre_dir"]:
|
or config.config_calibre_dir != to_save["config_calibre_dir"]:
|
||||||
if not os.path.exists(metadata_db) or not to_save['config_calibre_dir']:
|
if not os.path.exists(metadata_db) or not to_save['config_calibre_dir']:
|
||||||
return _db_configuration_result(_('DB Location is not Valid, Please Enter Correct Path'), gdrive_error)
|
return _db_configuration_result(_('DB Location is not Valid, Please Enter Correct Path'), gdrive_error)
|
||||||
else:
|
else:
|
||||||
@@ -1692,7 +1739,10 @@ def _db_configuration_update_helper():
|
|||||||
_config_string(to_save, "config_calibre_dir")
|
_config_string(to_save, "config_calibre_dir")
|
||||||
calibre_db.update_config(config)
|
calibre_db.update_config(config)
|
||||||
if not os.access(os.path.join(config.config_calibre_dir, "metadata.db"), os.W_OK):
|
if not os.access(os.path.join(config.config_calibre_dir, "metadata.db"), os.W_OK):
|
||||||
flash(_(u"DB is not Writeable"), category="warning")
|
flash(_("DB is not Writeable"), category="warning")
|
||||||
|
_config_string(to_save, "config_calibre_split_dir")
|
||||||
|
config.config_calibre_split = to_save.get('config_calibre_split', 0) == "on"
|
||||||
|
calibre_db.update_config(config)
|
||||||
config.save()
|
config.save()
|
||||||
return _db_configuration_result(None, gdrive_error)
|
return _db_configuration_result(None, gdrive_error)
|
||||||
|
|
||||||
@@ -1713,6 +1763,7 @@ def _configuration_update_helper():
|
|||||||
|
|
||||||
_config_checkbox_int(to_save, "config_uploading")
|
_config_checkbox_int(to_save, "config_uploading")
|
||||||
_config_checkbox_int(to_save, "config_unicode_filename")
|
_config_checkbox_int(to_save, "config_unicode_filename")
|
||||||
|
_config_checkbox_int(to_save, "config_embed_metadata")
|
||||||
# Reboot on config_anonbrowse with enabled ldap, as decoraters are changed in this case
|
# Reboot on config_anonbrowse with enabled ldap, as decoraters are changed in this case
|
||||||
reboot_required |= (_config_checkbox_int(to_save, "config_anonbrowse")
|
reboot_required |= (_config_checkbox_int(to_save, "config_anonbrowse")
|
||||||
and config.config_login_type == constants.LOGIN_LDAP)
|
and config.config_login_type == constants.LOGIN_LDAP)
|
||||||
@@ -1726,11 +1777,17 @@ def _configuration_update_helper():
|
|||||||
to_save["config_upload_formats"] = ','.join(
|
to_save["config_upload_formats"] = ','.join(
|
||||||
helper.uniq([x.lstrip().rstrip().lower() for x in to_save["config_upload_formats"].split(',')]))
|
helper.uniq([x.lstrip().rstrip().lower() for x in to_save["config_upload_formats"].split(',')]))
|
||||||
_config_string(to_save, "config_upload_formats")
|
_config_string(to_save, "config_upload_formats")
|
||||||
constants.EXTENSIONS_UPLOAD = config.config_upload_formats.split(',')
|
# constants.EXTENSIONS_UPLOAD = config.config_upload_formats.split(',')
|
||||||
|
|
||||||
_config_string(to_save, "config_calibre")
|
_config_string(to_save, "config_calibre")
|
||||||
_config_string(to_save, "config_converterpath")
|
_config_string(to_save, "config_binariesdir")
|
||||||
_config_string(to_save, "config_kepubifypath")
|
_config_string(to_save, "config_kepubifypath")
|
||||||
|
if "config_binariesdir" in to_save:
|
||||||
|
calibre_status = helper.check_calibre(config.config_binariesdir)
|
||||||
|
if calibre_status:
|
||||||
|
return _configuration_result(calibre_status)
|
||||||
|
to_save["config_converterpath"] = get_calibre_binarypath("ebook-convert")
|
||||||
|
_config_string(to_save, "config_converterpath")
|
||||||
|
|
||||||
reboot_required |= _config_int(to_save, "config_login_type")
|
reboot_required |= _config_int(to_save, "config_login_type")
|
||||||
|
|
||||||
@@ -1749,10 +1806,8 @@ def _configuration_update_helper():
|
|||||||
# Goodreads configuration
|
# Goodreads configuration
|
||||||
_config_checkbox(to_save, "config_use_goodreads")
|
_config_checkbox(to_save, "config_use_goodreads")
|
||||||
_config_string(to_save, "config_goodreads_api_key")
|
_config_string(to_save, "config_goodreads_api_key")
|
||||||
_config_string(to_save, "config_goodreads_api_secret")
|
|
||||||
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_int(to_save, "config_updatechannel")
|
_config_int(to_save, "config_updatechannel")
|
||||||
@@ -1765,10 +1820,29 @@ def _configuration_update_helper():
|
|||||||
if config.config_login_type == constants.LOGIN_OAUTH:
|
if config.config_login_type == constants.LOGIN_OAUTH:
|
||||||
reboot_required |= _configuration_oauth_helper(to_save)
|
reboot_required |= _configuration_oauth_helper(to_save)
|
||||||
|
|
||||||
|
# logfile configuration
|
||||||
reboot, message = _configuration_logfile_helper(to_save)
|
reboot, message = _configuration_logfile_helper(to_save)
|
||||||
if message:
|
if message:
|
||||||
return message
|
return message
|
||||||
reboot_required |= reboot
|
reboot_required |= reboot
|
||||||
|
|
||||||
|
# security configuration
|
||||||
|
_config_checkbox(to_save, "config_check_extensions")
|
||||||
|
_config_checkbox(to_save, "config_password_policy")
|
||||||
|
_config_checkbox(to_save, "config_password_number")
|
||||||
|
_config_checkbox(to_save, "config_password_lower")
|
||||||
|
_config_checkbox(to_save, "config_password_upper")
|
||||||
|
_config_checkbox(to_save, "config_password_character")
|
||||||
|
_config_checkbox(to_save, "config_password_special")
|
||||||
|
if 0 < int(to_save.get("config_password_min_length", "0")) < 41:
|
||||||
|
_config_int(to_save, "config_password_min_length")
|
||||||
|
else:
|
||||||
|
return _configuration_result(_('Password length has to be between 1 and 40'))
|
||||||
|
reboot_required |= _config_int(to_save, "config_session")
|
||||||
|
reboot_required |= _config_checkbox(to_save, "config_ratelimiter")
|
||||||
|
reboot_required |= _config_string(to_save, "config_limiter_uri")
|
||||||
|
reboot_required |= _config_string(to_save, "config_limiter_options")
|
||||||
|
|
||||||
# Rarfile Content configuration
|
# Rarfile Content configuration
|
||||||
_config_string(to_save, "config_rarfile_location")
|
_config_string(to_save, "config_rarfile_location")
|
||||||
if "config_rarfile_location" in to_save:
|
if "config_rarfile_location" in to_save:
|
||||||
@@ -1778,7 +1852,7 @@ def _configuration_update_helper():
|
|||||||
except (OperationalError, InvalidRequestError) as e:
|
except (OperationalError, InvalidRequestError) as e:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error_or_exception("Settings Database error: {}".format(e))
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
_configuration_result(_(u"Database error: %(error)s.", error=e.orig))
|
_configuration_result(_("Oops! Database Error: %(error)s.", error=e.orig))
|
||||||
|
|
||||||
config.save()
|
config.save()
|
||||||
if reboot_required:
|
if reboot_required:
|
||||||
@@ -1794,7 +1868,7 @@ def _configuration_result(error_flash=None, reboot=False):
|
|||||||
config.load()
|
config.load()
|
||||||
resp['result'] = [{'type': "danger", 'message': error_flash}]
|
resp['result'] = [{'type': "danger", 'message': error_flash}]
|
||||||
else:
|
else:
|
||||||
resp['result'] = [{'type': "success", 'message': _(u"Calibre-Web configuration updated")}]
|
resp['result'] = [{'type': "success", 'message': _("Calibre-Web configuration updated")}]
|
||||||
resp['reboot'] = reboot
|
resp['reboot'] = reboot
|
||||||
resp['config_upload'] = config.config_upload_formats
|
resp['config_upload'] = config.config_upload_formats
|
||||||
return Response(json.dumps(resp), mimetype='application/json')
|
return Response(json.dumps(resp), mimetype='application/json')
|
||||||
@@ -1825,7 +1899,7 @@ def _db_configuration_result(error_flash=None, gdrive_error=None):
|
|||||||
gdriveError=gdrive_error,
|
gdriveError=gdrive_error,
|
||||||
gdrivefolders=gdrivefolders,
|
gdrivefolders=gdrivefolders,
|
||||||
feature_support=feature_support,
|
feature_support=feature_support,
|
||||||
title=_(u"Database Configuration"), page="dbconfig")
|
title=_("Database Configuration"), page="dbconfig")
|
||||||
|
|
||||||
|
|
||||||
def _handle_new_user(to_save, content, languages, translations, kobo_support):
|
def _handle_new_user(to_save, content, languages, translations, kobo_support):
|
||||||
@@ -1837,11 +1911,11 @@ def _handle_new_user(to_save, content, languages, translations, kobo_support):
|
|||||||
content.sidebar_view |= constants.DETAIL_RANDOM
|
content.sidebar_view |= constants.DETAIL_RANDOM
|
||||||
|
|
||||||
content.role = constants.selected_roles(to_save)
|
content.role = constants.selected_roles(to_save)
|
||||||
content.password = generate_password_hash(to_save["password"])
|
|
||||||
try:
|
try:
|
||||||
if not to_save["name"] or not to_save["email"] or not to_save["password"]:
|
if not to_save["name"] or not to_save["email"] or not to_save["password"]:
|
||||||
log.info("Missing entries on new user")
|
log.info("Missing entries on new user")
|
||||||
raise Exception(_(u"Please fill out all fields!"))
|
raise Exception(_("Oops! Please complete all fields."))
|
||||||
|
content.password = generate_password_hash(helper.valid_password(to_save.get("password", "")))
|
||||||
content.email = check_email(to_save["email"])
|
content.email = check_email(to_save["email"])
|
||||||
# Query username, if not existing, change
|
# Query username, if not existing, change
|
||||||
content.name = check_username(to_save["name"])
|
content.name = check_username(to_save["name"])
|
||||||
@@ -1849,13 +1923,13 @@ def _handle_new_user(to_save, content, languages, translations, kobo_support):
|
|||||||
content.kindle_mail = valid_email(to_save["kindle_mail"])
|
content.kindle_mail = valid_email(to_save["kindle_mail"])
|
||||||
if config.config_public_reg and not check_valid_domain(content.email):
|
if config.config_public_reg and not check_valid_domain(content.email):
|
||||||
log.info("E-mail: {} for new user is not from valid domain".format(content.email))
|
log.info("E-mail: {} for new user is not from valid domain".format(content.email))
|
||||||
raise Exception(_(u"E-mail is not from valid domain"))
|
raise Exception(_("E-mail is not from valid domain"))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
flash(str(ex), category="error")
|
flash(str(ex), category="error")
|
||||||
return render_title_template("user_edit.html", new_user=1, content=content,
|
return render_title_template("user_edit.html", new_user=1, content=content,
|
||||||
config=config,
|
config=config,
|
||||||
translations=translations,
|
translations=translations,
|
||||||
languages=languages, title=_(u"Add new user"), page="newuser",
|
languages=languages, title=_("Add new user"), page="newuser",
|
||||||
kobo_support=kobo_support, registered_oauth=oauth_check)
|
kobo_support=kobo_support, registered_oauth=oauth_check)
|
||||||
try:
|
try:
|
||||||
content.allowed_tags = config.config_allowed_tags
|
content.allowed_tags = config.config_allowed_tags
|
||||||
@@ -1866,17 +1940,17 @@ def _handle_new_user(to_save, content, languages, translations, kobo_support):
|
|||||||
content.kobo_only_shelves_sync = to_save.get("kobo_only_shelves_sync", 0) == "on"
|
content.kobo_only_shelves_sync = to_save.get("kobo_only_shelves_sync", 0) == "on"
|
||||||
ub.session.add(content)
|
ub.session.add(content)
|
||||||
ub.session.commit()
|
ub.session.commit()
|
||||||
flash(_(u"User '%(user)s' created", user=content.name), category="success")
|
flash(_("User '%(user)s' created", user=content.name), category="success")
|
||||||
log.debug("User {} created".format(content.name))
|
log.debug("User {} created".format(content.name))
|
||||||
return redirect(url_for('admin.admin'))
|
return redirect(url_for('admin.admin'))
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error("Found an existing account for {} or {}".format(content.name, content.email))
|
log.error("Found an existing account for {} or {}".format(content.name, content.email))
|
||||||
flash(_("Found an existing account for this e-mail address or name."), category="error")
|
flash(_("Oops! An account already exists for this Email. or name."), category="error")
|
||||||
except OperationalError as e:
|
except OperationalError as e:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error_or_exception("Settings Database error: {}".format(e))
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||||
|
|
||||||
|
|
||||||
def _delete_user(content):
|
def _delete_user(content):
|
||||||
@@ -1904,10 +1978,10 @@ def _delete_user(content):
|
|||||||
log.info("User {} deleted".format(content.name))
|
log.info("User {} deleted".format(content.name))
|
||||||
return _("User '%(nick)s' deleted", nick=content.name)
|
return _("User '%(nick)s' deleted", nick=content.name)
|
||||||
else:
|
else:
|
||||||
log.warning(_("Can't delete Guest User"))
|
# log.warning(_("Can't delete Guest User"))
|
||||||
raise Exception(_("Can't delete Guest User"))
|
raise Exception(_("Can't delete Guest User"))
|
||||||
else:
|
else:
|
||||||
log.warning("No admin user remaining, can't delete user")
|
# log.warning("No admin user remaining, can't delete user")
|
||||||
raise Exception(_("No admin user remaining, can't delete user"))
|
raise Exception(_("No admin user remaining, can't delete user"))
|
||||||
|
|
||||||
|
|
||||||
@@ -1925,14 +1999,6 @@ def _handle_edit_user(to_save, content, languages, translations, kobo_support):
|
|||||||
log.warning("No admin user remaining, can't remove admin role from {}".format(content.name))
|
log.warning("No admin user remaining, can't remove admin role from {}".format(content.name))
|
||||||
flash(_("No admin user remaining, can't remove admin role"), category="error")
|
flash(_("No admin user remaining, can't remove admin role"), category="error")
|
||||||
return redirect(url_for('admin.admin'))
|
return redirect(url_for('admin.admin'))
|
||||||
if to_save.get("password"):
|
|
||||||
content.password = generate_password_hash(to_save["password"])
|
|
||||||
anonymous = content.is_anonymous
|
|
||||||
content.role = constants.selected_roles(to_save)
|
|
||||||
if anonymous:
|
|
||||||
content.role |= constants.ROLE_ANONYMOUS
|
|
||||||
else:
|
|
||||||
content.role &= ~constants.ROLE_ANONYMOUS
|
|
||||||
|
|
||||||
val = [int(k[5:]) for k in to_save if k.startswith('show_')]
|
val = [int(k[5:]) for k in to_save if k.startswith('show_')]
|
||||||
sidebar, __ = get_sidebar_config()
|
sidebar, __ = get_sidebar_config()
|
||||||
@@ -1960,8 +2026,20 @@ def _handle_edit_user(to_save, content, languages, translations, kobo_support):
|
|||||||
if to_save.get("locale"):
|
if to_save.get("locale"):
|
||||||
content.locale = to_save["locale"]
|
content.locale = to_save["locale"]
|
||||||
try:
|
try:
|
||||||
if to_save.get("email", content.email) != content.email:
|
anonymous = content.is_anonymous
|
||||||
content.email = check_email(to_save["email"])
|
content.role = constants.selected_roles(to_save)
|
||||||
|
if anonymous:
|
||||||
|
content.role |= constants.ROLE_ANONYMOUS
|
||||||
|
else:
|
||||||
|
content.role &= ~constants.ROLE_ANONYMOUS
|
||||||
|
if to_save.get("password", ""):
|
||||||
|
content.password = generate_password_hash(helper.valid_password(to_save.get("password", "")))
|
||||||
|
|
||||||
|
new_email = valid_email(to_save.get("email", content.email))
|
||||||
|
if not new_email:
|
||||||
|
raise Exception(_("Email can't be empty and has to be a valid Email"))
|
||||||
|
if new_email != content.email:
|
||||||
|
content.email = check_email(new_email)
|
||||||
# Query username, if not existing, change
|
# Query username, if not existing, change
|
||||||
if to_save.get("name", content.name) != content.name:
|
if to_save.get("name", content.name) != content.name:
|
||||||
if to_save.get("name") == "Guest":
|
if to_save.get("name") == "Guest":
|
||||||
@@ -1981,19 +2059,19 @@ def _handle_edit_user(to_save, content, languages, translations, kobo_support):
|
|||||||
content=content,
|
content=content,
|
||||||
config=config,
|
config=config,
|
||||||
registered_oauth=oauth_check,
|
registered_oauth=oauth_check,
|
||||||
title=_(u"Edit User %(nick)s", nick=content.name),
|
title=_("Edit User %(nick)s", nick=content.name),
|
||||||
page="edituser")
|
page="edituser")
|
||||||
try:
|
try:
|
||||||
ub.session_commit()
|
ub.session_commit()
|
||||||
flash(_(u"User '%(nick)s' updated", nick=content.name), category="success")
|
flash(_("User '%(nick)s' updated", nick=content.name), category="success")
|
||||||
except IntegrityError as ex:
|
except IntegrityError as ex:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error("An unknown error occurred while changing user: {}".format(str(ex)))
|
log.error("An unknown error occurred while changing user: {}".format(str(ex)))
|
||||||
flash(_(u"An unknown error occurred. Please try again later."), category="error")
|
flash(_("Oops! An unknown error occurred. Please try again later."), category="error")
|
||||||
except OperationalError as e:
|
except OperationalError as e:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error_or_exception("Settings Database error: {}".format(e))
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from babel import negotiate_locale
|
from babel import negotiate_locale
|
||||||
from flask_babel import Babel, Locale
|
from flask_babel import Babel, Locale
|
||||||
from babel.core import UnknownLocaleError
|
from babel.core import UnknownLocaleError
|
||||||
from flask import request, g
|
from flask import request
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
from . import logger
|
from . import logger
|
||||||
|
|
||||||
@@ -10,13 +11,12 @@ log = logger.create()
|
|||||||
babel = Babel()
|
babel = Babel()
|
||||||
|
|
||||||
|
|
||||||
@babel.localeselector
|
|
||||||
def get_locale():
|
def get_locale():
|
||||||
# if a user is logged in, use the locale from the user settings
|
# if a user is logged in, use the locale from the user settings
|
||||||
user = getattr(g, 'user', None)
|
if current_user is not None and hasattr(current_user, "locale"):
|
||||||
if user is not None and hasattr(user, "locale"):
|
# if the account is the guest account bypass the config lang settings
|
||||||
if user.name != 'Guest': # if the account is the guest account bypass the config lang settings
|
if current_user.name != 'Guest':
|
||||||
return user.locale
|
return current_user.locale
|
||||||
|
|
||||||
preferred = list()
|
preferred = list()
|
||||||
if request.accept_languages:
|
if request.accept_languages:
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# -*- 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
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# at least bleach 6.0 is needed -> incomplatible change from list arguments to set arguments
|
||||||
|
from bleach import clean as clean_html
|
||||||
|
from bleach.sanitizer import ALLOWED_TAGS
|
||||||
|
bleach = True
|
||||||
|
except ImportError:
|
||||||
|
from nh3 import clean as clean_html
|
||||||
|
bleach = False
|
||||||
|
|
||||||
|
|
||||||
|
def clean_string(unsafe_text, book_id=0):
|
||||||
|
try:
|
||||||
|
if bleach:
|
||||||
|
allowed_tags = list(ALLOWED_TAGS)
|
||||||
|
allowed_tags.extend(['p', 'span', 'div', 'pre'])
|
||||||
|
safe_text = clean_html(unsafe_text, tags=set(allowed_tags))
|
||||||
|
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
|
||||||
@@ -29,12 +29,25 @@ 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 - unknown 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])
|
||||||
|
|
||||||
|
|
||||||
class CliParameter(object):
|
class CliParameter(object):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.user_credentials = None
|
||||||
|
self.ip_address = None
|
||||||
|
self.allow_localhost = None
|
||||||
|
self.reconnect_enable = None
|
||||||
|
self.memory_backend = None
|
||||||
|
self.dry_run = None
|
||||||
|
self.certfilepath = None
|
||||||
|
self.keyfilepath = None
|
||||||
|
self.gd_path = None
|
||||||
|
self.settings_path = None
|
||||||
|
self.logpath = None
|
||||||
|
|
||||||
def init(self):
|
def init(self):
|
||||||
self.arg_parser()
|
self.arg_parser()
|
||||||
|
|
||||||
@@ -44,22 +57,28 @@ class CliParameter(object):
|
|||||||
prog='cps.py')
|
prog='cps.py')
|
||||||
parser.add_argument('-p', metavar='path', help='path and name to settings db, e.g. /opt/cw.db')
|
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('-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, '
|
parser.add_argument('-c', metavar='path', help='path and name to SSL certfile, '
|
||||||
'works only in combination with keyfile')
|
'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, '
|
parser.add_argument('-k', metavar='path', help='path and name to SSL keyfile, e.g. /opt/test.key, '
|
||||||
'works only in combination with certfile')
|
'works only in combination with certfile')
|
||||||
parser.add_argument('-v', '--version', action='version', help='Shows version number and exits Calibre-Web',
|
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())
|
version=version_info())
|
||||||
parser.add_argument('-i', metavar='ip-address', help='Server IP-Address to listen')
|
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',
|
parser.add_argument('-s', metavar='user:pass',
|
||||||
help='Sets specific username to new password and exits Calibre-Web')
|
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('-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 '
|
parser.add_argument('-d', action='store_true', help='Dry run of updater to check file permissions '
|
||||||
'and exits Calibre-Web')
|
'in advance and exits Calibre-Web')
|
||||||
parser.add_argument('-r', action='store_true', help='Enable public database reconnect route under /reconnect')
|
parser.add_argument('-r', action='store_true', help='Enable public database reconnect '
|
||||||
|
'route under /reconnect')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
self.logpath = args.o or ""
|
||||||
self.settings_path = args.p or os.path.join(_CONFIG_DIR, DEFAULT_SETTINGS_FILE)
|
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)
|
self.gd_path = args.g or os.path.join(_CONFIG_DIR, DEFAULT_GDRIVE_FILE)
|
||||||
|
|
||||||
@@ -96,6 +115,8 @@ class CliParameter(object):
|
|||||||
if args.k == "":
|
if args.k == "":
|
||||||
self.keyfilepath = ""
|
self.keyfilepath = ""
|
||||||
|
|
||||||
|
# overwrite limiter backend
|
||||||
|
self.memory_backend = args.m or None
|
||||||
# dry run updater
|
# dry run updater
|
||||||
self.dry_run = args.d or None
|
self.dry_run = args.d or None
|
||||||
# enable reconnect endpoint for docker database reconnect
|
# enable reconnect endpoint for docker database reconnect
|
||||||
@@ -125,6 +146,3 @@ class CliParameter(object):
|
|||||||
if self.user_credentials and ":" not in self.user_credentials:
|
if self.user_credentials and ":" not in self.user_credentials:
|
||||||
print("No valid 'username:password' format")
|
print("No valid 'username:password' format")
|
||||||
sys.exit(3)
|
sys.exit(3)
|
||||||
|
|
||||||
if args.f:
|
|
||||||
print("Warning: -f flag is depreciated and will be removed in next version")
|
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ try:
|
|||||||
from comicapi import __version__ as comic_version
|
from comicapi import __version__ as comic_version
|
||||||
except ImportError:
|
except ImportError:
|
||||||
comic_version = ''
|
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:
|
except (ImportError, LookupError) as e:
|
||||||
log.debug('Cannot import comicapi, extracting comic metadata will not work: %s', e)
|
log.debug('Cannot import comicapi, extracting comic metadata will not work: %s', e)
|
||||||
import zipfile
|
import zipfile
|
||||||
@@ -46,6 +52,12 @@ except (ImportError, LookupError) as e:
|
|||||||
except (ImportError, SyntaxError) 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
|
||||||
|
|
||||||
|
|
||||||
@@ -78,23 +90,40 @@ def _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_exec
|
|||||||
if len(ext) > 1:
|
if len(ext) > 1:
|
||||||
extension = ext[1].lower()
|
extension = ext[1].lower()
|
||||||
if extension in cover.COVER_EXTENSIONS:
|
if extension in cover.COVER_EXTENSIONS:
|
||||||
cover_data = cf.read(name)
|
cover_data = cf.read([name])
|
||||||
break
|
break
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.debug('Rarfile failed with error: {}'.format(ex))
|
log.error('Rarfile failed with error: {}'.format(ex))
|
||||||
|
elif original_file_extension.upper() == '.CB7' and use_7zip:
|
||||||
|
cf = py7zr.SevenZipFile(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:
|
||||||
|
try:
|
||||||
|
cover_data = cf.read([name])[name].read()
|
||||||
|
except (py7zr.Bad7zFile, OSError) as ex:
|
||||||
|
log.error('7Zip file failed with error: {}'.format(ex))
|
||||||
|
break
|
||||||
return cover_data, extension
|
return cover_data, extension
|
||||||
|
|
||||||
|
|
||||||
def _extract_cover(tmp_file_name, original_file_extension, rar_executable):
|
def _extract_cover(tmp_file_name, original_file_extension, rar_executable):
|
||||||
cover_data = extension = None
|
cover_data = extension = None
|
||||||
if use_comic_meta:
|
if use_comic_meta:
|
||||||
archive = ComicArchive(tmp_file_name, rar_exe_path=rar_executable)
|
try:
|
||||||
for index, name in enumerate(archive.getPageNameList()):
|
archive = ComicArchive(tmp_file_name, rar_exe_path=rar_executable)
|
||||||
|
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)
|
ext = os.path.splitext(name)
|
||||||
if len(ext) > 1:
|
if len(ext) > 1:
|
||||||
extension = ext[1].lower()
|
extension = ext[1].lower()
|
||||||
if extension in cover.COVER_EXTENSIONS:
|
if extension in cover.COVER_EXTENSIONS:
|
||||||
cover_data = archive.getPage(index)
|
get_page = archive.getPage if hasattr(archive, "getPageNameList") else archive.get_page
|
||||||
|
cover_data = get_page(index)
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
cover_data, extension = _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_executable)
|
cover_data, extension = _extract_cover_from_archive(original_file_extension, tmp_file_name, rar_executable)
|
||||||
@@ -103,17 +132,26 @@ def _extract_cover(tmp_file_name, original_file_extension, rar_executable):
|
|||||||
|
|
||||||
def get_comic_info(tmp_file_path, original_file_name, original_file_extension, rar_executable):
|
def get_comic_info(tmp_file_path, original_file_name, original_file_extension, rar_executable):
|
||||||
if use_comic_meta:
|
if use_comic_meta:
|
||||||
archive = ComicArchive(tmp_file_path, rar_exe_path=rar_executable)
|
try:
|
||||||
if archive.seemsToBeAComicArchive():
|
archive = ComicArchive(tmp_file_path, rar_exe_path=rar_executable)
|
||||||
if archive.hasMetadata(MetaDataStyle.CIX):
|
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
|
||||||
loaded_metadata = archive.readMetadata(style)
|
loaded_metadata = read_metadata(style)
|
||||||
|
|
||||||
lang = loaded_metadata.language or ""
|
lang = loaded_metadata.language or ""
|
||||||
loaded_metadata.language = isoLanguages.get_lang3(lang)
|
loaded_metadata.language = isoLanguages.get_lang3(lang)
|
||||||
@@ -138,7 +176,7 @@ def get_comic_info(tmp_file_path, original_file_name, original_file_extension, r
|
|||||||
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=_extract_cover(tmp_file_path, original_file_extension, rar_executable),
|
cover=_extract_cover(tmp_file_path, original_file_extension, rar_executable),
|
||||||
description="",
|
description="",
|
||||||
tags="",
|
tags="",
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import json
|
|||||||
from sqlalchemy import Column, String, Integer, SmallInteger, Boolean, BLOB, JSON
|
from sqlalchemy import Column, String, Integer, SmallInteger, Boolean, BLOB, JSON
|
||||||
from sqlalchemy.exc import OperationalError
|
from sqlalchemy.exc import OperationalError
|
||||||
from sqlalchemy.sql.expression import text
|
from sqlalchemy.sql.expression import text
|
||||||
|
from sqlalchemy import exists
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
import cryptography.exceptions
|
||||||
|
from base64 import urlsafe_b64decode
|
||||||
try:
|
try:
|
||||||
# Compatibility with sqlalchemy 2.0
|
# Compatibility with sqlalchemy 2.0
|
||||||
from sqlalchemy.orm import declarative_base
|
from sqlalchemy.orm import declarative_base
|
||||||
@@ -30,6 +34,7 @@ except ImportError:
|
|||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
|
||||||
from . import constants, logger
|
from . import constants, logger
|
||||||
|
from .subproc_wrapper import process_wait
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
@@ -43,6 +48,7 @@ class _Flask_Settings(_Base):
|
|||||||
flask_session_key = Column(BLOB, default=b"")
|
flask_session_key = Column(BLOB, default=b"")
|
||||||
|
|
||||||
def __init__(self, key):
|
def __init__(self, key):
|
||||||
|
super().__init__()
|
||||||
self.flask_session_key = key
|
self.flask_session_key = key
|
||||||
|
|
||||||
|
|
||||||
@@ -56,7 +62,8 @@ 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_size = Column(Integer, default=25*1024*1024)
|
||||||
mail_server_type = Column(SmallInteger, default=0)
|
mail_server_type = Column(SmallInteger, default=0)
|
||||||
@@ -64,24 +71,27 @@ class _Settings(_Base):
|
|||||||
|
|
||||||
config_calibre_dir = Column(String)
|
config_calibre_dir = Column(String)
|
||||||
config_calibre_uuid = 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_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_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=r'^(A|The|An|Der|Die|Das|Den|Ein|Eine|Einen|Dem|Des|Einem|Eines)\s+')
|
config_title_regex = Column(String,
|
||||||
# config_mature_content_tags = Column(String, default='')
|
default=r'^(A|The|An|Der|Die|Das|Den|Ein|Eine'
|
||||||
|
r'|Einen|Dem|Des|Einem|Eines|Le|La|Les|L\'|Un|Une)\s+')
|
||||||
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)
|
||||||
@@ -107,7 +117,6 @@ class _Settings(_Base):
|
|||||||
|
|
||||||
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_register_email = Column(Boolean, default=False)
|
||||||
config_login_type = Column(Integer, default=0)
|
config_login_type = Column(Integer, default=0)
|
||||||
|
|
||||||
@@ -117,7 +126,8 @@ class _Settings(_Base):
|
|||||||
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_cacert_path = Column(String, default="")
|
||||||
config_ldap_cert_path = Column(String, default="")
|
config_ldap_cert_path = Column(String, default="")
|
||||||
@@ -132,10 +142,12 @@ class _Settings(_Base):
|
|||||||
|
|
||||||
config_kepubifypath = Column(String, default=None)
|
config_kepubifypath = Column(String, default=None)
|
||||||
config_converterpath = Column(String, default=None)
|
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, default=None)
|
config_rarfile_location = Column(String, default=None)
|
||||||
config_upload_formats = Column(String, default=','.join(constants.EXTENSIONS_UPLOAD))
|
config_upload_formats = Column(String, default=','.join(constants.EXTENSIONS_UPLOAD))
|
||||||
config_unicode_filename = Column(Boolean, default=False)
|
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)
|
||||||
|
|
||||||
@@ -147,35 +159,72 @@ class _Settings(_Base):
|
|||||||
schedule_generate_book_covers = Column(Boolean, default=False)
|
schedule_generate_book_covers = Column(Boolean, default=False)
|
||||||
schedule_generate_series_covers = Column(Boolean, default=False)
|
schedule_generate_series_covers = Column(Boolean, default=False)
|
||||||
schedule_reconnect = 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="")
|
||||||
|
config_check_extensions = Column(Boolean, default=True)
|
||||||
|
|
||||||
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):
|
def __init__(self):
|
||||||
pass
|
'''self.config_calibre_uuid = None
|
||||||
|
self.config_calibre_split_dir = None
|
||||||
|
self.dirty = None
|
||||||
|
self.config_logfile = None
|
||||||
|
self.config_upload_formats = None
|
||||||
|
self.mail_gmail_token = None
|
||||||
|
self.mail_server_type = None
|
||||||
|
self.mail_server = None
|
||||||
|
self.config_log_level = None
|
||||||
|
self.config_allowed_column_value = None
|
||||||
|
self.config_denied_column_value = None
|
||||||
|
self.config_allowed_tags = None
|
||||||
|
self.config_denied_tags = None
|
||||||
|
self.config_default_show = None
|
||||||
|
self.config_default_role = None
|
||||||
|
self.config_keyfile = None
|
||||||
|
self.config_certfile = None
|
||||||
|
self.config_rarfile_location = None
|
||||||
|
self.config_kepubifypath = None
|
||||||
|
self.config_binariesdir = None'''
|
||||||
|
self.__dict__["dirty"] = list()
|
||||||
|
|
||||||
def init_config(self, session, cli):
|
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.load()
|
self._fernet = Fernet(secret_key)
|
||||||
self.cli = cli
|
self.cli = cli
|
||||||
|
self.load()
|
||||||
|
|
||||||
change = False
|
change = False
|
||||||
if self.config_converterpath == None: # pylint: disable=access-member-before-definition
|
|
||||||
change = True
|
|
||||||
self.config_converterpath = autodetect_calibre_binary()
|
|
||||||
|
|
||||||
if self.config_kepubifypath == None: # pylint: disable=access-member-before-definition
|
if self.config_binariesdir is None:
|
||||||
|
change = True
|
||||||
|
self.config_binariesdir = autodetect_calibre_binaries()
|
||||||
|
self.config_converterpath = autodetect_converter_binary(self.config_binariesdir)
|
||||||
|
|
||||||
|
if self.config_kepubifypath is None:
|
||||||
change = True
|
change = True
|
||||||
self.config_kepubifypath = autodetect_kepubify_binary()
|
self.config_kepubifypath = autodetect_kepubify_binary()
|
||||||
|
|
||||||
if self.config_rarfile_location == None: # pylint: disable=access-member-before-definition
|
if self.config_rarfile_location is None:
|
||||||
change = True
|
change = True
|
||||||
self.config_rarfile_location = autodetect_unrar_binary()
|
self.config_rarfile_location = autodetect_unrar_binary()
|
||||||
if change:
|
if change:
|
||||||
@@ -293,10 +342,10 @@ class _ConfigSQL(object):
|
|||||||
setattr(self, field, new_value)
|
setattr(self, field, new_value)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def toDict(self):
|
def to_dict(self):
|
||||||
storage = {}
|
storage = {}
|
||||||
for k, v in self.__dict__.items():
|
for k, v in self.__dict__.items():
|
||||||
if k[0] != '_' and not k.endswith("password") and not k.endswith("secret") and not k == "cli":
|
if k[0] != '_' and not k.endswith("_e") and not k == "cli":
|
||||||
storage[k] = v
|
storage[k] = v
|
||||||
return storage
|
return storage
|
||||||
|
|
||||||
@@ -310,38 +359,51 @@ 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:
|
||||||
|
setattr(self, k, self._fernet.decrypt(v).decode())
|
||||||
|
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:
|
||||||
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(',')]
|
# constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')]
|
||||||
|
from . import cli_param
|
||||||
if os.environ.get('FLASK_DEBUG'):
|
if os.environ.get('FLASK_DEBUG'):
|
||||||
logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG)
|
logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG)
|
||||||
else:
|
else:
|
||||||
# pylint: disable=access-member-before-definition
|
# pylint: disable=access-member-before-definition
|
||||||
logfile = logger.setup(self.config_logfile, self.config_log_level)
|
logfile = logger.setup(cli_param.logpath or self.config_logfile, self.config_log_level)
|
||||||
if logfile != self.config_logfile:
|
if logfile != os.path.abspath(self.config_logfile):
|
||||||
log.warning("Log path %s not valid, falling back to default", 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
|
self.config_logfile = logfile
|
||||||
|
s.config_logfile = logfile
|
||||||
self._session.merge(s)
|
self._session.merge(s)
|
||||||
try:
|
try:
|
||||||
self._session.commit()
|
self._session.commit()
|
||||||
except OperationalError as e:
|
except OperationalError as e:
|
||||||
log.error('Database error: %s', e)
|
log.error('Database error: %s', e)
|
||||||
self._session.rollback()
|
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)
|
||||||
@@ -357,9 +419,11 @@ 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 store_calibre_uuid(self, calibre_db, Library_table):
|
def store_calibre_uuid(self, calibre_db, Library_table):
|
||||||
try:
|
try:
|
||||||
calibre_uuid = calibre_db.session.query(Library_table).one_or_none()
|
calibre_uuid = calibre_db.session.query(Library_table).one_or_none()
|
||||||
@@ -369,8 +433,33 @@ class _ConfigSQL(object):
|
|||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def __setattr__(self, attr_name, attr_value):
|
||||||
|
super().__setattr__(attr_name, attr_value)
|
||||||
|
self.__dict__["dirty"].append(attr_name)
|
||||||
|
|
||||||
def _migrate_table(session, orm_class):
|
|
||||||
|
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():
|
||||||
@@ -408,17 +497,35 @@ def _migrate_table(session, orm_class):
|
|||||||
session.rollback()
|
session.rollback()
|
||||||
|
|
||||||
|
|
||||||
def autodetect_calibre_binary():
|
def autodetect_calibre_binaries():
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
calibre_path = ["C:\\program files\\calibre\\ebook-convert.exe",
|
calibre_path = ["C:\\program files\\calibre\\",
|
||||||
"C:\\program files(x86)\\calibre\\ebook-convert.exe",
|
"C:\\program files(x86)\\calibre\\",
|
||||||
"C:\\program files(x86)\\calibre2\\ebook-convert.exe",
|
"C:\\program files(x86)\\calibre2\\",
|
||||||
"C:\\program files\\calibre2\\ebook-convert.exe"]
|
"C:\\program files\\calibre2\\"]
|
||||||
else:
|
else:
|
||||||
calibre_path = ["/opt/calibre/ebook-convert"]
|
calibre_path = ["/opt/calibre/"]
|
||||||
for element in calibre_path:
|
for element in calibre_path:
|
||||||
if os.path.isfile(element) and os.access(element, os.X_OK):
|
supported_binary_paths = [os.path.join(element, binary)
|
||||||
return element
|
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 ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -446,28 +553,47 @@ def autodetect_kepubify_binary():
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _migrate_database(session):
|
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(conf, session, cli):
|
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()
|
|
||||||
conf.init_config(session, cli)
|
|
||||||
# return conf
|
|
||||||
|
|
||||||
|
|
||||||
def get_flask_session_key(_session):
|
def get_flask_session_key(_session):
|
||||||
flask_settings = _session.query(_Flask_Settings).one_or_none()
|
flask_settings = _session.query(_Flask_Settings).one_or_none()
|
||||||
if flask_settings == None:
|
if flask_settings is 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 = ""
|
||||||
|
key = None
|
||||||
|
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
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ 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
|
||||||
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(os.path.dirname(os.path.abspath(__file__)), os.pardir))
|
||||||
|
# if executable file the files should be placed in the parent dir (parallel to the exe file)
|
||||||
|
|
||||||
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')
|
||||||
@@ -49,6 +51,9 @@ if HOME_CONFIG:
|
|||||||
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_SETTINGS_FILE = "app.db"
|
||||||
DEFAULT_GDRIVE_FILE = "gdrive.db"
|
DEFAULT_GDRIVE_FILE = "gdrive.db"
|
||||||
@@ -144,17 +149,23 @@ del env_CALIBRE_PORT
|
|||||||
|
|
||||||
EXTENSIONS_AUDIO = {'mp3', 'mp4', 'ogg', 'opus', 'wav', 'flac', 'm4a', 'm4b'}
|
EXTENSIONS_AUDIO = {'mp3', 'mp4', 'ogg', 'opus', 'wav', 'flac', 'm4a', 'm4b'}
|
||||||
EXTENSIONS_CONVERT_FROM = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf',
|
EXTENSIONS_CONVERT_FROM = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf',
|
||||||
'txt', 'htmlz', 'rtf', 'odt', 'cbz', 'cbr']
|
'txt', 'htmlz', 'rtf', 'odt', 'cbz', 'cbr', 'prc']
|
||||||
EXTENSIONS_CONVERT_TO = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2',
|
EXTENSIONS_CONVERT_TO = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2',
|
||||||
'lit', 'lrf', 'txt', 'htmlz', 'rtf', 'odt']
|
'lit', 'lrf', 'txt', 'htmlz', 'rtf', 'odt']
|
||||||
EXTENSIONS_UPLOAD = {'txt', 'pdf', 'epub', 'kepub', 'mobi', 'azw', 'azw3', 'cbr', 'cbz', 'cbt', 'djvu',
|
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',
|
'prc', 'doc', 'docx', 'fb2', 'html', 'rtf', 'lit', 'odt', 'mp3', 'mp4', 'ogg',
|
||||||
'opus', 'wav', 'flac', 'm4a', 'm4b'}
|
'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):
|
||||||
return bit_flag == (bit_flag & (value or 0))
|
return bit_flag == (bit_flag & (value or 0))
|
||||||
|
|
||||||
|
|
||||||
def selected_roles(dictionary):
|
def selected_roles(dictionary):
|
||||||
return sum(v for k, v in ALL_ROLES.items() if k in dictionary)
|
return sum(v for k, v in ALL_ROLES.items() if k in dictionary)
|
||||||
|
|
||||||
@@ -163,13 +174,12 @@ def selected_roles(dictionary):
|
|||||||
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, publisher, pubdate, identifiers')
|
'series_id, languages, publisher, pubdate, identifiers')
|
||||||
|
|
||||||
STABLE_VERSION = {'version': '0.6.19'}
|
# python build process likes to have x.y.zbw -> b for beta and w a counting number
|
||||||
|
STABLE_VERSION = {'version': '0.6.22'}
|
||||||
|
|
||||||
NIGHTLY_VERSION = dict()
|
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
|
||||||
CACHE_TYPE_THUMBNAILS = 'thumbnails'
|
CACHE_TYPE_THUMBNAILS = 'thumbnails'
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ class Identifiers(Base):
|
|||||||
book = Column(Integer, ForeignKey('books.id'), nullable=False)
|
book = Column(Integer, ForeignKey('books.id'), nullable=False)
|
||||||
|
|
||||||
def __init__(self, val, id_type, book):
|
def __init__(self, val, id_type, book):
|
||||||
|
super().__init__()
|
||||||
self.val = val
|
self.val = val
|
||||||
self.type = id_type
|
self.type = id_type
|
||||||
self.book = book
|
self.book = book
|
||||||
@@ -111,66 +112,77 @@ class Identifiers(Base):
|
|||||||
def format_type(self):
|
def format_type(self):
|
||||||
format_type = self.type.lower()
|
format_type = self.type.lower()
|
||||||
if format_type == 'amazon':
|
if format_type == 'amazon':
|
||||||
return u"Amazon"
|
return "Amazon"
|
||||||
elif format_type.startswith("amazon_"):
|
elif format_type.startswith("amazon_"):
|
||||||
return u"Amazon.{0}".format(format_type[7:])
|
return "Amazon.{0}".format(format_type[7:])
|
||||||
elif format_type == "isbn":
|
elif format_type == "isbn":
|
||||||
return u"ISBN"
|
return "ISBN"
|
||||||
elif format_type == "doi":
|
elif format_type == "doi":
|
||||||
return u"DOI"
|
return "DOI"
|
||||||
elif format_type == "douban":
|
elif format_type == "douban":
|
||||||
return u"Douban"
|
return "Douban"
|
||||||
elif format_type == "goodreads":
|
elif format_type == "goodreads":
|
||||||
return u"Goodreads"
|
return "Goodreads"
|
||||||
elif format_type == "babelio":
|
elif format_type == "babelio":
|
||||||
return u"Babelio"
|
return "Babelio"
|
||||||
elif format_type == "google":
|
elif format_type == "google":
|
||||||
return u"Google Books"
|
return "Google Books"
|
||||||
elif format_type == "kobo":
|
elif format_type == "kobo":
|
||||||
return u"Kobo"
|
return "Kobo"
|
||||||
|
elif format_type == "barnesnoble":
|
||||||
|
return "Barnes & Noble"
|
||||||
elif format_type == "litres":
|
elif format_type == "litres":
|
||||||
return u"ЛитРес"
|
return "ЛитРес"
|
||||||
elif format_type == "issn":
|
elif format_type == "issn":
|
||||||
return u"ISSN"
|
return "ISSN"
|
||||||
elif format_type == "isfdb":
|
elif format_type == "isfdb":
|
||||||
return u"ISFDB"
|
return "ISFDB"
|
||||||
if format_type == "lubimyczytac":
|
if format_type == "lubimyczytac":
|
||||||
return u"Lubimyczytac"
|
return "Lubimyczytac"
|
||||||
|
if format_type == "databazeknih":
|
||||||
|
return "Databáze knih"
|
||||||
else:
|
else:
|
||||||
return self.type
|
return self.type
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
format_type = self.type.lower()
|
format_type = self.type.lower()
|
||||||
if format_type == "amazon" or format_type == "asin":
|
if format_type == "amazon" or format_type == "asin":
|
||||||
return u"https://amazon.com/dp/{0}".format(self.val)
|
return "https://amazon.com/dp/{0}".format(self.val)
|
||||||
elif format_type.startswith('amazon_'):
|
elif format_type.startswith('amazon_'):
|
||||||
return u"https://amazon.{0}/dp/{1}".format(format_type[7:], self.val)
|
return "https://amazon.{0}/dp/{1}".format(format_type[7:], self.val)
|
||||||
elif format_type == "isbn":
|
elif format_type == "isbn":
|
||||||
return u"https://www.worldcat.org/isbn/{0}".format(self.val)
|
return "https://www.worldcat.org/isbn/{0}".format(self.val)
|
||||||
elif format_type == "doi":
|
elif format_type == "doi":
|
||||||
return u"https://dx.doi.org/{0}".format(self.val)
|
return "https://dx.doi.org/{0}".format(self.val)
|
||||||
elif format_type == "goodreads":
|
elif format_type == "goodreads":
|
||||||
return u"https://www.goodreads.com/book/show/{0}".format(self.val)
|
return "https://www.goodreads.com/book/show/{0}".format(self.val)
|
||||||
elif format_type == "babelio":
|
elif format_type == "babelio":
|
||||||
return u"https://www.babelio.com/livres/titre/{0}".format(self.val)
|
return "https://www.babelio.com/livres/titre/{0}".format(self.val)
|
||||||
elif format_type == "douban":
|
elif format_type == "douban":
|
||||||
return u"https://book.douban.com/subject/{0}".format(self.val)
|
return "https://book.douban.com/subject/{0}".format(self.val)
|
||||||
elif format_type == "google":
|
elif format_type == "google":
|
||||||
return u"https://books.google.com/books?id={0}".format(self.val)
|
return "https://books.google.com/books?id={0}".format(self.val)
|
||||||
elif format_type == "kobo":
|
elif format_type == "kobo":
|
||||||
return u"https://www.kobo.com/ebook/{0}".format(self.val)
|
return "https://www.kobo.com/ebook/{0}".format(self.val)
|
||||||
|
elif format_type == "barnesnoble":
|
||||||
|
return "https://www.barnesandnoble.com/w/{0}".format(self.val)
|
||||||
elif format_type == "lubimyczytac":
|
elif format_type == "lubimyczytac":
|
||||||
return u"https://lubimyczytac.pl/ksiazka/{0}/ksiazka".format(self.val)
|
return "https://lubimyczytac.pl/ksiazka/{0}/ksiazka".format(self.val)
|
||||||
elif format_type == "litres":
|
elif format_type == "litres":
|
||||||
return u"https://www.litres.ru/{0}".format(self.val)
|
return "https://www.litres.ru/{0}".format(self.val)
|
||||||
elif format_type == "issn":
|
elif format_type == "issn":
|
||||||
return u"https://portal.issn.org/resource/ISSN/{0}".format(self.val)
|
return "https://portal.issn.org/resource/ISSN/{0}".format(self.val)
|
||||||
elif format_type == "isfdb":
|
elif format_type == "isfdb":
|
||||||
return u"http://www.isfdb.org/cgi-bin/pl.cgi?{0}".format(self.val)
|
return "http://www.isfdb.org/cgi-bin/pl.cgi?{0}".format(self.val)
|
||||||
|
elif format_type == "databazeknih":
|
||||||
|
return "https://www.databazeknih.cz/knihy/{0}".format(self.val)
|
||||||
elif self.val.lower().startswith("javascript:"):
|
elif self.val.lower().startswith("javascript:"):
|
||||||
return quote(self.val)
|
return quote(self.val)
|
||||||
|
elif self.val.lower().startswith("data:"):
|
||||||
|
link, __, __ = str.partition(self.val, ",")
|
||||||
|
return link
|
||||||
else:
|
else:
|
||||||
return u"{0}".format(self.val)
|
return "{0}".format(self.val)
|
||||||
|
|
||||||
|
|
||||||
class Comments(Base):
|
class Comments(Base):
|
||||||
@@ -181,6 +193,7 @@ class Comments(Base):
|
|||||||
text = Column(String(collation='NOCASE'), nullable=False)
|
text = Column(String(collation='NOCASE'), nullable=False)
|
||||||
|
|
||||||
def __init__(self, comment, book):
|
def __init__(self, comment, book):
|
||||||
|
super().__init__()
|
||||||
self.text = comment
|
self.text = comment
|
||||||
self.book = book
|
self.book = book
|
||||||
|
|
||||||
@@ -188,7 +201,7 @@ class Comments(Base):
|
|||||||
return self.text
|
return self.text
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return u"<Comments({0})>".format(self.text)
|
return "<Comments({0})>".format(self.text)
|
||||||
|
|
||||||
|
|
||||||
class Tags(Base):
|
class Tags(Base):
|
||||||
@@ -198,13 +211,17 @@ class Tags(Base):
|
|||||||
name = Column(String(collation='NOCASE'), unique=True, nullable=False)
|
name = Column(String(collation='NOCASE'), unique=True, nullable=False)
|
||||||
|
|
||||||
def __init__(self, name):
|
def __init__(self, name):
|
||||||
|
super().__init__()
|
||||||
self.name = name
|
self.name = name
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return self.name == other
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return u"<Tags('{0})>".format(self.name)
|
return "<Tags('{0})>".format(self.name)
|
||||||
|
|
||||||
|
|
||||||
class Authors(Base):
|
class Authors(Base):
|
||||||
@@ -215,7 +232,8 @@ class Authors(Base):
|
|||||||
sort = Column(String(collation='NOCASE'))
|
sort = Column(String(collation='NOCASE'))
|
||||||
link = Column(String, nullable=False, default="")
|
link = Column(String, nullable=False, default="")
|
||||||
|
|
||||||
def __init__(self, name, sort, link):
|
def __init__(self, name, sort, link=""):
|
||||||
|
super().__init__()
|
||||||
self.name = name
|
self.name = name
|
||||||
self.sort = sort
|
self.sort = sort
|
||||||
self.link = link
|
self.link = link
|
||||||
@@ -223,8 +241,11 @@ class Authors(Base):
|
|||||||
def get(self):
|
def get(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return self.name == other
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return u"<Authors('{0},{1}{2}')>".format(self.name, self.sort, self.link)
|
return "<Authors('{0},{1}{2}')>".format(self.name, self.sort, self.link)
|
||||||
|
|
||||||
|
|
||||||
class Series(Base):
|
class Series(Base):
|
||||||
@@ -235,14 +256,18 @@ class Series(Base):
|
|||||||
sort = Column(String(collation='NOCASE'))
|
sort = Column(String(collation='NOCASE'))
|
||||||
|
|
||||||
def __init__(self, name, sort):
|
def __init__(self, name, sort):
|
||||||
|
super().__init__()
|
||||||
self.name = name
|
self.name = name
|
||||||
self.sort = sort
|
self.sort = sort
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return self.name == other
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return u"<Series('{0},{1}')>".format(self.name, self.sort)
|
return "<Series('{0},{1}')>".format(self.name, self.sort)
|
||||||
|
|
||||||
|
|
||||||
class Ratings(Base):
|
class Ratings(Base):
|
||||||
@@ -252,13 +277,17 @@ class Ratings(Base):
|
|||||||
rating = Column(Integer, CheckConstraint('rating>-1 AND rating<11'), unique=True)
|
rating = Column(Integer, CheckConstraint('rating>-1 AND rating<11'), unique=True)
|
||||||
|
|
||||||
def __init__(self, rating):
|
def __init__(self, rating):
|
||||||
|
super().__init__()
|
||||||
self.rating = rating
|
self.rating = rating
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
return self.rating
|
return self.rating
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return self.rating == other
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return u"<Ratings('{0}')>".format(self.rating)
|
return "<Ratings('{0}')>".format(self.rating)
|
||||||
|
|
||||||
|
|
||||||
class Languages(Base):
|
class Languages(Base):
|
||||||
@@ -268,16 +297,20 @@ class Languages(Base):
|
|||||||
lang_code = Column(String(collation='NOCASE'), nullable=False, unique=True)
|
lang_code = Column(String(collation='NOCASE'), nullable=False, unique=True)
|
||||||
|
|
||||||
def __init__(self, lang_code):
|
def __init__(self, lang_code):
|
||||||
|
super().__init__()
|
||||||
self.lang_code = lang_code
|
self.lang_code = lang_code
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
if self.language_name:
|
if hasattr(self, "language_name"):
|
||||||
return self.language_name
|
return self.language_name
|
||||||
else:
|
else:
|
||||||
return self.lang_code
|
return self.lang_code
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return self.lang_code == other
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return u"<Languages('{0}')>".format(self.lang_code)
|
return "<Languages('{0}')>".format(self.lang_code)
|
||||||
|
|
||||||
|
|
||||||
class Publishers(Base):
|
class Publishers(Base):
|
||||||
@@ -288,14 +321,18 @@ class Publishers(Base):
|
|||||||
sort = Column(String(collation='NOCASE'))
|
sort = Column(String(collation='NOCASE'))
|
||||||
|
|
||||||
def __init__(self, name, sort):
|
def __init__(self, name, sort):
|
||||||
|
super().__init__()
|
||||||
self.name = name
|
self.name = name
|
||||||
self.sort = sort
|
self.sort = sort
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return self.name == other
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return u"<Publishers('{0},{1}')>".format(self.name, self.sort)
|
return "<Publishers('{0},{1}')>".format(self.name, self.sort)
|
||||||
|
|
||||||
|
|
||||||
class Data(Base):
|
class Data(Base):
|
||||||
@@ -309,6 +346,7 @@ class Data(Base):
|
|||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
|
|
||||||
def __init__(self, book, book_format, uncompressed_size, name):
|
def __init__(self, book, book_format, uncompressed_size, name):
|
||||||
|
super().__init__()
|
||||||
self.book = book
|
self.book = book
|
||||||
self.format = book_format
|
self.format = book_format
|
||||||
self.uncompressed_size = uncompressed_size
|
self.uncompressed_size = uncompressed_size
|
||||||
@@ -319,7 +357,17 @@ class Data(Base):
|
|||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return u"<Data('{0},{1}{2}{3}')>".format(self.book, self.format, self.uncompressed_size, self.name)
|
return "<Data('{0},{1}{2}{3}')>".format(self.book, self.format, self.uncompressed_size, self.name)
|
||||||
|
|
||||||
|
|
||||||
|
class Metadata_Dirtied(Base):
|
||||||
|
__tablename__ = 'metadata_dirtied'
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
book = Column(Integer, ForeignKey('books.id'), nullable=False, unique=True)
|
||||||
|
|
||||||
|
def __init__(self, book):
|
||||||
|
super().__init__()
|
||||||
|
self.book = book
|
||||||
|
|
||||||
|
|
||||||
class Books(Base):
|
class Books(Base):
|
||||||
@@ -353,6 +401,7 @@ class Books(Base):
|
|||||||
|
|
||||||
def __init__(self, title, sort, author_sort, timestamp, pubdate, series_index, last_modified, path, has_cover,
|
def __init__(self, title, sort, author_sort, timestamp, pubdate, series_index, last_modified, path, has_cover,
|
||||||
authors, tags, languages=None):
|
authors, tags, languages=None):
|
||||||
|
super().__init__()
|
||||||
self.title = title
|
self.title = title
|
||||||
self.sort = sort
|
self.sort = sort
|
||||||
self.author_sort = author_sort
|
self.author_sort = author_sort
|
||||||
@@ -361,12 +410,12 @@ class Books(Base):
|
|||||||
self.series_index = series_index
|
self.series_index = series_index
|
||||||
self.last_modified = last_modified
|
self.last_modified = last_modified
|
||||||
self.path = path
|
self.path = path
|
||||||
self.has_cover = (has_cover != None)
|
self.has_cover = (has_cover is not None)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return u"<Books('{0},{1}{2}{3}{4}{5}{6}{7}{8}')>".format(self.title, self.sort, self.author_sort,
|
return "<Books('{0},{1}{2}{3}{4}{5}{6}{7}{8}')>".format(self.title, self.sort, self.author_sort,
|
||||||
self.timestamp, self.pubdate, self.series_index,
|
self.timestamp, self.pubdate, self.series_index,
|
||||||
self.last_modified, self.path, self.has_cover)
|
self.last_modified, self.path, self.has_cover)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def atom_timestamp(self):
|
def atom_timestamp(self):
|
||||||
@@ -390,6 +439,35 @@ class CustomColumns(Base):
|
|||||||
display_dict = json.loads(self.display)
|
display_dict = json.loads(self.display)
|
||||||
return display_dict
|
return display_dict
|
||||||
|
|
||||||
|
def to_json(self, value, extra, sequence):
|
||||||
|
content = dict()
|
||||||
|
content['table'] = "custom_column_" + str(self.id)
|
||||||
|
content['column'] = "value"
|
||||||
|
content['datatype'] = self.datatype
|
||||||
|
content['is_multiple'] = None if not self.is_multiple else "|"
|
||||||
|
content['kind'] = "field"
|
||||||
|
content['name'] = self.name
|
||||||
|
content['search_terms'] = ['#' + self.label]
|
||||||
|
content['label'] = self.label
|
||||||
|
content['colnum'] = self.id
|
||||||
|
content['display'] = self.get_display_dict()
|
||||||
|
content['is_custom'] = True
|
||||||
|
content['is_category'] = self.datatype in ['text', 'rating', 'enumeration', 'series']
|
||||||
|
content['link_column'] = "value"
|
||||||
|
content['category_sort'] = "value"
|
||||||
|
content['is_csp'] = False
|
||||||
|
content['is_editable'] = self.editable
|
||||||
|
content['rec_index'] = sequence + 22 # toDo why ??
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
content['#value#'] = {"__class__": "datetime.datetime",
|
||||||
|
"__value__": value.strftime("%Y-%m-%dT%H:%M:%S+00:00")}
|
||||||
|
else:
|
||||||
|
content['#value#'] = value
|
||||||
|
content['#extra#'] = extra
|
||||||
|
content['is_multiple2'] = {} if not self.is_multiple else {"cache_to_list": "|", "ui_to_list": ",",
|
||||||
|
"list_to_ui": ", "}
|
||||||
|
return json.dumps(content, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
class AlchemyEncoder(json.JSONEncoder):
|
class AlchemyEncoder(json.JSONEncoder):
|
||||||
|
|
||||||
@@ -447,7 +525,6 @@ class CalibreDB:
|
|||||||
if init:
|
if init:
|
||||||
self.init_db(expire_on_commit)
|
self.init_db(expire_on_commit)
|
||||||
|
|
||||||
|
|
||||||
def init_db(self, expire_on_commit=True):
|
def init_db(self, expire_on_commit=True):
|
||||||
if self._init:
|
if self._init:
|
||||||
self.init_session(expire_on_commit)
|
self.init_session(expire_on_commit)
|
||||||
@@ -602,7 +679,7 @@ class CalibreDB:
|
|||||||
|
|
||||||
cls.session_factory = scoped_session(sessionmaker(autocommit=False,
|
cls.session_factory = scoped_session(sessionmaker(autocommit=False,
|
||||||
autoflush=True,
|
autoflush=True,
|
||||||
bind=cls.engine))
|
bind=cls.engine, future=True))
|
||||||
for inst in cls.instances:
|
for inst in cls.instances:
|
||||||
inst.init_session()
|
inst.init_session()
|
||||||
|
|
||||||
@@ -641,12 +718,24 @@ class CalibreDB:
|
|||||||
def get_book_format(self, book_id, file_format):
|
def get_book_format(self, book_id, file_format):
|
||||||
return self.session.query(Data).filter(Data.book == book_id).filter(Data.format == file_format).first()
|
return self.session.query(Data).filter(Data.book == book_id).filter(Data.format == file_format).first()
|
||||||
|
|
||||||
|
def set_metadata_dirty(self, book_id):
|
||||||
|
if not self.session.query(Metadata_Dirtied).filter(Metadata_Dirtied.book == book_id).one_or_none():
|
||||||
|
self.session.add(Metadata_Dirtied(book_id))
|
||||||
|
|
||||||
|
def delete_dirty_metadata(self, book_id):
|
||||||
|
try:
|
||||||
|
self.session.query(Metadata_Dirtied).filter(Metadata_Dirtied.book == book_id).delete()
|
||||||
|
self.session.commit()
|
||||||
|
except (OperationalError) as e:
|
||||||
|
self.session.rollback()
|
||||||
|
log.error("Database error: {}".format(e))
|
||||||
|
|
||||||
# Language and content filters for displaying in the UI
|
# Language and content filters for displaying in the UI
|
||||||
def common_filters(self, allow_show_archived=False, return_all_languages=False):
|
def common_filters(self, allow_show_archived=False, return_all_languages=False):
|
||||||
if not allow_show_archived:
|
if not allow_show_archived:
|
||||||
archived_books = (ub.session.query(ub.ArchivedBook)
|
archived_books = (ub.session.query(ub.ArchivedBook)
|
||||||
.filter(ub.ArchivedBook.user_id == int(current_user.id))
|
.filter(ub.ArchivedBook.user_id==int(current_user.id))
|
||||||
.filter(ub.ArchivedBook.is_archived == True)
|
.filter(ub.ArchivedBook.is_archived==True)
|
||||||
.all())
|
.all())
|
||||||
archived_book_ids = [archived_book.book_id for archived_book in archived_books]
|
archived_book_ids = [archived_book.book_id for archived_book in archived_books]
|
||||||
archived_filter = Books.id.notin_(archived_book_ids)
|
archived_filter = Books.id.notin_(archived_book_ids)
|
||||||
@@ -766,8 +855,7 @@ class CalibreDB:
|
|||||||
entries = list()
|
entries = list()
|
||||||
pagination = list()
|
pagination = list()
|
||||||
try:
|
try:
|
||||||
pagination = Pagination(page, pagesize,
|
pagination = Pagination(page, pagesize, query.count())
|
||||||
len(query.all()))
|
|
||||||
entries = query.order_by(*order).offset(off).limit(pagesize).all()
|
entries = query.order_by(*order).offset(off).limit(pagesize).all()
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.error_or_exception(ex)
|
log.error_or_exception(ex)
|
||||||
@@ -777,8 +865,6 @@ class CalibreDB:
|
|||||||
|
|
||||||
# Orders all Authors in the list according to authors sort
|
# Orders all Authors in the list according to authors sort
|
||||||
def order_authors(self, entries, list_return=False, combined=False):
|
def order_authors(self, entries, list_return=False, combined=False):
|
||||||
# entries_copy = copy.deepcopy(entries)
|
|
||||||
# entries_copy =[]
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if combined:
|
if combined:
|
||||||
sort_authors = entry.Books.author_sort.split('&')
|
sort_authors = entry.Books.author_sort.split('&')
|
||||||
@@ -885,7 +971,7 @@ class CalibreDB:
|
|||||||
pagination = None
|
pagination = None
|
||||||
result = self.search_query(term, config, *join).order_by(*order).all()
|
result = self.search_query(term, config, *join).order_by(*order).all()
|
||||||
result_count = len(result)
|
result_count = len(result)
|
||||||
if offset != None and limit != None:
|
if offset is not None and limit is not None:
|
||||||
offset = int(offset)
|
offset = int(offset)
|
||||||
limit_all = offset + int(limit)
|
limit_all = offset + int(limit)
|
||||||
pagination = Pagination((offset / (int(limit)) + 1), limit, result_count)
|
pagination = Pagination((offset / (int(limit)) + 1), limit, result_count)
|
||||||
@@ -915,7 +1001,7 @@ class CalibreDB:
|
|||||||
if not return_all_languages:
|
if not return_all_languages:
|
||||||
no_lang_count = (self.session.query(Books)
|
no_lang_count = (self.session.query(Books)
|
||||||
.outerjoin(books_languages_link).outerjoin(Languages)
|
.outerjoin(books_languages_link).outerjoin(Languages)
|
||||||
.filter(Languages.lang_code == None)
|
.filter(Languages.lang_code==None)
|
||||||
.filter(self.common_filters())
|
.filter(self.common_filters())
|
||||||
.count())
|
.count())
|
||||||
if no_lang_count:
|
if no_lang_count:
|
||||||
@@ -943,7 +1029,12 @@ class CalibreDB:
|
|||||||
title = title[len(prep):] + ', ' + prep
|
title = title[len(prep):] + ', ' + prep
|
||||||
return title.strip()
|
return title.strip()
|
||||||
|
|
||||||
conn = conn or self.session.connection().connection.connection
|
try:
|
||||||
|
# sqlalchemy <1.4.24
|
||||||
|
conn = conn or self.session.connection().connection.driver_connection
|
||||||
|
except AttributeError:
|
||||||
|
# sqlalchemy >1.4.24 and sqlalchemy 2.0
|
||||||
|
conn = conn or self.session.connection().connection.connection
|
||||||
try:
|
try:
|
||||||
conn.create_function("title_sort", 1, _title_sort)
|
conn.create_function("title_sort", 1, _title_sort)
|
||||||
except sqliteOperationalError:
|
except sqliteOperationalError:
|
||||||
@@ -1008,9 +1099,3 @@ class Category:
|
|||||||
self.id = cat_id
|
self.id = cat_id
|
||||||
self.rating = rating
|
self.rating = rating
|
||||||
self.count = 1
|
self.count = 1
|
||||||
|
|
||||||
'''class Count:
|
|
||||||
count = None
|
|
||||||
|
|
||||||
def __init__(self, count):
|
|
||||||
self.count = count'''
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from .about import collect_stats
|
|||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
class lazyEncoder(json.JSONEncoder):
|
class lazyEncoder(json.JSONEncoder):
|
||||||
def default(self, obj):
|
def default(self, obj):
|
||||||
if isinstance(obj, LazyString):
|
if isinstance(obj, LazyString):
|
||||||
@@ -40,6 +41,7 @@ class lazyEncoder(json.JSONEncoder):
|
|||||||
# Let the base class default method raise the TypeError
|
# Let the base class default method raise the TypeError
|
||||||
return json.JSONEncoder.default(self, obj)
|
return json.JSONEncoder.default(self, obj)
|
||||||
|
|
||||||
|
|
||||||
def assemble_logfiles(file_name):
|
def assemble_logfiles(file_name):
|
||||||
log_list = sorted(glob.glob(file_name + '*'), reverse=True)
|
log_list = sorted(glob.glob(file_name + '*'), reverse=True)
|
||||||
wfd = BytesIO()
|
wfd = BytesIO()
|
||||||
@@ -65,7 +67,7 @@ def send_debug():
|
|||||||
file_list.remove(element)
|
file_list.remove(element)
|
||||||
memory_zip = BytesIO()
|
memory_zip = BytesIO()
|
||||||
with zipfile.ZipFile(memory_zip, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
|
with zipfile.ZipFile(memory_zip, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
zf.writestr('settings.txt', json.dumps(config.toDict(), sort_keys=True, indent=2))
|
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))
|
zf.writestr('libs.txt', json.dumps(collect_stats(), sort_keys=True, indent=2, cls=lazyEncoder))
|
||||||
for fp in file_list:
|
for fp in file_list:
|
||||||
zf.write(fp, os.path.basename(fp))
|
zf.write(fp, os.path.basename(fp))
|
||||||
|
|||||||
@@ -58,10 +58,12 @@ def load_dependencies(optional=False):
|
|||||||
|
|
||||||
def dependency_check(optional=False):
|
def dependency_check(optional=False):
|
||||||
d = list()
|
d = list()
|
||||||
|
dep_version_int = None
|
||||||
|
low_check = None
|
||||||
deps = load_dependencies(optional)
|
deps = load_dependencies(optional)
|
||||||
for dep in deps:
|
for dep in deps:
|
||||||
try:
|
try:
|
||||||
dep_version_int = [int(x) for x in dep[0].split('.')]
|
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('.')]
|
low_check = [int(x) for x in dep[3].split('.')]
|
||||||
high_check = [int(x) for x in dep[5].split('.')]
|
high_check = [int(x) for x in dep[5].split('.')]
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
|
|||||||
@@ -23,32 +23,30 @@
|
|||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
from shutil import copyfile
|
from shutil import copyfile, move
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from markupsafe import escape # dependency of flask
|
from markupsafe import escape, Markup # dependency of flask
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
try:
|
from flask import Blueprint, request, flash, redirect, url_for, abort, Response
|
||||||
from lxml.html.clean import clean_html
|
|
||||||
except ImportError:
|
|
||||||
clean_html = None
|
|
||||||
|
|
||||||
from flask import Blueprint, request, flash, redirect, url_for, abort, Markup, Response
|
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_babel import lazy_gettext as N_
|
from flask_babel import lazy_gettext as N_
|
||||||
from flask_babel import get_locale
|
from flask_babel import get_locale
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user, login_required
|
||||||
from sqlalchemy.exc import OperationalError, IntegrityError
|
from sqlalchemy.exc import OperationalError, IntegrityError, InterfaceError
|
||||||
from sqlalchemy.orm.exc import StaleDataError
|
from sqlalchemy.orm.exc import StaleDataError
|
||||||
|
from sqlalchemy.sql.expression import func
|
||||||
|
|
||||||
from . import constants, logger, isoLanguages, gdriveutils, uploader, helper, kobo_sync_status
|
from . import constants, logger, isoLanguages, gdriveutils, uploader, helper, kobo_sync_status
|
||||||
|
from .clean_html import clean_string
|
||||||
from . import config, ub, db, calibre_db
|
from . import config, ub, db, calibre_db
|
||||||
from .services.worker import WorkerThread
|
from .services.worker import WorkerThread
|
||||||
from .tasks.upload import TaskUpload
|
from .tasks.upload import TaskUpload
|
||||||
from .render_template import render_title_template
|
from .render_template import render_title_template
|
||||||
from .usermanagement import login_required_if_no_ano
|
from .usermanagement import login_required_if_no_ano
|
||||||
from .kobo_sync_status import change_archived_books
|
from .kobo_sync_status import change_archived_books
|
||||||
|
from .redirect import get_redirect_location
|
||||||
|
from .file_helper import validate_mime_type
|
||||||
|
|
||||||
editbook = Blueprint('edit-book', __name__)
|
editbook = Blueprint('edit-book', __name__)
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
@@ -84,7 +82,7 @@ def delete_book_from_details(book_id):
|
|||||||
@editbook.route("/delete/<int:book_id>/<string:book_format>", methods=["POST"])
|
@editbook.route("/delete/<int:book_id>/<string:book_format>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def delete_book_ajax(book_id, book_format):
|
def delete_book_ajax(book_id, book_format):
|
||||||
return delete_book_from_table(book_id, book_format, False)
|
return delete_book_from_table(book_id, book_format, False, request.form.to_dict().get('location', ""))
|
||||||
|
|
||||||
|
|
||||||
@editbook.route("/admin/book/<int:book_id>", methods=['GET'])
|
@editbook.route("/admin/book/<int:book_id>", methods=['GET'])
|
||||||
@@ -107,7 +105,7 @@ def edit_book(book_id):
|
|||||||
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
|
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
|
||||||
# Book not found
|
# Book not found
|
||||||
if not book:
|
if not book:
|
||||||
flash(_(u"Oops! Selected book title is unavailable. File does not exist or is not accessible"),
|
flash(_("Oops! Selected book is unavailable. File does not exist or is not accessible"),
|
||||||
category="error")
|
category="error")
|
||||||
return redirect(url_for("web.index"))
|
return redirect(url_for("web.index"))
|
||||||
|
|
||||||
@@ -120,14 +118,13 @@ def edit_book(book_id):
|
|||||||
# handle book title change
|
# handle book title change
|
||||||
title_change = handle_title_on_edit(book, to_save["book_title"])
|
title_change = handle_title_on_edit(book, to_save["book_title"])
|
||||||
# handle book author change
|
# handle book author change
|
||||||
input_authors, author_change, renamed = handle_author_on_edit(book, to_save["author_name"])
|
input_authors, author_change = handle_author_on_edit(book, to_save["author_name"])
|
||||||
if author_change or title_change:
|
if author_change or title_change:
|
||||||
edited_books_id = book.id
|
edited_books_id = book.id
|
||||||
modify_date = True
|
modify_date = True
|
||||||
title_author_error = helper.update_dir_structure(edited_books_id,
|
title_author_error = helper.update_dir_structure(edited_books_id,
|
||||||
config.config_calibre_dir,
|
config.get_book_path(),
|
||||||
input_authors[0],
|
input_authors[0])
|
||||||
renamed_author=renamed)
|
|
||||||
if title_author_error:
|
if title_author_error:
|
||||||
flash(title_author_error, category="error")
|
flash(title_author_error, category="error")
|
||||||
calibre_db.session.rollback()
|
calibre_db.session.rollback()
|
||||||
@@ -151,7 +148,7 @@ def edit_book(book_id):
|
|||||||
if to_save.get("cover_url", None):
|
if to_save.get("cover_url", None):
|
||||||
if not current_user.role_upload():
|
if not current_user.role_upload():
|
||||||
edit_error = True
|
edit_error = True
|
||||||
flash(_(u"User has no rights to upload cover"), category="error")
|
flash(_("User has no rights to upload cover"), category="error")
|
||||||
if to_save["cover_url"].endswith('/static/generic_cover.jpg'):
|
if to_save["cover_url"].endswith('/static/generic_cover.jpg'):
|
||||||
book.has_cover = 0
|
book.has_cover = 0
|
||||||
else:
|
else:
|
||||||
@@ -203,6 +200,7 @@ def edit_book(book_id):
|
|||||||
if modify_date:
|
if modify_date:
|
||||||
book.last_modified = datetime.utcnow()
|
book.last_modified = datetime.utcnow()
|
||||||
kobo_sync_status.remove_synced_book(edited_books_id, all=True)
|
kobo_sync_status.remove_synced_book(edited_books_id, all=True)
|
||||||
|
calibre_db.set_metadata_dirty(book.id)
|
||||||
|
|
||||||
calibre_db.session.merge(book)
|
calibre_db.session.merge(book)
|
||||||
calibre_db.session.commit()
|
calibre_db.session.commit()
|
||||||
@@ -222,10 +220,10 @@ def edit_book(book_id):
|
|||||||
calibre_db.session.rollback()
|
calibre_db.session.rollback()
|
||||||
flash(str(e), category="error")
|
flash(str(e), category="error")
|
||||||
return redirect(url_for('web.show_book', book_id=book.id))
|
return redirect(url_for('web.show_book', book_id=book.id))
|
||||||
except (OperationalError, IntegrityError, StaleDataError) as e:
|
except (OperationalError, IntegrityError, StaleDataError, InterfaceError) as e:
|
||||||
log.error_or_exception("Database error: {}".format(e))
|
log.error_or_exception("Database error: {}".format(e))
|
||||||
calibre_db.session.rollback()
|
calibre_db.session.rollback()
|
||||||
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig if hasattr(e, "orig") else e), category="error")
|
||||||
return redirect(url_for('web.show_book', book_id=book.id))
|
return redirect(url_for('web.show_book', book_id=book.id))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.error_or_exception(ex)
|
log.error_or_exception(ex)
|
||||||
@@ -252,7 +250,7 @@ def upload():
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
|
|
||||||
db_book, input_authors, title_dir, renamed_authors = create_book_on_upload(modify_date, meta)
|
db_book, input_authors, title_dir = create_book_on_upload(modify_date, meta)
|
||||||
|
|
||||||
# Comments need book id therefore only possible after flush
|
# Comments need book id therefore only possible after flush
|
||||||
modify_date |= edit_book_comments(Markup(meta.description).unescape(), db_book)
|
modify_date |= edit_book_comments(Markup(meta.description).unescape(), db_book)
|
||||||
@@ -262,21 +260,24 @@ def upload():
|
|||||||
if config.config_use_google_drive:
|
if config.config_use_google_drive:
|
||||||
helper.upload_new_file_gdrive(book_id,
|
helper.upload_new_file_gdrive(book_id,
|
||||||
input_authors[0],
|
input_authors[0],
|
||||||
renamed_authors,
|
|
||||||
title,
|
title,
|
||||||
title_dir,
|
title_dir,
|
||||||
meta.file_path,
|
meta.file_path,
|
||||||
meta.extension.lower())
|
meta.extension.lower())
|
||||||
|
for file_format in db_book.data:
|
||||||
|
file_format.name = (helper.get_valid_filename(title, chars=42) + ' - '
|
||||||
|
+ helper.get_valid_filename(input_authors[0], chars=42))
|
||||||
else:
|
else:
|
||||||
error = helper.update_dir_structure(book_id,
|
error = helper.update_dir_structure(book_id,
|
||||||
config.config_calibre_dir,
|
config.get_book_path(),
|
||||||
input_authors[0],
|
input_authors[0],
|
||||||
meta.file_path,
|
meta.file_path,
|
||||||
title_dir + meta.extension.lower(),
|
title_dir + meta.extension.lower())
|
||||||
renamed_author=renamed_authors)
|
|
||||||
|
|
||||||
move_coverfile(meta, db_book)
|
move_coverfile(meta, db_book)
|
||||||
|
|
||||||
|
if modify_date:
|
||||||
|
calibre_db.set_metadata_dirty(book_id)
|
||||||
# save data to database, reread data
|
# save data to database, reread data
|
||||||
calibre_db.session.commit()
|
calibre_db.session.commit()
|
||||||
|
|
||||||
@@ -285,7 +286,7 @@ def upload():
|
|||||||
if error:
|
if error:
|
||||||
flash(error, category="error")
|
flash(error, category="error")
|
||||||
link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book_id), escape(title))
|
link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book_id), escape(title))
|
||||||
upload_text = N_(u"File %(file)s uploaded", file=link)
|
upload_text = N_("File %(file)s uploaded", file=link)
|
||||||
WorkerThread.add(current_user.name, TaskUpload(upload_text, escape(title)))
|
WorkerThread.add(current_user.name, TaskUpload(upload_text, escape(title)))
|
||||||
helper.add_book_to_thumbnail_cache(book_id)
|
helper.add_book_to_thumbnail_cache(book_id)
|
||||||
|
|
||||||
@@ -299,7 +300,8 @@ def upload():
|
|||||||
except (OperationalError, IntegrityError, StaleDataError) as e:
|
except (OperationalError, IntegrityError, StaleDataError) as e:
|
||||||
calibre_db.session.rollback()
|
calibre_db.session.rollback()
|
||||||
log.error_or_exception("Database error: {}".format(e))
|
log.error_or_exception("Database error: {}".format(e))
|
||||||
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig if hasattr(e, "orig") else e),
|
||||||
|
category="error")
|
||||||
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
|
return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
|
||||||
|
|
||||||
|
|
||||||
@@ -312,19 +314,19 @@ def convert_bookformat(book_id):
|
|||||||
book_format_to = request.form.get('book_format_to', None)
|
book_format_to = request.form.get('book_format_to', None)
|
||||||
|
|
||||||
if (book_format_from is None) or (book_format_to is None):
|
if (book_format_from is None) or (book_format_to is None):
|
||||||
flash(_(u"Source or destination format for conversion missing"), category="error")
|
flash(_("Source or destination format for conversion missing"), category="error")
|
||||||
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
|
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
|
||||||
|
|
||||||
log.info('converting: book id: %s from: %s to: %s', book_id, book_format_from, book_format_to)
|
log.info('converting: book id: %s from: %s to: %s', book_id, book_format_from, book_format_to)
|
||||||
rtn = helper.convert_book_format(book_id, config.config_calibre_dir, book_format_from.upper(),
|
rtn = helper.convert_book_format(book_id, config.get_book_path(), book_format_from.upper(),
|
||||||
book_format_to.upper(), current_user.name)
|
book_format_to.upper(), current_user.name)
|
||||||
|
|
||||||
if rtn is None:
|
if rtn is None:
|
||||||
flash(_(u"Book successfully queued for converting to %(book_format)s",
|
flash(_("Book successfully queued for converting to %(book_format)s",
|
||||||
book_format=book_format_to),
|
book_format=book_format_to),
|
||||||
category="success")
|
category="success")
|
||||||
else:
|
else:
|
||||||
flash(_(u"There was an error converting this book: %(res)s", res=rtn), category="error")
|
flash(_("There was an error converting this book: %(res)s", res=rtn), category="error")
|
||||||
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
|
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
|
||||||
|
|
||||||
|
|
||||||
@@ -386,7 +388,7 @@ def edit_list_book(param):
|
|||||||
elif param == 'title':
|
elif param == 'title':
|
||||||
sort_param = book.sort
|
sort_param = book.sort
|
||||||
if handle_title_on_edit(book, vals.get('value', "")):
|
if handle_title_on_edit(book, vals.get('value', "")):
|
||||||
rename_error = helper.update_dir_structure(book.id, config.config_calibre_dir)
|
rename_error = helper.update_dir_structure(book.id, config.get_book_path())
|
||||||
if not rename_error:
|
if not rename_error:
|
||||||
ret = Response(json.dumps({'success': True, 'newValue': book.title}),
|
ret = Response(json.dumps({'success': True, 'newValue': book.title}),
|
||||||
mimetype='application/json')
|
mimetype='application/json')
|
||||||
@@ -403,9 +405,8 @@ def edit_list_book(param):
|
|||||||
ret = Response(json.dumps({'success': True, 'newValue': book.comments[0].text}),
|
ret = Response(json.dumps({'success': True, 'newValue': book.comments[0].text}),
|
||||||
mimetype='application/json')
|
mimetype='application/json')
|
||||||
elif param == 'authors':
|
elif param == 'authors':
|
||||||
input_authors, __, renamed = handle_author_on_edit(book, vals['value'], vals.get('checkA', None) == "true")
|
input_authors, __ = handle_author_on_edit(book, vals['value'], vals.get('checkA', None) == "true")
|
||||||
rename_error = helper.update_dir_structure(book.id, config.config_calibre_dir, input_authors[0],
|
rename_error = helper.update_dir_structure(book.id, config.get_book_path(), input_authors[0])
|
||||||
renamed_author=renamed)
|
|
||||||
if not rename_error:
|
if not rename_error:
|
||||||
ret = Response(json.dumps({
|
ret = Response(json.dumps({
|
||||||
'success': True,
|
'success': True,
|
||||||
@@ -448,7 +449,7 @@ def edit_list_book(param):
|
|||||||
calibre_db.session.rollback()
|
calibre_db.session.rollback()
|
||||||
log.error_or_exception("Database error: {}".format(e))
|
log.error_or_exception("Database error: {}".format(e))
|
||||||
ret = Response(json.dumps({'success': False,
|
ret = Response(json.dumps({'success': False,
|
||||||
'msg': 'Database error: {}'.format(e.orig)}),
|
'msg': 'Database error: {}'.format(e.orig if hasattr(e, "orig") else e)}),
|
||||||
mimetype='application/json')
|
mimetype='application/json')
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
@@ -466,7 +467,7 @@ def get_sorted_entry(field, bookid):
|
|||||||
if field == 'sort':
|
if field == 'sort':
|
||||||
return json.dumps({'sort': book.title})
|
return json.dumps({'sort': book.title})
|
||||||
if field == 'author_sort':
|
if field == 'author_sort':
|
||||||
return json.dumps({'author_sort': book.author})
|
return json.dumps({'authors': " & ".join([a.name for a in calibre_db.order_authors([book])])})
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -508,10 +509,10 @@ def merge_list_book():
|
|||||||
for element in from_book.data:
|
for element in from_book.data:
|
||||||
if element.format not in to_file:
|
if element.format not in to_file:
|
||||||
# create new data entry with: book_id, book_format, uncompressed_size, name
|
# create new data entry with: book_id, book_format, uncompressed_size, name
|
||||||
filepath_new = os.path.normpath(os.path.join(config.config_calibre_dir,
|
filepath_new = os.path.normpath(os.path.join(config.get_book_path(),
|
||||||
to_book.path,
|
to_book.path,
|
||||||
to_name + "." + element.format.lower()))
|
to_name + "." + element.format.lower()))
|
||||||
filepath_old = os.path.normpath(os.path.join(config.config_calibre_dir,
|
filepath_old = os.path.normpath(os.path.join(config.get_book_path(),
|
||||||
from_book.path,
|
from_book.path,
|
||||||
element.name + "." + element.format.lower()))
|
element.name + "." + element.format.lower()))
|
||||||
copyfile(filepath_old, filepath_new)
|
copyfile(filepath_old, filepath_new)
|
||||||
@@ -541,7 +542,7 @@ def table_xchange_author_title():
|
|||||||
author_names.append(authr.name.replace('|', ','))
|
author_names.append(authr.name.replace('|', ','))
|
||||||
|
|
||||||
title_change = handle_title_on_edit(book, " ".join(author_names))
|
title_change = handle_title_on_edit(book, " ".join(author_names))
|
||||||
input_authors, author_change, renamed = handle_author_on_edit(book, authors)
|
input_authors, author_change = handle_author_on_edit(book, authors)
|
||||||
if author_change or title_change:
|
if author_change or title_change:
|
||||||
edited_books_id = book.id
|
edited_books_id = book.id
|
||||||
modify_date = True
|
modify_date = True
|
||||||
@@ -551,15 +552,15 @@ def table_xchange_author_title():
|
|||||||
|
|
||||||
if edited_books_id:
|
if edited_books_id:
|
||||||
# toDo: Handle error
|
# toDo: Handle error
|
||||||
edit_error = helper.update_dir_structure(edited_books_id, config.config_calibre_dir, input_authors[0],
|
edit_error = helper.update_dir_structure(edited_books_id, config.get_book_path(), input_authors[0])
|
||||||
renamed_author=renamed)
|
|
||||||
if modify_date:
|
if modify_date:
|
||||||
book.last_modified = datetime.utcnow()
|
book.last_modified = datetime.utcnow()
|
||||||
|
calibre_db.set_metadata_dirty(book.id)
|
||||||
try:
|
try:
|
||||||
calibre_db.session.commit()
|
calibre_db.session.commit()
|
||||||
except (OperationalError, IntegrityError, StaleDataError) as e:
|
except (OperationalError, IntegrityError, StaleDataError) as e:
|
||||||
calibre_db.session.rollback()
|
calibre_db.session.rollback()
|
||||||
log.error_or_exception("Database error: %s", e)
|
log.error_or_exception("Database error: {}".format(e))
|
||||||
return json.dumps({'success': False})
|
return json.dumps({'success': False})
|
||||||
|
|
||||||
if config.config_use_google_drive:
|
if config.config_use_google_drive:
|
||||||
@@ -569,9 +570,9 @@ def table_xchange_author_title():
|
|||||||
|
|
||||||
|
|
||||||
def merge_metadata(to_save, meta):
|
def merge_metadata(to_save, meta):
|
||||||
if to_save.get('author_name', "") == _(u'Unknown'):
|
if to_save.get('author_name', "") == _('Unknown'):
|
||||||
to_save['author_name'] = ''
|
to_save['author_name'] = ''
|
||||||
if to_save.get('book_title', "") == _(u'Unknown'):
|
if to_save.get('book_title', "") == _('Unknown'):
|
||||||
to_save['book_title'] = ''
|
to_save['book_title'] = ''
|
||||||
for s_field, m_field in [
|
for s_field, m_field in [
|
||||||
('tags', 'tags'), ('author_name', 'author'), ('series', 'series'),
|
('tags', 'tags'), ('author_name', 'author'), ('series', 'series'),
|
||||||
@@ -593,11 +594,15 @@ def identifier_list(to_save, book):
|
|||||||
val_key = id_val_prefix + type_key[len(id_type_prefix):]
|
val_key = id_val_prefix + type_key[len(id_type_prefix):]
|
||||||
if val_key not in to_save.keys():
|
if val_key not in to_save.keys():
|
||||||
continue
|
continue
|
||||||
|
if to_save[val_key].startswith("data:"):
|
||||||
|
to_save[val_key], __, __ = str.partition(to_save[val_key], ",")
|
||||||
result.append(db.Identifiers(to_save[val_key], type_value, book.id))
|
result.append(db.Identifiers(to_save[val_key], type_value, book.id))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def prepare_authors(authr):
|
def prepare_authors(authr, calibre_path, gdrive=False):
|
||||||
|
if gdrive:
|
||||||
|
calibre_path = ""
|
||||||
# handle authors
|
# handle authors
|
||||||
input_authors = authr.split('&')
|
input_authors = authr.split('&')
|
||||||
# handle_authors(input_authors)
|
# handle_authors(input_authors)
|
||||||
@@ -607,36 +612,63 @@ def prepare_authors(authr):
|
|||||||
|
|
||||||
# we have all author names now
|
# we have all author names now
|
||||||
if input_authors == ['']:
|
if input_authors == ['']:
|
||||||
input_authors = [_(u'Unknown')] # prevent empty Author
|
input_authors = [_('Unknown')] # prevent empty Author
|
||||||
|
|
||||||
renamed = list()
|
|
||||||
for in_aut in input_authors:
|
for in_aut in input_authors:
|
||||||
renamed_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == in_aut).first()
|
renamed_author = calibre_db.session.query(db.Authors).filter(func.lower(db.Authors.name).ilike(in_aut)).first()
|
||||||
if renamed_author and in_aut != renamed_author.name:
|
if renamed_author and in_aut != renamed_author.name:
|
||||||
renamed.append(renamed_author.name)
|
old_author_name = renamed_author.name
|
||||||
|
# rename author in Database
|
||||||
|
create_objects_for_addition(renamed_author, in_aut,"author")
|
||||||
|
# rename all Books with this author as first author:
|
||||||
|
# rename all book author_sort strings with the new author name
|
||||||
all_books = calibre_db.session.query(db.Books) \
|
all_books = calibre_db.session.query(db.Books) \
|
||||||
.filter(db.Books.authors.any(db.Authors.name == renamed_author.name)).all()
|
.filter(db.Books.authors.any(db.Authors.name == renamed_author.name)).all()
|
||||||
sorted_renamed_author = helper.get_sorted_author(renamed_author.name)
|
|
||||||
sorted_old_author = helper.get_sorted_author(in_aut)
|
|
||||||
for one_book in all_books:
|
for one_book in all_books:
|
||||||
one_book.author_sort = one_book.author_sort.replace(sorted_renamed_author, sorted_old_author)
|
# ToDo: check
|
||||||
return input_authors, renamed
|
sorted_old_author = helper.get_sorted_author(old_author_name)
|
||||||
|
sorted_renamed_author = helper.get_sorted_author(in_aut)
|
||||||
|
# change author sort path
|
||||||
|
try:
|
||||||
|
author_index = one_book.author_sort.index(sorted_old_author)
|
||||||
|
one_book.author_sort = one_book.author_sort.replace(sorted_old_author, sorted_renamed_author)
|
||||||
|
except ValueError:
|
||||||
|
log.error("Sorted author {} not found in database".format(sorted_old_author))
|
||||||
|
author_index = -1
|
||||||
|
# change book path if changed author is first author -> match on first position
|
||||||
|
if author_index == 0:
|
||||||
|
one_titledir = one_book.path.split('/')[1]
|
||||||
|
one_old_authordir = one_book.path.split('/')[0]
|
||||||
|
# rename author path only once per renamed author -> search all books with author name in book.path
|
||||||
|
# das muss einmal geschehen aber pro Buch geprüft werden ansonsten habe ich das Problem das vlt. 2 gleiche Ordner bis auf Groß/Kleinschreibung vorhanden sind im Umzug
|
||||||
|
new_author_dir = helper.rename_author_path(in_aut, one_old_authordir, renamed_author.name, calibre_path, gdrive)
|
||||||
|
one_book.path = os.path.join(new_author_dir, one_titledir).replace('\\', '/')
|
||||||
|
# rename all books in book data with the new author name and move corresponding files to new locations
|
||||||
|
# old_path = os.path.join(calibre_path, new_author_dir, one_titledir)
|
||||||
|
new_path = os.path.join(calibre_path, new_author_dir, one_titledir)
|
||||||
|
all_new_name = helper.get_valid_filename(one_book.title, chars=42) + ' - ' \
|
||||||
|
+ helper.get_valid_filename(renamed_author.name, chars=42)
|
||||||
|
# change location in database to new author/title path
|
||||||
|
helper.rename_all_files_on_change(one_book, new_path, new_path, all_new_name, gdrive)
|
||||||
|
|
||||||
|
return input_authors
|
||||||
|
|
||||||
|
|
||||||
def prepare_authors_on_upload(title, authr):
|
def prepare_authors_on_upload(title, authr):
|
||||||
if title != _(u'Unknown') and authr != _(u'Unknown'):
|
if title != _('Unknown') and authr != _('Unknown'):
|
||||||
entry = calibre_db.check_exists_book(authr, title)
|
entry = calibre_db.check_exists_book(authr, title)
|
||||||
if entry:
|
if entry:
|
||||||
log.info("Uploaded book probably exists in library")
|
log.info("Uploaded book probably exists in library")
|
||||||
flash(_(u"Uploaded book probably exists in the library, consider to change before upload new: ")
|
flash(_("Uploaded book probably exists in the library, consider to change before upload new: ")
|
||||||
+ Markup(render_title_template('book_exists_flash.html', entry=entry)), category="warning")
|
+ Markup(render_title_template('book_exists_flash.html', entry=entry)), category="warning")
|
||||||
|
|
||||||
input_authors, renamed = prepare_authors(authr)
|
input_authors = prepare_authors(authr, config.get_book_path(), config.config_use_google_drive)
|
||||||
|
|
||||||
sort_authors_list = list()
|
sort_authors_list = list()
|
||||||
db_author = None
|
db_author = None
|
||||||
for inp in input_authors:
|
for inp in input_authors:
|
||||||
stored_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == inp).first()
|
# stored_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == inp).first()
|
||||||
|
stored_author = calibre_db.session.query(db.Authors).filter(func.lower(db.Authors.name).ilike(inp)).first()
|
||||||
if not stored_author:
|
if not stored_author:
|
||||||
if not db_author:
|
if not db_author:
|
||||||
db_author = db.Authors(inp, helper.get_sorted_author(inp), "")
|
db_author = db.Authors(inp, helper.get_sorted_author(inp), "")
|
||||||
@@ -649,13 +681,13 @@ def prepare_authors_on_upload(title, authr):
|
|||||||
sort_author = stored_author.sort
|
sort_author = stored_author.sort
|
||||||
sort_authors_list.append(sort_author)
|
sort_authors_list.append(sort_author)
|
||||||
sort_authors = ' & '.join(sort_authors_list)
|
sort_authors = ' & '.join(sort_authors_list)
|
||||||
return sort_authors, input_authors, db_author, renamed
|
return sort_authors, input_authors, db_author
|
||||||
|
|
||||||
|
|
||||||
def create_book_on_upload(modify_date, meta):
|
def create_book_on_upload(modify_date, meta):
|
||||||
title = meta.title
|
title = meta.title
|
||||||
authr = meta.author
|
authr = meta.author
|
||||||
sort_authors, input_authors, db_author, renamed_authors = prepare_authors_on_upload(title, authr)
|
sort_authors, input_authors, db_author = prepare_authors_on_upload(title, authr)
|
||||||
|
|
||||||
title_dir = helper.get_valid_filename(title, chars=96)
|
title_dir = helper.get_valid_filename(title, chars=96)
|
||||||
author_dir = helper.get_valid_filename(db_author.name, chars=96)
|
author_dir = helper.get_valid_filename(db_author.name, chars=96)
|
||||||
@@ -683,7 +715,7 @@ def create_book_on_upload(modify_date, meta):
|
|||||||
modify_date |= edit_book_languages(meta.languages, db_book, upload_mode=True, invalid=invalid)
|
modify_date |= edit_book_languages(meta.languages, db_book, upload_mode=True, invalid=invalid)
|
||||||
if invalid:
|
if invalid:
|
||||||
for lang in invalid:
|
for lang in invalid:
|
||||||
flash(_(u"'%(langname)s' is not a valid language", langname=lang), category="warning")
|
flash(_("'%(langname)s' is not a valid language", langname=lang), category="warning")
|
||||||
|
|
||||||
# handle tags
|
# handle tags
|
||||||
modify_date |= edit_book_tags(meta.tags, db_book)
|
modify_date |= edit_book_tags(meta.tags, db_book)
|
||||||
@@ -712,14 +744,20 @@ def create_book_on_upload(modify_date, meta):
|
|||||||
flash(_("Identifiers are not Case Sensitive, Overwriting Old Identifier"), category="warning")
|
flash(_("Identifiers are not Case Sensitive, Overwriting Old Identifier"), category="warning")
|
||||||
modify_date |= modification
|
modify_date |= modification
|
||||||
|
|
||||||
return db_book, input_authors, title_dir, renamed_authors
|
return db_book, input_authors, title_dir
|
||||||
|
|
||||||
|
|
||||||
def file_handling_on_upload(requested_file):
|
def file_handling_on_upload(requested_file):
|
||||||
# check if file extension is correct
|
# check if file extension is correct
|
||||||
|
allowed_extensions = config.config_upload_formats.split(',')
|
||||||
|
if requested_file:
|
||||||
|
if config.config_check_extensions and allowed_extensions != ['']:
|
||||||
|
if not validate_mime_type(requested_file, allowed_extensions):
|
||||||
|
flash(_("File type isn't allowed to be uploaded to this server"), category="error")
|
||||||
|
return None, Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
|
||||||
if '.' in requested_file.filename:
|
if '.' in requested_file.filename:
|
||||||
file_ext = requested_file.filename.rsplit('.', 1)[-1].lower()
|
file_ext = requested_file.filename.rsplit('.', 1)[-1].lower()
|
||||||
if file_ext not in constants.EXTENSIONS_UPLOAD and '' not in constants.EXTENSIONS_UPLOAD:
|
if file_ext not in allowed_extensions and '' not in allowed_extensions:
|
||||||
flash(
|
flash(
|
||||||
_("File extension '%(ext)s' is not allowed to be uploaded to this server",
|
_("File extension '%(ext)s' is not allowed to be uploaded to this server",
|
||||||
ext=file_ext), category="error")
|
ext=file_ext), category="error")
|
||||||
@@ -733,7 +771,7 @@ def file_handling_on_upload(requested_file):
|
|||||||
meta = uploader.upload(requested_file, config.config_rarfile_location)
|
meta = uploader.upload(requested_file, config.config_rarfile_location)
|
||||||
except (IOError, OSError):
|
except (IOError, OSError):
|
||||||
log.error("File %s could not saved to temp dir", requested_file.filename)
|
log.error("File %s could not saved to temp dir", requested_file.filename)
|
||||||
flash(_(u"File %(filename)s could not saved to temp dir",
|
flash(_("File %(filename)s could not saved to temp dir",
|
||||||
filename=requested_file.filename), category="error")
|
filename=requested_file.filename), category="error")
|
||||||
return None, Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
|
return None, Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json')
|
||||||
return meta, None
|
return meta, None
|
||||||
@@ -745,7 +783,7 @@ def move_coverfile(meta, db_book):
|
|||||||
cover_file = meta.cover
|
cover_file = meta.cover
|
||||||
else:
|
else:
|
||||||
cover_file = os.path.join(constants.STATIC_DIR, 'generic_cover.jpg')
|
cover_file = os.path.join(constants.STATIC_DIR, 'generic_cover.jpg')
|
||||||
new_cover_path = os.path.join(config.config_calibre_dir, db_book.path)
|
new_cover_path = os.path.join(config.get_book_path(), db_book.path)
|
||||||
try:
|
try:
|
||||||
os.makedirs(new_cover_path, exist_ok=True)
|
os.makedirs(new_cover_path, exist_ok=True)
|
||||||
copyfile(cover_file, os.path.join(new_cover_path, "cover.jpg"))
|
copyfile(cover_file, os.path.join(new_cover_path, "cover.jpg"))
|
||||||
@@ -753,7 +791,7 @@ def move_coverfile(meta, db_book):
|
|||||||
os.unlink(meta.cover)
|
os.unlink(meta.cover)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
log.error("Failed to move cover file %s: %s", new_cover_path, e)
|
log.error("Failed to move cover file %s: %s", new_cover_path, e)
|
||||||
flash(_(u"Failed to Move Cover File %(file)s: %(error)s", file=new_cover_path,
|
flash(_("Failed to Move Cover File %(file)s: %(error)s", file=new_cover_path,
|
||||||
error=e),
|
error=e),
|
||||||
category="error")
|
category="error")
|
||||||
|
|
||||||
@@ -767,7 +805,7 @@ def delete_whole_book(book_id, book):
|
|||||||
|
|
||||||
# check if only this book links to:
|
# check if only this book links to:
|
||||||
# author, language, series, tags, custom columns
|
# author, language, series, tags, custom columns
|
||||||
modify_database_object([u''], book.authors, db.Authors, calibre_db.session, 'author')
|
modify_database_object([''], book.authors, db.Authors, calibre_db.session, 'author')
|
||||||
modify_database_object([u''], book.tags, db.Tags, calibre_db.session, 'tags')
|
modify_database_object([u''], book.tags, db.Tags, calibre_db.session, 'tags')
|
||||||
modify_database_object([u''], book.series, db.Series, calibre_db.session, 'series')
|
modify_database_object([u''], book.series, db.Series, calibre_db.session, 'series')
|
||||||
modify_database_object([u''], book.languages, db.Languages, calibre_db.session, 'languages')
|
modify_database_object([u''], book.languages, db.Languages, calibre_db.session, 'languages')
|
||||||
@@ -804,7 +842,7 @@ def delete_whole_book(book_id, book):
|
|||||||
calibre_db.session.query(db.Books).filter(db.Books.id == book_id).delete()
|
calibre_db.session.query(db.Books).filter(db.Books.id == book_id).delete()
|
||||||
|
|
||||||
|
|
||||||
def render_delete_book_result(book_format, json_response, warning, book_id):
|
def render_delete_book_result(book_format, json_response, warning, book_id, location=""):
|
||||||
if book_format:
|
if book_format:
|
||||||
if json_response:
|
if json_response:
|
||||||
return json.dumps([warning, {"location": url_for("edit-book.show_edit_book", book_id=book_id),
|
return json.dumps([warning, {"location": url_for("edit-book.show_edit_book", book_id=book_id),
|
||||||
@@ -816,22 +854,22 @@ def render_delete_book_result(book_format, json_response, warning, book_id):
|
|||||||
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
|
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
|
||||||
else:
|
else:
|
||||||
if json_response:
|
if json_response:
|
||||||
return json.dumps([warning, {"location": url_for('web.index'),
|
return json.dumps([warning, {"location": get_redirect_location(location, "web.index"),
|
||||||
"type": "success",
|
"type": "success",
|
||||||
"format": book_format,
|
"format": book_format,
|
||||||
"message": _('Book Successfully Deleted')}])
|
"message": _('Book Successfully Deleted')}])
|
||||||
else:
|
else:
|
||||||
flash(_('Book Successfully Deleted'), category="success")
|
flash(_('Book Successfully Deleted'), category="success")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(get_redirect_location(location, "web.index"))
|
||||||
|
|
||||||
|
|
||||||
def delete_book_from_table(book_id, book_format, json_response):
|
def delete_book_from_table(book_id, book_format, json_response, location=""):
|
||||||
warning = {}
|
warning = {}
|
||||||
if current_user.role_delete_books():
|
if current_user.role_delete_books():
|
||||||
book = calibre_db.get_book(book_id)
|
book = calibre_db.get_book(book_id)
|
||||||
if book:
|
if book:
|
||||||
try:
|
try:
|
||||||
result, error = helper.delete_book(book, config.config_calibre_dir, book_format=book_format.upper())
|
result, error = helper.delete_book(book, config.get_book_path(), book_format=book_format.upper())
|
||||||
if not result:
|
if not result:
|
||||||
if json_response:
|
if json_response:
|
||||||
return json.dumps([{"location": url_for("edit-book.show_edit_book", book_id=book_id),
|
return json.dumps([{"location": url_for("edit-book.show_edit_book", book_id=book_id),
|
||||||
@@ -872,7 +910,7 @@ def delete_book_from_table(book_id, book_format, json_response):
|
|||||||
else:
|
else:
|
||||||
# book not found
|
# book not found
|
||||||
log.error('Book with id "%s" could not be deleted: not found', book_id)
|
log.error('Book with id "%s" could not be deleted: not found', book_id)
|
||||||
return render_delete_book_result(book_format, json_response, warning, book_id)
|
return render_delete_book_result(book_format, json_response, warning, book_id, location)
|
||||||
message = _("You are missing permissions to delete books")
|
message = _("You are missing permissions to delete books")
|
||||||
if json_response:
|
if json_response:
|
||||||
return json.dumps({"location": url_for("edit-book.show_edit_book", book_id=book_id),
|
return json.dumps({"location": url_for("edit-book.show_edit_book", book_id=book_id),
|
||||||
@@ -888,7 +926,7 @@ def render_edit_book(book_id):
|
|||||||
cc = calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.datatype.notin_(db.cc_exceptions)).all()
|
cc = calibre_db.session.query(db.CustomColumns).filter(db.CustomColumns.datatype.notin_(db.cc_exceptions)).all()
|
||||||
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
|
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
|
||||||
if not book:
|
if not book:
|
||||||
flash(_(u"Oops! Selected book title is unavailable. File does not exist or is not accessible"),
|
flash(_("Oops! Selected book is unavailable. File does not exist or is not accessible"),
|
||||||
category="error")
|
category="error")
|
||||||
return redirect(url_for("web.index"))
|
return redirect(url_for("web.index"))
|
||||||
|
|
||||||
@@ -923,7 +961,7 @@ def render_edit_book(book_id):
|
|||||||
if kepub_possible:
|
if kepub_possible:
|
||||||
allowed_conversion_formats.append('kepub')
|
allowed_conversion_formats.append('kepub')
|
||||||
return render_title_template('book_edit.html', book=book, authors=author_names, cc=cc,
|
return render_title_template('book_edit.html', book=book, authors=author_names, cc=cc,
|
||||||
title=_(u"edit metadata"), page="editbook",
|
title=_("edit metadata"), page="editbook",
|
||||||
conversion_formats=allowed_conversion_formats,
|
conversion_formats=allowed_conversion_formats,
|
||||||
config=config,
|
config=config,
|
||||||
source_formats=valid_source_formats)
|
source_formats=valid_source_formats)
|
||||||
@@ -984,7 +1022,7 @@ def edit_book_series_index(series_index, book):
|
|||||||
def edit_book_comments(comments, book):
|
def edit_book_comments(comments, book):
|
||||||
modify_date = False
|
modify_date = False
|
||||||
if comments:
|
if comments:
|
||||||
comments = clean_html(comments)
|
comments = clean_string(comments, book.id)
|
||||||
if len(book.comments):
|
if len(book.comments):
|
||||||
if book.comments[0].text != comments:
|
if book.comments[0].text != comments:
|
||||||
book.comments[0].text = comments
|
book.comments[0].text = comments
|
||||||
@@ -1008,7 +1046,7 @@ def edit_book_languages(languages, book, upload_mode=False, invalid=None):
|
|||||||
if isinstance(invalid, list):
|
if isinstance(invalid, list):
|
||||||
invalid.append(lang)
|
invalid.append(lang)
|
||||||
else:
|
else:
|
||||||
raise ValueError(_(u"'%(langname)s' is not a valid language", langname=lang))
|
raise ValueError(_("'%(langname)s' is not a valid language", langname=lang))
|
||||||
# ToDo: Not working correct
|
# ToDo: Not working correct
|
||||||
if upload_mode and len(input_l) == 1:
|
if upload_mode and len(input_l) == 1:
|
||||||
# If the language of the file is excluded from the users view, it's not imported, to allow the user to view
|
# If the language of the file is excluded from the users view, it's not imported, to allow the user to view
|
||||||
@@ -1042,7 +1080,7 @@ def edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string):
|
|||||||
elif c.datatype == 'comments':
|
elif c.datatype == 'comments':
|
||||||
to_save[cc_string] = Markup(to_save[cc_string]).unescape()
|
to_save[cc_string] = Markup(to_save[cc_string]).unescape()
|
||||||
if to_save[cc_string]:
|
if to_save[cc_string]:
|
||||||
to_save[cc_string] = clean_html(to_save[cc_string])
|
to_save[cc_string] = clean_string(to_save[cc_string], book_id)
|
||||||
elif c.datatype == 'datetime':
|
elif c.datatype == 'datetime':
|
||||||
try:
|
try:
|
||||||
to_save[cc_string] = datetime.strptime(to_save[cc_string], "%Y-%m-%d")
|
to_save[cc_string] = datetime.strptime(to_save[cc_string], "%Y-%m-%d")
|
||||||
@@ -1119,9 +1157,10 @@ def edit_cc_data(book_id, book, to_save, cc):
|
|||||||
cc_db_value = None
|
cc_db_value = None
|
||||||
if to_save[cc_string].strip():
|
if to_save[cc_string].strip():
|
||||||
if c.datatype in ['int', 'bool', 'float', "datetime", "comments"]:
|
if c.datatype in ['int', 'bool', 'float', "datetime", "comments"]:
|
||||||
changed, to_save = edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string)
|
change, to_save = edit_cc_data_value(book_id, book, c, to_save, cc_db_value, cc_string)
|
||||||
else:
|
else:
|
||||||
changed, to_save = edit_cc_data_string(book, c, to_save, cc_db_value, cc_string)
|
change, to_save = edit_cc_data_string(book, c, to_save, cc_db_value, cc_string)
|
||||||
|
changed |= change
|
||||||
else:
|
else:
|
||||||
if cc_db_value is not None:
|
if cc_db_value is not None:
|
||||||
# remove old cc_val
|
# remove old cc_val
|
||||||
@@ -1146,15 +1185,20 @@ def edit_cc_data(book_id, book, to_save, cc):
|
|||||||
def upload_single_file(file_request, book, book_id):
|
def upload_single_file(file_request, book, book_id):
|
||||||
# Check and handle Uploaded file
|
# Check and handle Uploaded file
|
||||||
requested_file = file_request.files.get('btn-upload-format', None)
|
requested_file = file_request.files.get('btn-upload-format', None)
|
||||||
|
allowed_extensions = config.config_upload_formats.split(',')
|
||||||
if requested_file:
|
if requested_file:
|
||||||
|
if config.config_check_extensions and allowed_extensions != ['']:
|
||||||
|
if not validate_mime_type(requested_file, allowed_extensions):
|
||||||
|
flash(_("File type isn't allowed to be uploaded to this server"), category="error")
|
||||||
|
return False
|
||||||
# check for empty request
|
# check for empty request
|
||||||
if requested_file.filename != '':
|
if requested_file.filename != '':
|
||||||
if not current_user.role_upload():
|
if not current_user.role_upload():
|
||||||
flash(_(u"User has no rights to upload additional file formats"), category="error")
|
flash(_("User has no rights to upload additional file formats"), category="error")
|
||||||
return False
|
return False
|
||||||
if '.' in requested_file.filename:
|
if '.' in requested_file.filename:
|
||||||
file_ext = requested_file.filename.rsplit('.', 1)[-1].lower()
|
file_ext = requested_file.filename.rsplit('.', 1)[-1].lower()
|
||||||
if file_ext not in constants.EXTENSIONS_UPLOAD and '' not in constants.EXTENSIONS_UPLOAD:
|
if file_ext not in allowed_extensions and '' not in allowed_extensions:
|
||||||
flash(_("File extension '%(ext)s' is not allowed to be uploaded to this server", ext=file_ext),
|
flash(_("File extension '%(ext)s' is not allowed to be uploaded to this server", ext=file_ext),
|
||||||
category="error")
|
category="error")
|
||||||
return False
|
return False
|
||||||
@@ -1163,7 +1207,7 @@ def upload_single_file(file_request, book, book_id):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
file_name = book.path.rsplit('/', 1)[-1]
|
file_name = book.path.rsplit('/', 1)[-1]
|
||||||
filepath = os.path.normpath(os.path.join(config.config_calibre_dir, book.path))
|
filepath = os.path.normpath(os.path.join(config.get_book_path(), book.path))
|
||||||
saved_filename = os.path.join(filepath, file_name + '.' + file_ext)
|
saved_filename = os.path.join(filepath, file_name + '.' + file_ext)
|
||||||
|
|
||||||
# check if file path exists, otherwise create it, copy file to calibre path and delete temp file
|
# check if file path exists, otherwise create it, copy file to calibre path and delete temp file
|
||||||
@@ -1171,12 +1215,13 @@ def upload_single_file(file_request, book, book_id):
|
|||||||
try:
|
try:
|
||||||
os.makedirs(filepath)
|
os.makedirs(filepath)
|
||||||
except OSError:
|
except OSError:
|
||||||
flash(_(u"Failed to create path %(path)s (Permission denied).", path=filepath), category="error")
|
flash(_("Failed to create path %(path)s (Permission denied).", path=filepath),
|
||||||
|
category="error")
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
requested_file.save(saved_filename)
|
requested_file.save(saved_filename)
|
||||||
except OSError:
|
except OSError:
|
||||||
flash(_(u"Failed to store file %(file)s.", file=saved_filename), category="error")
|
flash(_("Failed to store file %(file)s.", file=saved_filename), category="error")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
file_size = os.path.getsize(saved_filename)
|
file_size = os.path.getsize(saved_filename)
|
||||||
@@ -1194,17 +1239,18 @@ def upload_single_file(file_request, book, book_id):
|
|||||||
except (OperationalError, IntegrityError, StaleDataError) as e:
|
except (OperationalError, IntegrityError, StaleDataError) as e:
|
||||||
calibre_db.session.rollback()
|
calibre_db.session.rollback()
|
||||||
log.error_or_exception("Database error: {}".format(e))
|
log.error_or_exception("Database error: {}".format(e))
|
||||||
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig if hasattr(e, "orig") else e),
|
||||||
|
category="error")
|
||||||
return False # return redirect(url_for('web.show_book', book_id=book.id))
|
return False # return redirect(url_for('web.show_book', book_id=book.id))
|
||||||
|
|
||||||
# Queue uploader info
|
# Queue uploader info
|
||||||
link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book.id), escape(book.title))
|
link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book.id), escape(book.title))
|
||||||
upload_text = N_(u"File format %(ext)s added to %(book)s", ext=file_ext.upper(), book=link)
|
upload_text = N_("File format %(ext)s added to %(book)s", ext=file_ext.upper(), book=link)
|
||||||
WorkerThread.add(current_user.name, TaskUpload(upload_text, escape(book.title)))
|
WorkerThread.add(current_user.name, TaskUpload(upload_text, escape(book.title)))
|
||||||
|
|
||||||
return uploader.process(
|
return uploader.process(
|
||||||
saved_filename, *os.path.splitext(requested_file.filename),
|
saved_filename, *os.path.splitext(requested_file.filename),
|
||||||
rarExecutable=config.config_rarfile_location)
|
rar_executable=config.config_rarfile_location)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -1214,7 +1260,7 @@ def upload_cover(cover_request, book):
|
|||||||
# check for empty request
|
# check for empty request
|
||||||
if requested_file.filename != '':
|
if requested_file.filename != '':
|
||||||
if not current_user.role_upload():
|
if not current_user.role_upload():
|
||||||
flash(_(u"User has no rights to upload cover"), category="error")
|
flash(_("User has no rights to upload cover"), category="error")
|
||||||
return False
|
return False
|
||||||
ret, message = helper.save_cover(requested_file, book.path)
|
ret, message = helper.save_cover(requested_file, book.path)
|
||||||
if ret is True:
|
if ret is True:
|
||||||
@@ -1238,18 +1284,18 @@ def handle_title_on_edit(book, book_title):
|
|||||||
|
|
||||||
|
|
||||||
def handle_author_on_edit(book, author_name, update_stored=True):
|
def handle_author_on_edit(book, author_name, update_stored=True):
|
||||||
|
change = False
|
||||||
# handle author(s)
|
# handle author(s)
|
||||||
input_authors, renamed = prepare_authors(author_name)
|
input_authors = prepare_authors(author_name, config.get_book_path(), config.config_use_google_drive)
|
||||||
|
|
||||||
change = modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author')
|
|
||||||
|
|
||||||
|
# change |= modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author')
|
||||||
# Search for each author if author is in database, if not, author name and sorted author name is generated new
|
# Search for each author if author is in database, if not, author name and sorted author name is generated new
|
||||||
# everything then is assembled for sorted author field in database
|
# everything then is assembled for sorted author field in database
|
||||||
sort_authors_list = list()
|
sort_authors_list = list()
|
||||||
for inp in input_authors:
|
for inp in input_authors:
|
||||||
stored_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == inp).first()
|
stored_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == inp).first()
|
||||||
if not stored_author:
|
if not stored_author:
|
||||||
stored_author = helper.get_sorted_author(inp)
|
stored_author = helper.get_sorted_author(inp.replace('|', ','))
|
||||||
else:
|
else:
|
||||||
stored_author = stored_author.sort
|
stored_author = stored_author.sort
|
||||||
sort_authors_list.append(helper.get_sorted_author(stored_author))
|
sort_authors_list.append(helper.get_sorted_author(stored_author))
|
||||||
@@ -1257,21 +1303,23 @@ def handle_author_on_edit(book, author_name, update_stored=True):
|
|||||||
if book.author_sort != sort_authors and update_stored:
|
if book.author_sort != sort_authors and update_stored:
|
||||||
book.author_sort = sort_authors
|
book.author_sort = sort_authors
|
||||||
change = True
|
change = True
|
||||||
return input_authors, change, renamed
|
|
||||||
|
change |= modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author')
|
||||||
|
|
||||||
|
return input_authors, change
|
||||||
|
|
||||||
|
|
||||||
def search_objects_remove(db_book_object, db_type, input_elements):
|
def search_objects_remove(db_book_object, db_type, input_elements):
|
||||||
del_elements = []
|
del_elements = []
|
||||||
for c_elements in db_book_object:
|
for c_elements in db_book_object:
|
||||||
found = False
|
found = False
|
||||||
if db_type == 'languages':
|
if db_type == 'custom':
|
||||||
type_elements = c_elements.lang_code
|
|
||||||
elif db_type == 'custom':
|
|
||||||
type_elements = c_elements.value
|
type_elements = c_elements.value
|
||||||
else:
|
else:
|
||||||
type_elements = c_elements.name
|
# type_elements = c_elements.name
|
||||||
|
type_elements = c_elements
|
||||||
for inp_element in input_elements:
|
for inp_element in input_elements:
|
||||||
if inp_element.lower() == type_elements.lower():
|
if type_elements == inp_element:
|
||||||
found = True
|
found = True
|
||||||
break
|
break
|
||||||
# if the element was not found in the new list, add it to remove list
|
# if the element was not found in the new list, add it to remove list
|
||||||
@@ -1285,13 +1333,11 @@ def search_objects_add(db_book_object, db_type, input_elements):
|
|||||||
for inp_element in input_elements:
|
for inp_element in input_elements:
|
||||||
found = False
|
found = False
|
||||||
for c_elements in db_book_object:
|
for c_elements in db_book_object:
|
||||||
if db_type == 'languages':
|
if db_type == 'custom':
|
||||||
type_elements = c_elements.lang_code
|
|
||||||
elif db_type == 'custom':
|
|
||||||
type_elements = c_elements.value
|
type_elements = c_elements.value
|
||||||
else:
|
else:
|
||||||
type_elements = c_elements.name
|
type_elements = c_elements
|
||||||
if inp_element == type_elements:
|
if type_elements == inp_element:
|
||||||
found = True
|
found = True
|
||||||
break
|
break
|
||||||
if not found:
|
if not found:
|
||||||
@@ -1307,6 +1353,7 @@ def remove_objects(db_book_object, db_session, del_elements):
|
|||||||
changed = True
|
changed = True
|
||||||
if len(del_element.books) == 0:
|
if len(del_element.books) == 0:
|
||||||
db_session.delete(del_element)
|
db_session.delete(del_element)
|
||||||
|
db_session.flush()
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
||||||
@@ -1320,27 +1367,34 @@ def add_objects(db_book_object, db_object, db_session, db_type, add_elements):
|
|||||||
db_filter = db_object.name
|
db_filter = db_object.name
|
||||||
for add_element in add_elements:
|
for add_element in add_elements:
|
||||||
# check if an element with that name exists
|
# check if an element with that name exists
|
||||||
db_element = db_session.query(db_object).filter(db_filter == add_element).first()
|
changed = True
|
||||||
|
# db_session.query(db.Tags).filter((func.lower(db.Tags.name).ilike("GênOt"))).all()
|
||||||
|
db_element = db_session.query(db_object).filter((func.lower(db_filter).ilike(add_element))).first()
|
||||||
|
# db_element = db_session.query(db_object).filter(func.lower(db_filter) == add_element.lower()).first()
|
||||||
# if no element is found add it
|
# if no element is found add it
|
||||||
if db_type == 'author':
|
|
||||||
new_element = db_object(add_element, helper.get_sorted_author(add_element.replace('|', ',')), "")
|
|
||||||
elif db_type == 'series':
|
|
||||||
new_element = db_object(add_element, add_element)
|
|
||||||
elif db_type == 'custom':
|
|
||||||
new_element = db_object(value=add_element)
|
|
||||||
elif db_type == 'publisher':
|
|
||||||
new_element = db_object(add_element, None)
|
|
||||||
else: # db_type should be tag or language
|
|
||||||
new_element = db_object(add_element)
|
|
||||||
if db_element is None:
|
if db_element is None:
|
||||||
changed = True
|
if db_type == 'author':
|
||||||
|
new_element = db_object(add_element, helper.get_sorted_author(add_element.replace('|', ',')))
|
||||||
|
elif db_type == 'series':
|
||||||
|
new_element = db_object(add_element, add_element)
|
||||||
|
elif db_type == 'custom':
|
||||||
|
new_element = db_object(value=add_element)
|
||||||
|
elif db_type == 'publisher':
|
||||||
|
new_element = db_object(add_element, None)
|
||||||
|
else: # db_type should be tag or language
|
||||||
|
new_element = db_object(add_element)
|
||||||
db_session.add(new_element)
|
db_session.add(new_element)
|
||||||
db_book_object.append(new_element)
|
db_book_object.append(new_element)
|
||||||
else:
|
else:
|
||||||
db_element = create_objects_for_addition(db_element, add_element, db_type)
|
db_no_case = db_session.query(db_object).filter(db_filter == add_element).first()
|
||||||
|
if db_no_case:
|
||||||
|
# check for new case of element
|
||||||
|
db_element = create_objects_for_addition(db_element, add_element, db_type)
|
||||||
|
#else:
|
||||||
|
# db_element = create_objects_for_addition(db_element, add_element, db_type)
|
||||||
# add element to book
|
# add element to book
|
||||||
changed = True
|
|
||||||
db_book_object.append(db_element)
|
db_book_object.append(db_element)
|
||||||
|
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
||||||
@@ -1375,13 +1429,24 @@ def modify_database_object(input_elements, db_book_object, db_object, db_session
|
|||||||
if not isinstance(input_elements, list):
|
if not isinstance(input_elements, list):
|
||||||
raise TypeError(str(input_elements) + " should be passed as a list")
|
raise TypeError(str(input_elements) + " should be passed as a list")
|
||||||
input_elements = [x for x in input_elements if x != '']
|
input_elements = [x for x in input_elements if x != '']
|
||||||
# we have all input element (authors, series, tags) names now
|
|
||||||
|
changed = False
|
||||||
|
# If elements are renamed (upper lower case), rename it
|
||||||
|
for rec_a, rec_b in zip(db_book_object, input_elements):
|
||||||
|
if db_type == "custom":
|
||||||
|
if rec_a.value.casefold() == rec_b.casefold() and rec_a.value != rec_b:
|
||||||
|
create_objects_for_addition(rec_a, rec_b, db_type)
|
||||||
|
else:
|
||||||
|
if rec_a.get().casefold() == rec_b.casefold() and rec_a.get() != rec_b:
|
||||||
|
create_objects_for_addition(rec_a, rec_b, db_type)
|
||||||
|
# we have all input element (authors, series, tags) names now
|
||||||
# 1. search for elements to remove
|
# 1. search for elements to remove
|
||||||
del_elements = search_objects_remove(db_book_object, db_type, input_elements)
|
del_elements = search_objects_remove(db_book_object, db_type, input_elements)
|
||||||
# 2. search for elements that need to be added
|
# 2. search for elements that need to be added
|
||||||
add_elements = search_objects_add(db_book_object, db_type, input_elements)
|
add_elements = search_objects_add(db_book_object, db_type, input_elements)
|
||||||
|
|
||||||
# if there are elements to remove, we remove them now
|
# if there are elements to remove, we remove them now
|
||||||
changed = remove_objects(db_book_object, db_session, del_elements)
|
changed |= remove_objects(db_book_object, db_session, del_elements)
|
||||||
# if there are elements to add, we add them now!
|
# if there are elements to add, we add them now!
|
||||||
if len(add_elements) > 0:
|
if len(add_elements) > 0:
|
||||||
changed |= add_objects(db_book_object, db_object, db_session, db_type, add_elements)
|
changed |= add_objects(db_book_object, db_object, db_session, db_type, add_elements)
|
||||||
|
|||||||
@@ -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 ""
|
||||||
@@ -21,25 +21,48 @@ import zipfile
|
|||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
from . import isoLanguages, cover
|
from . import isoLanguages, cover
|
||||||
|
from . import config, logger
|
||||||
from .helper import split_authors
|
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 _extract_cover(zip_file, cover_file, cover_path, tmp_file_name):
|
def _extract_cover(zip_file, cover_file, cover_path, tmp_file_name):
|
||||||
if cover_file is None:
|
if cover_file is None:
|
||||||
return None
|
return None
|
||||||
else:
|
|
||||||
cf = extension = None
|
|
||||||
zip_cover_path = os.path.join(cover_path, cover_file).replace('\\', '/')
|
|
||||||
|
|
||||||
prefix = os.path.splitext(tmp_file_name)[0]
|
cf = extension = None
|
||||||
tmp_cover_name = prefix + '.' + os.path.basename(zip_cover_path)
|
zip_cover_path = os.path.join(cover_path, cover_file).replace('\\', '/')
|
||||||
ext = os.path.splitext(tmp_cover_name)
|
|
||||||
if len(ext) > 1:
|
prefix = os.path.splitext(tmp_file_name)[0]
|
||||||
extension = ext[1].lower()
|
tmp_cover_name = prefix + '.' + os.path.basename(zip_cover_path)
|
||||||
if extension in cover.COVER_EXTENSIONS:
|
ext = os.path.splitext(tmp_cover_name)
|
||||||
cf = zip_file.read(zip_cover_path)
|
if len(ext) > 1:
|
||||||
return cover.cover_processing(tmp_file_name, cf, extension)
|
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
|
||||||
|
else:
|
||||||
|
return layout[0]
|
||||||
|
|
||||||
|
|
||||||
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):
|
||||||
@@ -49,13 +72,7 @@ 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/'
|
||||||
}
|
}
|
||||||
|
|
||||||
epub_zip = zipfile.ZipFile(tmp_file_path)
|
tree, cf_name = get_content_opf(tmp_file_path, ns)
|
||||||
|
|
||||||
txt = epub_zip.read('META-INF/container.xml')
|
|
||||||
tree = etree.fromstring(txt)
|
|
||||||
cf_name = tree.xpath('n:rootfiles/n:rootfile/@full-path', namespaces=ns)[0]
|
|
||||||
cf = epub_zip.read(cf_name)
|
|
||||||
tree = etree.fromstring(cf)
|
|
||||||
|
|
||||||
cover_path = os.path.dirname(cf_name)
|
cover_path = os.path.dirname(cf_name)
|
||||||
|
|
||||||
@@ -73,20 +90,20 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
|
|||||||
elif s == 'date':
|
elif s == 'date':
|
||||||
epub_metadata[s] = tmp[0][:10]
|
epub_metadata[s] = tmp[0][:10]
|
||||||
else:
|
else:
|
||||||
epub_metadata[s] = tmp[0]
|
epub_metadata[s] = tmp[0].strip()
|
||||||
else:
|
else:
|
||||||
epub_metadata[s] = 'Unknown'
|
epub_metadata[s] = 'Unknown'
|
||||||
|
|
||||||
if epub_metadata['subject'] == 'Unknown':
|
if epub_metadata['subject'] == 'Unknown':
|
||||||
epub_metadata['subject'] = ''
|
epub_metadata['subject'] = ''
|
||||||
|
|
||||||
if epub_metadata['publisher'] == u'Unknown':
|
if epub_metadata['publisher'] == 'Unknown':
|
||||||
epub_metadata['publisher'] = ''
|
epub_metadata['publisher'] = ''
|
||||||
|
|
||||||
if epub_metadata['date'] == u'Unknown':
|
if epub_metadata['date'] == 'Unknown':
|
||||||
epub_metadata['date'] = ''
|
epub_metadata['date'] = ''
|
||||||
|
|
||||||
if epub_metadata['description'] == u'Unknown':
|
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
|
||||||
@@ -98,15 +115,19 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
|
|||||||
|
|
||||||
epub_metadata = parse_epub_series(ns, tree, epub_metadata)
|
epub_metadata = parse_epub_series(ns, tree, epub_metadata)
|
||||||
|
|
||||||
|
epub_zip = zipfile.ZipFile(tmp_file_path)
|
||||||
cover_file = parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path)
|
cover_file = parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path)
|
||||||
|
|
||||||
identifiers = []
|
identifiers = []
|
||||||
for node in p.xpath('dc:identifier', namespaces=ns):
|
for node in p.xpath('dc:identifier', namespaces=ns):
|
||||||
identifier_name=node.attrib.values()[-1];
|
try:
|
||||||
identifier_value=node.text;
|
identifier_name = node.attrib.values()[-1]
|
||||||
if identifier_name in ('uuid','calibre'):
|
except IndexError:
|
||||||
continue;
|
continue
|
||||||
identifiers.append( [identifier_name, identifier_value] )
|
identifier_value = node.text
|
||||||
|
if identifier_name in ('uuid', 'calibre') or identifier_value is None:
|
||||||
|
continue
|
||||||
|
identifiers.append([identifier_name, identifier_value])
|
||||||
|
|
||||||
if not epub_metadata['title']:
|
if not epub_metadata['title']:
|
||||||
title = original_file_name
|
title = original_file_name
|
||||||
@@ -131,40 +152,40 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
|
|||||||
|
|
||||||
def parse_epub_cover(ns, tree, epub_zip, cover_path, tmp_file_path):
|
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)
|
cover_section = tree.xpath("/pkg:package/pkg:manifest/pkg:item[@id='cover-image']/@href", namespaces=ns)
|
||||||
cover_file = None
|
|
||||||
# if len(cover_section) > 0:
|
|
||||||
for cs in cover_section:
|
for cs in cover_section:
|
||||||
cover_file = _extract_cover(epub_zip, cs, cover_path, tmp_file_path)
|
cover_file = _extract_cover(epub_zip, cs, cover_path, tmp_file_path)
|
||||||
if cover_file:
|
if cover_file:
|
||||||
break
|
return cover_file
|
||||||
if not cover_file:
|
|
||||||
meta_cover = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='cover']/@content", namespaces=ns)
|
meta_cover = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='cover']/@content", namespaces=ns)
|
||||||
if len(meta_cover) > 0:
|
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(
|
cover_section = tree.xpath(
|
||||||
"/pkg:package/pkg:manifest/pkg:item[@id='"+meta_cover[0]+"']/@href", namespaces=ns)
|
"/pkg:package/pkg:manifest/pkg:item[@properties='" + meta_cover[0] + "']/@href", namespaces=ns)
|
||||||
if not cover_section:
|
else:
|
||||||
cover_section = tree.xpath(
|
cover_section = tree.xpath("/pkg:package/pkg:guide/pkg:reference/@href", namespaces=ns)
|
||||||
"/pkg:package/pkg:manifest/pkg:item[@properties='" + meta_cover[0] + "']/@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:
|
else:
|
||||||
cover_section = tree.xpath("/pkg:package/pkg:guide/pkg:reference/@href", namespaces=ns)
|
cover_file = _extract_cover(epub_zip, cs, cover_path, tmp_file_path)
|
||||||
for cs in cover_section:
|
if cover_file:
|
||||||
filetype = cs.rsplit('.', 1)[-1]
|
break
|
||||||
if filetype == "xhtml" or filetype == "html": # if cover is (x)html format
|
|
||||||
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
|
return cover_file
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# -*- 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=None):
|
||||||
|
if ns is None:
|
||||||
|
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')
|
||||||
|
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ from . import config, app, logger, services
|
|||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
# custom error page
|
# custom error page
|
||||||
|
|
||||||
def error_http(error):
|
def error_http(error):
|
||||||
return render_template('http_error.html',
|
return render_template('http_error.html',
|
||||||
error_code="Error {0}".format(error.code),
|
error_code="Error {0}".format(error.code),
|
||||||
@@ -52,6 +53,7 @@ def internal_error(error):
|
|||||||
instance=config.config_calibre_web_title
|
instance=config.config_calibre_web_title
|
||||||
), 500
|
), 500
|
||||||
|
|
||||||
|
|
||||||
def init_errorhandler():
|
def init_errorhandler():
|
||||||
# http error handling
|
# http error handling
|
||||||
for ex in default_exceptions:
|
for ex in default_exceptions:
|
||||||
@@ -60,7 +62,6 @@ def init_errorhandler():
|
|||||||
elif ex == 500:
|
elif ex == 500:
|
||||||
app.register_error_handler(ex, internal_error)
|
app.register_error_handler(ex, internal_error)
|
||||||
|
|
||||||
|
|
||||||
if services.ldap:
|
if services.ldap:
|
||||||
# Only way of catching the LDAPException upon logging in with LDAP server down
|
# Only way of catching the LDAPException upon logging in with LDAP server down
|
||||||
@app.errorhandler(services.ldap.LDAPException)
|
@app.errorhandler(services.ldap.LDAPException)
|
||||||
|
|||||||
@@ -38,19 +38,19 @@ def get_fb2_info(tmp_file_path, original_file_extension):
|
|||||||
if len(last_name):
|
if len(last_name):
|
||||||
last_name = last_name[0]
|
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]
|
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]
|
first_name = first_name[0]
|
||||||
else:
|
else:
|
||||||
first_name = u''
|
first_name = ''
|
||||||
return (first_name + u' '
|
return (first_name + ' '
|
||||||
+ middle_name + u' '
|
+ middle_name + ' '
|
||||||
+ last_name)
|
+ last_name)
|
||||||
|
|
||||||
author = str(", ".join(map(get_author, authors)))
|
author = str(", ".join(map(get_author, authors)))
|
||||||
@@ -59,12 +59,12 @@ def get_fb2_info(tmp_file_path, original_file_extension):
|
|||||||
if len(title):
|
if len(title):
|
||||||
title = str(title[0])
|
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])
|
description = str(description[0])
|
||||||
else:
|
else:
|
||||||
description = u''
|
description = ''
|
||||||
|
|
||||||
return BookMeta(
|
return BookMeta(
|
||||||
file_path=tmp_file_path,
|
file_path=tmp_file_path,
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# -*- 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
|
||||||
|
import zipfile
|
||||||
|
import mimetypes
|
||||||
|
import copy
|
||||||
|
from io import BytesIO
|
||||||
|
try:
|
||||||
|
import magic
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
from . import logger
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_mime_type(file_buffer, allowed_extensions):
|
||||||
|
mime = magic.Magic(mime=True)
|
||||||
|
allowed_mimetypes =list()
|
||||||
|
for x in allowed_extensions:
|
||||||
|
try:
|
||||||
|
allowed_mimetypes.append(mimetypes.types_map["." + x])
|
||||||
|
except KeyError as e:
|
||||||
|
log.error("Unkown mimetype for Extension: {}".format(x))
|
||||||
|
tmp_mime_type = mime.from_buffer(file_buffer.read())
|
||||||
|
file_buffer.seek(0)
|
||||||
|
if any(mime_type in tmp_mime_type for mime_type in allowed_mimetypes):
|
||||||
|
return True
|
||||||
|
# Some epubs show up as zip mimetypes
|
||||||
|
elif "zip" in tmp_mime_type:
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(BytesIO(file_buffer.read()), 'r') as epub:
|
||||||
|
file_buffer.seek(0)
|
||||||
|
if "mimetype" in epub.namelist():
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
file_buffer.seek(0)
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
@@ -23,7 +23,6 @@
|
|||||||
import os
|
import os
|
||||||
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,6 +33,7 @@ from flask_login import login_required
|
|||||||
|
|
||||||
from . import logger, gdriveutils, config, ub, calibre_db, csrf
|
from . import logger, gdriveutils, config, ub, calibre_db, csrf
|
||||||
from .admin import admin_required
|
from .admin import admin_required
|
||||||
|
from .file_helper import get_temp_dir
|
||||||
|
|
||||||
gdrive = Blueprint('gdrive', __name__, url_prefix='/gdrive')
|
gdrive = Blueprint('gdrive', __name__, url_prefix='/gdrive')
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
@@ -45,7 +45,7 @@ except ImportError as err:
|
|||||||
|
|
||||||
current_milli_time = lambda: int(round(time() * 1000))
|
current_milli_time = lambda: int(round(time() * 1000))
|
||||||
|
|
||||||
gdrive_watch_callback_token = 'target=calibreweb-watch_files' #nosec
|
gdrive_watch_callback_token = 'target=calibreweb-watch_files' # nosec
|
||||||
|
|
||||||
|
|
||||||
@gdrive.route("/authenticate")
|
@gdrive.route("/authenticate")
|
||||||
@@ -55,7 +55,7 @@ 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)
|
||||||
@@ -86,14 +86,15 @@ def watch_gdrive():
|
|||||||
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 = result
|
config.config_google_drive_watch_changes_response = result
|
||||||
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, '
|
flash(_('Callback domain is not verified, '
|
||||||
u'please follow steps to verify domain in google developer console'), category="error")
|
'please follow steps to verify domain in google developer console'), category="error")
|
||||||
else:
|
else:
|
||||||
flash(reason['message'], category="error")
|
flash(reason['message'], category="error")
|
||||||
|
|
||||||
@@ -115,6 +116,7 @@ def revoke_watch_gdrive():
|
|||||||
config.save()
|
config.save()
|
||||||
return redirect(url_for('admin.db_configuration'))
|
return redirect(url_for('admin.db_configuration'))
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@csrf.exempt
|
@csrf.exempt
|
||||||
@gdrive.route("/watch/callback", methods=['GET', 'POST'])
|
@gdrive.route("/watch/callback", methods=['GET', 'POST'])
|
||||||
@@ -138,10 +140,8 @@ try:
|
|||||||
if response:
|
if response:
|
||||||
dbpath = os.path.join(config.config_calibre_dir, "metadata.db").encode()
|
dbpath = os.path.join(config.config_calibre_dir, "metadata.db").encode()
|
||||||
if not response['deleted'] and response['file']['title'] == 'metadata.db' \
|
if not response['deleted'] and response['file']['title'] == 'metadata.db' \
|
||||||
and response['file']['md5Checksum'] != hashlib.md5(dbpath): # nosec
|
and response['file']['md5Checksum'] != hashlib.md5(dbpath): # nosec
|
||||||
tmp_dir = os.path.join(tempfile.gettempdir(), 'calibre_web')
|
tmp_dir = get_temp_dir()
|
||||||
if not os.path.isdir(tmp_dir):
|
|
||||||
os.mkdir(tmp_dir)
|
|
||||||
|
|
||||||
log.info('Database file updated')
|
log.info('Database file updated')
|
||||||
copyfile(dbpath, os.path.join(tmp_dir, "metadata.db_" + str(current_milli_time())))
|
copyfile(dbpath, os.path.join(tmp_dir, "metadata.db_" + str(current_milli_time())))
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ import json
|
|||||||
import shutil
|
import shutil
|
||||||
import chardet
|
import chardet
|
||||||
import ssl
|
import ssl
|
||||||
|
import sqlite3
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
|
from werkzeug.datastructures import Headers
|
||||||
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
|
||||||
@@ -34,7 +37,6 @@ except ImportError:
|
|||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.exc import OperationalError, InvalidRequestError, IntegrityError
|
from sqlalchemy.exc import OperationalError, InvalidRequestError, IntegrityError
|
||||||
from sqlalchemy.orm.exc import StaleDataError
|
from sqlalchemy.orm.exc import StaleDataError
|
||||||
from sqlalchemy.sql.expression import text
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from httplib2 import __version__ as httplib2_version
|
from httplib2 import __version__ as httplib2_version
|
||||||
@@ -64,7 +66,7 @@ except ImportError as err:
|
|||||||
importError = err
|
importError = err
|
||||||
gdrive_support = False
|
gdrive_support = False
|
||||||
|
|
||||||
from . import logger, cli_param, config
|
from . import logger, cli_param, config, db
|
||||||
from .constants import CONFIG_DIR as _CONFIG_DIR
|
from .constants import CONFIG_DIR as _CONFIG_DIR
|
||||||
|
|
||||||
|
|
||||||
@@ -147,7 +149,7 @@ 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)
|
||||||
|
|
||||||
@@ -174,30 +176,12 @@ class PermissionAdded(Base):
|
|||||||
return str(self.gdrive_id)
|
return str(self.gdrive_id)
|
||||||
|
|
||||||
|
|
||||||
def migrate():
|
|
||||||
if not engine.dialect.has_table(engine.connect(), "permissions_added"):
|
|
||||||
PermissionAdded.__table__.create(bind = engine)
|
|
||||||
for sql in session.execute(text("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_param.gd_path):
|
if not os.path.exists(cli_param.gd_path):
|
||||||
try:
|
try:
|
||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.error("Error connect to database: {} - {}".format(cli_param.gd_path, 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):
|
||||||
@@ -226,6 +210,7 @@ def getDrive(drive=None, gauth=None):
|
|||||||
log.error("Google Drive error: {}".format(e))
|
log.error("Google Drive error: {}".format(e))
|
||||||
return drive
|
return drive
|
||||||
|
|
||||||
|
|
||||||
def listRootFolders():
|
def listRootFolders():
|
||||||
try:
|
try:
|
||||||
drive = getDrive(Gdrive.Instance().drive)
|
drive = getDrive(Gdrive.Instance().drive)
|
||||||
@@ -243,7 +228,7 @@ def getEbooksFolder(drive):
|
|||||||
|
|
||||||
def getFolderInFolder(parentId, folderName, drive):
|
def getFolderInFolder(parentId, folderName, drive):
|
||||||
# drive = getDrive(drive)
|
# drive = getDrive(drive)
|
||||||
query=""
|
query = ""
|
||||||
if folderName:
|
if folderName:
|
||||||
query = "title = '%s' and " % folderName.replace("'", r"\'")
|
query = "title = '%s' and " % folderName.replace("'", r"\'")
|
||||||
folder = query + "'%s' in parents and mimeType = 'application/vnd.google-apps.folder'" \
|
folder = query + "'%s' in parents and mimeType = 'application/vnd.google-apps.folder'" \
|
||||||
@@ -254,6 +239,7 @@ def getFolderInFolder(parentId, folderName, drive):
|
|||||||
else:
|
else:
|
||||||
return fileList[0]
|
return fileList[0]
|
||||||
|
|
||||||
|
|
||||||
# Search for id of root folder in gdrive database, if not found request from gdrive and store in internal database
|
# Search for id of root folder in gdrive database, if not found request from gdrive and store in internal database
|
||||||
def getEbooksFolderId(drive=None):
|
def getEbooksFolderId(drive=None):
|
||||||
storedPathName = session.query(GdriveId).filter(GdriveId.path == '/').first()
|
storedPathName = session.query(GdriveId).filter(GdriveId.path == '/').first()
|
||||||
@@ -275,17 +261,20 @@ def getEbooksFolderId(drive=None):
|
|||||||
return gDriveId.gdrive_id
|
return gDriveId.gdrive_id
|
||||||
|
|
||||||
|
|
||||||
def getFile(pathId, fileName, drive):
|
def getFile(pathId, fileName, drive, nocase):
|
||||||
metaDataFile = "'%s' in parents and trashed = false and title = '%s'" % (pathId, fileName.replace("'", r"\'"))
|
metaDataFile = "'%s' in parents and trashed = false and title contains '%s'" % (pathId, fileName.replace("'", r"\'"))
|
||||||
fileList = drive.ListFile({'q': metaDataFile}).GetList()
|
fileList = drive.ListFile({'q': metaDataFile}).GetList()
|
||||||
if fileList.__len__() == 0:
|
if fileList.__len__() == 0:
|
||||||
return None
|
return None
|
||||||
else:
|
if nocase:
|
||||||
return fileList[0]
|
return fileList[0] if db.lcase(fileList[0]['title']) == db.lcase(fileName) else None
|
||||||
|
for f in fileList:
|
||||||
|
if f['title'] == fileName:
|
||||||
|
return f
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def getFolderId(path, drive):
|
def getFolderId(path, drive):
|
||||||
# drive = getDrive(drive)
|
|
||||||
currentFolderId = None
|
currentFolderId = None
|
||||||
try:
|
try:
|
||||||
currentFolderId = getEbooksFolderId(drive)
|
currentFolderId = getEbooksFolderId(drive)
|
||||||
@@ -319,7 +308,7 @@ def getFolderId(path, drive):
|
|||||||
session.commit()
|
session.commit()
|
||||||
else:
|
else:
|
||||||
currentFolderId = storedPathName.gdrive_id
|
currentFolderId = storedPathName.gdrive_id
|
||||||
except (OperationalError, IntegrityError, StaleDataError) as ex:
|
except (OperationalError, IntegrityError, StaleDataError, sqlite3.IntegrityError) as ex:
|
||||||
log.error_or_exception('Database error: {}'.format(ex))
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
session.rollback()
|
session.rollback()
|
||||||
except ApiRequestError as ex:
|
except ApiRequestError as ex:
|
||||||
@@ -330,7 +319,7 @@ def getFolderId(path, drive):
|
|||||||
return currentFolderId
|
return currentFolderId
|
||||||
|
|
||||||
|
|
||||||
def getFileFromEbooksFolder(path, fileName):
|
def getFileFromEbooksFolder(path, fileName, nocase=False):
|
||||||
drive = getDrive(Gdrive.Instance().drive)
|
drive = getDrive(Gdrive.Instance().drive)
|
||||||
if path:
|
if path:
|
||||||
# sqlCheckPath=path if path[-1] =='/' else path + '/'
|
# sqlCheckPath=path if path[-1] =='/' else path + '/'
|
||||||
@@ -338,13 +327,13 @@ def getFileFromEbooksFolder(path, fileName):
|
|||||||
else:
|
else:
|
||||||
folderId = getEbooksFolderId(drive)
|
folderId = getEbooksFolderId(drive)
|
||||||
if folderId:
|
if folderId:
|
||||||
return getFile(folderId, fileName, drive)
|
return getFile(folderId, fileName, drive, nocase)
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
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()
|
||||||
|
|
||||||
|
|
||||||
@@ -354,33 +343,37 @@ def downloadFile(path, filename, output):
|
|||||||
f.GetContentFile(output)
|
f.GetContentFile(output)
|
||||||
|
|
||||||
|
|
||||||
def moveGdriveFolderRemote(origin_file, target_folder):
|
def moveGdriveFolderRemote(origin_file, target_folder, single_book=False):
|
||||||
drive = getDrive(Gdrive.Instance().drive)
|
drive = getDrive(Gdrive.Instance().drive)
|
||||||
previous_parents = ",".join([parent["id"] for parent in origin_file.get('parents')])
|
previous_parents = ",".join([parent["id"] for parent in origin_file.get('parents')])
|
||||||
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)
|
if single_book:
|
||||||
if not gFileTargetDir:
|
gFileTargetDir = getFileFromEbooksFolder(None, target_folder, nocase=True)
|
||||||
gFileTargetDir = drive.CreateFile(
|
if gFileTargetDir:
|
||||||
{'title': target_folder, 'parents': [{"kind": "drive#fileLink", 'id': getEbooksFolderId()}],
|
# Move the file to the new folder
|
||||||
"mimeType": "application/vnd.google-apps.folder"})
|
drive.auth.service.files().update(fileId=origin_file['id'],
|
||||||
gFileTargetDir.Upload()
|
addParents=gFileTargetDir['id'],
|
||||||
# Move the file to the new folder
|
removeParents=previous_parents,
|
||||||
drive.auth.service.files().update(fileId=origin_file['id'],
|
fields='id, parents').execute()
|
||||||
addParents=gFileTargetDir['id'],
|
else:
|
||||||
removeParents=previous_parents,
|
gFileTargetDir = drive.CreateFile(
|
||||||
fields='id, parents').execute()
|
{'title': target_folder, 'parents': [{"kind": "drive#fileLink", 'id': getEbooksFolderId()}],
|
||||||
|
"mimeType": "application/vnd.google-apps.folder"})
|
||||||
elif gFileTargetDir['title'] != target_folder:
|
gFileTargetDir.Upload()
|
||||||
|
# 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()
|
||||||
|
elif origin_file['title'] != target_folder:
|
||||||
|
#gFileTargetDir = getFileFromEbooksFolder(None, target_folder, nocase=True)
|
||||||
|
#if gFileTargetDir:
|
||||||
|
deleteDatabasePath(origin_file['title'])
|
||||||
# Folder is not existing, create, and move folder
|
# Folder is not existing, create, and move folder
|
||||||
drive.auth.service.files().patch(fileId=origin_file['id'],
|
drive.auth.service.files().patch(fileId=origin_file['id'],
|
||||||
body={'title': target_folder},
|
body={'title': target_folder},
|
||||||
fields='title').execute()
|
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 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)
|
||||||
@@ -388,20 +381,20 @@ def moveGdriveFolderRemote(origin_file, target_folder):
|
|||||||
|
|
||||||
|
|
||||||
def copyToDrive(drive, uploadFile, createRoot, replaceFiles,
|
def copyToDrive(drive, uploadFile, createRoot, replaceFiles,
|
||||||
ignoreFiles=None,
|
ignoreFiles=None,
|
||||||
parent=None, prevDir=''):
|
parent=None, prevDir=''):
|
||||||
ignoreFiles = ignoreFiles or []
|
ignoreFiles = ignoreFiles or []
|
||||||
drive = getDrive(drive)
|
drive = getDrive(drive)
|
||||||
isInitial = not bool(parent)
|
isInitial = not bool(parent)
|
||||||
if not parent:
|
if not parent:
|
||||||
parent = getEbooksFolder(drive)
|
parent = getEbooksFolder(drive)
|
||||||
if os.path.isdir(os.path.join(prevDir,uploadFile)):
|
if os.path.isdir(os.path.join(prevDir, uploadFile)):
|
||||||
existingFolder = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" %
|
existingFolder = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" %
|
||||||
(os.path.basename(uploadFile).replace("'", r"\'"), parent['id'])}).GetList()
|
(os.path.basename(uploadFile).replace("'", r"\'"), parent['id'])}).GetList()
|
||||||
if len(existingFolder) == 0 and (not isInitial or createRoot):
|
if len(existingFolder) == 0 and (not isInitial or createRoot):
|
||||||
parent = drive.CreateFile({'title': os.path.basename(uploadFile),
|
parent = drive.CreateFile({'title': os.path.basename(uploadFile),
|
||||||
'parents': [{"kind": "drive#fileLink", 'id': parent['id']}],
|
'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:
|
||||||
if (not isInitial or createRoot) and len(existingFolder) > 0:
|
if (not isInitial or createRoot) and len(existingFolder) > 0:
|
||||||
@@ -417,39 +410,42 @@ def copyToDrive(drive, uploadFile, createRoot, replaceFiles,
|
|||||||
driveFile = existingFiles[0]
|
driveFile = existingFiles[0]
|
||||||
else:
|
else:
|
||||||
driveFile = drive.CreateFile({'title': os.path.basename(uploadFile).replace("'", r"\'"),
|
driveFile = drive.CreateFile({'title': os.path.basename(uploadFile).replace("'", r"\'"),
|
||||||
'parents': [{"kind":"drive#fileLink", 'id': parent['id']}], })
|
'parents': [{"kind": "drive#fileLink", 'id': parent['id']}], })
|
||||||
driveFile.SetContentFile(os.path.join(prevDir, uploadFile))
|
driveFile.SetContentFile(os.path.join(prevDir, uploadFile))
|
||||||
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:
|
||||||
existing_Files = 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(existing_Files) > 0:
|
if len(existing_Files) > 0:
|
||||||
driveFile = existing_Files[0]
|
driveFile = existing_Files[0]
|
||||||
else:
|
else:
|
||||||
driveFile = drive.CreateFile({'title': x,
|
driveFile = drive.CreateFile({'title': x,
|
||||||
'parents': [{"kind": "drive#fileLink", 'id': parent['id']}], })
|
'parents': [{"kind": "drive#fileLink", 'id': parent['id']}], })
|
||||||
driveFile.SetContentFile(f)
|
if not string:
|
||||||
|
driveFile.SetContentFile(f)
|
||||||
|
else:
|
||||||
|
driveFile.SetContentString(f)
|
||||||
driveFile.Upload()
|
driveFile.Upload()
|
||||||
else:
|
else:
|
||||||
existing_Folder = 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(existing_Folder) == 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 = existing_Folder[0]
|
parent = existing_Folder[0]
|
||||||
|
|
||||||
|
|
||||||
def watchChange(drive, channel_id, channel_type, channel_address,
|
def watchChange(drive, channel_id, channel_type, channel_address,
|
||||||
channel_token=None, expiration=None):
|
channel_token=None, expiration=None):
|
||||||
# Watch for all changes to a user's Drive.
|
# Watch for all changes to a user's Drive.
|
||||||
# Args:
|
# Args:
|
||||||
# service: Drive API service instance.
|
# service: Drive API service instance.
|
||||||
@@ -520,7 +516,7 @@ def stopChannel(drive, channel_id, resource_id):
|
|||||||
return drive.auth.service.channels().stop(body=body).execute()
|
return drive.auth.service.channels().stop(body=body).execute()
|
||||||
|
|
||||||
|
|
||||||
def getChangeById (drive, change_id):
|
def getChangeById(drive, change_id):
|
||||||
# Print a single Change resource information.
|
# Print a single Change resource information.
|
||||||
#
|
#
|
||||||
# Args:
|
# Args:
|
||||||
@@ -554,9 +550,10 @@ def updateGdriveCalibreFromLocal():
|
|||||||
if os.path.isdir(os.path.join(config.config_calibre_dir, x)):
|
if os.path.isdir(os.path.join(config.config_calibre_dir, x)):
|
||||||
shutil.rmtree(os.path.join(config.config_calibre_dir, x))
|
shutil.rmtree(os.path.join(config.config_calibre_dir, x))
|
||||||
|
|
||||||
|
|
||||||
# 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
|
||||||
@@ -576,8 +573,17 @@ def deleteDatabaseEntry(ID):
|
|||||||
log.error_or_exception('Database error: {}'.format(ex))
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
session.rollback()
|
session.rollback()
|
||||||
|
|
||||||
|
def deleteDatabasePath(Pathname):
|
||||||
|
session.query(GdriveId).filter(GdriveId.path.contains(Pathname)).delete()
|
||||||
|
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:
|
||||||
@@ -591,6 +597,33 @@ 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)
|
||||||
|
try:
|
||||||
|
session.commit()
|
||||||
|
except (OperationalError, IntegrityError) as ex:
|
||||||
|
log.error_or_exception('Database error: {}'.format(ex))
|
||||||
|
session.rollback()
|
||||||
|
headers = Headers()
|
||||||
|
headers["Content-Type"] = 'image/jpeg'
|
||||||
|
resp, content = df.auth.Get_Http_Object().request(df.metadata.get('downloadUrl'), headers=headers)
|
||||||
|
return content
|
||||||
|
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:
|
try:
|
||||||
session.commit()
|
session.commit()
|
||||||
except OperationalError as ex:
|
except OperationalError as ex:
|
||||||
@@ -600,6 +633,7 @@ def get_cover_via_gdrive(cover_path):
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Creates chunks for downloading big files
|
# Creates chunks for downloading big files
|
||||||
def partial(total_byte_len, part_size_limit):
|
def partial(total_byte_len, part_size_limit):
|
||||||
s = []
|
s = []
|
||||||
@@ -608,6 +642,7 @@ def partial(total_byte_len, part_size_limit):
|
|||||||
s.append([p, last])
|
s.append([p, last])
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
# downloads files in chunks from gdrive
|
# downloads files in chunks from gdrive
|
||||||
def do_gdrive_download(df, headers, convert_encoding=False):
|
def do_gdrive_download(df, headers, convert_encoding=False):
|
||||||
total_size = int(df.metadata.get('fileSize'))
|
total_size = int(df.metadata.get('fileSize'))
|
||||||
@@ -647,6 +682,7 @@ oauth_scope:
|
|||||||
- https://www.googleapis.com/auth/drive
|
- https://www.googleapis.com/auth/drive
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def update_settings(client_id, client_secret, redirect_uri):
|
def update_settings(client_id, client_secret, redirect_uri):
|
||||||
if redirect_uri.endswith('/'):
|
if redirect_uri.endswith('/'):
|
||||||
redirect_uri = redirect_uri[:-1]
|
redirect_uri = redirect_uri[:-1]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
from gevent.pywsgi import WSGIHandler
|
from gevent.pywsgi import WSGIHandler
|
||||||
|
|
||||||
|
|
||||||
class MyWSGIHandler(WSGIHandler):
|
class MyWSGIHandler(WSGIHandler):
|
||||||
def get_environ(self):
|
def get_environ(self):
|
||||||
env = super().get_environ()
|
env = super().get_environ()
|
||||||
|
|||||||
@@ -49,15 +49,24 @@ except ImportError:
|
|||||||
|
|
||||||
|
|
||||||
def get_language_names(locale):
|
def get_language_names(locale):
|
||||||
return _LANGUAGE_NAMES.get(str(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):
|
||||||
try:
|
UNKNOWN_TRANSLATION = "Unknown"
|
||||||
return get_language_names(locale)[lang_code]
|
names = get_language_names(locale)
|
||||||
except KeyError:
|
if names is None:
|
||||||
log.error('Missing translation for language name: {}'.format(lang_code))
|
log.error(f"Missing language names for locale: {str(locale)}/{locale.language}")
|
||||||
return "Unknown"
|
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):
|
||||||
@@ -73,7 +82,6 @@ def get_language_codes(locale, language_names, remainder=None):
|
|||||||
return lang
|
return lang
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_valid_language_codes(locale, language_names, remainder=None):
|
def get_valid_language_codes(locale, language_names, remainder=None):
|
||||||
lang = list()
|
lang = list()
|
||||||
if "" in language_names:
|
if "" in language_names:
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ def formatseriesindex_filter(series_index):
|
|||||||
return int(series_index)
|
return int(series_index)
|
||||||
else:
|
else:
|
||||||
return series_index
|
return series_index
|
||||||
except ValueError:
|
except (ValueError, TypeError):
|
||||||
return series_index
|
return series_index
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import base64
|
|||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
import zipfile
|
||||||
from time import gmtime, strftime
|
from time import gmtime, strftime
|
||||||
import json
|
import json
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
@@ -45,7 +46,9 @@ import requests
|
|||||||
|
|
||||||
|
|
||||||
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status
|
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub, csrf, kobo_sync_status
|
||||||
from .constants import sqlalchemy_version2, COVER_THUMBNAIL_SMALL
|
from . import isoLanguages
|
||||||
|
from .epub import get_epub_layout
|
||||||
|
from .constants import COVER_THUMBNAIL_SMALL
|
||||||
from .helper import get_download_link
|
from .helper import get_download_link
|
||||||
from .services import SyncToken as SyncToken
|
from .services import SyncToken as SyncToken
|
||||||
from .web import download_required
|
from .web import download_required
|
||||||
@@ -53,7 +56,7 @@ from .kobo_auth import requires_kobo_auth, get_auth_token
|
|||||||
|
|
||||||
KOBO_FORMATS = {"KEPUB": ["KEPUB"], "EPUB": ["EPUB3", "EPUB"]}
|
KOBO_FORMATS = {"KEPUB": ["KEPUB"], "EPUB": ["EPUB3", "EPUB"]}
|
||||||
KOBO_STOREAPI_URL = "https://storeapi.kobo.com"
|
KOBO_STOREAPI_URL = "https://storeapi.kobo.com"
|
||||||
KOBO_IMAGEHOST_URL = "https://kbimages1-a.akamaihd.net"
|
KOBO_IMAGEHOST_URL = "https://cdn.kobo.com/book-images"
|
||||||
|
|
||||||
SYNC_ITEM_LIMIT = 100
|
SYNC_ITEM_LIMIT = 100
|
||||||
|
|
||||||
@@ -134,11 +137,15 @@ def convert_to_kobo_timestamp_string(timestamp):
|
|||||||
|
|
||||||
@kobo.route("/v1/library/sync")
|
@kobo.route("/v1/library/sync")
|
||||||
@requires_kobo_auth
|
@requires_kobo_auth
|
||||||
@download_required
|
# @download_required
|
||||||
def HandleSyncRequest():
|
def HandleSyncRequest():
|
||||||
|
if not current_user.role_download():
|
||||||
|
log.info("Users need download permissions for syncing library to Kobo reader")
|
||||||
|
return abort(403)
|
||||||
sync_token = SyncToken.SyncToken.from_headers(request.headers)
|
sync_token = SyncToken.SyncToken.from_headers(request.headers)
|
||||||
log.info("Kobo library sync request received.")
|
log.info("Kobo library sync request received")
|
||||||
log.debug("SyncToken: {}".format(sync_token))
|
log.debug("SyncToken: {}".format(sync_token))
|
||||||
|
log.debug("Download link format {}".format(get_download_url_for_book('[bookid]', '[bookformat]')))
|
||||||
if not current_app.wsgi_app.is_proxied:
|
if not current_app.wsgi_app.is_proxied:
|
||||||
log.debug('Kobo: Received unproxied request, changed request port to external server port')
|
log.debug('Kobo: Received unproxied request, changed request port to external server port')
|
||||||
|
|
||||||
@@ -162,16 +169,10 @@ def HandleSyncRequest():
|
|||||||
only_kobo_shelves = current_user.kobo_only_shelves_sync
|
only_kobo_shelves = current_user.kobo_only_shelves_sync
|
||||||
|
|
||||||
if only_kobo_shelves:
|
if only_kobo_shelves:
|
||||||
if sqlalchemy_version2:
|
changed_entries = calibre_db.session.query(db.Books,
|
||||||
changed_entries = select(db.Books,
|
ub.ArchivedBook.last_modified,
|
||||||
ub.ArchivedBook.last_modified,
|
ub.BookShelf.date_added,
|
||||||
ub.BookShelf.date_added,
|
ub.ArchivedBook.is_archived)
|
||||||
ub.ArchivedBook.is_archived)
|
|
||||||
else:
|
|
||||||
changed_entries = calibre_db.session.query(db.Books,
|
|
||||||
ub.ArchivedBook.last_modified,
|
|
||||||
ub.BookShelf.date_added,
|
|
||||||
ub.ArchivedBook.is_archived)
|
|
||||||
changed_entries = (changed_entries
|
changed_entries = (changed_entries
|
||||||
.join(db.Data).outerjoin(ub.ArchivedBook, and_(db.Books.id == ub.ArchivedBook.book_id,
|
.join(db.Data).outerjoin(ub.ArchivedBook, and_(db.Books.id == ub.ArchivedBook.book_id,
|
||||||
ub.ArchivedBook.user_id == current_user.id))
|
ub.ArchivedBook.user_id == current_user.id))
|
||||||
@@ -188,12 +189,9 @@ def HandleSyncRequest():
|
|||||||
.filter(ub.Shelf.kobo_sync)
|
.filter(ub.Shelf.kobo_sync)
|
||||||
.distinct())
|
.distinct())
|
||||||
else:
|
else:
|
||||||
if sqlalchemy_version2:
|
changed_entries = calibre_db.session.query(db.Books,
|
||||||
changed_entries = select(db.Books, ub.ArchivedBook.last_modified, ub.ArchivedBook.is_archived)
|
ub.ArchivedBook.last_modified,
|
||||||
else:
|
ub.ArchivedBook.is_archived)
|
||||||
changed_entries = calibre_db.session.query(db.Books,
|
|
||||||
ub.ArchivedBook.last_modified,
|
|
||||||
ub.ArchivedBook.is_archived)
|
|
||||||
changed_entries = (changed_entries
|
changed_entries = (changed_entries
|
||||||
.join(db.Data).outerjoin(ub.ArchivedBook, and_(db.Books.id == ub.ArchivedBook.book_id,
|
.join(db.Data).outerjoin(ub.ArchivedBook, and_(db.Books.id == ub.ArchivedBook.book_id,
|
||||||
ub.ArchivedBook.user_id == current_user.id))
|
ub.ArchivedBook.user_id == current_user.id))
|
||||||
@@ -205,19 +203,16 @@ def HandleSyncRequest():
|
|||||||
.order_by(db.Books.id))
|
.order_by(db.Books.id))
|
||||||
|
|
||||||
reading_states_in_new_entitlements = []
|
reading_states_in_new_entitlements = []
|
||||||
if sqlalchemy_version2:
|
books = changed_entries.limit(SYNC_ITEM_LIMIT)
|
||||||
books = calibre_db.session.execute(changed_entries.limit(SYNC_ITEM_LIMIT))
|
|
||||||
else:
|
|
||||||
books = changed_entries.limit(SYNC_ITEM_LIMIT)
|
|
||||||
log.debug("Books to Sync: {}".format(len(books.all())))
|
log.debug("Books to Sync: {}".format(len(books.all())))
|
||||||
for book in books:
|
for book in books:
|
||||||
formats = [data.format for data in book.Books.data]
|
formats = [data.format for data in book.Books.data]
|
||||||
if 'KEPUB' not in formats and config.config_kepubifypath and 'EPUB' in formats:
|
if 'KEPUB' not in formats and config.config_kepubifypath and 'EPUB' in formats:
|
||||||
helper.convert_book_format(book.Books.id, config.config_calibre_dir, 'EPUB', 'KEPUB', current_user.name)
|
helper.convert_book_format(book.Books.id, config.get_book_path(), 'EPUB', 'KEPUB', current_user.name)
|
||||||
|
|
||||||
kobo_reading_state = get_or_create_reading_state(book.Books.id)
|
kobo_reading_state = get_or_create_reading_state(book.Books.id)
|
||||||
entitlement = {
|
entitlement = {
|
||||||
"BookEntitlement": create_book_entitlement(book.Books, archived=(book.is_archived == True)),
|
"BookEntitlement": create_book_entitlement(book.Books, archived=(book.is_archived==True)),
|
||||||
"BookMetadata": get_metadata(book.Books),
|
"BookMetadata": get_metadata(book.Books),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +221,7 @@ def HandleSyncRequest():
|
|||||||
new_reading_state_last_modified = max(new_reading_state_last_modified, kobo_reading_state.last_modified)
|
new_reading_state_last_modified = max(new_reading_state_last_modified, kobo_reading_state.last_modified)
|
||||||
reading_states_in_new_entitlements.append(book.Books.id)
|
reading_states_in_new_entitlements.append(book.Books.id)
|
||||||
|
|
||||||
ts_created = book.Books.timestamp
|
ts_created = book.Books.timestamp.replace(tzinfo=None)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ts_created = max(ts_created, book.date_added)
|
ts_created = max(ts_created, book.date_added)
|
||||||
@@ -239,7 +234,7 @@ def HandleSyncRequest():
|
|||||||
sync_results.append({"ChangedEntitlement": entitlement})
|
sync_results.append({"ChangedEntitlement": entitlement})
|
||||||
|
|
||||||
new_books_last_modified = max(
|
new_books_last_modified = max(
|
||||||
book.Books.last_modified, new_books_last_modified
|
book.Books.last_modified.replace(tzinfo=None), new_books_last_modified
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
new_books_last_modified = max(
|
new_books_last_modified = max(
|
||||||
@@ -251,27 +246,16 @@ def HandleSyncRequest():
|
|||||||
new_books_last_created = max(ts_created, new_books_last_created)
|
new_books_last_created = max(ts_created, new_books_last_created)
|
||||||
kobo_sync_status.add_synced_books(book.Books.id)
|
kobo_sync_status.add_synced_books(book.Books.id)
|
||||||
|
|
||||||
if sqlalchemy_version2:
|
max_change = changed_entries.filter(ub.ArchivedBook.is_archived)\
|
||||||
max_change = calibre_db.session.execute(changed_entries
|
.filter(ub.ArchivedBook.user_id == current_user.id) \
|
||||||
.filter(ub.ArchivedBook.is_archived)
|
.order_by(func.datetime(ub.ArchivedBook.last_modified).desc()).first()
|
||||||
.filter(ub.ArchivedBook.user_id == current_user.id)
|
|
||||||
.order_by(func.datetime(ub.ArchivedBook.last_modified).desc()))\
|
|
||||||
.columns(db.Books).first()
|
|
||||||
else:
|
|
||||||
max_change = changed_entries.from_self().filter(ub.ArchivedBook.is_archived)\
|
|
||||||
.filter(ub.ArchivedBook.user_id == current_user.id) \
|
|
||||||
.order_by(func.datetime(ub.ArchivedBook.last_modified).desc()).first()
|
|
||||||
|
|
||||||
max_change = max_change.last_modified if max_change else new_archived_last_modified
|
max_change = max_change.last_modified if max_change else new_archived_last_modified
|
||||||
|
|
||||||
new_archived_last_modified = max(new_archived_last_modified, max_change)
|
new_archived_last_modified = max(new_archived_last_modified, max_change)
|
||||||
|
|
||||||
# no. of books returned
|
# no. of books returned
|
||||||
if sqlalchemy_version2:
|
book_count = changed_entries.count()
|
||||||
entries = calibre_db.session.execute(changed_entries).all()
|
|
||||||
book_count = len(entries)
|
|
||||||
else:
|
|
||||||
book_count = changed_entries.count()
|
|
||||||
# last entry:
|
# last entry:
|
||||||
cont_sync = bool(book_count)
|
cont_sync = bool(book_count)
|
||||||
log.debug("Remaining books to Sync: {}".format(book_count))
|
log.debug("Remaining books to Sync: {}".format(book_count))
|
||||||
@@ -334,7 +318,7 @@ def generate_sync_response(sync_token, sync_results, set_cont=False):
|
|||||||
extra_headers["x-kobo-recent-reads"] = store_response.headers.get("x-kobo-recent-reads")
|
extra_headers["x-kobo-recent-reads"] = store_response.headers.get("x-kobo-recent-reads")
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.error("Failed to receive or parse response from Kobo's sync endpoint: {}".format(ex))
|
log.error_or_exception("Failed to receive or parse response from Kobo's sync endpoint: {}".format(ex))
|
||||||
if set_cont:
|
if set_cont:
|
||||||
extra_headers["x-kobo-sync"] = "continue"
|
extra_headers["x-kobo-sync"] = "continue"
|
||||||
sync_token.to_headers(extra_headers)
|
sync_token.to_headers(extra_headers)
|
||||||
@@ -355,7 +339,7 @@ def HandleMetadataRequest(book_uuid):
|
|||||||
log.info("Kobo library metadata request received for book %s" % book_uuid)
|
log.info("Kobo library metadata request received for book %s" % book_uuid)
|
||||||
book = calibre_db.get_book_by_uuid(book_uuid)
|
book = calibre_db.get_book_by_uuid(book_uuid)
|
||||||
if not book or not book.data:
|
if not book or not book.data:
|
||||||
log.info(u"Book %s not found in database", book_uuid)
|
log.info("Book %s not found in database", book_uuid)
|
||||||
return redirect_or_proxy_request()
|
return redirect_or_proxy_request()
|
||||||
|
|
||||||
metadata = get_metadata(book)
|
metadata = get_metadata(book)
|
||||||
@@ -364,7 +348,7 @@ def HandleMetadataRequest(book_uuid):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
def get_download_url_for_book(book, book_format):
|
def get_download_url_for_book(book_id, book_format):
|
||||||
if not current_app.wsgi_app.is_proxied:
|
if not current_app.wsgi_app.is_proxied:
|
||||||
if ':' in request.host and not request.host.endswith(']'):
|
if ':' in request.host and not request.host.endswith(']'):
|
||||||
host = "".join(request.host.split(':')[:-1])
|
host = "".join(request.host.split(':')[:-1])
|
||||||
@@ -376,13 +360,13 @@ def get_download_url_for_book(book, book_format):
|
|||||||
url_base=host,
|
url_base=host,
|
||||||
url_port=config.config_external_port,
|
url_port=config.config_external_port,
|
||||||
auth_token=get_auth_token(),
|
auth_token=get_auth_token(),
|
||||||
book_id=book.id,
|
book_id=book_id,
|
||||||
book_format=book_format.lower()
|
book_format=book_format.lower()
|
||||||
)
|
)
|
||||||
return url_for(
|
return url_for(
|
||||||
"kobo.download_book",
|
"kobo.download_book",
|
||||||
auth_token=kobo_auth.get_auth_token(),
|
auth_token=kobo_auth.get_auth_token(),
|
||||||
book_id=book.id,
|
book_id=book_id,
|
||||||
book_format=book_format.lower(),
|
book_format=book_format.lower(),
|
||||||
_external=True,
|
_external=True,
|
||||||
)
|
)
|
||||||
@@ -443,6 +427,12 @@ def get_seriesindex(book):
|
|||||||
return book.series_index or 1
|
return book.series_index or 1
|
||||||
|
|
||||||
|
|
||||||
|
def get_language(book):
|
||||||
|
if not book.languages:
|
||||||
|
return 'en'
|
||||||
|
return isoLanguages.get(part3=book.languages[0].lang_code).part1
|
||||||
|
|
||||||
|
|
||||||
def get_metadata(book):
|
def get_metadata(book):
|
||||||
download_urls = []
|
download_urls = []
|
||||||
kepub = [data for data in book.data if data.format == 'KEPUB']
|
kepub = [data for data in book.data if data.format == 'KEPUB']
|
||||||
@@ -452,16 +442,21 @@ def get_metadata(book):
|
|||||||
continue
|
continue
|
||||||
for kobo_format in KOBO_FORMATS[book_data.format]:
|
for kobo_format in KOBO_FORMATS[book_data.format]:
|
||||||
# log.debug('Id: %s, Format: %s' % (book.id, kobo_format))
|
# log.debug('Id: %s, Format: %s' % (book.id, kobo_format))
|
||||||
download_urls.append(
|
try:
|
||||||
{
|
if get_epub_layout(book, book_data) == 'pre-paginated':
|
||||||
"Format": kobo_format,
|
kobo_format = 'EPUB3FL'
|
||||||
"Size": book_data.uncompressed_size,
|
download_urls.append(
|
||||||
"Url": get_download_url_for_book(book, book_data.format),
|
{
|
||||||
# The Kobo forma accepts platforms: (Generic, Android)
|
"Format": kobo_format,
|
||||||
"Platform": "Generic",
|
"Size": book_data.uncompressed_size,
|
||||||
# "DrmType": "None", # Not required
|
"Url": get_download_url_for_book(book.id, book_data.format),
|
||||||
}
|
# The Kobo forma accepts platforms: (Generic, Android)
|
||||||
)
|
"Platform": "Generic",
|
||||||
|
# "DrmType": "None", # Not required
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (zipfile.BadZipfile, FileNotFoundError) as e:
|
||||||
|
log.error(e)
|
||||||
|
|
||||||
book_uuid = book.uuid
|
book_uuid = book.uuid
|
||||||
metadata = {
|
metadata = {
|
||||||
@@ -480,7 +475,7 @@ def get_metadata(book):
|
|||||||
"IsInternetArchive": False,
|
"IsInternetArchive": False,
|
||||||
"IsPreOrder": False,
|
"IsPreOrder": False,
|
||||||
"IsSocialEnabled": True,
|
"IsSocialEnabled": True,
|
||||||
"Language": "en",
|
"Language": get_language(book),
|
||||||
"PhoneticPronunciations": {},
|
"PhoneticPronunciations": {},
|
||||||
"PublicationDate": convert_to_kobo_timestamp_string(book.pubdate),
|
"PublicationDate": convert_to_kobo_timestamp_string(book.pubdate),
|
||||||
"Publisher": {"Imprint": "", "Name": get_publisher(book), },
|
"Publisher": {"Imprint": "", "Name": get_publisher(book), },
|
||||||
@@ -508,7 +503,7 @@ def get_metadata(book):
|
|||||||
@requires_kobo_auth
|
@requires_kobo_auth
|
||||||
# Creates a Shelf with the given items, and returns the shelf's uuid.
|
# Creates a Shelf with the given items, and returns the shelf's uuid.
|
||||||
def HandleTagCreate():
|
def HandleTagCreate():
|
||||||
# catch delete requests, otherwise the are handled in the book delete handler
|
# catch delete requests, otherwise they are handled in the book delete handler
|
||||||
if request.method == "DELETE":
|
if request.method == "DELETE":
|
||||||
abort(405)
|
abort(405)
|
||||||
name, items = None, None
|
name, items = None, None
|
||||||
@@ -702,20 +697,12 @@ def sync_shelves(sync_token, sync_results, only_kobo_shelves=False):
|
|||||||
})
|
})
|
||||||
extra_filters.append(ub.Shelf.kobo_sync)
|
extra_filters.append(ub.Shelf.kobo_sync)
|
||||||
|
|
||||||
if sqlalchemy_version2:
|
shelflist = ub.session.query(ub.Shelf).outerjoin(ub.BookShelf).filter(
|
||||||
shelflist = ub.session.execute(select(ub.Shelf).outerjoin(ub.BookShelf).filter(
|
or_(func.datetime(ub.Shelf.last_modified) > sync_token.tags_last_modified,
|
||||||
or_(func.datetime(ub.Shelf.last_modified) > sync_token.tags_last_modified,
|
func.datetime(ub.BookShelf.date_added) > sync_token.tags_last_modified),
|
||||||
func.datetime(ub.BookShelf.date_added) > sync_token.tags_last_modified),
|
ub.Shelf.user_id == current_user.id,
|
||||||
ub.Shelf.user_id == current_user.id,
|
*extra_filters
|
||||||
*extra_filters
|
).distinct().order_by(func.datetime(ub.Shelf.last_modified).asc())
|
||||||
).distinct().order_by(func.datetime(ub.Shelf.last_modified).asc())).columns(ub.Shelf)
|
|
||||||
else:
|
|
||||||
shelflist = ub.session.query(ub.Shelf).outerjoin(ub.BookShelf).filter(
|
|
||||||
or_(func.datetime(ub.Shelf.last_modified) > sync_token.tags_last_modified,
|
|
||||||
func.datetime(ub.BookShelf.date_added) > sync_token.tags_last_modified),
|
|
||||||
ub.Shelf.user_id == current_user.id,
|
|
||||||
*extra_filters
|
|
||||||
).distinct().order_by(func.datetime(ub.Shelf.last_modified).asc())
|
|
||||||
|
|
||||||
for shelf in shelflist:
|
for shelf in shelflist:
|
||||||
if not shelf_lib.check_shelf_view_permissions(shelf):
|
if not shelf_lib.check_shelf_view_permissions(shelf):
|
||||||
@@ -752,7 +739,7 @@ def create_kobo_tag(shelf):
|
|||||||
for book_shelf in shelf.books:
|
for book_shelf in shelf.books:
|
||||||
book = calibre_db.get_book(book_shelf.book_id)
|
book = calibre_db.get_book(book_shelf.book_id)
|
||||||
if not book:
|
if not book:
|
||||||
log.info(u"Book (id: %s) in BookShelf (id: %s) not found in book database", book_shelf.book_id, shelf.id)
|
log.info("Book (id: %s) in BookShelf (id: %s) not found in book database", book_shelf.book_id, shelf.id)
|
||||||
continue
|
continue
|
||||||
tag["Items"].append(
|
tag["Items"].append(
|
||||||
{
|
{
|
||||||
@@ -769,7 +756,7 @@ def create_kobo_tag(shelf):
|
|||||||
def HandleStateRequest(book_uuid):
|
def HandleStateRequest(book_uuid):
|
||||||
book = calibre_db.get_book_by_uuid(book_uuid)
|
book = calibre_db.get_book_by_uuid(book_uuid)
|
||||||
if not book or not book.data:
|
if not book or not book.data:
|
||||||
log.info(u"Book %s not found in database", book_uuid)
|
log.info("Book %s not found in database", book_uuid)
|
||||||
return redirect_or_proxy_request()
|
return redirect_or_proxy_request()
|
||||||
|
|
||||||
kobo_reading_state = get_or_create_reading_state(book.id)
|
kobo_reading_state = get_or_create_reading_state(book.id)
|
||||||
@@ -916,20 +903,26 @@ def get_current_bookmark_response(current_bookmark):
|
|||||||
@kobo.route("/<book_uuid>/<width>/<height>/<Quality>/<isGreyscale>/image.jpg")
|
@kobo.route("/<book_uuid>/<width>/<height>/<Quality>/<isGreyscale>/image.jpg")
|
||||||
@requires_kobo_auth
|
@requires_kobo_auth
|
||||||
def HandleCoverImageRequest(book_uuid, width, height, Quality, isGreyscale):
|
def HandleCoverImageRequest(book_uuid, width, height, Quality, isGreyscale):
|
||||||
book_cover = helper.get_book_cover_with_uuid(book_uuid, resolution=COVER_THUMBNAIL_SMALL)
|
try:
|
||||||
if not book_cover:
|
resolution = None if int(height) > 1000 else COVER_THUMBNAIL_SMALL
|
||||||
if config.config_kobo_proxy:
|
except ValueError:
|
||||||
log.debug("Cover for unknown book: %s proxied to kobo" % book_uuid)
|
log.error("Requested height %s of book %s is invalid" % (book_uuid, height))
|
||||||
return redirect(KOBO_IMAGEHOST_URL +
|
resolution = COVER_THUMBNAIL_SMALL
|
||||||
"/{book_uuid}/{width}/{height}/false/image.jpg".format(book_uuid=book_uuid,
|
book_cover = helper.get_book_cover_with_uuid(book_uuid, resolution=resolution)
|
||||||
width=width,
|
if book_cover:
|
||||||
height=height), 307)
|
log.debug("Serving local cover image of book %s" % book_uuid)
|
||||||
else:
|
return book_cover
|
||||||
log.debug("Cover for unknown book: %s requested" % book_uuid)
|
|
||||||
# additional proxy request make no sense, -> direct return
|
if not config.config_kobo_proxy:
|
||||||
return make_response(jsonify({}))
|
log.debug("Returning 404 for cover image of unknown book %s" % book_uuid)
|
||||||
log.debug("Cover request received for book %s" % book_uuid)
|
# additional proxy request make no sense, -> direct return
|
||||||
return book_cover
|
return abort(404)
|
||||||
|
|
||||||
|
log.debug("Redirecting request for cover image of unknown book %s to Kobo" % book_uuid)
|
||||||
|
return redirect(KOBO_IMAGEHOST_URL +
|
||||||
|
"/{book_uuid}/{width}/{height}/false/image.jpg".format(book_uuid=book_uuid,
|
||||||
|
width=width,
|
||||||
|
height=height), 307)
|
||||||
|
|
||||||
|
|
||||||
@kobo.route("")
|
@kobo.route("")
|
||||||
@@ -944,7 +937,7 @@ def HandleBookDeletionRequest(book_uuid):
|
|||||||
log.info("Kobo book delete request received for book %s" % book_uuid)
|
log.info("Kobo book delete request received for book %s" % book_uuid)
|
||||||
book = calibre_db.get_book_by_uuid(book_uuid)
|
book = calibre_db.get_book_by_uuid(book_uuid)
|
||||||
if not book:
|
if not book:
|
||||||
log.info(u"Book %s not found in database", book_uuid)
|
log.info("Book %s not found in database", book_uuid)
|
||||||
return redirect_or_proxy_request()
|
return redirect_or_proxy_request()
|
||||||
|
|
||||||
book_id = book.id
|
book_id = book.id
|
||||||
@@ -958,7 +951,8 @@ def HandleBookDeletionRequest(book_uuid):
|
|||||||
@csrf.exempt
|
@csrf.exempt
|
||||||
@kobo.route("/v1/library/<dummy>", methods=["DELETE", "GET"])
|
@kobo.route("/v1/library/<dummy>", methods=["DELETE", "GET"])
|
||||||
def HandleUnimplementedRequest(dummy=None):
|
def HandleUnimplementedRequest(dummy=None):
|
||||||
log.debug("Unimplemented Library Request received: %s", request.base_url)
|
log.debug("Unimplemented Library Request received: %s (request is forwarded to kobo if configured)",
|
||||||
|
request.base_url)
|
||||||
return redirect_or_proxy_request()
|
return redirect_or_proxy_request()
|
||||||
|
|
||||||
|
|
||||||
@@ -969,8 +963,9 @@ def HandleUnimplementedRequest(dummy=None):
|
|||||||
@kobo.route("/v1/user/wishlist", methods=["GET", "POST"])
|
@kobo.route("/v1/user/wishlist", methods=["GET", "POST"])
|
||||||
@kobo.route("/v1/user/recommendations", methods=["GET", "POST"])
|
@kobo.route("/v1/user/recommendations", methods=["GET", "POST"])
|
||||||
@kobo.route("/v1/analytics/<dummy>", methods=["GET", "POST"])
|
@kobo.route("/v1/analytics/<dummy>", methods=["GET", "POST"])
|
||||||
|
@kobo.route("/v1/assets", methods=["GET"])
|
||||||
def HandleUserRequest(dummy=None):
|
def HandleUserRequest(dummy=None):
|
||||||
log.debug("Unimplemented User Request received: %s", request.base_url)
|
log.debug("Unimplemented User Request received: %s (request is forwarded to kobo if configured)", request.base_url)
|
||||||
return redirect_or_proxy_request()
|
return redirect_or_proxy_request()
|
||||||
|
|
||||||
|
|
||||||
@@ -1010,7 +1005,8 @@ def handle_getests():
|
|||||||
@kobo.route("/v1/affiliate", methods=["GET", "POST"])
|
@kobo.route("/v1/affiliate", methods=["GET", "POST"])
|
||||||
@kobo.route("/v1/deals", methods=["GET", "POST"])
|
@kobo.route("/v1/deals", methods=["GET", "POST"])
|
||||||
def HandleProductsRequest(dummy=None):
|
def HandleProductsRequest(dummy=None):
|
||||||
log.debug("Unimplemented Products Request received: %s", request.base_url)
|
log.debug("Unimplemented Products Request received: %s (request is forwarded to kobo if configured)",
|
||||||
|
request.base_url)
|
||||||
return redirect_or_proxy_request()
|
return redirect_or_proxy_request()
|
||||||
|
|
||||||
|
|
||||||
@@ -1027,7 +1023,7 @@ def make_calibre_web_auth_response():
|
|||||||
"RefreshToken": RefreshToken,
|
"RefreshToken": RefreshToken,
|
||||||
"TokenType": "Bearer",
|
"TokenType": "Bearer",
|
||||||
"TrackingId": str(uuid.uuid4()),
|
"TrackingId": str(uuid.uuid4()),
|
||||||
"UserKey": content['UserKey'],
|
"UserKey": content.get('UserKey', ""),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -64,11 +64,12 @@ from datetime import datetime
|
|||||||
from os import urandom
|
from os import urandom
|
||||||
from functools import wraps
|
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, current_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, config, calibre_db, db, helper, ub, lm
|
from . import logger, config, calibre_db, db, helper, ub, lm, limiter
|
||||||
from .render_template import render_title_template
|
from .render_template import render_title_template
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
@@ -112,9 +113,9 @@ def generate_auth_token(user_id):
|
|||||||
|
|
||||||
return render_title_template(
|
return render_title_template(
|
||||||
"generate_kobo_auth_url.html",
|
"generate_kobo_auth_url.html",
|
||||||
title=_(u"Kobo Setup"),
|
title=_("Kobo Setup"),
|
||||||
auth_token=auth_token.auth_token,
|
auth_token=auth_token.auth_token,
|
||||||
warning = warning
|
warning=warning
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -151,6 +152,13 @@ def requires_kobo_auth(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)
|
||||||
@@ -159,7 +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
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import datetime
|
|||||||
from sqlalchemy.sql.expression import or_, and_, true
|
from sqlalchemy.sql.expression import or_, and_, true
|
||||||
from sqlalchemy import exc
|
from sqlalchemy import exc
|
||||||
|
|
||||||
|
|
||||||
# Add the current book id to kobo_synced_books table for current user, if entry is already present,
|
# Add the current book id to kobo_synced_books table for current user, if entry is already present,
|
||||||
# do nothing (safety precaution)
|
# do nothing (safety precaution)
|
||||||
def add_synced_books(book_id):
|
def add_synced_books(book_id):
|
||||||
@@ -50,7 +51,6 @@ def remove_synced_book(book_id, all=False, session=None):
|
|||||||
ub.session_commit(_session=session)
|
ub.session_commit(_session=session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def change_archived_books(book_id, state=None, message=None):
|
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),
|
archived_book = ub.session.query(ub.ArchivedBook).filter(and_(ub.ArchivedBook.user_id == int(current_user.id),
|
||||||
ub.ArchivedBook.book_id == book_id)).first()
|
ub.ArchivedBook.book_id == book_id)).first()
|
||||||
@@ -71,7 +71,7 @@ def update_on_sync_shelfs(user_id):
|
|||||||
books_to_archive = (ub.session.query(ub.KoboSyncedBooks)
|
books_to_archive = (ub.session.query(ub.KoboSyncedBooks)
|
||||||
.join(ub.BookShelf, ub.KoboSyncedBooks.book_id == ub.BookShelf.book_id, isouter=True)
|
.join(ub.BookShelf, ub.KoboSyncedBooks.book_id == ub.BookShelf.book_id, isouter=True)
|
||||||
.join(ub.Shelf, ub.Shelf.user_id == user_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(or_(ub.Shelf.kobo_sync == 0, ub.Shelf.kobo_sync==None))
|
||||||
.filter(ub.KoboSyncedBooks.user_id == user_id).all())
|
.filter(ub.KoboSyncedBooks.user_id == user_id).all())
|
||||||
for b in books_to_archive:
|
for b in books_to_archive:
|
||||||
change_archived_books(b.book_id, True)
|
change_archived_books(b.book_id, True)
|
||||||
|
|||||||
@@ -43,13 +43,14 @@ logging.addLevelName(logging.CRITICAL, "CRIT")
|
|||||||
class _Logger(logging.Logger):
|
class _Logger(logging.Logger):
|
||||||
|
|
||||||
def error_or_exception(self, message, stacklevel=2, *args, **kwargs):
|
def error_or_exception(self, message, stacklevel=2, *args, **kwargs):
|
||||||
|
is_debug = self.getEffectiveLevel() <= logging.DEBUG
|
||||||
if sys.version_info > (3, 7):
|
if sys.version_info > (3, 7):
|
||||||
if is_debug_enabled():
|
if is_debug:
|
||||||
self.exception(message, stacklevel=stacklevel, *args, **kwargs)
|
self.exception(message, stacklevel=stacklevel, *args, **kwargs)
|
||||||
else:
|
else:
|
||||||
self.error(message, stacklevel=stacklevel, *args, **kwargs)
|
self.error(message, stacklevel=stacklevel, *args, **kwargs)
|
||||||
else:
|
else:
|
||||||
if is_debug_enabled():
|
if is_debug:
|
||||||
self.exception(message, stack_info=True, *args, **kwargs)
|
self.exception(message, stack_info=True, *args, **kwargs)
|
||||||
else:
|
else:
|
||||||
self.error(message, *args, **kwargs)
|
self.error(message, *args, **kwargs)
|
||||||
@@ -150,7 +151,7 @@ def setup(log_file, log_level=None):
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
file_handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=2, encoding='utf-8')
|
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=100000, backupCount=2, encoding='utf-8')
|
file_handler = RotatingFileHandler(DEFAULT_LOG_FILE, maxBytes=100000, backupCount=2, encoding='utf-8')
|
||||||
@@ -177,7 +178,7 @@ def create_access_log(log_file, log_name, formatter):
|
|||||||
access_log.setLevel(logging.INFO)
|
access_log.setLevel(logging.INFO)
|
||||||
try:
|
try:
|
||||||
file_handler = RotatingFileHandler(log_file, maxBytes=50000, backupCount=2, encoding='utf-8')
|
file_handler = RotatingFileHandler(log_file, maxBytes=50000, backupCount=2, encoding='utf-8')
|
||||||
except IOError:
|
except (IOError, PermissionError):
|
||||||
if log_file == DEFAULT_ACCESS_LOG:
|
if log_file == DEFAULT_ACCESS_LOG:
|
||||||
raise
|
raise
|
||||||
file_handler = RotatingFileHandler(DEFAULT_ACCESS_LOG, maxBytes=50000, backupCount=2, encoding='utf-8')
|
file_handler = RotatingFileHandler(DEFAULT_ACCESS_LOG, maxBytes=50000, backupCount=2, encoding='utf-8')
|
||||||
|
|||||||
@@ -18,9 +18,15 @@
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from . import create_app
|
from . import create_app, limiter
|
||||||
from .jinjia import jinjia
|
from .jinjia import jinjia
|
||||||
from .remotelogin import remotelogin
|
from .remotelogin import remotelogin
|
||||||
|
from flask import request
|
||||||
|
|
||||||
|
|
||||||
|
def request_username():
|
||||||
|
return request.authorization.username
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
app = create_app()
|
app = create_app()
|
||||||
@@ -39,15 +45,18 @@ def main():
|
|||||||
try:
|
try:
|
||||||
from .kobo import kobo, get_kobo_activated
|
from .kobo import kobo, get_kobo_activated
|
||||||
from .kobo_auth import kobo_auth
|
from .kobo_auth import kobo_auth
|
||||||
|
from flask_limiter.util import get_remote_address
|
||||||
kobo_available = get_kobo_activated()
|
kobo_available = get_kobo_activated()
|
||||||
except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator)
|
except (ImportError, AttributeError): # Catch also error for not installed flask-WTF (missing csrf decorator)
|
||||||
kobo_available = False
|
kobo_available = False
|
||||||
|
kobo = kobo_auth = get_remote_address = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .oauth_bb import oauth
|
from .oauth_bb import oauth
|
||||||
oauth_available = True
|
oauth_available = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
oauth_available = False
|
oauth_available = False
|
||||||
|
oauth = None
|
||||||
|
|
||||||
from . import web_server
|
from . import web_server
|
||||||
init_errorhandler()
|
init_errorhandler()
|
||||||
@@ -56,6 +65,7 @@ def main():
|
|||||||
app.register_blueprint(tasks)
|
app.register_blueprint(tasks)
|
||||||
app.register_blueprint(web)
|
app.register_blueprint(web)
|
||||||
app.register_blueprint(opds)
|
app.register_blueprint(opds)
|
||||||
|
limiter.limit("3/minute", key_func=request_username)(opds)
|
||||||
app.register_blueprint(jinjia)
|
app.register_blueprint(jinjia)
|
||||||
app.register_blueprint(about)
|
app.register_blueprint(about)
|
||||||
app.register_blueprint(shelf)
|
app.register_blueprint(shelf)
|
||||||
@@ -67,6 +77,7 @@ def main():
|
|||||||
if kobo_available:
|
if kobo_available:
|
||||||
app.register_blueprint(kobo)
|
app.register_blueprint(kobo)
|
||||||
app.register_blueprint(kobo_auth)
|
app.register_blueprint(kobo_auth)
|
||||||
|
limiter.limit("3/minute", key_func=get_remote_address)(kobo)
|
||||||
if oauth_available:
|
if oauth_available:
|
||||||
app.register_blueprint(oauth)
|
app.register_blueprint(oauth)
|
||||||
success = web_server.start()
|
success = web_server.start()
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ try:
|
|||||||
import cchardet #optional for better speed
|
import cchardet #optional for better speed
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
from cps import logger
|
|
||||||
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
from cps.services.Metadata import MetaRecord, MetaSourceInfo, Metadata
|
||||||
import cps.logger as logger
|
import cps.logger as logger
|
||||||
|
|
||||||
@@ -33,8 +33,6 @@ import cps.logger as logger
|
|||||||
from operator import itemgetter
|
from operator import itemgetter
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
log = logger.create()
|
|
||||||
|
|
||||||
|
|
||||||
class Amazon(Metadata):
|
class Amazon(Metadata):
|
||||||
__name__ = "Amazon"
|
__name__ = "Amazon"
|
||||||
@@ -63,11 +61,11 @@ class Amazon(Metadata):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.warning(ex)
|
log.warning(ex)
|
||||||
return
|
return None
|
||||||
long_soup = BS(r.text, "lxml") #~4sec :/
|
long_soup = BS(r.text, "lxml") #~4sec :/
|
||||||
soup2 = long_soup.find("div", attrs={"cel_widget_id": "dpx-books-ppd_csm_instrumentation_wrapper"})
|
soup2 = long_soup.find("div", attrs={"cel_widget_id": "dpx-books-ppd_csm_instrumentation_wrapper"})
|
||||||
if soup2 is None:
|
if soup2 is None:
|
||||||
return
|
return None
|
||||||
try:
|
try:
|
||||||
match = MetaRecord(
|
match = MetaRecord(
|
||||||
title = "",
|
title = "",
|
||||||
@@ -98,7 +96,7 @@ class Amazon(Metadata):
|
|||||||
try:
|
try:
|
||||||
match.authors = [next(
|
match.authors = [next(
|
||||||
filter(lambda i: i != " " and i != "\n" and not i.startswith("{"),
|
filter(lambda i: i != " " and i != "\n" and not i.startswith("{"),
|
||||||
x.findAll(text=True))).strip()
|
x.findAll(string=True))).strip()
|
||||||
for x in soup2.findAll("span", attrs={"class": "author"})]
|
for x in soup2.findAll("span", attrs={"class": "author"})]
|
||||||
except (AttributeError, TypeError, StopIteration):
|
except (AttributeError, TypeError, StopIteration):
|
||||||
match.authors = ""
|
match.authors = ""
|
||||||
@@ -115,7 +113,7 @@ class Amazon(Metadata):
|
|||||||
return match, index
|
return match, index
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error_or_exception(e)
|
log.error_or_exception(e)
|
||||||
return
|
return None
|
||||||
|
|
||||||
val = list()
|
val = list()
|
||||||
if self.active:
|
if self.active:
|
||||||
@@ -127,10 +125,10 @@ class Amazon(Metadata):
|
|||||||
results.raise_for_status()
|
results.raise_for_status()
|
||||||
except requests.exceptions.HTTPError as e:
|
except requests.exceptions.HTTPError as e:
|
||||||
log.error_or_exception(e)
|
log.error_or_exception(e)
|
||||||
return None
|
return []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(e)
|
log.warning(e)
|
||||||
return None
|
return []
|
||||||
soup = BS(results.text, 'html.parser')
|
soup = BS(results.text, 'html.parser')
|
||||||
links_list = [next(filter(lambda i: "digital-text" in i["href"], x.findAll("a")))["href"] for x in
|
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"})]
|
soup.findAll("div", attrs={"data-component-type": "s-search-result"})]
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ class Douban(Metadata):
|
|||||||
__id__ = "douban"
|
__id__ = "douban"
|
||||||
DESCRIPTION = "豆瓣"
|
DESCRIPTION = "豆瓣"
|
||||||
META_URL = "https://book.douban.com/"
|
META_URL = "https://book.douban.com/"
|
||||||
SEARCH_URL = "https://www.douban.com/j/search"
|
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+),")
|
ID_PATTERN = re.compile(r"sid: (?P<id>\d+),")
|
||||||
AUTHORS_PATTERN = re.compile(r"作者|译者")
|
AUTHORS_PATTERN = re.compile(r"作者|译者")
|
||||||
@@ -52,6 +53,7 @@ class Douban(Metadata):
|
|||||||
PUBLISHED_DATE_PATTERN = re.compile(r"出版年")
|
PUBLISHED_DATE_PATTERN = re.compile(r"出版年")
|
||||||
SERIES_PATTERN = re.compile(r"丛书")
|
SERIES_PATTERN = re.compile(r"丛书")
|
||||||
IDENTIFIERS_PATTERN = re.compile(r"ISBN|统一书号")
|
IDENTIFIERS_PATTERN = re.compile(r"ISBN|统一书号")
|
||||||
|
CRITERIA_PATTERN = re.compile("criteria = '(.+)'")
|
||||||
|
|
||||||
TITTLE_XPATH = "//span[@property='v:itemreviewed']"
|
TITTLE_XPATH = "//span[@property='v:itemreviewed']"
|
||||||
COVER_XPATH = "//a[@class='nbg']"
|
COVER_XPATH = "//a[@class='nbg']"
|
||||||
@@ -63,56 +65,90 @@ class Douban(Metadata):
|
|||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
session.headers = {
|
session.headers = {
|
||||||
'user-agent':
|
'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',
|
'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(
|
def search(self,
|
||||||
self, query: str, generic_cover: str = "", locale: str = "en"
|
query: str,
|
||||||
) -> Optional[List[MetaRecord]]:
|
generic_cover: str = "",
|
||||||
|
locale: str = "en") -> List[MetaRecord]:
|
||||||
|
val = []
|
||||||
if self.active:
|
if self.active:
|
||||||
log.debug(f"starting search {query} on douban")
|
log.debug(f"start searching {query} on douban")
|
||||||
if title_tokens := list(
|
if title_tokens := list(
|
||||||
self.get_title_tokens(query, strip_joiners=False)
|
self.get_title_tokens(query, strip_joiners=False)):
|
||||||
):
|
|
||||||
query = "+".join(title_tokens)
|
query = "+".join(title_tokens)
|
||||||
|
|
||||||
try:
|
book_id_list = self._get_book_id_list_from_html(query)
|
||||||
r = self.session.get(
|
|
||||||
self.SEARCH_URL, params={"cat": 1001, "q": query}
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
|
||||||
except Exception as e:
|
if not book_id_list:
|
||||||
log.warning(e)
|
log.debug("No search results in Douban")
|
||||||
return None
|
|
||||||
|
|
||||||
results = r.json()
|
|
||||||
if results["total"] == 0:
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
book_id_list = [
|
with futures.ThreadPoolExecutor(
|
||||||
self.ID_PATTERN.search(item).group("id")
|
max_workers=5, thread_name_prefix='douban') as executor:
|
||||||
for item in results["items"][:10] if self.ID_PATTERN.search(item)
|
|
||||||
]
|
|
||||||
|
|
||||||
with futures.ThreadPoolExecutor(max_workers=5) as executor:
|
|
||||||
|
|
||||||
fut = [
|
fut = [
|
||||||
executor.submit(self._parse_single_book, book_id, generic_cover)
|
executor.submit(self._parse_single_book, book_id,
|
||||||
for book_id in book_id_list
|
generic_cover) for book_id in book_id_list
|
||||||
]
|
]
|
||||||
|
|
||||||
val = [
|
val = [
|
||||||
future.result()
|
future.result() for future in futures.as_completed(fut)
|
||||||
for future in futures.as_completed(fut) if future.result()
|
if future.result()
|
||||||
]
|
]
|
||||||
|
|
||||||
return val
|
return val
|
||||||
|
|
||||||
def _parse_single_book(
|
def _get_book_id_list_from_html(self, query: str) -> List[str]:
|
||||||
self, id: str, generic_cover: str = ""
|
try:
|
||||||
) -> Optional[MetaRecord]:
|
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}/"
|
url = f"https://book.douban.com/subject/{id}/"
|
||||||
|
log.debug(f"start parsing {url}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
r = self.session.get(url)
|
r = self.session.get(url)
|
||||||
@@ -133,10 +169,12 @@ class Douban(Metadata):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
html = etree.HTML(r.content.decode("utf8"))
|
decode_content = r.content.decode("utf8")
|
||||||
|
html = etree.HTML(decode_content)
|
||||||
|
|
||||||
match.title = html.xpath(self.TITTLE_XPATH)[0].text
|
match.title = html.xpath(self.TITTLE_XPATH)[0].text
|
||||||
match.cover = html.xpath(self.COVER_XPATH)[0].attrib["href"] or generic_cover
|
match.cover = html.xpath(
|
||||||
|
self.COVER_XPATH)[0].attrib["href"] or generic_cover
|
||||||
try:
|
try:
|
||||||
rating_num = float(html.xpath(self.RATING_XPATH)[0].text.strip())
|
rating_num = float(html.xpath(self.RATING_XPATH)[0].text.strip())
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -146,36 +184,41 @@ class Douban(Metadata):
|
|||||||
tag_elements = html.xpath(self.TAGS_XPATH)
|
tag_elements = html.xpath(self.TAGS_XPATH)
|
||||||
if len(tag_elements):
|
if len(tag_elements):
|
||||||
match.tags = [tag_element.text for tag_element in 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)
|
description_element = html.xpath(self.DESCRIPTION_XPATH)
|
||||||
if len(description_element):
|
if len(description_element):
|
||||||
match.description = html2text(etree.tostring(
|
match.description = html2text(
|
||||||
description_element[-1], encoding="utf8").decode("utf8"))
|
etree.tostring(description_element[-1]).decode("utf8"))
|
||||||
|
|
||||||
info = html.xpath(self.INFO_XPATH)
|
info = html.xpath(self.INFO_XPATH)
|
||||||
|
|
||||||
for element in info:
|
for element in info:
|
||||||
text = element.text
|
text = element.text
|
||||||
if self.AUTHORS_PATTERN.search(text):
|
if self.AUTHORS_PATTERN.search(text):
|
||||||
next = element.getnext()
|
next_element = element.getnext()
|
||||||
while next is not None and next.tag != "br":
|
while next_element is not None and next_element.tag != "br":
|
||||||
match.authors.append(next.text)
|
match.authors.append(next_element.text)
|
||||||
next = next.getnext()
|
next_element = next_element.getnext()
|
||||||
elif self.PUBLISHER_PATTERN.search(text):
|
elif self.PUBLISHER_PATTERN.search(text):
|
||||||
match.publisher = element.tail.strip()
|
if publisher := element.tail.strip():
|
||||||
|
match.publisher = publisher
|
||||||
|
else:
|
||||||
|
match.publisher = element.getnext().text
|
||||||
elif self.SUBTITLE_PATTERN.search(text):
|
elif self.SUBTITLE_PATTERN.search(text):
|
||||||
match.title = f'{match.title}:' + element.tail.strip()
|
match.title = f'{match.title}:{element.tail.strip()}'
|
||||||
elif self.PUBLISHED_DATE_PATTERN.search(text):
|
elif self.PUBLISHED_DATE_PATTERN.search(text):
|
||||||
match.publishedDate = self._clean_date(element.tail.strip())
|
match.publishedDate = self._clean_date(element.tail.strip())
|
||||||
elif self.SUBTITLE_PATTERN.search(text):
|
elif self.SERIES_PATTERN.search(text):
|
||||||
match.series = element.getnext().text
|
match.series = element.getnext().text
|
||||||
elif i_type := self.IDENTIFIERS_PATTERN.search(text):
|
elif i_type := self.IDENTIFIERS_PATTERN.search(text):
|
||||||
match.identifiers[i_type.group()] = element.tail.strip()
|
match.identifiers[i_type.group()] = element.tail.strip()
|
||||||
|
|
||||||
return match
|
return match
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def _clean_date(self, date: str) -> str:
|
def _clean_date(date: str) -> str:
|
||||||
"""
|
"""
|
||||||
Clean up the date string to be in the format YYYY-MM-DD
|
Clean up the date string to be in the format YYYY-MM-DD
|
||||||
|
|
||||||
@@ -194,13 +237,24 @@ class Douban(Metadata):
|
|||||||
if date[i].isdigit():
|
if date[i].isdigit():
|
||||||
digit.append(date[i])
|
digit.append(date[i])
|
||||||
elif digit:
|
elif digit:
|
||||||
ls.append("".join(digit) if len(digit)==2 else f"0{digit[0]}")
|
ls.append("".join(digit) if len(digit) ==
|
||||||
|
2 else f"0{digit[0]}")
|
||||||
digit = []
|
digit = []
|
||||||
if digit:
|
if digit:
|
||||||
ls.append("".join(digit) if len(digit)==2 else f"0{digit[0]}")
|
ls.append("".join(digit) if len(digit) ==
|
||||||
|
2 else f"0{digit[0]}")
|
||||||
|
|
||||||
moon = ls[0]
|
moon = ls[0]
|
||||||
if len(ls)>1:
|
if len(ls) > 1:
|
||||||
day = ls[1]
|
day = ls[1]
|
||||||
|
|
||||||
return f"{year}-{moon}-{day}"
|
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
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
# Google Books api document: https://developers.google.com/books/docs/v1/using
|
# Google Books api document: https://developers.google.com/books/docs/v1/using
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
@@ -81,7 +82,11 @@ class Google(Metadata):
|
|||||||
match.description = result["volumeInfo"].get("description", "")
|
match.description = result["volumeInfo"].get("description", "")
|
||||||
match.languages = self._parse_languages(result=result, locale=locale)
|
match.languages = self._parse_languages(result=result, locale=locale)
|
||||||
match.publisher = result["volumeInfo"].get("publisher", "")
|
match.publisher = result["volumeInfo"].get("publisher", "")
|
||||||
match.publishedDate = result["volumeInfo"].get("publishedDate", "")
|
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.rating = result["volumeInfo"].get("averageRating", 0)
|
||||||
match.series, match.series_index = "", 1
|
match.series, match.series_index = "", 1
|
||||||
match.tags = result["volumeInfo"].get("categories", [])
|
match.tags = result["volumeInfo"].get("categories", [])
|
||||||
@@ -103,6 +108,13 @@ class Google(Metadata):
|
|||||||
def _parse_cover(result: Dict, generic_cover: str) -> str:
|
def _parse_cover(result: Dict, generic_cover: str) -> str:
|
||||||
if result["volumeInfo"].get("imageLinks"):
|
if result["volumeInfo"].get("imageLinks"):
|
||||||
cover_url = result["volumeInfo"]["imageLinks"]["thumbnail"]
|
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 cover_url.replace("http://", "https://")
|
||||||
return generic_cover
|
return generic_cover
|
||||||
|
|
||||||
|
|||||||
@@ -97,12 +97,14 @@ class LubimyCzytac(Metadata):
|
|||||||
LANGUAGES = f"{CONTAINER}//dt[contains(text(),'Język:')]{SIBLINGS}/text()"
|
LANGUAGES = f"{CONTAINER}//dt[contains(text(),'Język:')]{SIBLINGS}/text()"
|
||||||
DESCRIPTION = f"{CONTAINER}//div[@class='collapse-content']"
|
DESCRIPTION = f"{CONTAINER}//div[@class='collapse-content']"
|
||||||
SERIES = f"{CONTAINER}//span/a[contains(@href,'/cykl/')]/text()"
|
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']"
|
DETAILS = "//div[@id='book-details']"
|
||||||
PUBLISH_DATE = "//dt[contains(@title,'Data pierwszego wydania"
|
PUBLISH_DATE = "//dt[contains(@title,'Data pierwszego wydania"
|
||||||
FIRST_PUBLISH_DATE = f"{DETAILS}{PUBLISH_DATE} oryginalnego')]{SIBLINGS}[1]/text()"
|
FIRST_PUBLISH_DATE = f"{DETAILS}{PUBLISH_DATE} oryginalnego')]{SIBLINGS}[1]/text()"
|
||||||
FIRST_PUBLISH_DATE_PL = f"{DETAILS}{PUBLISH_DATE} polskiego')]{SIBLINGS}[1]/text()"
|
FIRST_PUBLISH_DATE_PL = f"{DETAILS}{PUBLISH_DATE} polskiego')]{SIBLINGS}[1]/text()"
|
||||||
TAGS = "//nav[@aria-label='breadcrumb']//a[contains(@href,'/ksiazki/k/')]/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"
|
RATING = "//meta[@property='books:rating:value']/@content"
|
||||||
COVER = "//meta[@property='og:image']/@content"
|
COVER = "//meta[@property='og:image']/@content"
|
||||||
@@ -158,6 +160,7 @@ class LubimyCzytac(Metadata):
|
|||||||
|
|
||||||
class LubimyCzytacParser:
|
class LubimyCzytacParser:
|
||||||
PAGES_TEMPLATE = "<p id='strony'>Książka ma {0} stron(y).</p>"
|
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_TEMPLATE = "<p id='pierwsze_wydanie'>Data pierwszego wydania: {0}</p>"
|
||||||
PUBLISH_DATE_PL_TEMPLATE = (
|
PUBLISH_DATE_PL_TEMPLATE = (
|
||||||
"<p id='pierwsze_wydanie'>Data pierwszego wydania w Polsce: {0}</p>"
|
"<p id='pierwsze_wydanie'>Data pierwszego wydania w Polsce: {0}</p>"
|
||||||
@@ -282,11 +285,13 @@ class LubimyCzytacParser:
|
|||||||
|
|
||||||
def _parse_tags(self) -> List[str]:
|
def _parse_tags(self) -> List[str]:
|
||||||
tags = self._parse_xpath_node(xpath=LubimyCzytac.TAGS, take_first=False)
|
tags = self._parse_xpath_node(xpath=LubimyCzytac.TAGS, take_first=False)
|
||||||
return [
|
if tags:
|
||||||
strip_accents(w.replace(", itd.", " itd."))
|
return [
|
||||||
for w in tags
|
strip_accents(w.replace(", itd.", " itd."))
|
||||||
if isinstance(w, str)
|
for w in tags
|
||||||
]
|
if isinstance(w, str)
|
||||||
|
]
|
||||||
|
return None
|
||||||
|
|
||||||
def _parse_from_summary(self, attribute_name: str) -> Optional[str]:
|
def _parse_from_summary(self, attribute_name: str) -> Optional[str]:
|
||||||
value = None
|
value = None
|
||||||
@@ -346,5 +351,9 @@ class LubimyCzytacParser:
|
|||||||
description += LubimyCzytacParser.PUBLISH_DATE_PL_TEMPLATE.format(
|
description += LubimyCzytacParser.PUBLISH_DATE_PL_TEMPLATE.format(
|
||||||
first_publish_date_pl.strftime("%d.%m.%Y")
|
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
|
return description
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class scholar(Metadata):
|
|||||||
scholar_gen = itertools.islice(scholarly.search_pubs(query), 10)
|
scholar_gen = itertools.islice(scholarly.search_pubs(query), 10)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(e)
|
log.warning(e)
|
||||||
return None
|
return list()
|
||||||
for result in scholar_gen:
|
for result in scholar_gen:
|
||||||
match = self._parse_search_result(
|
match = self._parse_search_result(
|
||||||
result=result, generic_cover="", locale=locale
|
result=result, generic_cover="", locale=locale
|
||||||
|
|||||||
@@ -74,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
|
||||||
@@ -134,8 +134,8 @@ def bind_oauth_or_register(provider_id, provider_user_id, redirect_url, provider
|
|||||||
# already bind with user, just login
|
# already bind with user, just login
|
||||||
if oauth_entry.user:
|
if oauth_entry.user:
|
||||||
login_user(oauth_entry.user)
|
login_user(oauth_entry.user)
|
||||||
log.debug(u"You are now logged in as: '%s'", oauth_entry.user.name)
|
log.debug("You are now logged in as: '%s'", oauth_entry.user.name)
|
||||||
flash(_(u"you are now logged in as: '%(nickname)s'", nickname= oauth_entry.user.name),
|
flash(_("Success! You are now logged in as: %(nickname)s", nickname=oauth_entry.user.name),
|
||||||
category="success")
|
category="success")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
else:
|
else:
|
||||||
@@ -145,21 +145,21 @@ def bind_oauth_or_register(provider_id, provider_user_id, redirect_url, provider
|
|||||||
try:
|
try:
|
||||||
ub.session.add(oauth_entry)
|
ub.session.add(oauth_entry)
|
||||||
ub.session.commit()
|
ub.session.commit()
|
||||||
flash(_(u"Link to %(oauth)s Succeeded", oauth=provider_name), category="success")
|
flash(_("Link to %(oauth)s Succeeded", oauth=provider_name), category="success")
|
||||||
log.info("Link to {} Succeeded".format(provider_name))
|
log.info("Link to {} Succeeded".format(provider_name))
|
||||||
return redirect(url_for('web.profile'))
|
return redirect(url_for('web.profile'))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.error_or_exception(ex)
|
log.error_or_exception(ex)
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
else:
|
else:
|
||||||
flash(_(u"Login failed, No User Linked With OAuth Account"), category="error")
|
flash(_("Login failed, No User Linked With OAuth Account"), category="error")
|
||||||
log.info('Login failed, No User Linked With OAuth Account')
|
log.info('Login failed, No User Linked With OAuth Account')
|
||||||
return redirect(url_for('web.login'))
|
return redirect(url_for('web.login'))
|
||||||
# return redirect(url_for('web.login'))
|
# return redirect(url_for('web.login'))
|
||||||
# if config.config_public_reg:
|
# if config.config_public_reg:
|
||||||
# return redirect(url_for('web.register'))
|
# return redirect(url_for('web.register'))
|
||||||
# else:
|
# else:
|
||||||
# flash(_(u"Public registration is not enabled"), category="error")
|
# flash(_("Public registration is not enabled"), category="error")
|
||||||
# return redirect(url_for(redirect_url))
|
# return redirect(url_for(redirect_url))
|
||||||
except (NoResultFound, AttributeError):
|
except (NoResultFound, AttributeError):
|
||||||
return redirect(url_for(redirect_url))
|
return redirect(url_for(redirect_url))
|
||||||
@@ -194,17 +194,18 @@ def unlink_oauth(provider):
|
|||||||
ub.session.delete(oauth_entry)
|
ub.session.delete(oauth_entry)
|
||||||
ub.session.commit()
|
ub.session.commit()
|
||||||
logout_oauth_user()
|
logout_oauth_user()
|
||||||
flash(_(u"Unlink to %(oauth)s Succeeded", oauth=oauth_check[provider]), category="success")
|
flash(_("Unlink to %(oauth)s Succeeded", oauth=oauth_check[provider]), category="success")
|
||||||
log.info("Unlink to {} Succeeded".format(oauth_check[provider]))
|
log.info("Unlink to {} Succeeded".format(oauth_check[provider]))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.error_or_exception(ex)
|
log.error_or_exception(ex)
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
flash(_(u"Unlink to %(oauth)s Failed", oauth=oauth_check[provider]), category="error")
|
flash(_("Unlink to %(oauth)s Failed", oauth=oauth_check[provider]), category="error")
|
||||||
except NoResultFound:
|
except NoResultFound:
|
||||||
log.warning("oauth %s for user %d not found", provider, current_user.id)
|
log.warning("oauth %s for user %d not found", provider, current_user.id)
|
||||||
flash(_(u"Not Linked to %(oauth)s", oauth=provider), category="error")
|
flash(_("Not Linked to %(oauth)s", oauth=provider), category="error")
|
||||||
return redirect(url_for('web.profile'))
|
return redirect(url_for('web.profile'))
|
||||||
|
|
||||||
|
|
||||||
def generate_oauth_blueprints():
|
def generate_oauth_blueprints():
|
||||||
if not ub.session.query(ub.OAuthProvider).count():
|
if not ub.session.query(ub.OAuthProvider).count():
|
||||||
for provider in ("github", "google"):
|
for provider in ("github", "google"):
|
||||||
@@ -258,13 +259,13 @@ if ub.oauth_support:
|
|||||||
@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")
|
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")
|
log.error("Failed to fetch user info from GitHub")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -276,13 +277,13 @@ 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")
|
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")
|
log.error("Failed to fetch user info from Google")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -291,12 +292,13 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 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,
|
||||||
@@ -308,8 +310,8 @@ if ub.oauth_support:
|
|||||||
@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,
|
||||||
@@ -329,10 +331,10 @@ def github_login():
|
|||||||
if account_info.ok:
|
if account_info.ok:
|
||||||
account_info_json = account_info.json()
|
account_info_json = account_info.json()
|
||||||
return bind_oauth_or_register(oauthblueprints[0]['id'], account_info_json['id'], 'github.login', 'github')
|
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")
|
flash(_("GitHub Oauth error, please retry later."), category="error")
|
||||||
log.error("GitHub Oauth error, please retry later")
|
log.error("GitHub Oauth error, please retry later")
|
||||||
except (InvalidGrantError, TokenExpiredError) as e:
|
except (InvalidGrantError, TokenExpiredError) as e:
|
||||||
flash(_(u"GitHub Oauth error: {}").format(e), category="error")
|
flash(_("GitHub Oauth error: {}").format(e), category="error")
|
||||||
log.error(e)
|
log.error(e)
|
||||||
return redirect(url_for('web.login'))
|
return redirect(url_for('web.login'))
|
||||||
|
|
||||||
@@ -353,10 +355,10 @@ def google_login():
|
|||||||
if resp.ok:
|
if resp.ok:
|
||||||
account_info_json = resp.json()
|
account_info_json = resp.json()
|
||||||
return bind_oauth_or_register(oauthblueprints[1]['id'], account_info_json['id'], 'google.login', 'google')
|
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")
|
flash(_("Google Oauth error, please retry later."), category="error")
|
||||||
log.error("Google Oauth error, please retry later")
|
log.error("Google Oauth error, please retry later")
|
||||||
except (InvalidGrantError, TokenExpiredError) as e:
|
except (InvalidGrantError, TokenExpiredError) as e:
|
||||||
flash(_(u"Google Oauth error: {}").format(e), category="error")
|
flash(_("Google Oauth error: {}").format(e), category="error")
|
||||||
log.error(e)
|
log.error(e)
|
||||||
return redirect(url_for('web.login'))
|
return redirect(url_for('web.login'))
|
||||||
|
|
||||||
|
|||||||
@@ -21,41 +21,28 @@
|
|||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
from urllib.parse import unquote_plus
|
from urllib.parse import unquote_plus
|
||||||
from functools import wraps
|
|
||||||
|
|
||||||
from flask import Blueprint, request, render_template, Response, g, make_response, abort
|
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 flask_babel import get_locale
|
from flask_babel import get_locale
|
||||||
|
from flask_babel import gettext as _
|
||||||
from sqlalchemy.sql.expression import func, text, or_, and_, true
|
from sqlalchemy.sql.expression import func, text, or_, and_, true
|
||||||
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
from sqlalchemy.exc import InvalidRequestError, OperationalError
|
||||||
from werkzeug.security import check_password_hash
|
|
||||||
|
|
||||||
from . import constants, logger, config, db, calibre_db, ub, services, isoLanguages
|
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 .helper import get_download_link, get_book_cover
|
||||||
from .pagination import Pagination
|
from .pagination import Pagination
|
||||||
from .web import render_read_books
|
from .web import render_read_books
|
||||||
from .usermanagement import load_user_from_request
|
|
||||||
from flask_babel import gettext as _
|
|
||||||
|
|
||||||
opds = Blueprint('opds', __name__)
|
opds = Blueprint('opds', __name__)
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
def requires_basic_auth_if_no_ano(f):
|
|
||||||
@wraps(f)
|
|
||||||
def decorated(*args, **kwargs):
|
|
||||||
auth = request.authorization
|
|
||||||
if config.config_anonbrowse != 1:
|
|
||||||
if not auth or auth.type != 'basic' or not check_auth(auth.username, auth.password):
|
|
||||||
return authenticate()
|
|
||||||
return f(*args, **kwargs)
|
|
||||||
if config.config_login_type == constants.LOGIN_LDAP and services.ldap and config.config_anonbrowse != 1:
|
|
||||||
return services.ldap.basic_auth_required(f)
|
|
||||||
return decorated
|
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/")
|
@opds.route("/opds/")
|
||||||
@opds.route("/opds")
|
@opds.route("/opds")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
@@ -69,7 +56,7 @@ def feed_osd():
|
|||||||
return render_xml_template('osd.xml', lang='en-EN')
|
return render_xml_template('osd.xml', lang='en-EN')
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/search", defaults={'query': ""})
|
# @opds.route("/opds/search", defaults={'query': ""})
|
||||||
@opds.route("/opds/search/<path:query>")
|
@opds.route("/opds/search/<path:query>")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_cc_search(query):
|
def feed_cc_search(query):
|
||||||
@@ -107,6 +94,8 @@ def feed_letter_books(book_id):
|
|||||||
@opds.route("/opds/new")
|
@opds.route("/opds/new")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_new():
|
def feed_new():
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_RECENT):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
db.Books, True, [db.Books.timestamp.desc()],
|
db.Books, True, [db.Books.timestamp.desc()],
|
||||||
@@ -117,6 +106,8 @@ def feed_new():
|
|||||||
@opds.route("/opds/discover")
|
@opds.route("/opds/discover")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_discover():
|
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)
|
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)
|
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))
|
pagination = Pagination(1, config.config_books_per_page, int(config.config_books_per_page))
|
||||||
@@ -126,6 +117,8 @@ def feed_discover():
|
|||||||
@opds.route("/opds/rated")
|
@opds.route("/opds/rated")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_best_rated():
|
def feed_best_rated():
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_BEST_RATED):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 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, db.Books.ratings.any(db.Ratings.rating > 9),
|
||||||
@@ -137,6 +130,8 @@ def feed_best_rated():
|
|||||||
@opds.route("/opds/hot")
|
@opds.route("/opds/hot")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_hot():
|
def feed_hot():
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_HOT):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
all_books = ub.session.query(ub.Downloads, func.count(ub.Downloads.book_id)).order_by(
|
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)
|
func.count(ub.Downloads.book_id).desc()).group_by(ub.Downloads.book_id)
|
||||||
@@ -159,12 +154,16 @@ def feed_hot():
|
|||||||
@opds.route("/opds/author")
|
@opds.route("/opds/author")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_authorindex():
|
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')
|
return render_element_index(db.Authors.sort, db.books_authors_link, 'opds.feed_letter_author')
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/author/letter/<book_id>")
|
@opds.route("/opds/author/letter/<book_id>")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_letter_author(book_id):
|
def feed_letter_author(book_id):
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_AUTHOR):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
letter = true() if book_id == "00" else func.upper(db.Authors.sort).startswith(book_id)
|
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)\
|
entries = calibre_db.session.query(db.Authors).join(db.books_authors_link).join(db.Books)\
|
||||||
@@ -186,6 +185,8 @@ def feed_author(book_id):
|
|||||||
@opds.route("/opds/publisher")
|
@opds.route("/opds/publisher")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_publisherindex():
|
def feed_publisherindex():
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_PUBLISHER):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries = calibre_db.session.query(db.Publishers)\
|
entries = calibre_db.session.query(db.Publishers)\
|
||||||
.join(db.books_publishers_link)\
|
.join(db.books_publishers_link)\
|
||||||
@@ -207,12 +208,16 @@ def feed_publisher(book_id):
|
|||||||
@opds.route("/opds/category")
|
@opds.route("/opds/category")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_categoryindex():
|
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')
|
return render_element_index(db.Tags.name, db.books_tags_link, 'opds.feed_letter_category')
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/category/letter/<book_id>")
|
@opds.route("/opds/category/letter/<book_id>")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_letter_category(book_id):
|
def feed_letter_category(book_id):
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_CATEGORY):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
letter = true() if book_id == "00" else func.upper(db.Tags.name).startswith(book_id)
|
letter = true() if book_id == "00" else func.upper(db.Tags.name).startswith(book_id)
|
||||||
entries = calibre_db.session.query(db.Tags)\
|
entries = calibre_db.session.query(db.Tags)\
|
||||||
@@ -236,12 +241,16 @@ def feed_category(book_id):
|
|||||||
@opds.route("/opds/series")
|
@opds.route("/opds/series")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_seriesindex():
|
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')
|
return render_element_index(db.Series.sort, db.books_series_link, 'opds.feed_letter_series')
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/opds/series/letter/<book_id>")
|
@opds.route("/opds/series/letter/<book_id>")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_letter_series(book_id):
|
def feed_letter_series(book_id):
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_SERIES):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
letter = true() if book_id == "00" else func.upper(db.Series.sort).startswith(book_id)
|
letter = true() if book_id == "00" else func.upper(db.Series.sort).startswith(book_id)
|
||||||
entries = calibre_db.session.query(db.Series)\
|
entries = calibre_db.session.query(db.Series)\
|
||||||
@@ -271,6 +280,8 @@ def feed_series(book_id):
|
|||||||
@opds.route("/opds/ratings")
|
@opds.route("/opds/ratings")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_ratingindex():
|
def feed_ratingindex():
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_RATING):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries = calibre_db.session.query(db.Ratings, func.count('books_ratings_link.book').label('count'),
|
entries = calibre_db.session.query(db.Ratings, func.count('books_ratings_link.book').label('count'),
|
||||||
(db.Ratings.rating / 2).label('name')) \
|
(db.Ratings.rating / 2).label('name')) \
|
||||||
@@ -297,6 +308,8 @@ def feed_ratings(book_id):
|
|||||||
@opds.route("/opds/formats")
|
@opds.route("/opds/formats")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_formatindex():
|
def feed_formatindex():
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_FORMAT):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries = calibre_db.session.query(db.Data).join(db.Books)\
|
entries = calibre_db.session.query(db.Data).join(db.Books)\
|
||||||
.filter(calibre_db.common_filters()) \
|
.filter(calibre_db.common_filters()) \
|
||||||
@@ -304,7 +317,6 @@ def feed_formatindex():
|
|||||||
.order_by(db.Data.format).all()
|
.order_by(db.Data.format).all()
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
len(entries))
|
len(entries))
|
||||||
|
|
||||||
element = list()
|
element = list()
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
element.append(FeedObject(entry.format, entry.format))
|
element.append(FeedObject(entry.format, entry.format))
|
||||||
@@ -327,8 +339,10 @@ def feed_format(book_id):
|
|||||||
@opds.route("/opds/language/")
|
@opds.route("/opds/language/")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_languagesindex():
|
def feed_languagesindex():
|
||||||
|
if not current_user.check_visibility(constants.SIDEBAR_LANGUAGE):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
if current_user.filter_language() == u"all":
|
if current_user.filter_language() == "all":
|
||||||
languages = calibre_db.speaking_language()
|
languages = calibre_db.speaking_language()
|
||||||
else:
|
else:
|
||||||
languages = calibre_db.session.query(db.Languages).filter(
|
languages = calibre_db.session.query(db.Languages).filter(
|
||||||
@@ -354,8 +368,11 @@ def feed_languages(book_id):
|
|||||||
@opds.route("/opds/shelfindex")
|
@opds.route("/opds/shelfindex")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_shelfindex():
|
def feed_shelfindex():
|
||||||
|
if not (current_user.is_authenticated or g.allow_anonymous):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
shelf = g.shelves_access
|
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)
|
number = len(shelf)
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
number)
|
number)
|
||||||
@@ -365,6 +382,8 @@ def feed_shelfindex():
|
|||||||
@opds.route("/opds/shelf/<int:book_id>")
|
@opds.route("/opds/shelf/<int:book_id>")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_shelf(book_id):
|
def feed_shelf(book_id):
|
||||||
|
if not (current_user.is_authenticated or g.allow_anonymous):
|
||||||
|
abort(404)
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
if current_user.is_anonymous:
|
if current_user.is_anonymous:
|
||||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1,
|
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.is_public == 1,
|
||||||
@@ -375,6 +394,7 @@ def feed_shelf(book_id):
|
|||||||
and_(ub.Shelf.is_public == 1,
|
and_(ub.Shelf.is_public == 1,
|
||||||
ub.Shelf.id == book_id))).first()
|
ub.Shelf.id == book_id))).first()
|
||||||
result = list()
|
result = list()
|
||||||
|
pagination = list()
|
||||||
# user is allowed to access shelf
|
# user is allowed to access shelf
|
||||||
if shelf:
|
if shelf:
|
||||||
result, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
result, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
||||||
@@ -402,11 +422,7 @@ def feed_shelf(book_id):
|
|||||||
@opds.route("/opds/download/<book_id>/<book_format>/")
|
@opds.route("/opds/download/<book_id>/<book_format>/")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def opds_download_link(book_id, book_format):
|
def opds_download_link(book_id, book_format):
|
||||||
# I gave up with this: With enabled ldap login, the user doesn't get logged in, therefore it's always guest
|
if not current_user.role_download():
|
||||||
# workaround, loading the user from the request and checking its download rights here
|
|
||||||
# in case of anonymous browsing user is None
|
|
||||||
user = load_user_from_request(request) or current_user
|
|
||||||
if not user.role_download():
|
|
||||||
return abort(403)
|
return abort(403)
|
||||||
if "Kobo" in request.headers.get('User-Agent'):
|
if "Kobo" in request.headers.get('User-Agent'):
|
||||||
client = "kobo"
|
client = "kobo"
|
||||||
@@ -429,6 +445,17 @@ def get_metadata_calibre_companion(uuid, library):
|
|||||||
return ""
|
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/thumb_240_240/<book_id>")
|
||||||
@opds.route("/opds/cover_240_240/<book_id>")
|
@opds.route("/opds/cover_240_240/<book_id>")
|
||||||
@opds.route("/opds/cover_90_90/<book_id>")
|
@opds.route("/opds/cover_90_90/<book_id>")
|
||||||
@@ -441,6 +468,8 @@ def feed_get_cover(book_id):
|
|||||||
@opds.route("/opds/readbooks")
|
@opds.route("/opds/readbooks")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_read_books():
|
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
|
off = request.args.get("offset") or 0
|
||||||
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, True, True)
|
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)
|
return render_xml_template('feed.xml', entries=result, pagination=pagination)
|
||||||
@@ -449,6 +478,8 @@ def feed_read_books():
|
|||||||
@opds.route("/opds/unreadbooks")
|
@opds.route("/opds/unreadbooks")
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_unread_books():
|
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
|
off = request.args.get("offset") or 0
|
||||||
result, pagination = render_read_books(int(off) / (int(config.config_books_per_page)) + 1, False, True)
|
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)
|
return render_xml_template('feed.xml', entries=result, pagination=pagination)
|
||||||
@@ -478,32 +509,11 @@ def feed_search(term):
|
|||||||
return render_xml_template('feed.xml', searchterm="")
|
return render_xml_template('feed.xml', searchterm="")
|
||||||
|
|
||||||
|
|
||||||
def check_auth(username, password):
|
|
||||||
try:
|
|
||||||
username = username.encode('windows-1252')
|
|
||||||
except UnicodeEncodeError:
|
|
||||||
username = username.encode('utf-8')
|
|
||||||
user = ub.session.query(ub.User).filter(func.lower(ub.User.name) ==
|
|
||||||
username.decode('utf-8').lower()).first()
|
|
||||||
if bool(user and check_password_hash(str(user.password), password)):
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
ip_address = request.headers.get('X-Forwarded-For', request.remote_addr)
|
|
||||||
log.warning('OPDS Login failed for user "%s" IP-address: %s', username.decode('utf-8'), ip_address)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -528,7 +538,7 @@ def render_element_index(database_column, linked_table, folder):
|
|||||||
entries = entries.join(linked_table).join(db.Books)
|
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()
|
entries = entries.filter(calibre_db.common_filters()).group_by(func.upper(func.substr(database_column, 1, 1))).all()
|
||||||
elements = []
|
elements = []
|
||||||
if off == 0:
|
if off == 0 and entries:
|
||||||
elements.append({'id': "00", 'name': _("All")})
|
elements.append({'id': "00", 'name': _("All")})
|
||||||
shift = 1
|
shift = 1
|
||||||
for entry in entries[
|
for entry in entries[
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
from urllib.parse import urlparse, urljoin
|
from urllib.parse 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):
|
||||||
@@ -38,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
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ def remote_login():
|
|||||||
ub.session.add(auth_token)
|
ub.session.add(auth_token)
|
||||||
ub.session_commit()
|
ub.session_commit()
|
||||||
verify_url = url_for('remotelogin.verify_token', token=auth_token.auth_token, _external=true)
|
verify_url = url_for('remotelogin.verify_token', token=auth_token.auth_token, _external=true)
|
||||||
log.debug(u"Remot Login request with token: %s", auth_token.auth_token)
|
log.debug("Remot Login request with token: %s", auth_token.auth_token)
|
||||||
return render_title_template('remote_login.html', title=_(u"Login"), token=auth_token.auth_token,
|
return render_title_template('remote_login.html', title=_("Login"), token=auth_token.auth_token,
|
||||||
verify_url=verify_url, page="remotelogin")
|
verify_url=verify_url, page="remotelogin")
|
||||||
|
|
||||||
|
|
||||||
@@ -71,8 +71,8 @@ def verify_token(token):
|
|||||||
|
|
||||||
# Token not found
|
# Token not found
|
||||||
if auth_token is None:
|
if auth_token is None:
|
||||||
flash(_(u"Token not found"), category="error")
|
flash(_("Token not found"), category="error")
|
||||||
log.error(u"Remote Login token not found")
|
log.error("Remote Login token not found")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
|
|
||||||
# Token expired
|
# Token expired
|
||||||
@@ -80,8 +80,8 @@ def verify_token(token):
|
|||||||
ub.session.delete(auth_token)
|
ub.session.delete(auth_token)
|
||||||
ub.session_commit()
|
ub.session_commit()
|
||||||
|
|
||||||
flash(_(u"Token has expired"), category="error")
|
flash(_("Token has expired"), category="error")
|
||||||
log.error(u"Remote Login token expired")
|
log.error("Remote Login token expired")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
|
|
||||||
# Update token with user information
|
# Update token with user information
|
||||||
@@ -89,8 +89,8 @@ def verify_token(token):
|
|||||||
auth_token.verified = True
|
auth_token.verified = True
|
||||||
ub.session_commit()
|
ub.session_commit()
|
||||||
|
|
||||||
flash(_(u"Success! Please return to your device"), category="success")
|
flash(_("Success! Please return to your device"), category="success")
|
||||||
log.debug(u"Remote Login token for userid %s verified", auth_token.user_id)
|
log.debug("Remote Login token for userid %s verified", auth_token.user_id)
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
|
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ def token_verified():
|
|||||||
# Token not found
|
# Token not found
|
||||||
if auth_token is None:
|
if auth_token is None:
|
||||||
data['status'] = 'error'
|
data['status'] = 'error'
|
||||||
data['message'] = _(u"Token not found")
|
data['message'] = _("Token not found")
|
||||||
|
|
||||||
# Token expired
|
# Token expired
|
||||||
elif datetime.now() > auth_token.expiration:
|
elif datetime.now() > auth_token.expiration:
|
||||||
@@ -113,7 +113,7 @@ def token_verified():
|
|||||||
ub.session_commit()
|
ub.session_commit()
|
||||||
|
|
||||||
data['status'] = 'error'
|
data['status'] = 'error'
|
||||||
data['message'] = _(u"Token has expired")
|
data['message'] = _("Token has expired")
|
||||||
|
|
||||||
elif not auth_token.verified:
|
elif not auth_token.verified:
|
||||||
data['status'] = 'not_verified'
|
data['status'] = 'not_verified'
|
||||||
@@ -126,8 +126,8 @@ def token_verified():
|
|||||||
ub.session_commit("User {} logged in via remotelogin, token deleted".format(user.name))
|
ub.session_commit("User {} logged in via remotelogin, token deleted".format(user.name))
|
||||||
|
|
||||||
data['status'] = 'success'
|
data['status'] = 'success'
|
||||||
log.debug(u"Remote Login for userid %s succeeded", user.id)
|
log.debug("Remote Login for userid %s succeeded", user.id)
|
||||||
flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.name), category="success")
|
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 = make_response(json.dumps(data, ensure_ascii=False))
|
||||||
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ from flask import render_template, g, abort, request
|
|||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from werkzeug.local import LocalProxy
|
from werkzeug.local import LocalProxy
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
|
from sqlalchemy.sql.expression import or_
|
||||||
|
|
||||||
from . import config, constants, logger
|
from . import config, constants, logger, ub
|
||||||
from .ub import User
|
from .ub import User
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
def get_sidebar_config(kwargs=None):
|
def get_sidebar_config(kwargs=None):
|
||||||
@@ -45,12 +47,12 @@ def get_sidebar_config(kwargs=None):
|
|||||||
"show_text": _('Show Hot Books'), "config_show": True})
|
"show_text": _('Show Hot Books'), "config_show": True})
|
||||||
if current_user.role_admin():
|
if current_user.role_admin():
|
||||||
sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.download_list',
|
sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.download_list',
|
||||||
"id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not g.user.is_anonymous),
|
"id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not current_user.is_anonymous),
|
||||||
"page": "download", "show_text": _('Show Downloaded Books'),
|
"page": "download", "show_text": _('Show Downloaded Books'),
|
||||||
"config_show": content})
|
"config_show": content})
|
||||||
else:
|
else:
|
||||||
sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.books_list',
|
sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.books_list',
|
||||||
"id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not g.user.is_anonymous),
|
"id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not current_user.is_anonymous),
|
||||||
"page": "download", "show_text": _('Show Downloaded Books'),
|
"page": "download", "show_text": _('Show Downloaded Books'),
|
||||||
"config_show": content})
|
"config_show": content})
|
||||||
sidebar.append(
|
sidebar.append(
|
||||||
@@ -58,47 +60,50 @@ def get_sidebar_config(kwargs=None):
|
|||||||
"visibility": constants.SIDEBAR_BEST_RATED, 'public': True, "page": "rated",
|
"visibility": constants.SIDEBAR_BEST_RATED, 'public': True, "page": "rated",
|
||||||
"show_text": _('Show Top Rated Books'), "config_show": True})
|
"show_text": _('Show Top Rated Books'), "config_show": True})
|
||||||
sidebar.append({"glyph": "glyphicon-eye-open", "text": _('Read Books'), "link": 'web.books_list', "id": "read",
|
sidebar.append({"glyph": "glyphicon-eye-open", "text": _('Read Books'), "link": 'web.books_list', "id": "read",
|
||||||
"visibility": constants.SIDEBAR_READ_AND_UNREAD, 'public': (not g.user.is_anonymous),
|
"visibility": constants.SIDEBAR_READ_AND_UNREAD, 'public': (not current_user.is_anonymous),
|
||||||
"page": "read", "show_text": _('Show read and unread'), "config_show": content})
|
"page": "read", "show_text": _('Show Read and Unread'), "config_show": content})
|
||||||
sidebar.append(
|
sidebar.append(
|
||||||
{"glyph": "glyphicon-eye-close", "text": _('Unread Books'), "link": 'web.books_list', "id": "unread",
|
{"glyph": "glyphicon-eye-close", "text": _('Unread Books'), "link": 'web.books_list', "id": "unread",
|
||||||
"visibility": constants.SIDEBAR_READ_AND_UNREAD, 'public': (not g.user.is_anonymous), "page": "unread",
|
"visibility": constants.SIDEBAR_READ_AND_UNREAD, 'public': (not current_user.is_anonymous), "page": "unread",
|
||||||
"show_text": _('Show unread'), "config_show": False})
|
"show_text": _('Show unread'), "config_show": False})
|
||||||
sidebar.append({"glyph": "glyphicon-random", "text": _('Discover'), "link": 'web.books_list', "id": "rand",
|
sidebar.append({"glyph": "glyphicon-random", "text": _('Discover'), "link": 'web.books_list', "id": "rand",
|
||||||
"visibility": constants.SIDEBAR_RANDOM, 'public': True, "page": "discover",
|
"visibility": constants.SIDEBAR_RANDOM, 'public': True, "page": "discover",
|
||||||
"show_text": _('Show Random Books'), "config_show": True})
|
"show_text": _('Show Random Books'), "config_show": True})
|
||||||
sidebar.append({"glyph": "glyphicon-inbox", "text": _('Categories'), "link": 'web.category_list', "id": "cat",
|
sidebar.append({"glyph": "glyphicon-inbox", "text": _('Categories'), "link": 'web.category_list', "id": "cat",
|
||||||
"visibility": constants.SIDEBAR_CATEGORY, 'public': True, "page": "category",
|
"visibility": constants.SIDEBAR_CATEGORY, 'public': True, "page": "category",
|
||||||
"show_text": _('Show category selection'), "config_show": True})
|
"show_text": _('Show Category Section'), "config_show": True})
|
||||||
sidebar.append({"glyph": "glyphicon-bookmark", "text": _('Series'), "link": 'web.series_list', "id": "serie",
|
sidebar.append({"glyph": "glyphicon-bookmark", "text": _('Series'), "link": 'web.series_list', "id": "serie",
|
||||||
"visibility": constants.SIDEBAR_SERIES, 'public': True, "page": "series",
|
"visibility": constants.SIDEBAR_SERIES, 'public': True, "page": "series",
|
||||||
"show_text": _('Show series selection'), "config_show": True})
|
"show_text": _('Show Series Section'), "config_show": True})
|
||||||
sidebar.append({"glyph": "glyphicon-user", "text": _('Authors'), "link": 'web.author_list', "id": "author",
|
sidebar.append({"glyph": "glyphicon-user", "text": _('Authors'), "link": 'web.author_list', "id": "author",
|
||||||
"visibility": constants.SIDEBAR_AUTHOR, 'public': True, "page": "author",
|
"visibility": constants.SIDEBAR_AUTHOR, 'public': True, "page": "author",
|
||||||
"show_text": _('Show author selection'), "config_show": True})
|
"show_text": _('Show Author Section'), "config_show": True})
|
||||||
sidebar.append(
|
sidebar.append(
|
||||||
{"glyph": "glyphicon-text-size", "text": _('Publishers'), "link": 'web.publisher_list', "id": "publisher",
|
{"glyph": "glyphicon-text-size", "text": _('Publishers'), "link": 'web.publisher_list', "id": "publisher",
|
||||||
"visibility": constants.SIDEBAR_PUBLISHER, 'public': True, "page": "publisher",
|
"visibility": constants.SIDEBAR_PUBLISHER, 'public': True, "page": "publisher",
|
||||||
"show_text": _('Show publisher selection'), "config_show":True})
|
"show_text": _('Show Publisher Section'), "config_show":True})
|
||||||
sidebar.append({"glyph": "glyphicon-flag", "text": _('Languages'), "link": 'web.language_overview', "id": "lang",
|
sidebar.append({"glyph": "glyphicon-flag", "text": _('Languages'), "link": 'web.language_overview', "id": "lang",
|
||||||
"visibility": constants.SIDEBAR_LANGUAGE, 'public': (g.user.filter_language() == 'all'),
|
"visibility": constants.SIDEBAR_LANGUAGE, 'public': (current_user.filter_language() == 'all'),
|
||||||
"page": "language",
|
"page": "language",
|
||||||
"show_text": _('Show language selection'), "config_show": True})
|
"show_text": _('Show Language Section'), "config_show": True})
|
||||||
sidebar.append({"glyph": "glyphicon-star-empty", "text": _('Ratings'), "link": 'web.ratings_list', "id": "rate",
|
sidebar.append({"glyph": "glyphicon-star-empty", "text": _('Ratings'), "link": 'web.ratings_list', "id": "rate",
|
||||||
"visibility": constants.SIDEBAR_RATING, 'public': True,
|
"visibility": constants.SIDEBAR_RATING, 'public': True,
|
||||||
"page": "rating", "show_text": _('Show ratings selection'), "config_show": 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",
|
sidebar.append({"glyph": "glyphicon-file", "text": _('File formats'), "link": 'web.formats_list', "id": "format",
|
||||||
"visibility": constants.SIDEBAR_FORMAT, 'public': True,
|
"visibility": constants.SIDEBAR_FORMAT, 'public': True,
|
||||||
"page": "format", "show_text": _('Show file formats selection'), "config_show": True})
|
"page": "format", "show_text": _('Show File Formats Section'), "config_show": True})
|
||||||
sidebar.append(
|
sidebar.append(
|
||||||
{"glyph": "glyphicon-trash", "text": _('Archived Books'), "link": 'web.books_list', "id": "archived",
|
{"glyph": "glyphicon-trash", "text": _('Archived Books'), "link": 'web.books_list', "id": "archived",
|
||||||
"visibility": constants.SIDEBAR_ARCHIVED, 'public': (not g.user.is_anonymous), "page": "archived",
|
"visibility": constants.SIDEBAR_ARCHIVED, 'public': (not current_user.is_anonymous), "page": "archived",
|
||||||
"show_text": _('Show archived books'), "config_show": content})
|
"show_text": _('Show Archived Books'), "config_show": content})
|
||||||
if not simple:
|
if not simple:
|
||||||
sidebar.append(
|
sidebar.append(
|
||||||
{"glyph": "glyphicon-th-list", "text": _('Books List'), "link": 'web.books_table', "id": "list",
|
{"glyph": "glyphicon-th-list", "text": _('Books List'), "link": 'web.books_table', "id": "list",
|
||||||
"visibility": constants.SIDEBAR_LIST, 'public': (not g.user.is_anonymous), "page": "list",
|
"visibility": constants.SIDEBAR_LIST, 'public': (not current_user.is_anonymous), "page": "list",
|
||||||
"show_text": _('Show Books List'), "config_show": content})
|
"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
|
return sidebar, simple
|
||||||
|
|
||||||
|
|
||||||
@@ -107,7 +112,7 @@ def render_title_template(*args, **kwargs):
|
|||||||
sidebar, simple = get_sidebar_config(kwargs)
|
sidebar, simple = get_sidebar_config(kwargs)
|
||||||
try:
|
try:
|
||||||
return render_template(instance=config.config_calibre_web_title, sidebar=sidebar, simple=simple,
|
return render_template(instance=config.config_calibre_web_title, sidebar=sidebar, simple=simple,
|
||||||
accept=constants.EXTENSIONS_UPLOAD,
|
accept=config.config_upload_formats.split(','),
|
||||||
*args, **kwargs)
|
*args, **kwargs)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
log.error("No permission to access {} file.".format(args[0]))
|
log.error("No permission to access {} file.".format(args[0]))
|
||||||
|
|||||||
@@ -19,19 +19,26 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from . import config, constants
|
from . import config, constants
|
||||||
from .services.background_scheduler import BackgroundScheduler, use_APScheduler
|
from .services.background_scheduler import BackgroundScheduler, CronTrigger, use_APScheduler
|
||||||
from .tasks.database import TaskReconnectDatabase
|
from .tasks.database import TaskReconnectDatabase
|
||||||
|
from .tasks.tempFolder import TaskDeleteTempFolder
|
||||||
from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails, TaskClearCoverThumbnailCache
|
from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails, TaskClearCoverThumbnailCache
|
||||||
from .services.worker import WorkerThread
|
from .services.worker import WorkerThread
|
||||||
|
from .tasks.metadata_backup import TaskBackupMetadata
|
||||||
|
|
||||||
def get_scheduled_tasks(reconnect=True):
|
def get_scheduled_tasks(reconnect=True):
|
||||||
tasks = list()
|
tasks = list()
|
||||||
# config.schedule_reconnect or
|
# Reconnect Calibre database (metadata.db) based on config.schedule_reconnect
|
||||||
# Reconnect Calibre database (metadata.db)
|
|
||||||
if reconnect:
|
if reconnect:
|
||||||
tasks.append([lambda: TaskReconnectDatabase(), 'reconnect', False])
|
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
|
# Generate all missing book cover thumbnails
|
||||||
if config.schedule_generate_book_covers:
|
if config.schedule_generate_book_covers:
|
||||||
tasks.append([lambda: TaskClearCoverThumbnailCache(0), 'delete superfluous book covers', True])
|
tasks.append([lambda: TaskClearCoverThumbnailCache(0), 'delete superfluous book covers', True])
|
||||||
@@ -62,10 +69,13 @@ def register_scheduled_tasks(reconnect=True):
|
|||||||
duration = config.schedule_duration
|
duration = config.schedule_duration
|
||||||
|
|
||||||
# Register scheduled tasks
|
# Register scheduled tasks
|
||||||
scheduler.schedule_tasks(tasks=get_scheduled_tasks(reconnect), trigger='cron', hour=start)
|
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)
|
end_time = calclulate_end_time(start, duration)
|
||||||
scheduler.schedule(func=end_scheduled_tasks, trigger='cron', name="end scheduled task", hour=end_time.hour,
|
scheduler.schedule(func=end_scheduled_tasks, trigger=CronTrigger(hour=end_time.hour, minute=end_time.minute,
|
||||||
minute=end_time.minute)
|
timezone=timezone_info),
|
||||||
|
name="end scheduled task")
|
||||||
|
|
||||||
# Kick-off tasks, if they should currently be running
|
# Kick-off tasks, if they should currently be running
|
||||||
if should_task_be_running(start, duration):
|
if should_task_be_running(start, duration):
|
||||||
@@ -83,6 +93,8 @@ def register_startup_tasks():
|
|||||||
# Ignore tasks that should currently be running, as these will be added when registering scheduled tasks
|
# 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):
|
if constants.APP_MODE in ['development', 'test'] and not should_task_be_running(start, duration):
|
||||||
scheduler.schedule_tasks_immediately(tasks=get_scheduled_tasks(False))
|
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):
|
def should_task_be_running(start, duration):
|
||||||
@@ -91,6 +103,7 @@ def should_task_be_running(start, duration):
|
|||||||
end_time = start_time + datetime.timedelta(hours=duration // 60, minutes=duration % 60)
|
end_time = start_time + datetime.timedelta(hours=duration // 60, minutes=duration % 60)
|
||||||
return start_time < now < end_time
|
return start_time < now < end_time
|
||||||
|
|
||||||
|
|
||||||
def calclulate_end_time(start, duration):
|
def calclulate_end_time(start, duration):
|
||||||
start_time = datetime.datetime.now().replace(hour=start, minute=0)
|
start_time = datetime.datetime.now().replace(hour=start, minute=0)
|
||||||
return start_time + datetime.timedelta(hours=duration // 60, minutes=duration % 60)
|
return start_time + datetime.timedelta(hours=duration // 60, minutes=duration % 60)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ def simple_search():
|
|||||||
return render_title_template('search.html',
|
return render_title_template('search.html',
|
||||||
searchterm="",
|
searchterm="",
|
||||||
result_count=0,
|
result_count=0,
|
||||||
title=_(u"Search"),
|
title=_("Search"),
|
||||||
page="search")
|
page="search")
|
||||||
|
|
||||||
|
|
||||||
@@ -185,18 +185,18 @@ def extend_search_term(searchterm,
|
|||||||
searchterm.extend((author_name.replace('|', ','), book_title, publisher))
|
searchterm.extend((author_name.replace('|', ','), book_title, publisher))
|
||||||
if pub_start:
|
if pub_start:
|
||||||
try:
|
try:
|
||||||
searchterm.extend([_(u"Published after ") +
|
searchterm.extend([_("Published after ") +
|
||||||
format_date(datetime.strptime(pub_start, "%Y-%m-%d"),
|
format_date(datetime.strptime(pub_start, "%Y-%m-%d"),
|
||||||
format='medium')])
|
format='medium')])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pub_start = u""
|
pub_start = ""
|
||||||
if pub_end:
|
if pub_end:
|
||||||
try:
|
try:
|
||||||
searchterm.extend([_(u"Published before ") +
|
searchterm.extend([_("Published before ") +
|
||||||
format_date(datetime.strptime(pub_end, "%Y-%m-%d"),
|
format_date(datetime.strptime(pub_end, "%Y-%m-%d"),
|
||||||
format='medium')])
|
format='medium')])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pub_end = u""
|
pub_end = ""
|
||||||
elements = {'tag': db.Tags, 'serie':db.Series, 'shelf':ub.Shelf}
|
elements = {'tag': db.Tags, 'serie':db.Series, 'shelf':ub.Shelf}
|
||||||
for key, db_element in elements.items():
|
for key, db_element in elements.items():
|
||||||
tag_names = calibre_db.session.query(db_element).filter(db_element.id.in_(tags['include_' + key])).all()
|
tag_names = calibre_db.session.query(db_element).filter(db_element.id.in_(tags['include_' + key])).all()
|
||||||
@@ -214,11 +214,11 @@ def extend_search_term(searchterm,
|
|||||||
language_names = calibre_db.speaking_language(language_names)
|
language_names = calibre_db.speaking_language(language_names)
|
||||||
searchterm.extend(language.name for language in language_names)
|
searchterm.extend(language.name for language in language_names)
|
||||||
if rating_high:
|
if rating_high:
|
||||||
searchterm.extend([_(u"Rating <= %(rating)s", rating=rating_high)])
|
searchterm.extend([_("Rating <= %(rating)s", rating=rating_high)])
|
||||||
if rating_low:
|
if rating_low:
|
||||||
searchterm.extend([_(u"Rating >= %(rating)s", rating=rating_low)])
|
searchterm.extend([_("Rating >= %(rating)s", rating=rating_low)])
|
||||||
if read_status:
|
if read_status != "Any":
|
||||||
searchterm.extend([_(u"Read Status = %(status)s", status=read_status)])
|
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['include_extension'])
|
||||||
searchterm.extend(ext for ext in tags['exclude_extension'])
|
searchterm.extend(ext for ext in tags['exclude_extension'])
|
||||||
# handle custom columns
|
# handle custom columns
|
||||||
@@ -267,23 +267,23 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
|
|||||||
column_start = term.get('custom_column_' + str(c.id) + '_start')
|
column_start = term.get('custom_column_' + str(c.id) + '_start')
|
||||||
column_end = term.get('custom_column_' + str(c.id) + '_end')
|
column_end = term.get('custom_column_' + str(c.id) + '_end')
|
||||||
if column_start:
|
if column_start:
|
||||||
search_term.extend([u"{} >= {}".format(c.name,
|
search_term.extend(["{} >= {}".format(c.name,
|
||||||
format_date(datetime.strptime(column_start, "%Y-%m-%d").date(),
|
format_date(datetime.strptime(column_start, "%Y-%m-%d").date(),
|
||||||
format='medium')
|
format='medium')
|
||||||
)])
|
)])
|
||||||
cc_present = True
|
cc_present = True
|
||||||
if column_end:
|
if column_end:
|
||||||
search_term.extend([u"{} <= {}".format(c.name,
|
search_term.extend(["{} <= {}".format(c.name,
|
||||||
format_date(datetime.strptime(column_end, "%Y-%m-%d").date(),
|
format_date(datetime.strptime(column_end, "%Y-%m-%d").date(),
|
||||||
format='medium')
|
format='medium')
|
||||||
)])
|
)])
|
||||||
cc_present = True
|
cc_present = True
|
||||||
elif term.get('custom_column_' + str(c.id)):
|
elif term.get('custom_column_' + str(c.id)):
|
||||||
search_term.extend([(u"{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))])
|
search_term.extend([("{}: {}".format(c.name, term.get('custom_column_' + str(c.id))))])
|
||||||
cc_present = True
|
cc_present = True
|
||||||
|
|
||||||
if any(tags.values()) or author_name or book_title or publisher or pub_start or pub_end or rating_low \
|
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:
|
or rating_high or description or cc_present or read_status != "Any":
|
||||||
search_term, pub_start, pub_end = extend_search_term(search_term,
|
search_term, pub_start, pub_end = extend_search_term(search_term,
|
||||||
author_name,
|
author_name,
|
||||||
book_title,
|
book_title,
|
||||||
@@ -302,7 +302,8 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
|
|||||||
q = q.filter(func.datetime(db.Books.pubdate) > func.datetime(pub_start))
|
q = q.filter(func.datetime(db.Books.pubdate) > func.datetime(pub_start))
|
||||||
if pub_end:
|
if pub_end:
|
||||||
q = q.filter(func.datetime(db.Books.pubdate) < func.datetime(pub_end))
|
q = q.filter(func.datetime(db.Books.pubdate) < func.datetime(pub_end))
|
||||||
q = q.filter(adv_search_read_status(read_status))
|
if read_status != "Any":
|
||||||
|
q = q.filter(adv_search_read_status(read_status))
|
||||||
if publisher:
|
if publisher:
|
||||||
q = q.filter(db.Books.publishers.any(func.lower(db.Publishers.name).ilike("%" + 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_tag(q, tags['include_tag'], tags['exclude_tag'])
|
||||||
@@ -339,7 +340,7 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
|
|||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
entries=entries,
|
entries=entries,
|
||||||
result_count=result_count,
|
result_count=result_count,
|
||||||
title=_(u"Advanced Search"), page="advsearch",
|
title=_("Advanced Search"), page="advsearch",
|
||||||
order=order[1])
|
order=order[1])
|
||||||
|
|
||||||
|
|
||||||
@@ -366,22 +367,28 @@ def render_prepare_search_form(cc):
|
|||||||
.filter(calibre_db.common_filters()) \
|
.filter(calibre_db.common_filters()) \
|
||||||
.group_by(db.Data.format)\
|
.group_by(db.Data.format)\
|
||||||
.order_by(db.Data.format).all()
|
.order_by(db.Data.format).all()
|
||||||
if current_user.filter_language() == u"all":
|
if current_user.filter_language() == "all":
|
||||||
languages = calibre_db.speaking_language()
|
languages = calibre_db.speaking_language()
|
||||||
else:
|
else:
|
||||||
languages = None
|
languages = None
|
||||||
return render_title_template('search_form.html', tags=tags, languages=languages, extensions=extensions,
|
return render_title_template('search_form.html', tags=tags, languages=languages, extensions=extensions,
|
||||||
series=series,shelves=shelves, title=_(u"Advanced Search"), cc=cc, page="advsearch")
|
series=series,shelves=shelves, title=_("Advanced Search"), cc=cc, page="advsearch")
|
||||||
|
|
||||||
|
|
||||||
def render_search_results(term, offset=None, order=None, limit=None):
|
def render_search_results(term, offset=None, order=None, limit=None):
|
||||||
join = db.books_series_link, db.Books.id == db.books_series_link.c.book, db.Series
|
if term:
|
||||||
entries, result_count, pagination = calibre_db.get_search_results(term,
|
join = db.books_series_link, db.Books.id == db.books_series_link.c.book, db.Series
|
||||||
config,
|
entries, result_count, pagination = calibre_db.get_search_results(term,
|
||||||
offset,
|
config,
|
||||||
order,
|
offset,
|
||||||
limit,
|
order,
|
||||||
*join)
|
limit,
|
||||||
|
*join)
|
||||||
|
else:
|
||||||
|
entries = list()
|
||||||
|
order = [None, None]
|
||||||
|
pagination = result_count = None
|
||||||
|
|
||||||
return render_title_template('search.html',
|
return render_title_template('search.html',
|
||||||
searchterm=term,
|
searchterm=term,
|
||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
@@ -389,7 +396,7 @@ def render_search_results(term, offset=None, order=None, limit=None):
|
|||||||
adv_searchterm=term,
|
adv_searchterm=term,
|
||||||
entries=entries,
|
entries=entries,
|
||||||
result_count=result_count,
|
result_count=result_count,
|
||||||
title=_(u"Search"),
|
title=_("Search"),
|
||||||
page="search",
|
page="search",
|
||||||
order=order[1])
|
order=order[1])
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,13 @@ import os
|
|||||||
import errno
|
import errno
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import subprocess # nosec
|
import asyncio
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from gevent.pywsgi import WSGIServer
|
from gevent.pywsgi import WSGIServer
|
||||||
from .gevent_wsgi import MyWSGIHandler
|
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
|
from greenlet import GreenletExit
|
||||||
import ssl
|
import ssl
|
||||||
@@ -36,6 +37,7 @@ except ImportError:
|
|||||||
from .tornado_wsgi import MyWSGIContainer
|
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
|
||||||
@@ -95,7 +97,13 @@ 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):
|
@staticmethod
|
||||||
|
def _make_gevent_socket_activated():
|
||||||
|
# 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)
|
||||||
@@ -103,35 +111,41 @@ 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))
|
||||||
|
|
||||||
|
address = ('::', self.listen_port)
|
||||||
try:
|
try:
|
||||||
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)
|
||||||
|
|
||||||
@@ -152,7 +166,7 @@ class WebServer(object):
|
|||||||
# The value of __package__ indicates how Python was called. It may
|
# 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
|
# not exist if a setuptools script is installed as an egg. It may be
|
||||||
# set incorrectly for entry points created with pip on Windows.
|
# set incorrectly for entry points created with pip on Windows.
|
||||||
if getattr(__main__, "__package__", None) is None or (
|
if getattr(__main__, "__package__", "") in ["", None] or (
|
||||||
os.name == "nt"
|
os.name == "nt"
|
||||||
and __main__.__package__ == ""
|
and __main__.__package__ == ""
|
||||||
and not os.path.exists(py_script)
|
and not os.path.exists(py_script)
|
||||||
@@ -193,15 +207,15 @@ class WebServer(object):
|
|||||||
rv.extend(("-m", py_module.lstrip(".")))
|
rv.extend(("-m", py_module.lstrip(".")))
|
||||||
|
|
||||||
rv.extend(args)
|
rv.extend(args)
|
||||||
|
if os.name == 'nt':
|
||||||
|
rv = ['"{}"'.format(a) for a in rv]
|
||||||
return 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, handler_class=MyWSGIHandler,
|
self.wsgiserver = WSGIServer(sock, self.app, log=self.access_logger, handler_class=MyWSGIHandler,
|
||||||
error_log=log,
|
error_log=log,
|
||||||
@@ -226,17 +240,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(MyWSGIContainer(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:
|
||||||
@@ -262,8 +301,14 @@ class WebServer(object):
|
|||||||
|
|
||||||
log.info("Performing restart of Calibre-Web")
|
log.info("Performing restart of Calibre-Web")
|
||||||
args = self._get_args_for_reloading()
|
args = self._get_args_for_reloading()
|
||||||
subprocess.call(args, close_fds=True) # nosec
|
os.execv(args[0].lstrip('"').rstrip('"'), args)
|
||||||
return True
|
|
||||||
|
@staticmethod
|
||||||
|
def shutdown_scheduler():
|
||||||
|
from .services.background_scheduler import BackgroundScheduler
|
||||||
|
scheduler = BackgroundScheduler()
|
||||||
|
if scheduler:
|
||||||
|
scheduler.scheduler.shutdown()
|
||||||
|
|
||||||
def _killServer(self, __, ___):
|
def _killServer(self, __, ___):
|
||||||
self.stop()
|
self.stop()
|
||||||
@@ -273,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)
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,9 @@
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
from flask import json
|
from flask import json
|
||||||
from .. import logger
|
from .. import logger
|
||||||
|
|
||||||
@@ -32,10 +30,10 @@ log = logger.create()
|
|||||||
|
|
||||||
|
|
||||||
def b64encode_json(json_data):
|
def b64encode_json(json_data):
|
||||||
return b64encode(json.dumps(json_data).encode())
|
return b64encode(json.dumps(json_data).encode()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
# 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()
|
||||||
|
|
||||||
@@ -49,7 +47,7 @@ def get_datetime_from_json(json_object, field_name):
|
|||||||
|
|
||||||
|
|
||||||
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.
|
||||||
@@ -175,8 +173,8 @@ class SyncToken:
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "{},{},{},{},{},{}".format(self.books_last_created,
|
return "{},{},{},{},{},{}".format(self.books_last_created,
|
||||||
self.books_last_modified,
|
self.books_last_modified,
|
||||||
self.archive_last_modified,
|
self.archive_last_modified,
|
||||||
self.reading_state_last_modified,
|
self.reading_state_last_modified,
|
||||||
self.tags_last_modified,
|
self.tags_last_modified,
|
||||||
self.raw_kobo_store_token)
|
self.raw_kobo_store_token)
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ from .worker import WorkerThread
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler as BScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler as BScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
from apscheduler.triggers.date import DateTrigger
|
||||||
use_APScheduler = True
|
use_APScheduler = True
|
||||||
except (ImportError, RuntimeError) as e:
|
except (ImportError, RuntimeError) as e:
|
||||||
use_APScheduler = False
|
use_APScheduler = False
|
||||||
@@ -43,35 +45,33 @@ class BackgroundScheduler:
|
|||||||
cls.scheduler = BScheduler()
|
cls.scheduler = BScheduler()
|
||||||
cls.scheduler.start()
|
cls.scheduler.start()
|
||||||
|
|
||||||
atexit.register(lambda: cls.scheduler.shutdown())
|
|
||||||
|
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
def schedule(self, func, trigger, name=None, **trigger_args):
|
def schedule(self, func, trigger, name=None):
|
||||||
if use_APScheduler:
|
if use_APScheduler:
|
||||||
return self.scheduler.add_job(func=func, trigger=trigger, name=name, **trigger_args)
|
return self.scheduler.add_job(func=func, trigger=trigger, name=name)
|
||||||
|
|
||||||
# Expects a lambda expression for the task
|
# Expects a lambda expression for the task
|
||||||
def schedule_task(self, task, user=None, name=None, hidden=False, trigger='cron', **trigger_args):
|
def schedule_task(self, task, user=None, name=None, hidden=False, trigger=None):
|
||||||
if use_APScheduler:
|
if use_APScheduler:
|
||||||
def scheduled_task():
|
def scheduled_task():
|
||||||
worker_task = task()
|
worker_task = task()
|
||||||
worker_task.scheduled = True
|
worker_task.scheduled = True
|
||||||
WorkerThread.add(user, worker_task, hidden=hidden)
|
WorkerThread.add(user, worker_task, hidden=hidden)
|
||||||
return self.schedule(func=scheduled_task, trigger=trigger, name=name, **trigger_args)
|
return self.schedule(func=scheduled_task, trigger=trigger, name=name)
|
||||||
|
|
||||||
# Expects a list of lambda expressions for the tasks
|
# Expects a list of lambda expressions for the tasks
|
||||||
def schedule_tasks(self, tasks, user=None, trigger='cron', **trigger_args):
|
def schedule_tasks(self, tasks, user=None, trigger=None):
|
||||||
if use_APScheduler:
|
if use_APScheduler:
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
self.schedule_task(task[0], user=user, trigger=trigger, name=task[1], hidden=task[2], **trigger_args)
|
self.schedule_task(task[0], user=user, trigger=trigger, name=task[1], hidden=task[2])
|
||||||
|
|
||||||
# Expects a lambda expression for the task
|
# Expects a lambda expression for the task
|
||||||
def schedule_task_immediately(self, task, user=None, name=None, hidden=False):
|
def schedule_task_immediately(self, task, user=None, name=None, hidden=False):
|
||||||
if use_APScheduler:
|
if use_APScheduler:
|
||||||
def immediate_task():
|
def immediate_task():
|
||||||
WorkerThread.add(user, task(), hidden)
|
WorkerThread.add(user, task(), hidden)
|
||||||
return self.schedule(func=immediate_task, trigger='date', name=name)
|
return self.schedule(func=immediate_task, trigger=DateTrigger(), name=name)
|
||||||
|
|
||||||
# Expects a list of lambda expressions for the tasks
|
# Expects a list of lambda expressions for the tasks
|
||||||
def schedule_tasks_immediately(self, tasks, user=None):
|
def schedule_tasks_immediately(self, tasks, user=None):
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ SCOPES = ['openid', 'https://www.googleapis.com/auth/gmail.send', 'https://www.g
|
|||||||
def setup_gmail(token):
|
def setup_gmail(token):
|
||||||
# If there are no (valid) credentials available, let the user log in.
|
# If there are no (valid) credentials available, let the user log in.
|
||||||
creds = None
|
creds = None
|
||||||
|
user_info = None
|
||||||
if "token" in token:
|
if "token" in token:
|
||||||
creds = Credentials(
|
creds = Credentials(
|
||||||
token=token['token'],
|
token=token['token'],
|
||||||
|
|||||||
@@ -18,16 +18,51 @@
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from goodreads.client import GoodreadsClient
|
||||||
|
from goodreads.request import GoodreadsRequest
|
||||||
|
import xmltodict
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from goodreads.client import GoodreadsClient
|
import Levenshtein
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from betterreads.client import GoodreadsClient
|
Levenshtein = False
|
||||||
|
|
||||||
try: 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()
|
||||||
@@ -38,20 +73,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):
|
||||||
@@ -76,6 +111,7 @@ def get_author_info(author_name):
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -91,7 +127,8 @@ def get_other_books(author_info, library_books=None):
|
|||||||
identifiers = []
|
identifiers = []
|
||||||
library_titles = []
|
library_titles = []
|
||||||
if library_books:
|
if library_books:
|
||||||
identifiers = list(reduce(lambda acc, book: acc + [i.val for i in book.identifiers if i.val], library_books, []))
|
identifiers = list(
|
||||||
|
reduce(lambda acc, book: acc + [i.val for i in book.identifiers if i.val], library_books, []))
|
||||||
library_titles = [book.title for book in library_books]
|
library_titles = [book.title for book in library_books]
|
||||||
|
|
||||||
for book in author_info.books:
|
for book in author_info.books:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import base64
|
|||||||
|
|
||||||
from flask_simpleldap import LDAP, LDAPException
|
from flask_simpleldap import LDAP, LDAPException
|
||||||
from flask_simpleldap import ldap as pyLDAP
|
from flask_simpleldap import ldap as pyLDAP
|
||||||
|
from flask import current_app
|
||||||
from .. import constants, logger
|
from .. import constants, logger
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -28,7 +29,49 @@ except ImportError:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
_ldap = LDAP()
|
|
||||||
|
|
||||||
|
class LDAPLogger(object):
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def write(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):
|
||||||
@@ -44,15 +87,15 @@ def init_app(app, config):
|
|||||||
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_CUSTOM_OPTIONS'].update({
|
app.config['LDAP_CUSTOM_OPTIONS'].update({
|
||||||
pyLDAP.OPT_X_TLS_REQUIRE_CERT: pyLDAP.OPT_X_TLS_DEMAND,
|
pyLDAP.OPT_X_TLS_REQUIRE_CERT: pyLDAP.OPT_X_TLS_DEMAND,
|
||||||
@@ -70,7 +113,7 @@ 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_LOGLEVEL'] = config.config_log_level
|
||||||
try:
|
try:
|
||||||
_ldap.init_app(app)
|
_ldap.init_app(app)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -84,7 +127,7 @@ def init_app(app, config):
|
|||||||
log.error(e)
|
log.error(e)
|
||||||
|
|
||||||
|
|
||||||
def get_object_details(user=None,query_filter=None):
|
def get_object_details(user=None, query_filter=None):
|
||||||
return _ldap.get_object_details(user, query_filter=query_filter)
|
return _ldap.get_object_details(user, query_filter=query_filter)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -266,3 +266,6 @@ class CalibreTask:
|
|||||||
def _handleSuccess(self):
|
def _handleSuccess(self):
|
||||||
self.stat = STAT_FINISH_SUCCESS
|
self.stat = STAT_FINISH_SUCCESS
|
||||||
self.progress = 1
|
self.progress = 1
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|||||||
@@ -46,13 +46,13 @@ 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 that shelf"), category="error")
|
flash(_("Sorry you are not allowed to add a book to that shelf"), 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 that shelf", 403
|
return "Sorry you are not allowed to add a book to the that shelf", 403
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ def add_to_shelf(shelf_id, book_id):
|
|||||||
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
|
||||||
|
|
||||||
@@ -71,6 +71,14 @@ 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()
|
||||||
try:
|
try:
|
||||||
@@ -79,14 +87,14 @@ def add_to_shelf(shelf_id, book_id):
|
|||||||
except (OperationalError, InvalidRequestError) as e:
|
except (OperationalError, InvalidRequestError) as e:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error_or_exception("Settings Database error: {}".format(e))
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||||
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:
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
if not xhr:
|
if not xhr:
|
||||||
log.debug("Book has been added to shelf: {}".format(shelf.name))
|
log.debug("Book has been added to shelf: {}".format(shelf.name))
|
||||||
flash(_(u"Book has been added to shelf: %(sname)s", sname=shelf.name), category="success")
|
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:
|
||||||
@@ -100,12 +108,12 @@ 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: {}".format(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):
|
||||||
log.warning("You are not allowed to add a book to the shelf".format(shelf.name))
|
log.warning("You are not allowed to add a book to the shelf".format(shelf.name))
|
||||||
flash(_(u"You are not allowed to add a book to the shelf"), category="error")
|
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 ub.searched_ids and ub.searched_ids[current_user.id]:
|
if current_user.id in ub.searched_ids and ub.searched_ids[current_user.id]:
|
||||||
@@ -123,7 +131,7 @@ def search_to_shelf(shelf_id):
|
|||||||
|
|
||||||
if not books_for_shelf:
|
if not books_for_shelf:
|
||||||
log.error("Books are already part of {}".format(shelf.name))
|
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()[0] or 0
|
maxOrder = ub.session.query(func.max(ub.BookShelf.order)).filter(ub.BookShelf.shelf == shelf_id).first()[0] or 0
|
||||||
@@ -135,14 +143,14 @@ def search_to_shelf(shelf_id):
|
|||||||
try:
|
try:
|
||||||
ub.session.merge(shelf)
|
ub.session.merge(shelf)
|
||||||
ub.session.commit()
|
ub.session.commit()
|
||||||
flash(_(u"Books have been added to shelf: %(sname)s", sname=shelf.name), category="success")
|
flash(_("Books have been added to shelf: %(sname)s", sname=shelf.name), category="success")
|
||||||
except (OperationalError, InvalidRequestError) as e:
|
except (OperationalError, InvalidRequestError) as e:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error_or_exception("Settings Database error: {}".format(e))
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||||
else:
|
else:
|
||||||
log.error("Could not add books to shelf: {}".format(shelf.name))
|
log.error("Could not add books to shelf: {}".format(shelf.name))
|
||||||
flash(_(u"Could not add books to shelf: %(sname)s", sname=shelf.name), category="error")
|
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'))
|
||||||
|
|
||||||
|
|
||||||
@@ -182,13 +190,13 @@ def remove_from_shelf(shelf_id, book_id):
|
|||||||
except (OperationalError, InvalidRequestError) as e:
|
except (OperationalError, InvalidRequestError) as e:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error_or_exception("Settings Database error: {}".format(e))
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||||
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:
|
||||||
return redirect(url_for('web.index'))
|
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:
|
||||||
@@ -197,7 +205,7 @@ def remove_from_shelf(shelf_id, book_id):
|
|||||||
else:
|
else:
|
||||||
if not xhr:
|
if not xhr:
|
||||||
log.warning("You are not allowed to remove a book from shelf: {}".format(shelf.name))
|
log.warning("You are not allowed to remove a book from shelf: {}".format(shelf.name))
|
||||||
flash(_(u"Sorry you are not allowed to remove a book from this shelf"),
|
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", 403
|
return "Sorry you are not allowed to remove a book from this shelf", 403
|
||||||
@@ -207,7 +215,7 @@ def remove_from_shelf(shelf_id, book_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def create_shelf():
|
def create_shelf():
|
||||||
shelf = ub.Shelf()
|
shelf = ub.Shelf()
|
||||||
return create_edit_shelf(shelf, page_title=_(u"Create a Shelf"), page="shelfcreate")
|
return create_edit_shelf(shelf, page_title=_("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"])
|
||||||
@@ -215,9 +223,9 @@ def create_shelf():
|
|||||||
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 not check_shelf_edit_permissions(shelf):
|
if not check_shelf_edit_permissions(shelf):
|
||||||
flash(_(u"Sorry you are not allowed to edit this shelf"), category="error")
|
flash(_("Sorry you are not allowed to edit this shelf"), category="error")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
return create_edit_shelf(shelf, page_title=_(u"Edit a shelf"), page="shelfedit", shelf_id=shelf_id)
|
return create_edit_shelf(shelf, page_title=_("Edit a shelf"), page="shelfedit", shelf_id=shelf_id)
|
||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/delete/<int:shelf_id>", methods=["POST"])
|
@shelf.route("/shelf/delete/<int:shelf_id>", methods=["POST"])
|
||||||
@@ -232,7 +240,7 @@ def delete_shelf(shelf_id):
|
|||||||
except InvalidRequestError as e:
|
except InvalidRequestError as e:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error_or_exception("Settings Database error: {}".format(e))
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
|
|
||||||
|
|
||||||
@@ -269,7 +277,7 @@ def order_shelf(shelf_id):
|
|||||||
except (OperationalError, InvalidRequestError) as e:
|
except (OperationalError, InvalidRequestError) as e:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error_or_exception("Settings Database error: {}".format(e))
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||||
|
|
||||||
result = list()
|
result = list()
|
||||||
if shelf:
|
if shelf:
|
||||||
@@ -278,7 +286,7 @@ def order_shelf(shelf_id):
|
|||||||
.add_columns(calibre_db.common_filters().label("visible")) \
|
.add_columns(calibre_db.common_filters().label("visible")) \
|
||||||
.filter(ub.BookShelf.shelf == shelf_id).order_by(ub.BookShelf.order.asc()).all()
|
.filter(ub.BookShelf.shelf == shelf_id).order_by(ub.BookShelf.order.asc()).all()
|
||||||
return render_title_template('shelf_order.html', entries=result,
|
return render_title_template('shelf_order.html', entries=result,
|
||||||
title=_(u"Change order of Shelf: '%(name)s'", name=shelf.name),
|
title=_("Change order of Shelf: '%(name)s'", name=shelf.name),
|
||||||
shelf=shelf, page="shelforder")
|
shelf=shelf, page="shelforder")
|
||||||
else:
|
else:
|
||||||
abort(404)
|
abort(404)
|
||||||
@@ -295,11 +303,14 @@ def check_shelf_edit_permissions(cur_shelf):
|
|||||||
|
|
||||||
|
|
||||||
def check_shelf_view_permissions(cur_shelf):
|
def check_shelf_view_permissions(cur_shelf):
|
||||||
if cur_shelf.is_public:
|
try:
|
||||||
return True
|
if cur_shelf.is_public:
|
||||||
if current_user.is_anonymous or cur_shelf.user_id != current_user.id:
|
return True
|
||||||
log.error("User is unauthorized to view non-public shelf: {}".format(cur_shelf.name))
|
if current_user.is_anonymous or cur_shelf.user_id != current_user.id:
|
||||||
return False
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -310,7 +321,7 @@ def create_edit_shelf(shelf, page_title, page, shelf_id=False):
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
to_save = request.form.to_dict()
|
to_save = request.form.to_dict()
|
||||||
if not current_user.role_edit_shelfs() and to_save.get("is_public") == "on":
|
if not current_user.role_edit_shelfs() and to_save.get("is_public") == "on":
|
||||||
flash(_(u"Sorry you are not allowed to create a public shelf"), category="error")
|
flash(_("Sorry you are not allowed to create a public shelf"), category="error")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
is_public = 1 if to_save.get("is_public") == "on" else 0
|
is_public = 1 if to_save.get("is_public") == "on" else 0
|
||||||
if config.config_kobo_sync:
|
if config.config_kobo_sync:
|
||||||
@@ -327,24 +338,24 @@ def create_edit_shelf(shelf, page_title, page, shelf_id=False):
|
|||||||
shelf.user_id = int(current_user.id)
|
shelf.user_id = int(current_user.id)
|
||||||
ub.session.add(shelf)
|
ub.session.add(shelf)
|
||||||
shelf_action = "created"
|
shelf_action = "created"
|
||||||
flash_text = _(u"Shelf %(title)s created", title=shelf_title)
|
flash_text = _("Shelf %(title)s created", title=shelf_title)
|
||||||
else:
|
else:
|
||||||
shelf_action = "changed"
|
shelf_action = "changed"
|
||||||
flash_text = _(u"Shelf %(title)s changed", title=shelf_title)
|
flash_text = _("Shelf %(title)s changed", title=shelf_title)
|
||||||
try:
|
try:
|
||||||
ub.session.commit()
|
ub.session.commit()
|
||||||
log.info(u"Shelf {} {}".format(shelf_title, shelf_action))
|
log.info("Shelf {} {}".format(shelf_title, shelf_action))
|
||||||
flash(flash_text, category="success")
|
flash(flash_text, category="success")
|
||||||
return redirect(url_for('shelf.show_shelf', shelf_id=shelf.id))
|
return redirect(url_for('shelf.show_shelf', shelf_id=shelf.id))
|
||||||
except (OperationalError, InvalidRequestError) as ex:
|
except (OperationalError, InvalidRequestError) as ex:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error_or_exception(ex)
|
log.error_or_exception(ex)
|
||||||
log.error_or_exception("Settings Database error: {}".format(ex))
|
log.error_or_exception("Settings Database error: {}".format(ex))
|
||||||
flash(_(u"Database error: %(error)s.", error=ex.orig), category="error")
|
flash(_("Oops! Database Error: %(error)s.", error=ex.orig), category="error")
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error_or_exception(ex)
|
log.error_or_exception(ex)
|
||||||
flash(_(u"There was an error"), category="error")
|
flash(_("There was an error"), category="error")
|
||||||
return render_title_template('shelf_edit.html',
|
return render_title_template('shelf_edit.html',
|
||||||
shelf=shelf,
|
shelf=shelf,
|
||||||
title=page_title,
|
title=page_title,
|
||||||
@@ -366,7 +377,7 @@ def check_shelf_is_unique(title, is_public, shelf_id=False):
|
|||||||
|
|
||||||
if not is_shelf_name_unique:
|
if not is_shelf_name_unique:
|
||||||
log.error("A public shelf with the name '{}' already exists.".format(title))
|
log.error("A public shelf with the name '{}' already exists.".format(title))
|
||||||
flash(_(u"A public shelf with the name '%(title)s' already exists.", title=title),
|
flash(_("A public shelf with the name '%(title)s' already exists.", title=title),
|
||||||
category="error")
|
category="error")
|
||||||
else:
|
else:
|
||||||
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
||||||
@@ -377,7 +388,7 @@ def check_shelf_is_unique(title, is_public, shelf_id=False):
|
|||||||
|
|
||||||
if not is_shelf_name_unique:
|
if not is_shelf_name_unique:
|
||||||
log.error("A private shelf with the name '{}' already exists.".format(title))
|
log.error("A private shelf with the name '{}' already exists.".format(title))
|
||||||
flash(_(u"A private shelf with the name '%(title)s' already exists.", title=title),
|
flash(_("A private shelf with the name '%(title)s' already exists.", title=title),
|
||||||
category="error")
|
category="error")
|
||||||
return is_shelf_name_unique
|
return is_shelf_name_unique
|
||||||
|
|
||||||
@@ -454,14 +465,14 @@ def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
|
|||||||
except (OperationalError, InvalidRequestError) as e:
|
except (OperationalError, InvalidRequestError) as e:
|
||||||
ub.session.rollback()
|
ub.session.rollback()
|
||||||
log.error_or_exception("Settings Database error: {}".format(e))
|
log.error_or_exception("Settings Database error: {}".format(e))
|
||||||
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
|
flash(_("Oops! Database Error: %(error)s.", error=e.orig), category="error")
|
||||||
|
|
||||||
return render_title_template(page,
|
return render_title_template(page,
|
||||||
entries=result,
|
entries=result,
|
||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
title=_(u"Shelf: '%(name)s'", name=shelf.name),
|
title=_("Shelf: '%(name)s'", name=shelf.name),
|
||||||
shelf=shelf,
|
shelf=shelf,
|
||||||
page="shelf")
|
page="shelf")
|
||||||
else:
|
else:
|
||||||
flash(_(u"Error opening shelf. Shelf does not exist or is not accessible"), category="error")
|
flash(_("Error opening shelf. Shelf does not exist or is not accessible"), category="error")
|
||||||
return redirect(url_for("web.index"))
|
return redirect(url_for("web.index"))
|
||||||
|
|||||||
@@ -3290,10 +3290,13 @@ div.btn-group[role=group][aria-label="Download, send to Kindle, reading"] .dropd
|
|||||||
-ms-transform-origin: center top;
|
-ms-transform-origin: center top;
|
||||||
transform-origin: center top;
|
transform-origin: center top;
|
||||||
border: 0;
|
border: 0;
|
||||||
left: 0 !important;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
.dropdown-menu:not(.datepicker-dropdown):not(.profileDropli) {
|
||||||
|
left: 0 !important;
|
||||||
|
}
|
||||||
#add-to-shelves {
|
#add-to-shelves {
|
||||||
|
min-height: 48px;
|
||||||
max-height: calc(100% - 120px);
|
max-height: calc(100% - 120px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
@@ -4423,38 +4426,6 @@ body.advanced_search > div.container-fluid > div.row-fluid > div.col-sm-10 > div
|
|||||||
left: 49px;
|
left: 49px;
|
||||||
margin-top: 5px
|
margin-top: 5px
|
||||||
}
|
}
|
||||||
|
|
||||||
body:not(.blur) > .navbar > .container-fluid > .navbar-header:after, body:not(.blur) > .navbar > .container-fluid > .navbar-header:before {
|
|
||||||
color: hsla(0, 0%, 100%, .7);
|
|
||||||
cursor: pointer;
|
|
||||||
display: block;
|
|
||||||
font-family: plex-icons-new, serif;
|
|
||||||
font-size: 20px;
|
|
||||||
font-stretch: 100%;
|
|
||||||
font-style: normal;
|
|
||||||
font-variant-caps: normal;
|
|
||||||
font-variant-east-asian: normal;
|
|
||||||
font-variant-numeric: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
height: 60px;
|
|
||||||
letter-spacing: normal;
|
|
||||||
line-height: 60px;
|
|
||||||
position: absolute
|
|
||||||
}
|
|
||||||
|
|
||||||
body:not(.blur) > .navbar > .container-fluid > .navbar-header:before {
|
|
||||||
content: "\EA30";
|
|
||||||
-webkit-font-variant-ligatures: normal;
|
|
||||||
font-variant-ligatures: normal;
|
|
||||||
left: 20px
|
|
||||||
}
|
|
||||||
|
|
||||||
body:not(.blur) > .navbar > .container-fluid > .navbar-header:after {
|
|
||||||
content: "\EA2F";
|
|
||||||
-webkit-font-variant-ligatures: normal;
|
|
||||||
font-variant-ligatures: normal;
|
|
||||||
left: 60px
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body.admin > div.container-fluid > div > div.col-sm-10 > div.container-fluid > div.row:first-of-type > div.col > h2:before, body.admin > div.container-fluid > div > div.col-sm-10 > div.discover > h2:first-of-type:before, body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.discover > h1:before, body.newuser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.discover > h1:before {
|
body.admin > div.container-fluid > div > div.col-sm-10 > div.container-fluid > div.row:first-of-type > div.col > h2:before, body.admin > div.container-fluid > div > div.col-sm-10 > div.discover > h2:first-of-type:before, body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.discover > h1:before, body.newuser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.discover > h1:before {
|
||||||
@@ -4842,8 +4813,14 @@ body.advsearch:not(.blur) > div.container-fluid > div.row-fluid > div.col-sm-10
|
|||||||
z-index: 999999999999999999999999999999999999
|
z-index: 999999999999999999999999999999999999
|
||||||
}
|
}
|
||||||
|
|
||||||
.search #shelf-actions, body.login .home-btn {
|
body.search #shelf-actions button#add-to-shelf {
|
||||||
display: none
|
height: 40px;
|
||||||
|
}
|
||||||
|
@media screen and (max-width: 767px) {
|
||||||
|
body.search .discover, body.advsearch .discover {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body.read:not(.blur) a[href*=readbooks] {
|
body.read:not(.blur) a[href*=readbooks] {
|
||||||
@@ -5164,7 +5141,7 @@ body.login > div.navbar.navbar-default.navbar-static-top > div > div.navbar-head
|
|||||||
right: 5px
|
right: 5px
|
||||||
}
|
}
|
||||||
|
|
||||||
#shelf-actions > .btn-group.open, .downloadBtn.open, .profileDrop[aria-expanded=true] {
|
body:not(.search) #shelf-actions > .btn-group.open, .downloadBtn.open, .profileDrop[aria-expanded=true] {
|
||||||
pointer-events: none
|
pointer-events: none
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5181,7 +5158,7 @@ body.login > div.navbar.navbar-default.navbar-static-top > div > div.navbar-head
|
|||||||
color: var(--color-primary)
|
color: var(--color-primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
#shelf-actions, #shelf-actions > .btn-group, #shelf-actions > .btn-group > .empty-ul {
|
body:not(.search) #shelf-actions, body:not(.search) #shelf-actions > .btn-group, body:not(.search) #shelf-actions > .btn-group > .empty-ul {
|
||||||
pointer-events: none
|
pointer-events: none
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7309,6 +7286,11 @@ body.edituser.admin > div.container-fluid > div.row-fluid > div.col-sm-10 > div.
|
|||||||
float: right
|
float: right
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.blur #main-nav + #scnd-nav .create-shelf, body.blur #main-nav + .col-sm-2 #scnd-nav .create-shelf {
|
||||||
|
float: none;
|
||||||
|
margin: 5px 0 10px -10px;
|
||||||
|
}
|
||||||
|
|
||||||
#main-nav + #scnd-nav .nav-head.hidden-xs {
|
#main-nav + #scnd-nav .nav-head.hidden-xs {
|
||||||
display: list-item !important;
|
display: list-item !important;
|
||||||
width: 225px
|
width: 225px
|
||||||
|
|||||||
@@ -22,3 +22,7 @@ body.serieslist.grid-view div.container-fluid > div > div.col-sm-10::before {
|
|||||||
padding: 0 0;
|
padding: 0 0;
|
||||||
line-height: 15px;
|
line-height: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input.datepicker {color: transparent}
|
||||||
|
input.datepicker:focus {color: transparent}
|
||||||
|
input.datepicker:focus + input {color: #555}
|
||||||
|
|||||||
@@ -149,6 +149,20 @@ body {
|
|||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#mainContent > canvas {
|
||||||
|
display: block;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.long-strip > .mainImage {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.long-strip > .mainImage:last-child {
|
||||||
|
margin-bottom: 0px !important;
|
||||||
|
}
|
||||||
|
|
||||||
#titlebar {
|
#titlebar {
|
||||||
min-height: 25px;
|
min-height: 25px;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M5.375 7.625V11.875C5.375 12.0408 5.44085 12.1997 5.55806 12.3169C5.67527 12.4342 5.83424 12.5 6 12.5C6.16576 12.5 6.32473 12.4342 6.44194 12.3169C6.55915 12.1997 6.625 12.0408 6.625 11.875V7.625L7.125 7.125H11.375C11.5408 7.125 11.6997 7.05915 11.8169 6.94194C11.9342 6.82473 12 6.66576 12 6.5C12 6.33424 11.9342 6.17527 11.8169 6.05806C11.6997 5.94085 11.5408 5.875 11.375 5.875H7.125L6.625 5.375V1.125C6.625 0.95924 6.55915 0.800269 6.44194 0.683058C6.32473 0.565848 6.16576 0.5 6 0.5C5.83424 0.5 5.67527 0.565848 5.55806 0.683058C5.44085 0.800269 5.375 0.95924 5.375 1.125V5.375L4.875 5.875H0.625C0.45924 5.875 0.300269 5.94085 0.183058 6.05806C0.065848 6.17527 0 6.33424 0 6.5C0 6.66576 0.065848 6.82473 0.183058 6.94194C0.300269 7.05915 0.45924 7.125 0.625 7.125H4.762L5.375 7.625Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 920 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6 0.5C5.21207 0.5 4.43185 0.655195 3.7039 0.956723C2.97595 1.25825 2.31451 1.70021 1.75736 2.25736C1.20021 2.81451 0.758251 3.47595 0.456723 4.2039C0.155195 4.93185 0 5.71207 0 6.5C0 7.28793 0.155195 8.06815 0.456723 8.7961C0.758251 9.52405 1.20021 10.1855 1.75736 10.7426C2.31451 11.2998 2.97595 11.7417 3.7039 12.0433C4.43185 12.3448 5.21207 12.5 6 12.5C7.5913 12.5 9.11742 11.8679 10.2426 10.7426C11.3679 9.61742 12 8.0913 12 6.5C12 4.9087 11.3679 3.38258 10.2426 2.25736C9.11742 1.13214 7.5913 0.5 6 0.5ZM5.06 8.9L2.9464 6.7856C2.85273 6.69171 2.80018 6.56446 2.80033 6.43183C2.80048 6.29921 2.85331 6.17207 2.9472 6.0784C3.04109 5.98473 3.16834 5.93218 3.30097 5.93233C3.43359 5.93248 3.56073 5.98531 3.6544 6.0792L5.3112 7.7368L8.3464 4.7008C8.44109 4.6109 8.56715 4.56153 8.69771 4.56322C8.82827 4.56492 8.95301 4.61754 9.04534 4.70986C9.13766 4.80219 9.19028 4.92693 9.19198 5.05749C9.19367 5.18805 9.1443 5.31411 9.0544 5.4088L5.5624 8.9H5.06Z" fill="#FBFBFE"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" height="40" width="40">
|
||||||
|
<path d="M9 3.5a1.5 1.5 0 0 0-3-.001v7.95C6 12.83 7.12 14 8.5 14s2.5-1.17 2.5-2.55V5.5a.5.5 0 0 1 1 0v6.03C11.955 13.427 10.405 15 8.5 15S5.044 13.426 5 11.53V3.5a2.5 2.5 0 0 1 5 0v7.003a1.5 1.5 0 0 1-3-.003v-5a.5.5 0 0 1 1 0v5a.5.5 0 0 0 1 0Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 552 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" height="40" width="40">
|
||||||
|
<path d="M8.156 12.5a.99.99 0 0 0 .707-.294l.523-2.574L10.5 8.499l1.058-1.04 2.65-.601a.996.996 0 0 0 0-1.414l-3.657-3.658a.996.996 0 0 0-1.414 0l-.523 2.576L7.5 5.499 6.442 6.535l-2.65.6a.996.996 0 0 0 0 1.413l3.657 3.658a.999.999 0 0 0 .707.295z"/>
|
||||||
|
<path d="M9.842.996c-.386 0-.77.146-1.06.44a.5.5 0 0 0-.136.251l-.492 2.43-1.008 1.03-.953.933-2.511.566a.5.5 0 0 0-.243.133 1.505 1.505 0 0 0-.002 2.123l1.477 1.477-2.768 2.767a.5.5 0 0 0 0 .707.5.5 0 0 0 .708 0l2.767-2.767 1.475 1.474a1.494 1.494 0 0 0 2.123-.002.5.5 0 0 0 .135-.254l.492-2.427 1.008-1.024.953-.937 2.511-.57a.5.5 0 0 0 .243-.132c.586-.58.583-1.543.002-2.125l-3.659-3.656A1.501 1.501 0 0 0 9.842.996Zm.05 1.025a.394.394 0 0 1 .305.12l3.658 3.657c.18.18.141.432.002.627l-2.41.545a.5.5 0 0 0-.24.131L10.15 8.142a.5.5 0 0 0-.007.006L9.029 9.283a.5.5 0 0 0-.133.25l-.48 2.36c-.082.053-.165.109-.26.109a.492.492 0 0 1-.353-.149L4.145 8.195c-.18-.18-.141-.432-.002-.627l2.41-.545a.5.5 0 0 0 .238-.13L7.85 5.857a.5.5 0 0 0 .007-.008l1.114-1.138a.5.5 0 0 0 .133-.25l.472-2.323a.619.619 0 0 1 .317-.117Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.2 3.09C12.28 3.01 12.43 3 12.43 3C12.48 3 12.58 3.02 12.66 3.1L14.45 4.89C14.58 5.02 14.58 5.22 14.45 5.35L11.7713 8.02872L9.51628 5.77372L12.2 3.09ZM13.2658 5.12L11.7713 6.6145L10.9305 5.77372L12.425 4.27921L13.2658 5.12Z" fill="#FBFBFE"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.98 9.32L8.23 11.57L10.7106 9.08938L8.45562 6.83438L5.98 9.31V9.32ZM8.23 10.1558L9.29641 9.08938L8.45562 8.24859L7.38921 9.315L8.23 10.1558Z" fill="#FBFBFE"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.1526 13.1816L16.2125 7.1217C16.7576 6.58919 17.05 5.8707 17.05 5.12C17.05 4.36931 16.7576 3.65084 16.2126 3.11834L14.4317 1.33747C13.8992 0.79242 13.1807 0.5 12.43 0.5C11.6643 0.5 10.9529 0.812929 10.4329 1.33289L3.68289 8.08289C3.04127 8.72452 3.00459 9.75075 3.57288 10.4363L1.29187 12.7239C1.09186 12.9245 0.990263 13.1957 1.0007 13.4685L1 14.5C0.447715 14.5 0 14.9477 0 15.5V17.5C0 18.0523 0.447715 18.5 1 18.5H16C16.5523 18.5 17 18.0523 17 17.5V15.5C17 14.9477 16.5523 14.5 16 14.5H10.2325C9.83594 14.5 9.39953 13.9347 10.1526 13.1816ZM4.39 9.85L4.9807 10.4407L2.39762 13.0312H6.63877L7.10501 12.565L7.57125 13.0312H8.88875L15.51 6.41C15.86 6.07 16.05 5.61 16.05 5.12C16.05 4.63 15.86 4.17 15.51 3.83L13.72 2.04C13.38 1.69 12.92 1.5 12.43 1.5C11.94 1.5 11.48 1.7 11.14 2.04L4.39 8.79C4.1 9.08 4.1 9.56 4.39 9.85ZM16 17.5V15.5H1V17.5H16Z" fill="#FBFBFE"/>
|
||||||
|
<path d="M15.1616 6.05136L15.1616 6.05132L15.1564 6.05645L8.40645 12.8064C8.35915 12.8537 8.29589 12.88 8.23 12.88C8.16411 12.88 8.10085 12.8537 8.05355 12.8064L7.45857 12.2115L7.10501 11.8579L6.75146 12.2115L6.03289 12.93H3.20465L5.33477 10.7937L5.6873 10.4402L5.33426 10.0871L4.74355 9.49645C4.64882 9.40171 4.64882 9.23829 4.74355 9.14355L11.4936 2.39355C11.7436 2.14354 12.0779 2 12.43 2C12.7883 2 13.1179 2.13776 13.3614 2.38839L13.3613 2.38843L13.3664 2.39355L15.1564 4.18355L15.1564 4.18359L15.1616 4.18864C15.4122 4.43211 15.55 4.76166 15.55 5.12C15.55 5.47834 15.4122 5.80789 15.1616 6.05136ZM7.87645 11.9236L8.23 12.2771L8.58355 11.9236L11.0642 9.44293L11.4177 9.08938L11.0642 8.73582L8.80918 6.48082L8.45562 6.12727L8.10207 6.48082L5.62645 8.95645L5.48 9.10289V9.31V9.32V9.52711L5.62645 9.67355L7.87645 11.9236ZM11.4177 8.38227L11.7713 8.73582L12.1248 8.38227L14.8036 5.70355C15.1288 5.37829 15.1288 4.86171 14.8036 4.53645L13.0136 2.74645C12.8186 2.55146 12.5792 2.5 12.43 2.5H12.4134L12.3967 2.50111L12.43 3C12.3967 2.50111 12.3966 2.50112 12.3965 2.50112L12.3963 2.50114L12.3957 2.50117L12.3947 2.50125L12.3924 2.50142L12.387 2.50184L12.3732 2.50311C12.3628 2.50416 12.3498 2.50567 12.3346 2.50784C12.3049 2.51208 12.2642 2.51925 12.2178 2.53146C12.1396 2.55202 11.9797 2.60317 11.8464 2.73645L9.16273 5.42016L8.80918 5.77372L9.16273 6.12727L11.4177 8.38227ZM1.5 16H15.5V17H1.5V16Z" stroke="#15141A"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2.75H12.5V2.25V1V0.5H12H10.358C9.91165 0.5 9.47731 0.625661 9.09989 0.860442L9.09886 0.861087L8 1.54837L6.89997 0.860979L6.89911 0.860443C6.5218 0.625734 6.08748 0.5 5.642 0.5H4H3.5V1V2.25V2.75H4H5.642C5.66478 2.75 5.6885 2.75641 5.71008 2.76968C5.71023 2.76977 5.71038 2.76986 5.71053 2.76995L6.817 3.461C6.81704 3.46103 6.81709 3.46105 6.81713 3.46108C6.81713 3.46108 6.81713 3.46108 6.81714 3.46109C6.8552 3.48494 6.876 3.52285 6.876 3.567V8V12.433C6.876 12.4771 6.85523 12.515 6.81722 12.5389C6.81715 12.5389 6.81707 12.539 6.817 12.539L5.70953 13.23C5.70941 13.2301 5.70929 13.2302 5.70917 13.2303C5.68723 13.2438 5.6644 13.25 5.641 13.25H4H3.5V13.75V15V15.5H4H5.642C6.08835 15.5 6.52269 15.3743 6.90011 15.1396L6.90086 15.1391L8 14.4526L9.10003 15.14L9.10089 15.1406C9.47831 15.3753 9.91265 15.501 10.359 15.501H12H12.5V15.001V13.751V13.251H12H10.358C10.3352 13.251 10.3115 13.2446 10.2899 13.2313C10.2897 13.2312 10.2896 13.2311 10.2895 13.231L9.183 12.54C9.18298 12.54 9.18295 12.54 9.18293 12.54C9.18291 12.5399 9.18288 12.5399 9.18286 12.5399C9.14615 12.5169 9.125 12.4797 9.125 12.434V8V3.567C9.125 3.52266 9.14603 3.48441 9.18364 3.4606C9.18377 3.46052 9.1839 3.46043 9.18404 3.46035L10.2895 2.76995C10.2896 2.76985 10.2898 2.76975 10.2899 2.76966C10.3119 2.75619 10.3346 2.75 10.358 2.75H12Z" fill="black" stroke="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M0.0189877 13.6645L0.612989 10.4635C0.687989 10.0545 0.884989 9.6805 1.18099 9.3825L9.98199 0.5805C10.756 -0.1925 12.015 -0.1945 12.792 0.5805L14.42 2.2085C15.194 2.9835 15.194 4.2435 14.42 5.0185L5.61599 13.8215C5.31999 14.1165 4.94599 14.3125 4.53799 14.3875L1.33599 14.9815C1.26599 14.9935 1.19799 15.0005 1.12999 15.0005C0.832989 15.0005 0.544988 14.8835 0.330988 14.6695C0.0679874 14.4055 -0.0490122 14.0305 0.0189877 13.6645Z" fill="white"/>
|
||||||
|
<path d="M0.0189877 13.6645L0.612989 10.4635C0.687989 10.0545 0.884989 9.6805 1.18099 9.3825L9.98199 0.5805C10.756 -0.1925 12.015 -0.1945 12.792 0.5805L14.42 2.2085C15.194 2.9835 15.194 4.2435 14.42 5.0185L5.61599 13.8215C5.31999 14.1165 4.94599 14.3125 4.53799 14.3875L1.33599 14.9815C1.26599 14.9935 1.19799 15.0005 1.12999 15.0005C0.832989 15.0005 0.544988 14.8835 0.330988 14.6695C0.0679874 14.4055 -0.0490122 14.0305 0.0189877 13.6645ZM12.472 5.1965L13.632 4.0365L13.631 3.1885L11.811 1.3675L10.963 1.3685L9.80299 2.5285L12.472 5.1965ZM4.31099 13.1585C4.47099 13.1285 4.61799 13.0515 4.73399 12.9345L11.587 6.0815L8.91899 3.4135L2.06599 10.2655C1.94899 10.3835 1.87199 10.5305 1.84099 10.6915L1.36699 13.2485L1.75199 13.6335L4.31099 13.1585Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg width="29" height="32" viewBox="0 0 29 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M28 16.75C28.2761 16.75 28.5 16.5261 28.5 16.25V15C28.5 14.7239 28.2761 14.5 28 14.5H26.358C25.9117 14.5 25.4773 14.6257 25.0999 14.8604L25.0989 14.8611L24 15.5484L22.9 14.861L22.8991 14.8604C22.5218 14.6257 22.0875 14.5 21.642 14.5H20C19.7239 14.5 19.5 14.7239 19.5 15V16.25C19.5 16.5261 19.7239 16.75 20 16.75H21.642C21.6648 16.75 21.6885 16.7564 21.7101 16.7697C21.7102 16.7698 21.7104 16.7699 21.7105 16.77L22.817 17.461C22.817 17.461 22.8171 17.4611 22.8171 17.4611C22.8171 17.4611 22.8171 17.4611 22.8171 17.4611C22.8552 17.4849 22.876 17.5229 22.876 17.567V22.625V27.683C22.876 27.7271 22.8552 27.765 22.8172 27.7889C22.8171 27.7889 22.8171 27.789 22.817 27.789L21.7095 28.48C21.7094 28.4801 21.7093 28.4802 21.7092 28.4803C21.6872 28.4938 21.6644 28.5 21.641 28.5H20C19.7239 28.5 19.5 28.7239 19.5 29V30.25C19.5 30.5261 19.7239 30.75 20 30.75H21.642C22.0883 30.75 22.5227 30.6243 22.9001 30.3896L22.9009 30.3891L24 29.7026L25.1 30.39L25.1009 30.3906C25.4783 30.6253 25.9127 30.751 26.359 30.751H28C28.2761 30.751 28.5 30.5271 28.5 30.251V29.001C28.5 28.7249 28.2761 28.501 28 28.501H26.358C26.3352 28.501 26.3115 28.4946 26.2899 28.4813C26.2897 28.4812 26.2896 28.4811 26.2895 28.481L25.183 27.79C25.183 27.79 25.183 27.79 25.1829 27.79C25.1829 27.7899 25.1829 27.7899 25.1829 27.7899C25.1462 27.7669 25.125 27.7297 25.125 27.684V22.625V17.567C25.125 17.5227 25.146 17.4844 25.1836 17.4606C25.1838 17.4605 25.1839 17.4604 25.184 17.4603L26.2895 16.77C26.2896 16.7699 26.2898 16.7698 26.2899 16.7697C26.3119 16.7562 26.3346 16.75 26.358 16.75H28Z" fill="black" stroke="#FBFBFE" stroke-linejoin="round"/>
|
||||||
|
<path d="M24.625 17.567C24.625 17.35 24.735 17.152 24.918 17.037L26.026 16.345C26.126 16.283 26.24 16.25 26.358 16.25H28V15H26.358C26.006 15 25.663 15.099 25.364 15.285L24.256 15.978C24.161 16.037 24.081 16.113 24 16.187C23.918 16.113 23.839 16.037 23.744 15.978L22.635 15.285C22.336 15.099 21.993 15 21.642 15H20V16.25H21.642C21.759 16.25 21.874 16.283 21.974 16.345L23.082 17.037C23.266 17.152 23.376 17.35 23.376 17.567V22.625V27.683C23.376 27.9 23.266 28.098 23.082 28.213L21.973 28.905C21.873 28.967 21.759 29 21.641 29H20V30.25H21.642C21.994 30.25 22.337 30.151 22.636 29.965L23.744 29.273C23.84 29.213 23.919 29.137 24 29.064C24.081 29.137 24.161 29.213 24.256 29.273L25.365 29.966C25.664 30.152 26.007 30.251 26.359 30.251H28V29.001H26.358C26.241 29.001 26.126 28.968 26.026 28.906L24.918 28.214C24.734 28.099 24.625 27.901 24.625 27.684V22.625V17.567Z" fill="black"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.2 2.59C12.28 2.51 12.43 2.5 12.43 2.5C12.48 2.5 12.58 2.52 12.66 2.6L14.45 4.39C14.58 4.52 14.58 4.72 14.45 4.85L11.7713 7.52872L9.51628 5.27372L12.2 2.59ZM13.2658 4.62L11.7713 6.1145L10.9305 5.27372L12.425 3.77921L13.2658 4.62Z" fill="#FBFBFE"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.98 8.82L8.23 11.07L10.7106 8.58938L8.45562 6.33438L5.98 8.81V8.82ZM8.23 9.65579L9.29641 8.58938L8.45562 7.74859L7.38921 8.815L8.23 9.65579Z" fill="#FBFBFE"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.1526 12.6816L16.2125 6.6217C16.7576 6.08919 17.05 5.3707 17.05 4.62C17.05 3.86931 16.7576 3.15084 16.2126 2.61834L14.4317 0.837474C13.8992 0.29242 13.1807 0 12.43 0C11.6643 0 10.9529 0.312929 10.4329 0.832893L3.68289 7.58289C3.04127 8.22452 3.00459 9.25075 3.57288 9.93634L1.29187 12.2239C1.09186 12.4245 0.990263 12.6957 1.0007 12.9685L1 14C0.447715 14 0 14.4477 0 15V17C0 17.5523 0.447715 18 1 18H16C16.5523 18 17 17.5523 17 17V15C17 14.4477 16.5523 14 16 14H10.2325C9.83594 14 9.39953 13.4347 10.1526 12.6816ZM4.39 9.35L4.9807 9.9407L2.39762 12.5312H6.63877L7.10501 12.065L7.57125 12.5312H8.88875L15.51 5.91C15.86 5.57 16.05 5.11 16.05 4.62C16.05 4.13 15.86 3.67 15.51 3.33L13.72 1.54C13.38 1.19 12.92 1 12.43 1C11.94 1 11.48 1.2 11.14 1.54L4.39 8.29C4.1 8.58 4.1 9.06 4.39 9.35ZM16 17V15H1V17H16Z" fill="#FBFBFE"/>
|
||||||
|
<path d="M15.1616 5.55136L15.1616 5.55132L15.1564 5.55645L8.40645 12.3064C8.35915 12.3537 8.29589 12.38 8.23 12.38C8.16411 12.38 8.10085 12.3537 8.05355 12.3064L7.45857 11.7115L7.10501 11.3579L6.75146 11.7115L6.03289 12.43H3.20465L5.33477 10.2937L5.6873 9.94019L5.33426 9.58715L4.74355 8.99645C4.64882 8.90171 4.64882 8.73829 4.74355 8.64355L11.4936 1.89355C11.7436 1.64354 12.0779 1.5 12.43 1.5C12.7883 1.5 13.1179 1.63776 13.3614 1.88839L13.3613 1.88843L13.3664 1.89355L15.1564 3.68355L15.1564 3.68359L15.1616 3.68864C15.4122 3.93211 15.55 4.26166 15.55 4.62C15.55 4.97834 15.4122 5.30789 15.1616 5.55136ZM5.48 8.82V9.02711L5.62645 9.17355L7.87645 11.4236L8.23 11.7771L8.58355 11.4236L11.0642 8.94293L11.4177 8.58938L11.0642 8.23582L8.80918 5.98082L8.45562 5.62727L8.10207 5.98082L5.62645 8.45645L5.48 8.60289V8.81V8.82ZM11.4177 7.88227L11.7713 8.23582L12.1248 7.88227L14.8036 5.20355C15.1288 4.87829 15.1288 4.36171 14.8036 4.03645L13.0136 2.24645C12.8186 2.05146 12.5792 2 12.43 2H12.4134L12.3967 2.00111L12.43 2.5C12.3967 2.00111 12.3966 2.00112 12.3965 2.00112L12.3963 2.00114L12.3957 2.00117L12.3947 2.00125L12.3924 2.00142L12.387 2.00184L12.3732 2.00311C12.3628 2.00416 12.3498 2.00567 12.3346 2.00784C12.3049 2.01208 12.2642 2.01925 12.2178 2.03146C12.1396 2.05202 11.9797 2.10317 11.8464 2.23645L9.16273 4.92016L8.80918 5.27372L9.16273 5.62727L11.4177 7.88227ZM1.5 16.5V15.5H15.5V16.5H1.5Z" stroke="#15141A"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||||
|
d="M11 3H13.6C14 3 14.3 3.3 14.3 3.6C14.3 3.9 14 4.2 13.7 4.2H13.3V14C13.3 15.1 12.4 16 11.3 16H4.80005C3.70005 16 2.80005 15.1 2.80005 14V4.2H2.40005C2.00005 4.2 1.80005 4 1.80005 3.6C1.80005 3.2 2.00005 3 2.40005 3H5.00005V2C5.00005 0.9 5.90005 0 7.00005 0H9.00005C10.1 0 11 0.9 11 2V3ZM6.90005 1.2L6.30005 1.8V3H9.80005V1.8L9.20005 1.2H6.90005ZM11.4 14.7L12 14.1V4.2H4.00005V14.1L4.60005 14.7H11.4ZM7.00005 12.4C7.00005 12.7 6.70005 13 6.40005 13C6.10005 13 5.80005 12.7 5.80005 12.4V7.6C5.70005 7.3 6.00005 7 6.40005 7C6.80005 7 7.00005 7.3 7.00005 7.6V12.4ZM10.2001 12.4C10.2001 12.7 9.90006 13 9.60006 13C9.30006 13 9.00006 12.7 9.00006 12.4V7.6C9.00006 7.3 9.30006 7 9.60006 7C9.90006 7 10.2001 7.3 10.2001 7.6V12.4Z"
|
||||||
|
fill="black" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 909 B |
@@ -1,6 +0,0 @@
|
|||||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"
|
|
||||||
fill="rgba(255,255,255,1)"><path d="M8 12a1 1 0 0 1-.707-.293l-5-5a1 1 0 0 1 1.414-1.414L8
|
|
||||||
9.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-5 5A1 1 0 0 1 8 12z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 461 B |
@@ -1,4 +1,3 @@
|
|||||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
<path d="M10.999 8.352L5.534 13.818C5.41551 13.9303 5.25786 13.9918 5.09466 13.9895C4.93146 13.9872 4.77561 13.9212 4.66033 13.8057C4.54505 13.6902 4.47945 13.5342 4.47752 13.3709C4.47559 13.2077 4.53748 13.0502 4.65 12.932L9.585 7.998L4.651 3.067C4.53862 2.94864 4.47691 2.79106 4.47903 2.62786C4.48114 2.46466 4.54692 2.30874 4.66233 2.19333C4.77774 2.07792 4.93366 2.01215 5.09686 2.01003C5.26006 2.00792 5.41763 2.06962 5.536 2.182L11 7.647L10.999 8.352Z" fill="black"/>
|
||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 12a1 1 0 0 1-.707-.293l-5-5a1 1 0 0 1 1.414-1.414L8 9.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-5 5A1 1 0 0 1 8 12z"></path></svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 434 B After Width: | Height: | Size: 578 B |
@@ -1,5 +0,0 @@
|
|||||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"
|
|
||||||
fill="rgba(255,255,255,1)"><path d="M13 11a1 1 0 0 1-.707-.293L8 6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414l5-5a1 1 0 0 1 1.414 0l5 5A1 1 0 0 1 13 11z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 458 B |
@@ -1,4 +1,3 @@
|
|||||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
<path d="M5.001 8.352L10.466 13.818C10.5845 13.9303 10.7421 13.9918 10.9053 13.9895C11.0685 13.9872 11.2244 13.9212 11.3397 13.8057C11.4549 13.6902 11.5205 13.5342 11.5225 13.3709C11.5244 13.2077 11.4625 13.0502 11.35 12.932L6.416 7.999L11.349 3.067C11.4614 2.94864 11.5231 2.79106 11.521 2.62786C11.5189 2.46466 11.4531 2.30874 11.3377 2.19333C11.2223 2.07792 11.0663 2.01215 10.9031 2.01003C10.7399 2.00792 10.5824 2.06962 10.464 2.182L5 7.647L5.001 8.352Z" fill="black"/>
|
||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M13 11a1 1 0 0 1-.707-.293L8 6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414l5-5a1 1 0 0 1 1.414 0l5 5A1 1 0 0 1 13 11z"></path></svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 431 B After Width: | Height: | Size: 578 B |
|
Before Width: | Height: | Size: 326 B |
|
Before Width: | Height: | Size: 326 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.969 10.059C17.262 9.766 17.737 9.766 18.03 10.059C18.323 10.352 18.323 10.827 18.03 11.12L12.15 17H11.35L5.46896 11.12C5.17596 10.827 5.17596 10.352 5.46896 10.059C5.76196 9.766 6.23696 9.766 6.52996 10.059L11 14.529V2.75C11 2.336 11.336 2 11.75 2C12.164 2 12.5 2.336 12.499 2.75V14.529L16.969 10.059ZM4.98193 19.7L5.78193 20.5H17.7169L18.5169 19.7V17.75C18.5169 17.336 18.8529 17 19.2669 17C19.6809 17 20.0169 17.336 20.0169 17.75V19.5C20.0169 20.881 18.8979 22 17.5169 22H5.98193C4.60093 22 3.48193 20.881 3.48193 19.5V17.75C3.48193 17.336 3.81793 17 4.23193 17C4.64593 17 4.98193 17.336 4.98193 17.75V19.7Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 782 B |
@@ -1,24 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"
|
|
||||||
fill="rgba(255,255,255,1)" style="animation:spinLoadingIcon 1s steps(12,end)
|
|
||||||
infinite"><style>@keyframes
|
|
||||||
spinLoadingIcon{to{transform:rotate(360deg)}}</style><path
|
|
||||||
d="M7 3V1s0-1 1-1 1 1 1 1v2s0 1-1 1-1-1-1-1z"/><path d="M4.63
|
|
||||||
4.1l-1-1.73S3.13 1.5 4 1c.87-.5 1.37.37 1.37.37l1 1.73s.5.87-.37
|
|
||||||
1.37c-.87.57-1.37-.37-1.37-.37z" fill-opacity=".93"/><path
|
|
||||||
d="M3.1 6.37l-1.73-1S.5 4.87 1 4c.5-.87 1.37-.37 1.37-.37l1.73 1s.87.5.37
|
|
||||||
1.37c-.5.87-1.37.37-1.37.37z" fill-opacity=".86"/><path d="M3
|
|
||||||
9H1S0 9 0 8s1-1 1-1h2s1 0 1 1-1 1-1 1z" fill-opacity=".79"/><path d="M4.1 11.37l-1.73 1S1.5 12.87 1
|
|
||||||
12c-.5-.87.37-1.37.37-1.37l1.73-1s.87-.5 1.37.37c.5.87-.37 1.37-.37 1.37z"
|
|
||||||
fill-opacity=".72"/><path d="M3.63 13.56l1-1.73s.5-.87
|
|
||||||
1.37-.37c.87.5.37 1.37.37 1.37l-1 1.73s-.5.87-1.37.37c-.87-.5-.37-1.37-.37-1.37z"
|
|
||||||
fill-opacity=".65"/><path d="M7 15v-2s0-1 1-1 1 1 1 1v2s0 1-1
|
|
||||||
1-1-1-1-1z" fill-opacity=".58"/><path d="M10.63
|
|
||||||
14.56l-1-1.73s-.5-.87.37-1.37c.87-.5 1.37.37 1.37.37l1 1.73s.5.87-.37
|
|
||||||
1.37c-.87.5-1.37-.37-1.37-.37z" fill-opacity=".51"/><path
|
|
||||||
d="M13.56 12.37l-1.73-1s-.87-.5-.37-1.37c.5-.87 1.37-.37 1.37-.37l1.73 1s.87.5.37
|
|
||||||
1.37c-.5.87-1.37.37-1.37.37z" fill-opacity=".44"/><path d="M15
|
|
||||||
9h-2s-1 0-1-1 1-1 1-1h2s1 0 1 1-1 1-1 1z" fill-opacity=".37"/><path d="M14.56 5.37l-1.73
|
|
||||||
1s-.87.5-1.37-.37c-.5-.87.37-1.37.37-1.37l1.73-1s.87-.5 1.37.37c.5.87-.37 1.37-.37
|
|
||||||
1.37z" fill-opacity=".3"/><path d="M9.64 3.1l.98-1.66s.5-.874
|
|
||||||
1.37-.37c.87.5.37 1.37.37 1.37l-1 1.73s-.5.87-1.37.37c-.87-.5-.37-1.37-.37-1.37z"
|
|
||||||
fill-opacity=".23"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,16 +0,0 @@
|
|||||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16
|
|
||||||
16"
|
|
||||||
fill="rgba(255,255,255,1)">
|
|
||||||
<path
|
|
||||||
d="M8 16a8 8 0 1 1 8-8 8.009 8.009 0 0 1-8 8zM8 2a6 6 0 1 0 6 6 6.006 6.006 0 0 0-6-6z">
|
|
||||||
</path>
|
|
||||||
<path
|
|
||||||
d="M8 7a1 1 0 0 0-1 1v3a1 1 0 0 0 2 0V8a1 1 0 0 0-1-1z">
|
|
||||||
</path>
|
|
||||||
<circle
|
|
||||||
cx="8" cy="5" r="1.188">
|
|
||||||
</circle>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 557 B |
@@ -1,15 +1,3 @@
|
|||||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
<path d="M8 1.5C4.41015 1.5 1.5 4.41015 1.5 8C1.5 11.5899 4.41015 14.5 8 14.5C11.5899 14.5 14.5 11.5899 14.5 8C14.5 4.41015 11.5899 1.5 8 1.5ZM0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8ZM8.75 4V5.5H7.25V4H8.75ZM8.75 12V7H7.25V12H8.75Z" fill="black"/>
|
||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16
|
|
||||||
16">
|
|
||||||
<path
|
|
||||||
d="M8 16a8 8 0 1 1 8-8 8.009 8.009 0 0 1-8 8zM8 2a6 6 0 1 0 6 6 6.006 6.006 0 0 0-6-6z">
|
|
||||||
</path>
|
|
||||||
<path
|
|
||||||
d="M8 7a1 1 0 0 0-1 1v3a1 1 0 0 0 2 0V8a1 1 0 0 0-1-1z">
|
|
||||||
</path>
|
|
||||||
<circle
|
|
||||||
cx="8" cy="5" r="1.188">
|
|
||||||
</circle>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 530 B After Width: | Height: | Size: 417 B |
@@ -1,2 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
||||||
fill="rgba(255,255,255,1)"><path d="M13 13c-.3 0-.5-.1-.7-.3L8 8.4l-4.3 4.3c-.9.9-2.3-.5-1.4-1.4l5-5c.4-.4 1-.4 1.4 0l5 5c.6.6.2 1.7-.7 1.7zm0-11H3C1.7 2 1.7 4 3 4h10c1.3 0 1.3-2 0-2z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 255 B |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M13 13c-.3 0-.5-.1-.7-.3L8 8.4l-4.3 4.3c-.9.9-2.3-.5-1.4-1.4l5-5c.4-.4 1-.4 1.4 0l5 5c.6.6.2 1.7-.7 1.7zm0-11H3C1.7 2 1.7 4 3 4h10c1.3 0 1.3-2 0-2z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14 3.5H2V5H14V3.5ZM8 8.811L12.939 13.75L14.001 12.689L8.531 7.219C8.238 6.926 7.763 6.926 7.47 7.219L2 12.689L3.061 13.75L8 8.811Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 228 B After Width: | Height: | Size: 260 B |
@@ -1,2 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"
|
|
||||||
fill="rgba(255,255,255,1)"><path d="M15 3.7V13c0 1.5-1.53 3-3 3H7.13c-.72 0-1.63-.5-2.13-1l-5-5s.84-1 .87-1c.13-.1.33-.2.53-.2.1 0 .3.1.4.2L4 10.6V2.7c0-.6.4-1 1-1s1 .4 1 1v4.6h1V1c0-.6.4-1 1-1s1 .4 1 1v6.3h1V1.7c0-.6.4-1 1-1s1 .4 1 1v5.7h1V3.7c0-.6.4-1 1-1s1 .4 1 1z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 339 B |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M15 3.7V13c0 1.5-1.53 3-3 3H7.13c-.72 0-1.63-.5-2.13-1l-5-5s.84-1 .87-1c.13-.1.33-.2.53-.2.1 0 .3.1.4.2L4 10.6V2.7c0-.6.4-1 1-1s1 .4 1 1v4.6h1V1c0-.6.4-1 1-1s1 .4 1 1v6.3h1V1.7c0-.6.4-1 1-1s1 .4 1 1v5.7h1V3.7c0-.6.4-1 1-1s1 .4 1 1z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M7.75 2.125C7.75 1.78021 8.03021 1.5 8.375 1.5C8.71979 1.5 9 1.78021 9 2.125V3.125V8H10.5V3.125C10.5 2.78021 10.7802 2.5 11.125 2.5C11.4698 2.5 11.75 2.78021 11.75 3.125V4.625V8H13.25V4.625C13.25 4.28021 13.5302 4 13.875 4C14.2198 4 14.5 4.28021 14.5 4.625V12.0188L13.3802 13.6628C13.2954 13.7872 13.25 13.9344 13.25 14.085V16H14.75V14.3162L15.8698 12.6722C15.9546 12.5478 16 12.4006 16 12.25V4.625C16 3.45179 15.0482 2.5 13.875 2.5C13.6346 2.5 13.4035 2.53996 13.188 2.6136C12.959 1.68724 12.1219 1 11.125 1C10.8235 1 10.5366 1.06286 10.2768 1.17618C9.9281 0.478968 9.20726 0 8.375 0C7.54274 0 6.8219 0.478968 6.47323 1.17618C6.21337 1.06286 5.9265 1 5.625 1C4.45179 1 3.5 1.95179 3.5 3.125V7.25317C2.66504 6.54282 1.41035 6.58199 0.621672 7.37067C-0.208221 8.20056 -0.208221 9.54644 0.621672 10.3763L0.62188 10.3765L5.499 15.2498V16H6.999V14.939C6.999 14.74 6.9199 14.5491 6.77912 14.4085L1.68233 9.31567C1.43823 9.07156 1.43823 8.67544 1.68233 8.43133C1.92644 8.18722 2.32257 8.18722 2.56667 8.43133L3.71967 9.58433C3.93417 9.79883 4.25676 9.863 4.53701 9.74691C4.81727 9.63082 5 9.35735 5 9.054V3.125C5 2.78021 5.28022 2.5 5.625 2.5C5.96921 2.5 6.24906 2.77927 6.25 3.12326V8H7.75L7.75 3.125L7.75 3.12178V2.125Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 312 B After Width: | Height: | Size: 1.3 KiB |
@@ -1,2 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
||||||
fill="rgba(255,255,255,1)"><path d="M8 10c-.3 0-.5-.1-.7-.3l-5-5c-.9-.9.5-2.3 1.4-1.4L8 7.6l4.3-4.3c.9-.9 2.3.5 1.4 1.4l-5 5c-.2.2-.4.3-.7.3zm5 2H3c-1.3 0-1.3 2 0 2h10c1.3 0 1.3-2 0-2z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 256 B |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M8 10c-.3 0-.5-.1-.7-.3l-5-5c-.9-.9.5-2.3 1.4-1.4L8 7.6l4.3-4.3c.9-.9 2.3.5 1.4 1.4l-5 5c-.2.2-.4.3-.7.3zm5 2H3c-1.3 0-1.3 2 0 2h10c1.3 0 1.3-2 0-2z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8 8.189L12.939 3.25L14 4.311L8.531 9.781C8.238 10.074 7.763 10.074 7.47 9.781L2 4.311L3.061 3.25L8 8.189ZM14 13.5V12H2V13.5H14Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 229 B After Width: | Height: | Size: 257 B |
@@ -1,2 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"
|
|
||||||
fill="rgba(255,255,255,1)"><path d="M1 1a1 1 0 011 1v2.4A7 7 0 118 15a7 7 0 01-4.9-2 1 1 0 011.4-1.5 5 5 0 10-1-5.5H6a1 1 0 010 2H1a1 1 0 01-1-1V2a1 1 0 011-1z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 231 B |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M1 1a1 1 0 011 1v2.4A7 7 0 118 15a7 7 0 01-4.9-2 1 1 0 011.4-1.5 5 5 0 10-1-5.5H6a1 1 0 010 2H1a1 1 0 01-1-1V2a1 1 0 011-1z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3.4105 4.83612L4.77001 6.19601C5.06701 6.49201 4.85701 7.00001 4.43701 7.00001H0.862006C0.602006 7.00001 0.391006 6.78901 0.391006 6.52901V2.95401C0.391006 2.53401 0.899006 2.32401 1.19601 2.62101L2.32796 3.75328C3.67958 1.78973 5.9401 0.5 8.5 0.5C12.636 0.5 16 3.864 16 8C16 12.136 12.636 15.5 8.5 15.5C4.704 15.5 1.566 12.663 1.075 9H2.59C3.068 11.833 5.532 14 8.5 14C11.809 14 14.5 11.309 14.5 8C14.5 4.691 11.809 2 8.5 2C6.35262 2 4.46893 3.13503 3.4105 4.83612Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 204 B After Width: | Height: | Size: 596 B |
@@ -1,5 +0,0 @@
|
|||||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"
|
|
||||||
fill="rgba(255,255,255,1)"><path d="M15 1a1 1 0 0 0-1 1v2.418A6.995 6.995 0 1 0 8 15a6.954 6.954 0 0 0 4.95-2.05 1 1 0 0 0-1.414-1.414A5.019 5.019 0 1 1 12.549 6H10a1 1 0 0 0 0 2h5a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 521 B |
@@ -1,4 +1,3 @@
|
|||||||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
<path d="M12.5895 4.83613L11.23 6.19601C10.933 6.49201 11.143 7.00001 11.563 7.00001H15.138C15.398 7.00001 15.609 6.78901 15.609 6.52901V2.95401C15.609 2.53401 15.101 2.32401 14.804 2.62101L13.672 3.75328C12.3204 1.78973 10.0599 0.5 7.5 0.5C3.364 0.5 0 3.864 0 8C0 12.136 3.364 15.5 7.5 15.5C11.296 15.5 14.434 12.663 14.925 9H13.41C12.932 11.833 10.468 14 7.5 14C4.191 14 1.5 11.309 1.5 8C1.5 4.691 4.191 2 7.5 2C9.64738 2 11.5311 3.13503 12.5895 4.83613Z" fill="black"/>
|
||||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M15 1a1 1 0 0 0-1 1v2.418A6.995 6.995 0 1 0 8 15a6.954 6.954 0 0 0 4.95-2.05 1 1 0 0 0-1.414-1.414A5.019 5.019 0 1 1 12.549 6H10a1 1 0 0 0 0 2h5a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1z"></path></svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 494 B After Width: | Height: | Size: 576 B |
@@ -1,2 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"
|
|
||||||
fill="rgba(255,255,255,1)"><path d="M0 4h1.5c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5H0zM9.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C5 4.5 5.5 4 6.5 4zM16 4h-1.5c-1 0-1.5.5-1.5 1.5v5c0 1 .5 1.5 1.5 1.5H16z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 302 B |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M0 4h1.5c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5H0zM9.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C5 4.5 5.5 4 6.5 4zM16 4h-1.5c-1 0-1.5.5-1.5 1.5v5c0 1 .5 1.5 1.5 1.5H16z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 3.78C3 2.7621 2.13279 2.11834 1.25 2.01476V2H1V3.5C1.18133 3.5 1.32279 3.5609 1.40708 3.63029C1.48961 3.69823 1.5 3.75458 1.5 3.78V11.72C1.5 11.7454 1.48961 11.8018 1.40708 11.8697C1.32279 11.9391 1.18133 12 1 12V13.5H1.25V13.4852C2.13279 13.3817 3 12.7379 3 11.72V3.78ZM10.5 4C10.5 3.72386 10.2761 3.5 10 3.5H6.5C6.22386 3.5 6 3.72386 6 4V11.5C6 11.7761 6.22386 12 6.5 12H10C10.2761 12 10.5 11.7761 10.5 11.5V4ZM10 2C11.1046 2 12 2.89543 12 4V11.5C12 12.6046 11.1046 13.5 10 13.5H6.5C5.39543 13.5 4.5 12.6046 4.5 11.5V4C4.5 2.89543 5.39543 2 6.5 2H10ZM15.5 2H15.25V2.01476C14.3672 2.11834 13.5 2.7621 13.5 3.78V11.72C13.5 12.7379 14.3672 13.3817 15.25 13.4852V13.5H15.5V12C15.3187 12 15.1772 11.9391 15.0929 11.8697C15.0104 11.8018 15 11.7454 15 11.72V3.78C15 3.75458 15.0104 3.69823 15.0929 3.63029C15.1772 3.5609 15.3187 3.5 15.5 3.5V2Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 275 B After Width: | Height: | Size: 971 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3.5 2C3.5 1.72421 3.72421 1.5 4 1.5H12C12.2758 1.5 12.5 1.72421 12.5 2V14C12.5 14.2758 12.2758 14.5 12 14.5H4C3.72421 14.5 3.5 14.2758 3.5 14V2ZM4 0C2.89579 0 2 0.895786 2 2V14C2 15.1042 2.89579 16 4 16H12C13.1042 16 14 15.1042 14 14V2C14 0.895786 13.1042 0 12 0H4ZM5.89301 6H7.25V10H5.89301C5.54301 10 5.36801 10.423 5.61501 10.67L7.72101 12.776C7.87401 12.929 8.12301 12.929 8.27601 12.776L10.383 10.669C10.63 10.422 10.455 9.99902 10.105 9.99902H8.75V6H10.106C10.456 6 10.632 5.577 10.383 5.331L8.27601 3.224C8.12301 3.071 7.87401 3.071 7.72101 3.224L5.61501 5.33C5.36801 5.577 5.54301 6 5.89301 6Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 731 B |
@@ -1,2 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"
|
|
||||||
fill="rgba(255,255,255,1)"><path d="M9.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C5 4.5 5.5 4 6.5 4zM11 0v.5c0 1-.5 1.5-1.5 1.5h-3C5.5 2 5 1.5 5 .5V0h6zM11 16v-.5c0-1-.5-1.5-1.5-1.5h-3c-1 0-1.5.5-1.5 1.5v.5h6z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 307 B |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M9.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C5 4.5 5.5 4 6.5 4zM11 0v.5c0 1-.5 1.5-1.5 1.5h-3C5.5 2 5 1.5 5 .5V0h6zM11 16v-.5c0-1-.5-1.5-1.5-1.5h-3c-1 0-1.5.5-1.5 1.5v.5h6z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2 1V1.25H2.01476C2.11834 2.13279 2.7621 3 3.78 3H11.72C12.7379 3 13.3817 2.13279 13.4852 1.25H13.5V1H12C12 1.18133 11.9391 1.32279 11.8697 1.40708C11.8018 1.48961 11.7454 1.5 11.72 1.5H3.78C3.75458 1.5 3.69823 1.48961 3.63029 1.40708C3.5609 1.32279 3.5 1.18133 3.5 1H2ZM4 6C3.72386 6 3.5 6.22386 3.5 6.5V10C3.5 10.2761 3.72386 10.5 4 10.5H11.5C11.7761 10.5 12 10.2761 12 10V6.5C12 6.22386 11.7761 6 11.5 6H4ZM2 6.5C2 5.39543 2.89543 4.5 4 4.5H11.5C12.6046 4.5 13.5 5.39543 13.5 6.5V10C13.5 11.1046 12.6046 12 11.5 12H4C2.89543 12 2 11.1046 2 10V6.5ZM3.78 13.5C2.7621 13.5 2.11834 14.3672 2.01476 15.25H2V15.5H3.5C3.5 15.3187 3.5609 15.1772 3.63029 15.0929C3.69823 15.0104 3.75458 15 3.78 15H11.72C11.7454 15 11.8018 15.0104 11.8697 15.0929C11.9391 15.1772 12 15.3187 12 15.5H13.5V15.25H13.4852C13.3817 14.3672 12.7379 13.5 11.72 13.5H3.78Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 280 B After Width: | Height: | Size: 969 B |
@@ -1,2 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
|
||||||
fill="rgba(255,255,255,1)"><path d="M5.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C1 4.5 1.5 4 2.5 4zM7 0v.5C7 1.5 6.5 2 5.5 2h-3C1.5 2 1 1.5 1 .5V0h6zM7 16v-.5c0-1-.5-1.5-1.5-1.5h-3c-1 0-1.5.5-1.5 1.5v.5h6zM13.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5c0-1 .5-1.5 1.5-1.5zM15 0v.5c0 1-.5 1.5-1.5 1.5h-3C9.5 2 9 1.5 9 .5V0h6zM15 16v-.507c0-1-.5-1.5-1.5-1.5h-3C9.5 14 9 14.5 9 15.5v.5h6z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 509 B |
@@ -1 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M5.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C1 4.5 1.5 4 2.5 4zM7 0v.5C7 1.5 6.5 2 5.5 2h-3C1.5 2 1 1.5 1 .5V0h6zM7 16v-.5c0-1-.5-1.5-1.5-1.5h-3c-1 0-1.5.5-1.5 1.5v.5h6zM13.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5c0-1 .5-1.5 1.5-1.5zM15 0v.5c0 1-.5 1.5-1.5 1.5h-3C9.5 2 9 1.5 9 .5V0h6zM15 16v-.507c0-1-.5-1.5-1.5-1.5h-3C9.5 14 9 14.5 9 15.5v.5h6z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2.5 1C2.5 1.27579 2.72421 1.5 3 1.5H5C5.27579 1.5 5.5 1.27579 5.5 1H7C7 2.10421 6.10421 3 5 3H3C1.89579 3 1 2.10421 1 1H2.5ZM2.5 6C2.5 5.72421 2.72421 5.5 3 5.5H5C5.27579 5.5 5.5 5.72421 5.5 6V10C5.5 10.2758 5.27579 10.5 5 10.5H3C2.72421 10.5 2.5 10.2758 2.5 10V6ZM3 4C1.89579 4 1 4.89579 1 6V10C1 11.1042 1.89579 12 3 12H5C6.10421 12 7 11.1042 7 10V6C7 4.89579 6.10421 4 5 4H3ZM10 6C10 5.72421 10.2242 5.5 10.5 5.5H12.5C12.7758 5.5 13 5.72421 13 6V10C13 10.2758 12.7758 10.5 12.5 10.5H10.5C10.2242 10.5 10 10.2758 10 10V6ZM10.5 4C9.39579 4 8.5 4.89579 8.5 6V10C8.5 11.1042 9.39579 12 10.5 12H12.5C13.6042 12 14.5 11.1042 14.5 10V6C14.5 4.89579 13.6042 4 12.5 4H10.5ZM3 14.5C2.72421 14.5 2.5 14.7242 2.5 15H1C1 13.8958 1.89579 13 3 13H5C6.10421 13 7 13.8958 7 15H5.5C5.5 14.7242 5.27579 14.5 5 14.5H3ZM10 15C10 14.7242 10.2242 14.5 10.5 14.5H12.5C12.7758 14.5 13 14.7242 13 15H14.5C14.5 13.8958 13.6042 13 12.5 13H10.5C9.39579 13 8.5 13.8958 8.5 15H10ZM10.5 1.5C10.2242 1.5 10 1.27579 10 1H8.5C8.5 2.10421 9.39579 3 10.5 3H12.5C13.6042 3 14.5 2.10421 14.5 1H13C13 1.27579 12.7758 1.5 12.5 1.5H10.5Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 482 B After Width: | Height: | Size: 1.2 KiB |