Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54d06e580d | ||
|
|
7b1d23eba4 | ||
|
|
2502c279fc | ||
|
|
878fc8c2fd | ||
|
|
64902eebcb | ||
|
|
e520f6afc4 | ||
|
|
0cc1ae9324 | ||
|
|
bd4fde9e63 | ||
|
|
c85cfa90a4 | ||
|
|
a0449b50c8 | ||
|
|
394b063b8c | ||
|
|
b62d84424e | ||
|
|
20b84a9459 | ||
|
|
dafc68f049 | ||
|
|
4745fc0db1 | ||
|
|
b009dfe4ee | ||
|
|
eba94f430c | ||
|
|
06347b7e3c | ||
|
|
6bc426b21d | ||
|
|
4a849aab7f | ||
|
|
ad2d0c84e4 | ||
|
|
6bf360fbfb | ||
|
|
380292a8aa | ||
|
|
f9949da745 | ||
|
|
b0cc52e0aa | ||
|
|
9ef705650b | ||
|
|
c6dadbe75e | ||
|
|
21026340ad | ||
|
|
541c8c4b93 | ||
|
|
b97373bf37 | ||
|
|
c0b561cb5a | ||
|
|
bd3ccfd0a9 | ||
|
|
5470acd3af | ||
|
|
64696fe973 | ||
|
|
ed2fa4cdd8 | ||
|
|
03cca1d1c0 | ||
|
|
354b075885 | ||
|
|
de7f039b99 | ||
|
|
19797d5d23 | ||
|
|
144c2b5fc7 | ||
|
|
7da40d1c2e | ||
|
|
4e3a5ca33b | ||
|
|
97e4707f72 | ||
|
|
450ee43677 | ||
|
|
c0b2e886d2 | ||
|
|
bb4749c65b | ||
|
|
5aa37c68a2 | ||
|
|
6e5a1a1f4d | ||
|
|
b624ce16c3 | ||
|
|
3be5b5f919 | ||
|
|
04ac5b69ac | ||
|
|
9cc14ac5c7 | ||
|
|
755eb1405b | ||
|
|
0138294b36 | ||
|
|
c0a4addf30 | ||
|
|
39bbee0eeb | ||
|
|
39dda3f534 | ||
|
|
1cb8dbe795 | ||
|
|
e13820bbf0 | ||
|
|
3973362457 | ||
|
|
b38877e193 | ||
|
|
0e1dbb5377 | ||
|
|
f07cc8b103 | ||
|
|
67775bc797 | ||
|
|
05933a5f0c | ||
|
|
d32b2ca524 | ||
|
|
d0a895628e | ||
|
|
90f2b3fb21 | ||
|
|
0f95800dde | ||
|
|
04971f8672 | ||
|
|
b6177b27f4 | ||
|
|
ae97e87506 | ||
|
|
2d73f541c0 | ||
|
|
7561eabe52 | ||
|
|
067fb1b0b7 | ||
|
|
78071841cc | ||
|
|
fac232229e | ||
|
|
a43021e87c | ||
|
|
1a0de3b3cf | ||
|
|
947154dc9c | ||
|
|
8acd1f1fe4 | ||
|
|
665210e506 | ||
|
|
91b9370a21 | ||
|
|
ca0aa600b4 | ||
|
|
1c42c4c969 | ||
|
|
6e406311c3 | ||
|
|
87884c1af2 | ||
|
|
c0623a949c | ||
|
|
42a23ea23c | ||
|
|
710c31d1ca | ||
|
|
f637ba4dad | ||
|
|
80d2c60cef | ||
|
|
b17d71e4c3 | ||
|
|
3c5bd3a605 | ||
|
|
7cc8d1e693 | ||
|
|
99520d54a5 | ||
|
|
e10a8c078b | ||
|
|
7a196fed7c | ||
|
|
c418c7e725 | ||
|
|
e35748deff | ||
|
|
b57efbe31c | ||
|
|
8f91437701 | ||
|
|
a7eb547ca4 | ||
|
|
0ceb12f74f | ||
|
|
4960624de7 | ||
|
|
2c92f24d89 | ||
|
|
0b7c679dba | ||
|
|
aab3cdc58a | ||
|
|
3c63f5b204 | ||
|
|
0b97bbf827 | ||
|
|
970dbb0c59 | ||
|
|
d21d3c2ceb | ||
|
|
0f6493b8ce | ||
|
|
f26beec1d3 | ||
|
|
c4225e29ed | ||
|
|
9243326cd3 | ||
|
|
86beb8023a | ||
|
|
f4e134742b | ||
|
|
2b17bf4114 | ||
|
|
c4f0fc8f7b | ||
|
|
3d6c836e7d | ||
|
|
c279055af4 | ||
|
|
657cba042a | ||
|
|
7a58e48cae | ||
|
|
670eab62bf | ||
|
|
fc85586809 | ||
|
|
837fc4988d | ||
|
|
4664b47851 | ||
|
|
9864d932e0 | ||
|
|
436f60caa9 | ||
|
|
e9530eda9d | ||
|
|
f4ddac16f9 | ||
|
|
33bdc07f55 | ||
|
|
130a4ed2d3 | ||
|
|
59ebc1af8a | ||
|
|
82e15d2e98 | ||
|
|
9c842f1895 | ||
|
|
8cc849488b | ||
|
|
fcaa232967 | ||
|
|
d3f8153b90 | ||
|
|
99ecf22790 | ||
|
|
b1b7ee65b4 | ||
|
|
6889456662 | ||
|
|
081553dc9f | ||
|
|
5d8d796807 | ||
|
|
f3d88fc746 | ||
|
|
d87ccae6c9 | ||
|
|
2760a7816d | ||
|
|
8f5c649d0f | ||
|
|
fcf9e7a1ef | ||
|
|
2be7b6480a | ||
|
|
f5ded86c02 | ||
|
|
8abfaf0ffd | ||
|
|
b070ba142f | ||
|
|
bd7c6828bf | ||
|
|
d0671ec58c | ||
|
|
1e40ffd1cc | ||
|
|
da2c3e9ed7 | ||
|
|
f62d6abb69 | ||
|
|
12ad7a6322 | ||
|
|
b75247ea3a | ||
|
|
9a963bbe79 | ||
|
|
994bc8b0e4 | ||
|
|
2451605033 | ||
|
|
10942527f3 | ||
|
|
4909ed5ccd | ||
|
|
5cf5df68dc | ||
|
|
dd32cc99ea | ||
|
|
79092dc8eb | ||
|
|
6229e4610a | ||
|
|
bfe36d3f4a | ||
|
|
d42bf44fad | ||
|
|
33e352819c | ||
|
|
53ee0aaee1 | ||
|
|
42707a19bd | ||
|
|
0888706790 | ||
|
|
16453a05f8 | ||
|
|
2fbb7466d3 | ||
|
|
f29f94f45f | ||
|
|
cd973868fc | ||
|
|
3c35f02cac | ||
|
|
22c93e2389 | ||
|
|
8c751eb532 | ||
|
|
4df443e007 | ||
|
|
f52fa41439 | ||
|
|
f77d72fd86 | ||
|
|
9b80c84794 | ||
|
|
725fc658f8 | ||
|
|
e4e27662f5 | ||
|
|
83474da7b5 | ||
|
|
9146e5f287 | ||
|
|
ff4502c63a | ||
|
|
9711bd8fe1 | ||
|
|
05139e53be | ||
|
|
870b2642a9 | ||
|
|
d31b26ae7d | ||
|
|
5511925ba2 | ||
|
|
f96b20717d | ||
|
|
940c9c45d7 | ||
|
|
87d6008dfc | ||
|
|
b9c0c8d2dc | ||
|
|
81c30d5fd5 | ||
|
|
0aa33d88a5 | ||
|
|
e64a504bb1 | ||
|
|
bc876a159e | ||
|
|
4aa1a838ed | ||
|
|
095a51edd0 | ||
|
|
a3a11bdf3f | ||
|
|
70b503f3d4 | ||
|
|
bbf609b880 | ||
|
|
0992bafe30 | ||
|
|
c810c5275a | ||
|
|
3c1b06872d | ||
|
|
cefdd2f66c | ||
|
|
5dac13b1da | ||
|
|
8aebf48193 | ||
|
|
fbb905957b | ||
|
|
e0ce135838 | ||
|
|
60497c60c1 | ||
|
|
251a77c8b4 | ||
|
|
7b7494b8a4 | ||
|
|
9390dcdd43 | ||
|
|
e6fb460071 | ||
|
|
6137fdeb33 | ||
|
|
4a4d02ea6a | ||
|
|
be26e5f152 | ||
|
|
127bfba135 | ||
|
|
7efae3c125 | ||
|
|
1e5af21000 | ||
|
|
33a0a4c173 | ||
|
|
eeb7974e05 | ||
|
|
f45ea1a31c | ||
|
|
a866dbaa80 | ||
|
|
62447d6b89 | ||
|
|
93f0724b83 | ||
|
|
724762843d | ||
|
|
ff16afbf0b | ||
|
|
9d7ef25062 | ||
|
|
88078d65e9 | ||
|
|
b07a97c17e | ||
|
|
41e7d65e2a | ||
|
|
7fa5865cf6 | ||
|
|
e0b8fe3b1a | ||
|
|
4a11dd1e16 | ||
|
|
34a474101f | ||
|
|
e6799e7a04 | ||
|
|
0f83f9992c | ||
|
|
d2ad78eb40 | ||
|
|
0b32738f4e | ||
|
|
a9cedb3fca | ||
|
|
51f9cd4bb4 | ||
|
|
a1668e2411 | ||
|
|
9418045a2c | ||
|
|
a7da6d210a | ||
|
|
59a41dc844 | ||
|
|
e09f2c9beb | ||
|
|
4bc3c8d9ac | ||
|
|
9acea8adf4 | ||
|
|
e5f754ed0e | ||
|
|
263a8f9048 | ||
|
|
6f9e52792a | ||
|
|
4a9b01e93b | ||
|
|
b7de23e895 | ||
|
|
f358f78da8 | ||
|
|
b8ab66369e | ||
|
|
54a78d5565 | ||
|
|
b4324cd685 | ||
|
|
ca212c8737 | ||
|
|
6fe4ed3e24 | ||
|
|
a659f2e49d | ||
|
|
760fbbb357 | ||
|
|
56388145b5 | ||
|
|
cd60db417c | ||
|
|
5cce01215f | ||
|
|
bc8bdfe385 | ||
|
|
4578af7a6d | ||
|
|
9e1cdd8f57 | ||
|
|
682c3b834f | ||
|
|
e269bab186 | ||
|
|
33a89c5d89 | ||
|
|
139047b22b | ||
|
|
9b50114852 | ||
|
|
b100d198e8 | ||
|
|
7849f2fb4b | ||
|
|
b35ecddde3 | ||
|
|
bde7921016 | ||
|
|
ee28e3346b | ||
|
|
d33b0587cb | ||
|
|
7e0ed537b7 | ||
|
|
1e351eb01d | ||
|
|
1a83bddf8c | ||
|
|
2a63c35743 | ||
|
|
27dcbcd7e1 | ||
|
|
0cf4c7b7b7 | ||
|
|
623372387d | ||
|
|
abf0f4d699 | ||
|
|
2bea447de5 | ||
|
|
f6538b6110 | ||
|
|
2d3ae71a3d | ||
|
|
9a20faf640 | ||
|
|
123493ee59 | ||
|
|
2d498dd138 | ||
|
|
376214e2d2 | ||
|
|
62da469fd1 | ||
|
|
d64009e23e | ||
|
|
fd8b642d64 | ||
|
|
d5ed5cd665 | ||
|
|
fa95d064ff | ||
|
|
1905e0ee6f | ||
|
|
96b18faea1 | ||
|
|
0d7f2e157a | ||
|
|
2d66936d8b | ||
|
|
b637a63e71 | ||
|
|
1ae778d81e | ||
|
|
f4412ee96b | ||
|
|
9130aceb5a | ||
|
|
b8336c03c3 | ||
|
|
76c3ade394 | ||
|
|
d9b22fd513 | ||
|
|
88ea998f8b | ||
|
|
8b605aeaa8 | ||
|
|
db91577485 | ||
|
|
5d9404863d | ||
|
|
d3986ca14a | ||
|
|
2508c1abb2 | ||
|
|
983e3b2274 | ||
|
|
72a02e087c | ||
|
|
352b4a0b73 | ||
|
|
d957b2d20f | ||
|
|
dcab8af8ab | ||
|
|
e1987c34d9 | ||
|
|
a80a8aab1c | ||
|
|
d6fbcdb09d | ||
|
|
d39b28b011 | ||
|
|
8f36128fe3 | ||
|
|
986d4c99bd | ||
|
|
f677dcb1f4 | ||
|
|
1a9b220ec2 | ||
|
|
d15d252af7 | ||
|
|
5e3618716d | ||
|
|
f13522559d | ||
|
|
777c2726d3 | ||
|
|
c25afdc203 | ||
|
|
c25f6d7c38 | ||
|
|
388e46ee81 | ||
|
|
242866948f | ||
|
|
15bb0ce990 | ||
|
|
a82911ea5d | ||
|
|
9a8f20317b | ||
|
|
b605a0f622 | ||
|
|
046a074c3a | ||
|
|
7c96fac95c | ||
|
|
a3ef53102d | ||
|
|
68513b775b | ||
|
|
a79dcc93f6 | ||
|
|
7aabfc573b | ||
|
|
22dde5d18e | ||
|
|
56505457eb | ||
|
|
5930c6d5fb | ||
|
|
6b162a4e49 | ||
|
|
c3b9888b31 | ||
|
|
cb8dfdde4c | ||
|
|
ff5d333fc8 | ||
|
|
8eb4b6288a | ||
|
|
f18836be90 | ||
|
|
3372070a58 | ||
|
|
f99e2ebd13 | ||
|
|
4feb26eefb | ||
|
|
2da7cd2064 | ||
|
|
9d7daf7afd | ||
|
|
cb1ebc1cd0 | ||
|
|
6ad56a0859 | ||
|
|
8515781564 | ||
|
|
7e2bfbd255 | ||
|
|
e2325f7ba4 | ||
|
|
9afdab8c52 | ||
|
|
f620d4a9ca | ||
|
|
fb1e763bbe | ||
|
|
ecea7e7493 | ||
|
|
560ade00b4 | ||
|
|
81ea24ad54 | ||
|
|
747b25046a | ||
|
|
b9536812f4 | ||
|
|
eed2f0a430 | ||
|
|
31fe8cd263 | ||
|
|
a3fadbaa1a | ||
|
|
e2be655d74 | ||
|
|
2cd653c773 | ||
|
|
6538aff02f | ||
|
|
c1e3dec9be | ||
|
|
42c13ae135 | ||
|
|
9bd51c650b | ||
|
|
ba1c1c87c4 | ||
|
|
ceefba2743 | ||
|
|
70da46b05e | ||
|
|
32b7b39223 | ||
|
|
2343c79126 | ||
|
|
09a5a69f86 | ||
|
|
4081895a78 | ||
|
|
a522566a0c | ||
|
|
013c4e9c35 | ||
|
|
067f289050 | ||
|
|
06511b92aa | ||
|
|
32f4c9eabf | ||
|
|
14bc345883 | ||
|
|
d76b4fd7ea | ||
|
|
83cdd7e9fb | ||
|
|
50441bae62 | ||
|
|
5b0766a9b0 | ||
|
|
d979fe8e5f | ||
|
|
f2c52fd278 | ||
|
|
87d60308f2 | ||
|
|
64ebc56c87 | ||
|
|
58d485cbb5 | ||
|
|
e99dd3310c | ||
|
|
400f6e02a5 | ||
|
|
700b0609df | ||
|
|
bc52f90ed4 | ||
|
|
0771546dad | ||
|
|
95a1a71a66 | ||
|
|
b9b8e3f632 | ||
|
|
1e03a2ae40 | ||
|
|
25da6aeeca | ||
|
|
00b422807b | ||
|
|
130701a7bb | ||
|
|
ff88e68904 | ||
|
|
55fcf23d2b | ||
|
|
1b0b4c4cc5 | ||
|
|
cd57731593 | ||
|
|
37a80b935d | ||
|
|
4d61aec153 | ||
|
|
1adf25d50b | ||
|
|
ae33aee3f6 | ||
|
|
2c99e71626 | ||
|
|
e7f7775efa | ||
|
|
8b60a19577 | ||
|
|
5701e08db9 | ||
|
|
72a2fc49f8 | ||
|
|
d2617322c6 | ||
|
|
fa82745f64 | ||
|
|
19b2a334e4 | ||
|
|
627c2adf08 | ||
|
|
e3e137ca50 | ||
|
|
0978be580f | ||
|
|
5792838333 | ||
|
|
f9995583a5 | ||
|
|
4cc68bd139 | ||
|
|
d2510ad1bc | ||
|
|
4d81d3613c | ||
|
|
7d28963a32 | ||
|
|
b2594468b4 | ||
|
|
754b9832e9 | ||
|
|
097ac879ea | ||
|
|
bc0416cbb4 | ||
|
|
2814617e4b | ||
|
|
da9dfd166d | ||
|
|
1be07a42df | ||
|
|
1d83a6a898 | ||
|
|
2ff286b672 | ||
|
|
e8620a0986 | ||
|
|
7fb18bbdc7 | ||
|
|
5b67b687d3 | ||
|
|
9adcfa99f4 | ||
|
|
e723aaa5f6 | ||
|
|
d128b037a5 | ||
|
|
99fda00442 | ||
|
|
f574f8faf0 | ||
|
|
cedfa90d76 | ||
|
|
f1e6f6e505 | ||
|
|
b4f95cced7 | ||
|
|
e16c0caebb | ||
|
|
52489a484a | ||
|
|
e1d5c2c578 | ||
|
|
49f49632ad | ||
|
|
6dadc6fb1e | ||
|
|
a58a2f5fe4 | ||
|
|
c33c6bbff0 | ||
|
|
093f90d4c1 | ||
|
|
20ffa325d3 | ||
|
|
5027304801 | ||
|
|
85d5afd6d9 | ||
|
|
df295e92ee | ||
|
|
2e67bd2407 | ||
|
|
9aa01ee8cf | ||
|
|
6b993ad329 | ||
|
|
d70ded0993 | ||
|
|
3dacdcc8bb | ||
|
|
bb03026589 | ||
|
|
2f69e3141e | ||
|
|
1f4564da76 | ||
|
|
3b8e5ddfb3 | ||
|
|
edc293f96a | ||
|
|
3fa4149bb0 | ||
|
|
4b68a6ff23 | ||
|
|
28116c49dc | ||
|
|
6e6f144b7a | ||
|
|
e3f4f24c3e | ||
|
|
eb37e3a52b | ||
|
|
95d540630e | ||
|
|
23d66a0d68 | ||
|
|
0d64692c84 | ||
|
|
1cb640e51e | ||
|
|
2d98285545 | ||
|
|
376001100a | ||
|
|
e2954249f8 | ||
|
|
cc0b0196f4 | ||
|
|
6dfa171b4e | ||
|
|
b140073988 | ||
|
|
c22bc857b0 | ||
|
|
497fbdcdfc | ||
|
|
861f1b2ca3 | ||
|
|
6108ef4c89 | ||
|
|
a9c0bcb4a2 | ||
|
|
8f9de05768 | ||
|
|
e61e94f0fa | ||
|
|
85aac02593 | ||
|
|
9a678c41fe | ||
|
|
7c8f6ce62f | ||
|
|
9a896ea81e | ||
|
|
422c1893c0 | ||
|
|
f1cb5276d7 | ||
|
|
bed1b24340 | ||
|
|
da909ff084 | ||
|
|
8f743b70a4 | ||
|
|
a761017116 | ||
|
|
f06cc25a99 | ||
|
|
eff8480d5c | ||
|
|
eec303de49 | ||
|
|
07a936c5e8 | ||
|
|
a6002a2c6c | ||
|
|
7ba014ba49 | ||
|
|
165c649f31 | ||
|
|
4cf71dd336 | ||
|
|
c0a401216b | ||
|
|
2d712a3841 | ||
|
|
a2b5b4dd17 | ||
|
|
0480edce2a | ||
|
|
4eded82102 | ||
|
|
ec4ff83465 | ||
|
|
8745b8b051 | ||
|
|
4e28c3cadb | ||
|
|
76c724c783 | ||
|
|
032cb59388 | ||
|
|
e29247774c | ||
|
|
18d16f9a8b | ||
|
|
d137735be1 | ||
|
|
65929c02bc | ||
|
|
22466d6b98 | ||
|
|
23fe79c618 | ||
|
|
b202ca5619 | ||
|
|
7929711fea | ||
|
|
49a028a599 | ||
|
|
670cbcd336 | ||
|
|
449d31e8a1 | ||
|
|
fe82583813 | ||
|
|
1450a21d00 | ||
|
|
ae15544aed | ||
|
|
4b7a37cf7d | ||
|
|
cb7727900c | ||
|
|
e012726cd4 | ||
|
|
f49688fdb9 | ||
|
|
e32b017431 | ||
|
|
393869e538 | ||
|
|
d3bde0408f | ||
|
|
34d3225984 | ||
|
|
eaed53e25b | ||
|
|
feacbe8ebd | ||
|
|
bdf6052388 | ||
|
|
ec0a0190e7 | ||
|
|
99d653eece | ||
|
|
f825c5ae83 | ||
|
|
173484c30e | ||
|
|
884270093b | ||
|
|
62e7ab8c2b | ||
|
|
ded480207b | ||
|
|
ef49e2b5b3 | ||
|
|
b0a055a870 | ||
|
|
9b9e29a3b6 | ||
|
|
1a9a436cbe | ||
|
|
62dd29d2f3 | ||
|
|
d406d91856 | ||
|
|
98494c610a | ||
|
|
649a553fa4 | ||
|
|
e17a06d7bd | ||
|
|
4b14cc6a74 | ||
|
|
65560ab65e | ||
|
|
b65095fd0f | ||
|
|
28733179d2 | ||
|
|
960d23ca50 | ||
|
|
057f70ea9c | ||
|
|
3e378bd665 | ||
|
|
c120138f26 | ||
|
|
31b20362ec | ||
|
|
e7eb5b6ea6 | ||
|
|
fe91ed815e | ||
|
|
9e5cad0dc8 | ||
|
|
36cbc42363 | ||
|
|
4cb82ea9bd | ||
|
|
572ac4a17b | ||
|
|
6c552f6b43 | ||
|
|
9723129436 | ||
|
|
dc5191f8ba | ||
|
|
d79726899f | ||
|
|
8ab5710098 | ||
|
|
61d628d596 | ||
|
|
078fc25845 | ||
|
|
67eb4b317a | ||
|
|
6322919bc7 | ||
|
|
b81b8a1dea | ||
|
|
f3a3797850 | ||
|
|
5ec1283bb1 | ||
|
|
8634b0c6f0 | ||
|
|
d89830af61 | ||
|
|
ef1736b571 | ||
|
|
d951ee4b83 | ||
|
|
04081f62c4 | ||
|
|
0f28dc5e55 | ||
|
|
508f49df18 | ||
|
|
bec280c6b1 | ||
|
|
6a8ae9c0c4 | ||
|
|
ac22483f98 | ||
|
|
59d56d5c83 | ||
|
|
cdaad2fb4a | ||
|
|
843279bacb | ||
|
|
a000de0270 | ||
|
|
bf41b04cfa | ||
|
|
9ce2e8ea53 | ||
|
|
f066926fc9 | ||
|
|
4c38b0ab10 | ||
|
|
cf35c9dcef | ||
|
|
45ff9394f2 | ||
|
|
414043ded1 | ||
|
|
2533c9c14e | ||
|
|
f10f0dada6 | ||
|
|
282859bc1b | ||
|
|
a3ae97a5a3 | ||
|
|
ed8275a20b | ||
|
|
f2add3f788 | ||
|
|
ad144922fb | ||
|
|
b9c0f2a3d3 | ||
|
|
0cc07362b8 | ||
|
|
4ee5dcaff3 | ||
|
|
4d44746a88 | ||
|
|
1535bdbcd8 | ||
|
|
ecb160b28d | ||
|
|
7e9941def0 | ||
|
|
f9c6fb30bf | ||
|
|
94ad93ebd7 | ||
|
|
0e1ec5034e | ||
|
|
76b0505bd9 | ||
|
|
b309c1fc91 | ||
|
|
b9a802a19c | ||
|
|
a882ad3e65 | ||
|
|
969105b205 | ||
|
|
28bfb06c67 | ||
|
|
704dcb3e58 | ||
|
|
a7e723d8d4 | ||
|
|
4c6f5096be | ||
|
|
1a1d105fae | ||
|
|
42a0639bb5 | ||
|
|
e27b08203d | ||
|
|
1ca4583896 | ||
|
|
25fc6f1937 | ||
|
|
0ccc3f7252 | ||
|
|
66acd1821d | ||
|
|
93a0217d5f | ||
|
|
711a697570 | ||
|
|
f8139f3198 | ||
|
|
df01022f49 | ||
|
|
450411a732 | ||
|
|
f80c67828b | ||
|
|
d1889a5e06 | ||
|
|
12263ff02f | ||
|
|
76f914cbc2 | ||
|
|
c1f5252b3f | ||
|
|
20c6f79a44 | ||
|
|
ee6f1405d4 | ||
|
|
ee3541d74e | ||
|
|
e048388213 | ||
|
|
3ff3431b17 | ||
|
|
8608ff11f7 | ||
|
|
7e0d9fbace | ||
|
|
c6c9cfea22 | ||
|
|
a758976c69 | ||
|
|
a14192b7e0 | ||
|
|
601464083b | ||
|
|
ccca5d4d1c | ||
|
|
ba10657829 | ||
|
|
6315655f93 | ||
|
|
533cb23b73 | ||
|
|
0b95424a0d | ||
|
|
852f252d13 | ||
|
|
080042882f | ||
|
|
dde5b08c47 | ||
|
|
88d2c60ee8 | ||
|
|
eeff5a5d43 | ||
|
|
67dd4a72b0 | ||
|
|
a0b8cc21cc | ||
|
|
329a7a03a5 | ||
|
|
d0a3503d74 | ||
|
|
59b78f9984 | ||
|
|
bf75f16169 | ||
|
|
2f833dc457 | ||
|
|
22344a3971 | ||
|
|
8dde6ba60f | ||
|
|
d44f283a05 | ||
|
|
c18d5786dd | ||
|
|
f26ccfe16c | ||
|
|
1c681ee378 | ||
|
|
4c1ae44bbe | ||
|
|
94b5ec91cc | ||
|
|
6bfbf3ee41 | ||
|
|
fc24fa337e | ||
|
|
7b4306b1d6 | ||
|
|
4516cc0d65 | ||
|
|
308784c601 | ||
|
|
9145c9a52c | ||
|
|
4038cb5b85 | ||
|
|
628658972c | ||
|
|
db0fe9a755 | ||
|
|
fdf10e3d2e | ||
|
|
ded3e06a9b | ||
|
|
0dd0605a1f | ||
|
|
827b0c6e50 | ||
|
|
d1b533848d | ||
|
|
13ae28edab | ||
|
|
82253219e8 | ||
|
|
94592b74a6 | ||
|
|
eef2112e1e | ||
|
|
b83d56eff2 | ||
|
|
4c539b6db4 | ||
|
|
2ad329e64c | ||
|
|
4de89ec6ce | ||
|
|
fc885c8fa2 | ||
|
|
ef2c98ba39 | ||
|
|
41d922867e | ||
|
|
89223760e6 | ||
|
|
9a8a1f75ca | ||
|
|
52e8e7e4b0 | ||
|
|
93f65484de | ||
|
|
7d08cde8b8 | ||
|
|
d3c2bf7dd4 | ||
|
|
54cf3652b0 | ||
|
|
5607d2086d | ||
|
|
27ed918896 | ||
|
|
9b2df9cfd9 | ||
|
|
5dd08e438c | ||
|
|
2f8c8c3a28 | ||
|
|
dde7cf18f7 | ||
|
|
48aadbf716 | ||
|
|
5514af60e5 | ||
|
|
e8449eb02f | ||
|
|
9ea21a7ecb | ||
|
|
fec86c2862 | ||
|
|
11e5d4c5b7 | ||
|
|
b852fb0e26 | ||
|
|
0fc18bf3b1 | ||
|
|
d4be584782 | ||
|
|
2120d72901 | ||
|
|
6f9c08f906 | ||
|
|
70c666c380 | ||
|
|
46197d82b5 | ||
|
|
e4eab17595 | ||
|
|
cf10244f20 | ||
|
|
570684d308 | ||
|
|
244db8d894 | ||
|
|
96d6018ecc | ||
|
|
73ad6dd0c4 | ||
|
|
d0e15da352 | ||
|
|
b1b293a3ec | ||
|
|
5f0660a4e5 | ||
|
|
ec3a3a73ef | ||
|
|
087c4c5929 | ||
|
|
e0ee2e0801 | ||
|
|
c79a9e9858 | ||
|
|
b3fdce36af | ||
|
|
b7535b9526 | ||
|
|
0cf1cc5587 | ||
|
|
098dab889a | ||
|
|
cc856c7cd1 | ||
|
|
a20a155d39 | ||
|
|
1a458fe39f | ||
|
|
051ffdda35 | ||
|
|
a48418364c | ||
|
|
b75497231e | ||
|
|
5cceeb6ef2 | ||
|
|
29b94c5615 | ||
|
|
81fc1eccd3 | ||
|
|
22fae51c9d | ||
|
|
4332f7a640 | ||
|
|
d0e603e62d | ||
|
|
38c28f4358 | ||
|
|
742799b4ac | ||
|
|
5405dc5141 | ||
|
|
013793f989 | ||
|
|
2468cf63cc | ||
|
|
8e9b5d7e50 | ||
|
|
a33515d907 | ||
|
|
a276a33081 | ||
|
|
e325c3dbf6 | ||
|
|
b3fef625a8 | ||
|
|
13994a5f96 | ||
|
|
e8ac62cdd8 | ||
|
|
1bc7134ec2 | ||
|
|
273572f44c | ||
|
|
ac37483d47 | ||
|
|
0cc2b49296 | ||
|
|
44d5adc16f | ||
|
|
f749fc641b | ||
|
|
87c76c09e2 | ||
|
|
e787f9dd9f | ||
|
|
ec7d5b17ab | ||
|
|
47641eee59 | ||
|
|
42abe28cc1 | ||
|
|
b48afa38ac | ||
|
|
f7269d8df2 | ||
|
|
ce0fab8d2f | ||
|
|
33472567de | ||
|
|
f5e12328dc | ||
|
|
486c0f2937 | ||
|
|
e69c4cd1dc | ||
|
|
e0d3ccd8d1 | ||
|
|
16a3deec2c | ||
|
|
92db00692a | ||
|
|
fe88010a72 | ||
|
|
99ae4be2c2 | ||
|
|
a9085752c1 | ||
|
|
e1fbc9255c | ||
|
|
f33e25ac40 | ||
|
|
51365ab006 | ||
|
|
d61b7e48d7 | ||
|
|
f590b24f85 | ||
|
|
308fd55483 | ||
|
|
fefb44e612 | ||
|
|
dd3b562f1a | ||
|
|
30c9aa3df9 | ||
|
|
688184e255 | ||
|
|
75fb7c2e95 | ||
|
|
264b4b669e | ||
|
|
51f12c51ad | ||
|
|
03d134697c | ||
|
|
e706e1a68d | ||
|
|
ff3f42db95 | ||
|
|
e8aedeac36 | ||
|
|
2bf6b263ed | ||
|
|
bf166b757a | ||
|
|
b4165335a7 | ||
|
|
2a1bf2fa71 | ||
|
|
718d50a037 | ||
|
|
41960ada4a | ||
|
|
0a02caad04 | ||
|
|
ff75bdba9e | ||
|
|
189243a9b0 | ||
|
|
34e339c506 | ||
|
|
a437c603c6 | ||
|
|
4e940f7fa0 | ||
|
|
69fde7dead | ||
|
|
4368c182a0 | ||
|
|
48f4b12c0e | ||
|
|
6a6c1b6b21 | ||
|
|
51808d2ad4 | ||
|
|
148f1109c6 | ||
|
|
e1439b529b | ||
|
|
0adcd1b3d9 | ||
|
|
fcefd8031a | ||
|
|
64bebaa1d1 | ||
|
|
0138ff9e16 | ||
|
|
9bc085a23e | ||
|
|
0a92d79ec0 | ||
|
|
b95f6563cc | ||
|
|
ca2bcc647d | ||
|
|
b90048d72e | ||
|
|
a5fe08e84c | ||
|
|
2874cf531c | ||
|
|
ea7058e896 | ||
|
|
38bb10d36b | ||
|
|
58943bb156 | ||
|
|
2d66da3cb9 | ||
|
|
b7efbf9040 | ||
|
|
5222e157cb | ||
|
|
1e3a948977 | ||
|
|
20cc5107da | ||
|
|
a6a4d5f09b | ||
|
|
028c53287d | ||
|
|
24a9bea137 | ||
|
|
ef1a2d1730 | ||
|
|
6abff36e07 | ||
|
|
4ef7615d88 | ||
|
|
77c2783a3e | ||
|
|
5b1dfc123f | ||
|
|
38a255e069 |
@@ -6,6 +6,7 @@ labels: ''
|
|||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
<!-- Please 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.
|
||||||
@@ -27,12 +28,12 @@ A clear and concise description of what you expected to happen.
|
|||||||
If applicable, add screenshots to help explain your problem.
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
**Environment (please complete the following information):**
|
**Environment (please complete the following information):**
|
||||||
- OS: [e.g. Windows 10/raspian]
|
- 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.5 or master@16.02.20, 19:55 ]:
|
- Calibre-Web version: [e.g. 0.6.8 or 087c4c59 (git rev-parse --short HEAD)]:
|
||||||
- Docker container [ None/Technosoft2000/Linuxuser]:
|
- Docker container: [None/Technosoft2000/LinuxServer]:
|
||||||
- Special Hardware [e.g. Rasperry Pi Zero]
|
- Special Hardware: [e.g. Rasperry Pi Zero]
|
||||||
- Browser [e.g. chrome, safari]
|
- Browser: [e.g. Chrome 83.0.4103.97, Safari 13.3.7, Firefox 68.0.1 ESR]
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
Add any other context about the problem here. [e.g. access via reverse proxy]
|
Add any other context about the problem here. [e.g. access via reverse proxy, database background sync, special database location]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
@@ -7,6 +7,8 @@ assignees: ''
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<!-- 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 [...]
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ env/
|
|||||||
venv/
|
venv/
|
||||||
eggs/
|
eggs/
|
||||||
dist/
|
dist/
|
||||||
|
executable/
|
||||||
build/
|
build/
|
||||||
vendor/
|
vendor/
|
||||||
.eggs/
|
.eggs/
|
||||||
@@ -29,4 +30,4 @@ vendor/
|
|||||||
settings.yaml
|
settings.yaml
|
||||||
gdrive_credentials
|
gdrive_credentials
|
||||||
client_secrets.json
|
client_secrets.json
|
||||||
|
gmail.json
|
||||||
|
|||||||
@@ -1,49 +1,46 @@
|
|||||||
## How to contribute to Calibre-Web
|
## How to contribute to Calibre-Web
|
||||||
|
|
||||||
First of all, we would like to thank you for reading this text. we are happy you are willing to contribute to Calibre-Web
|
First of all, we would like to thank you for reading this text. We are happy you are willing to contribute to Calibre-Web.
|
||||||
|
|
||||||
### **General**
|
### **General**
|
||||||
|
|
||||||
**Communication language** is english. Google translated texts are not as bad as you might think, they are usually understandable, so don't worry if you generate your post that way.
|
**Communication language** is English. Google translated texts are not as bad as you might think, they are usually understandable, so don't worry if you generate your post that way.
|
||||||
|
|
||||||
**Calibre-Web** is not **Calibre**. If you are having a question regarding Calibre please post this at their [repository](https://github.com/kovidgoyal/calibre).
|
**Calibre-Web** is not **Calibre**. If you are having a question regarding Calibre please post this at their [repository](https://github.com/kovidgoyal/calibre).
|
||||||
|
|
||||||
**Docker-Containers** of Calibre-Web are maintained by different persons than the people who drive Calibre-Web. If we come to the conclusion during our analysis that the problem is related to Docker, we might ask you to open a new issue at the reprository of the Docker Container.
|
**Docker-Containers** of Calibre-Web are maintained by different persons than the people who drive Calibre-Web. If we come to the conclusion during our analysis that the problem is related to Docker, we might ask you to open a new issue at the repository of the Docker Container.
|
||||||
|
|
||||||
If you are having **Basic Installation Problems** with python or it's dependencies, please consider using your favorite search engine to find a solution. In case you can't find a solution, we are happy to help you.
|
If you are having **Basic Installation Problems** with python or its dependencies, please consider using your favorite search engine to find a solution. In case you can't find a solution, we are happy to help you.
|
||||||
|
|
||||||
We can offer only very limited support regarding configuration of **Reverse-Proxy Installations**, **OPDS-Reader** or other programs in combination with Calibre-Web.
|
We can offer only very limited support regarding configuration of **Reverse-Proxy Installations**, **OPDS-Reader** or other programs in combination with Calibre-Web.
|
||||||
|
|
||||||
### **Translation**
|
### **Translation**
|
||||||
|
|
||||||
Some of the user languages in Calibre-Web having missing translations. We are happy to add the missing texts if you are translate them. Create a Pull Request, create an issue with the .po file attached, or write an email to "ozzie.fernandez.isaacs@googlemail.com" with attached translation file. To display all book languages in your native language an additional file is used (iso_language_names.py). The content of this file is autogenerated with the corresponding translations of Calibre, please do not edit this file on your own.
|
Some of the user languages in Calibre-Web having missing translations. We are happy to add the missing texts if you translate them. Create a Pull Request, create an issue with the .po file attached, or write an email to "ozzie.fernandez.isaacs@googlemail.com" with attached translation file. To display all book languages in your native language an additional file is used (iso_language_names.py). The content of this file is auto-generated with the corresponding translations of Calibre, please do not edit this file on your own.
|
||||||
|
|
||||||
### **Documentation**
|
### **Documentation**
|
||||||
|
|
||||||
The Calibre-Web documentation is hosted in the Github [Wiki](https://github.com/janeczku/calibre-web/wiki). The Wiki is open to everybody, if you find a problem, feel free to correct it. If information is missing, you are welcome to add it. The content will be reviewed time by time. Please try to be consitent with the form with the other Wiki pages (e.g. the project name is Calibre-Web with 2 capital letters and a dash in between).
|
The Calibre-Web documentation is hosted in the Github [Wiki](https://github.com/janeczku/calibre-web/wiki). The Wiki is open to everybody, if you find a problem, feel free to correct it. If information is missing, you are welcome to add it. The content will be reviewed time by time. Please try to be consistent with the form with the other Wiki pages (e.g. the project name is Calibre-Web with 2 capital letters and a dash in between).
|
||||||
|
|
||||||
### **Reporting a bug**
|
### **Reporting a bug**
|
||||||
|
|
||||||
Do not open up a GitHub issue if the bug is a **security vulnerability** in Calibre-Web. Please write intead an email to "ozzie.fernandez.isaacs@googlemail.com".
|
Do not open up a GitHub issue if the bug is a **security vulnerability** in Calibre-Web. Instead, please write an email to "ozzie.fernandez.isaacs@googlemail.com".
|
||||||
|
|
||||||
Ensure the ***bug was not already reported** by searching on GitHub under [Issues](https://github.com/janeczku/calibre-web/issues). Please also check if a solution for your problem can be found in the [wiki](https://github.com/janeczku/calibre-web/wiki).
|
Ensure the ***bug was not already reported** by searching on GitHub under [Issues](https://github.com/janeczku/calibre-web/issues). Please also check if a solution for your problem can be found in the [wiki](https://github.com/janeczku/calibre-web/wiki).
|
||||||
|
|
||||||
If you're unable to find an **open issue** addressing the problem, open a [new one](https://github.com/janeczku/calibre-web/issues/new?assignees=&labels=&template=bug_report.md&title=). Be sure to include a **title** and **clear description**, as much relevant information as possible, the **issue form** helps you providing the right information. Deleting the form and just pasting the stack trace doesn't speed up fixing the problem. If your issue could be resolved, consider closing the issue.
|
If you're unable to find an **open issue** addressing the problem, open a [new one](https://github.com/janeczku/calibre-web/issues/new?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.
|
||||||
|
|
||||||
### **Feature Request**
|
### **Feature Request**
|
||||||
|
|
||||||
If there is afeature missing in Calibre-Web and you can't find a feature request in the [Issues](https://github.com/janeczku/calibre-web/issues) section, you could create a [feature request](https://github.com/janeczku/calibre-web/issues/new?assignees=&labels=&template=feature_request.md&title=).
|
If there is a feature missing in Calibre-Web and you can't find a feature request in the [Issues](https://github.com/janeczku/calibre-web/issues) section, you could create a [feature request](https://github.com/janeczku/calibre-web/issues/new?assignees=&labels=&template=feature_request.md&title=).
|
||||||
We will not extend Calibre-Web with any more login abilitys or add further files storages, or file syncing ability. Furthermore Calibre-Web is made for home usage for company inhouse usage, so requests regarding any sorts of social interaction capability, payment routines, search engine or web site analytics integration will not be implemeted.
|
We will not extend Calibre-Web with any more login abilities or add further files storages, or file syncing ability. Furthermore Calibre-Web is made for home usage for company in-house usage, so requests regarding any sorts of social interaction capability, payment routines, search engine or web site analytics integration will not be implemented.
|
||||||
|
|
||||||
### **Contributing code to Calibre-Web**
|
### **Contributing code to Calibre-Web**
|
||||||
|
|
||||||
Open a new GitHub pull request with the patch. Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable.
|
Open a new GitHub pull request with the patch. Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable.
|
||||||
|
|
||||||
In case your code enhances features of Calibre-Web: Create your pull request for the development branch if your enhancement consits of more than some lines of code in a local section of Calibre-Webs code. This makes it easier to test it and check all implication before it's made public.
|
In case your code enhances features of Calibre-Web: Create your pull request for the development branch if your enhancement consists of more than some lines of code in a local section of Calibre-Webs code. This makes it easier to test it and check all implication before it's made public.
|
||||||
|
|
||||||
Please check if your code runs on Python 2.7 (still necessary in 2020) and mainly on python 3. If possible and the feature is related to operating system functions, try to check it on Windows and Linux.
|
|
||||||
Calibre-Web is automatically tested on Linux in combination with python 3.7. The code for testing is in a [seperate repo](https://github.com/OzzieIsaacs/calibre-web-test) on Github. It uses unittest and performs real system tests with selenium, would be great if you could consider also writing some tests.
|
|
||||||
A static code analysis is done by Codacy, but it's partly broken and doesn't run automatically. You could check your code with ESLint before contributing, a configuration file can be found in the projects root folder.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Please check if your code runs on Python 2.7 (still necessary in 2020) and mainly on python 3. If possible and the feature is related to operating system functions, try to check it on Windows and Linux.
|
||||||
|
Calibre-Web is automatically tested on Linux in combination with python 3.7. The code for testing is in a [separate repo](https://github.com/OzzieIsaacs/calibre-web-test) on Github. It uses unit tests and performs real system tests with selenium; it would be great if you could consider also writing some tests.
|
||||||
|
A static code analysis is done by Codacy, but it's partly broken and doesn't run automatically. You could check your code with ESLint before contributing, a configuration file can be found in the projects root folder.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Calibre-Web is a web app providing a clean interface for browsing, reading and downloading eBooks using an existing [Calibre](https://calibre-ebook.com) database.
|
Calibre-Web is a web app providing a clean interface for browsing, reading and downloading eBooks using an existing [Calibre](https://calibre-ebook.com) database.
|
||||||
|
|
||||||
|
   [](https://discord.gg/h2VsJ2NEfB)
|
||||||
|
|
||||||
*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.*
|
||||||
|
|
||||||

|

|
||||||
@@ -12,7 +14,7 @@ Calibre-Web is a web app providing a clean interface for browsing, reading and d
|
|||||||
- full graphical setup
|
- full graphical setup
|
||||||
- User management with fine-grained per-user permissions
|
- User management with fine-grained per-user permissions
|
||||||
- Admin interface
|
- Admin interface
|
||||||
- User Interface in czech, dutch, english, finnish, french, german, hungarian, italian, japanese, khmer, polish, russian, simplified chinese, spanish, swedish, turkish, ukrainian
|
- User Interface in brazilian, czech, dutch, english, finnish, french, german, greek, hungarian, italian, japanese, khmer, polish, russian, simplified chinese, spanish, swedish, turkish, ukrainian
|
||||||
- OPDS feed for eBook reader apps
|
- OPDS feed for eBook reader apps
|
||||||
- Filter and search by titles, authors, tags, series and language
|
- Filter and search by titles, authors, tags, series and language
|
||||||
- Create a custom book collection (shelves)
|
- Create a custom book collection (shelves)
|
||||||
@@ -22,7 +24,7 @@ Calibre-Web is a web app providing a clean interface for browsing, reading and d
|
|||||||
- Support for public user registration
|
- Support for public user registration
|
||||||
- Send eBooks to Kindle devices with the click of a button
|
- Send eBooks to Kindle devices with the click of a button
|
||||||
- Sync your Kobo devices through Calibre-Web with your Calibre library
|
- Sync your Kobo devices through Calibre-Web with your Calibre library
|
||||||
- Support for reading eBooks directly in the browser (.txt, .epub, .pdf, .cbr, .cbt, .cbz)
|
- Support for reading eBooks directly in the browser (.txt, .epub, .pdf, .cbr, .cbt, .cbz, .djvu)
|
||||||
- Upload new books in many formats, including audio formats (.mp3, .m4a, .m4b)
|
- Upload new books in many formats, including audio formats (.mp3, .m4a, .m4b)
|
||||||
- Support for Calibre Custom Columns
|
- Support for Calibre Custom Columns
|
||||||
- Ability to hide content based on categories and Custom Column content per user
|
- Ability to hide content based on categories and Custom Column content per user
|
||||||
@@ -32,8 +34,8 @@ Calibre-Web is a web app providing a clean interface for browsing, reading and d
|
|||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
1. Install dependencies by running `pip3 install --target vendor -r requirements.txt` (python3.x) or `pip install --target vendor -r requirements.txt` (python2.7).
|
1. Install dependencies by running `pip3 install --target vendor -r requirements.txt` (python3.x). Alternativly set up a python virtual environment.
|
||||||
2. Execute the command: `python cps.py` (or `nohup python cps.py` - recommended if you want to exit the terminal window)
|
2. Execute the command: `python3 cps.py` (or `nohup python3 cps.py` - recommended if you want to exit the terminal window)
|
||||||
3. Point your browser to `http://localhost:8083` or `http://localhost:8083/opds` for the OPDS catalog
|
3. Point your browser to `http://localhost:8083` or `http://localhost:8083/opds` for the OPDS catalog
|
||||||
4. Set `Location of Calibre database` to the path of the folder where your Calibre library (metadata.db) lives, push "submit" button\
|
4. 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/Configuration#using-google-drive-integration)
|
Optionally a Google Drive can be used to host the calibre library [-> Using Google Drive integration](https://github.com/janeczku/calibre-web/wiki/Configuration#using-google-drive-integration)
|
||||||
@@ -48,29 +50,27 @@ Please note that running the above install command can fail on some versions of
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
python 3.x+, (Python 2.7+)
|
python 3.x+
|
||||||
|
|
||||||
Optionally, to enable on-the-fly conversion from one ebook format to another when using the send-to-kindle feature, or during editing of ebooks metadata:
|
Optionally, to enable on-the-fly conversion from one ebook format to another when using the send-to-kindle feature, or during editing of ebooks metadata:
|
||||||
|
|
||||||
[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 and install](https://calibre-ebook.com/download) the Calibre desktop program for your platform and enter the folder including program name (normally /opt/calibre/ebook-convert, or C:\Program Files\calibre\ebook-convert.exe) in the field "calibre's converter tool" on the setup page.
|
||||||
|
|
||||||
\*** DEPRECATED \*** Support will be removed in future releases
|
[Download](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`.
|
||||||
|
|
||||||
[Download](http://www.amazon.com/gp/feature.html?docId=1000765211) Amazon's KindleGen tool for your platform and place the binary named `kindlegen` in the `vendor` folder.
|
|
||||||
|
|
||||||
## Docker Images
|
## Docker Images
|
||||||
|
|
||||||
Pre-built Docker images are available in these Docker Hub repositories:
|
Pre-built Docker images are available in these Docker Hub repositories:
|
||||||
|
|
||||||
#### **Technosoft2000 - x64**
|
#### **Technosoft2000 - x64**
|
||||||
+ Docker Hub - [https://hub.docker.com/r/technosoft2000/calibre-web/](https://hub.docker.com/r/technosoft2000/calibre-web/)
|
+ Docker Hub - [https://hub.docker.com/r/technosoft2000/calibre-web](https://hub.docker.com/r/technosoft2000/calibre-web)
|
||||||
+ Github - [https://github.com/Technosoft2000/docker-calibre-web](https://github.com/Technosoft2000/docker-calibre-web)
|
+ Github - [https://github.com/Technosoft2000/docker-calibre-web](https://github.com/Technosoft2000/docker-calibre-web)
|
||||||
|
|
||||||
Includes the Calibre `ebook-convert` binary.
|
Includes the Calibre `ebook-convert` binary.
|
||||||
+ The "path to convertertool" should be set to `/opt/calibre/ebook-convert`
|
+ The "path to convertertool" should be set to `/opt/calibre/ebook-convert`
|
||||||
|
|
||||||
#### **LinuxServer - x64, armhf, aarch64**
|
#### **LinuxServer - x64, armhf, 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](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](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-calibre-web/tree/calibre](https://github.com/linuxserver/docker-calibre-web/tree/calibre)
|
||||||
|
|
||||||
@@ -82,6 +82,12 @@ Pre-built Docker images are available in these Docker Hub repositories:
|
|||||||
+ The "path to convertertool" should be set to `/usr/bin/ebook-convert`
|
+ The "path to convertertool" should be set to `/usr/bin/ebook-convert`
|
||||||
+ The "path to unrar" should be set to `/usr/bin/unrar`
|
+ The "path to unrar" should be set to `/usr/bin/unrar`
|
||||||
|
|
||||||
# Wiki
|
# Contact
|
||||||
|
|
||||||
|
Just reach us out on [Discord](https://discord.gg/h2VsJ2NEfB)
|
||||||
|
|
||||||
For further information, How To's and FAQ please check the [Wiki](https://github.com/janeczku/calibre-web/wiki)
|
For further information, How To's and FAQ please check the [Wiki](https://github.com/janeczku/calibre-web/wiki)
|
||||||
|
|
||||||
|
# Contributing to Calibre-Web
|
||||||
|
|
||||||
|
Please have a look at our [Contributing Guidelines](https://github.com/janeczku/calibre-web/blob/master/CONTRIBUTING.md)
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ from cps.shelf import shelf
|
|||||||
from cps.admin import admi
|
from cps.admin import admi
|
||||||
from cps.gdrive import gdrive
|
from cps.gdrive import gdrive
|
||||||
from cps.editbooks import editbook
|
from cps.editbooks import editbook
|
||||||
|
from cps.remotelogin import remotelogin
|
||||||
|
from cps.error_handler import init_errorhandler
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from cps.kobo import kobo, get_kobo_activated
|
from cps.kobo import kobo, get_kobo_activated
|
||||||
@@ -58,12 +60,17 @@ except ImportError:
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
|
init_errorhandler()
|
||||||
|
|
||||||
app.register_blueprint(web)
|
app.register_blueprint(web)
|
||||||
app.register_blueprint(opds)
|
app.register_blueprint(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)
|
||||||
app.register_blueprint(admi)
|
app.register_blueprint(admi)
|
||||||
|
app.register_blueprint(remotelogin)
|
||||||
|
# if config.config_use_google_drive:
|
||||||
app.register_blueprint(gdrive)
|
app.register_blueprint(gdrive)
|
||||||
app.register_blueprint(editbook)
|
app.register_blueprint(editbook)
|
||||||
if kobo_available:
|
if kobo_available:
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from . import config_sql, logger, cache_buster, cli, ub, db
|
|||||||
from .reverseproxy import ReverseProxied
|
from .reverseproxy import ReverseProxied
|
||||||
from .server import WebServer
|
from .server import WebServer
|
||||||
|
|
||||||
|
|
||||||
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')
|
||||||
@@ -44,6 +45,7 @@ mimetypes.add_type('application/fb2+zip', '.fb2')
|
|||||||
mimetypes.add_type('application/x-mobipocket-ebook', '.mobi')
|
mimetypes.add_type('application/x-mobipocket-ebook', '.mobi')
|
||||||
mimetypes.add_type('application/x-mobipocket-ebook', '.prc')
|
mimetypes.add_type('application/x-mobipocket-ebook', '.prc')
|
||||||
mimetypes.add_type('application/vnd.amazon.ebook', '.azw')
|
mimetypes.add_type('application/vnd.amazon.ebook', '.azw')
|
||||||
|
mimetypes.add_type('application/x-mobi8-ebook', '.azw3')
|
||||||
mimetypes.add_type('application/x-cbr', '.cbr')
|
mimetypes.add_type('application/x-cbr', '.cbr')
|
||||||
mimetypes.add_type('application/x-cbz', '.cbz')
|
mimetypes.add_type('application/x-cbz', '.cbz')
|
||||||
mimetypes.add_type('application/x-cbt', '.cbt')
|
mimetypes.add_type('application/x-cbt', '.cbt')
|
||||||
@@ -56,16 +58,22 @@ mimetypes.add_type('application/ogg', '.ogg')
|
|||||||
mimetypes.add_type('application/ogg', '.oga')
|
mimetypes.add_type('application/ogg', '.oga')
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
app.config.update(
|
||||||
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
|
SESSION_COOKIE_SAMESITE='Lax',
|
||||||
|
REMEMBER_COOKIE_SAMESITE='Lax', # will be available in flask-login 0.5.1 earliest
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
lm = LoginManager()
|
lm = LoginManager()
|
||||||
lm.login_view = 'web.login'
|
lm.login_view = 'web.login'
|
||||||
lm.anonymous_user = ub.Anonymous
|
lm.anonymous_user = ub.Anonymous
|
||||||
|
lm.session_protection = 'strong'
|
||||||
|
|
||||||
ub.init_db(cli.settingspath)
|
ub.init_db(cli.settingspath)
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
config = config_sql.load_configuration(ub.session)
|
config = config_sql.load_configuration(ub.session)
|
||||||
|
|
||||||
searched_ids = {}
|
|
||||||
web_server = WebServer()
|
web_server = WebServer()
|
||||||
|
|
||||||
babel = Babel()
|
babel = Babel()
|
||||||
@@ -75,23 +83,30 @@ log = logger.create()
|
|||||||
|
|
||||||
from . import services
|
from . import services
|
||||||
|
|
||||||
|
db.CalibreDB.setup_db(config, cli.settingspath)
|
||||||
|
|
||||||
|
calibre_db = db.CalibreDB()
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app.wsgi_app = ReverseProxied(app.wsgi_app)
|
app.wsgi_app = ReverseProxied(app.wsgi_app)
|
||||||
# For python2 convert path to unicode
|
# For python2 convert path to unicode
|
||||||
if sys.version_info < (3, 0):
|
if sys.version_info < (3, 0):
|
||||||
app.static_folder = app.static_folder.decode('utf-8')
|
app.static_folder = app.static_folder.decode('utf-8')
|
||||||
app.root_path = app.root_path.decode('utf-8')
|
app.root_path = app.root_path.decode('utf-8')
|
||||||
app.instance_path = app.instance_path .decode('utf-8')
|
app.instance_path = app.instance_path.decode('utf-8')
|
||||||
|
|
||||||
cache_buster.init_cache_busting(app)
|
if os.environ.get('FLASK_DEBUG'):
|
||||||
|
cache_buster.init_cache_busting(app)
|
||||||
|
|
||||||
log.info('Starting Calibre Web...')
|
log.info('Starting Calibre Web...')
|
||||||
|
if sys.version_info < (3, 0):
|
||||||
|
log.info('Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2 please consider upgrading to Python3')
|
||||||
|
print('Python2 is EOL since end of 2019, this version of Calibre-Web is no longer supporting Python2 please consider upgrading to Python3')
|
||||||
Principal(app)
|
Principal(app)
|
||||||
lm.init_app(app)
|
lm.init_app(app)
|
||||||
app.secret_key = os.getenv('SECRET_KEY', config_sql.get_flask_session_key(ub.session))
|
app.secret_key = os.getenv('SECRET_KEY', config_sql.get_flask_session_key(ub.session))
|
||||||
|
|
||||||
web_server.init_app(app, config)
|
web_server.init_app(app, config)
|
||||||
db.setup_db(config)
|
|
||||||
|
|
||||||
babel.init_app(app)
|
babel.init_app(app)
|
||||||
_BABEL_TRANSLATIONS.update(str(item) for item in babel.list_translations())
|
_BABEL_TRANSLATIONS.update(str(item) for item in babel.list_translations())
|
||||||
@@ -112,7 +127,7 @@ def get_locale():
|
|||||||
user = getattr(g, 'user', None)
|
user = getattr(g, 'user', None)
|
||||||
# user = None
|
# user = None
|
||||||
if user is not None and hasattr(user, "locale"):
|
if user is not None and hasattr(user, "locale"):
|
||||||
if user.nickname != 'Guest': # if the account is the guest account bypass the config lang settings
|
if user.name != 'Guest': # if the account is the guest account bypass the config lang settings
|
||||||
return user.locale
|
return user.locale
|
||||||
|
|
||||||
preferred = list()
|
preferred = list()
|
||||||
|
|||||||
@@ -30,13 +30,14 @@ import babel, pytz, requests, sqlalchemy
|
|||||||
import werkzeug, flask, flask_login, flask_principal, jinja2
|
import werkzeug, flask, flask_login, flask_principal, jinja2
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
from . import db, converter, uploader, server, isoLanguages, constants
|
from . import db, calibre_db, converter, uploader, server, isoLanguages, constants
|
||||||
from .web import render_title_template
|
from .render_template import render_title_template
|
||||||
try:
|
try:
|
||||||
from flask_login import __version__ as flask_loginVersion
|
from flask_login import __version__ as flask_loginVersion
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from flask_login.__about__ import __version__ as flask_loginVersion
|
from flask_login.__about__ import __version__ as flask_loginVersion
|
||||||
try:
|
try:
|
||||||
|
# pylint: disable=unused-import
|
||||||
import unidecode
|
import unidecode
|
||||||
# _() necessary to make babel aware of string for translation
|
# _() necessary to make babel aware of string for translation
|
||||||
unidecode_version = _(u'installed')
|
unidecode_version = _(u'installed')
|
||||||
@@ -48,6 +49,11 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
flask_danceVersion = None
|
flask_danceVersion = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from greenlet import __version__ as greenlet_Version
|
||||||
|
except ImportError:
|
||||||
|
greenlet_Version = None
|
||||||
|
|
||||||
from . import services
|
from . import services
|
||||||
|
|
||||||
about = flask.Blueprint('about', __name__)
|
about = flask.Blueprint('about', __name__)
|
||||||
@@ -77,18 +83,26 @@ _VERSIONS = OrderedDict(
|
|||||||
python_LDAP = services.ldapVersion if bool(services.ldapVersion) else None,
|
python_LDAP = services.ldapVersion if bool(services.ldapVersion) else None,
|
||||||
Goodreads = u'installed' if bool(services.goodreads_support) else None,
|
Goodreads = u'installed' if bool(services.goodreads_support) else None,
|
||||||
jsonschema = services.SyncToken.__version__ if bool(services.SyncToken) else None,
|
jsonschema = services.SyncToken.__version__ if bool(services.SyncToken) else None,
|
||||||
flask_dance = flask_danceVersion
|
flask_dance = flask_danceVersion,
|
||||||
|
greenlet = greenlet_Version
|
||||||
)
|
)
|
||||||
_VERSIONS.update(uploader.get_versions())
|
_VERSIONS.update(uploader.get_versions())
|
||||||
|
|
||||||
|
|
||||||
|
def collect_stats():
|
||||||
|
_VERSIONS['ebook converter'] = _(converter.get_calibre_version())
|
||||||
|
_VERSIONS['unrar'] = _(converter.get_unrar_version())
|
||||||
|
_VERSIONS['kepubify'] = _(converter.get_kepubify_version())
|
||||||
|
return _VERSIONS
|
||||||
|
|
||||||
@about.route("/stats")
|
@about.route("/stats")
|
||||||
@flask_login.login_required
|
@flask_login.login_required
|
||||||
def stats():
|
def stats():
|
||||||
counter = db.session.query(db.Books).count()
|
counter = calibre_db.session.query(db.Books).count()
|
||||||
authors = db.session.query(db.Authors).count()
|
authors = calibre_db.session.query(db.Authors).count()
|
||||||
categorys = db.session.query(db.Tags).count()
|
categorys = calibre_db.session.query(db.Tags).count()
|
||||||
series = db.session.query(db.Series).count()
|
series = calibre_db.session.query(db.Series).count()
|
||||||
_VERSIONS['ebook converter'] = _(converter.get_version())
|
return render_title_template('stats.html', bookcounter=counter, authorcounter=authors, versions=collect_stats(),
|
||||||
return render_title_template('stats.html', bookcounter=counter, authorcounter=authors, versions=_VERSIONS,
|
|
||||||
categorycounter=categorys, seriecounter=series, title=_(u"Statistics"), page="stat")
|
categorycounter=categorys, seriecounter=series, title=_(u"Statistics"), page="stat")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ def init_cache_busting(app):
|
|||||||
# compute version component
|
# compute version component
|
||||||
rooted_filename = os.path.join(dirpath, filename)
|
rooted_filename = os.path.join(dirpath, filename)
|
||||||
with open(rooted_filename, 'rb') as f:
|
with open(rooted_filename, 'rb') as f:
|
||||||
file_hash = hashlib.md5(f.read()).hexdigest()[:7]
|
file_hash = hashlib.md5(f.read()).hexdigest()[:7] # nosec
|
||||||
|
|
||||||
# save version to tables
|
# save version to tables
|
||||||
file_path = rooted_filename.replace(static_folder, "")
|
file_path = rooted_filename.replace(static_folder, "")
|
||||||
@@ -64,6 +64,7 @@ def init_cache_busting(app):
|
|||||||
return filename.split("?", 1)[0]
|
return filename.split("?", 1)[0]
|
||||||
|
|
||||||
@app.url_defaults
|
@app.url_defaults
|
||||||
|
# pylint: disable=unused-variable
|
||||||
def reverse_to_cache_busted_url(endpoint, values):
|
def reverse_to_cache_busted_url(endpoint, values):
|
||||||
"""
|
"""
|
||||||
Make `url_for` produce busted filenames when using the 'static' endpoint.
|
Make `url_for` produce busted filenames when using the 'static' endpoint.
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ parser.add_argument('-v', '--version', action='version', help='Shows version num
|
|||||||
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('-s', metavar='user:pass', help='Sets specific username to new password')
|
parser.add_argument('-s', metavar='user:pass', help='Sets specific username to new password')
|
||||||
|
parser.add_argument('-f', action='store_true', help='Enables filepicker in unconfigured mode')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if sys.version_info < (3, 0):
|
if sys.version_info < (3, 0):
|
||||||
@@ -70,7 +71,7 @@ if args.c:
|
|||||||
if os.path.isfile(args.c):
|
if os.path.isfile(args.c):
|
||||||
certfilepath = args.c
|
certfilepath = args.c
|
||||||
else:
|
else:
|
||||||
print("Certfilepath is invalid. Exiting...")
|
print("Certfile path is invalid. Exiting...")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if args.c == "":
|
if args.c == "":
|
||||||
@@ -80,7 +81,7 @@ if args.k:
|
|||||||
if os.path.isfile(args.k):
|
if os.path.isfile(args.k):
|
||||||
keyfilepath = args.k
|
keyfilepath = args.k
|
||||||
else:
|
else:
|
||||||
print("Keyfilepath is invalid. Exiting...")
|
print("Keyfile path is invalid. Exiting...")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if (args.k and not args.c) or (not args.k and args.c):
|
if (args.k and not args.c) or (not args.k and args.c):
|
||||||
@@ -90,23 +91,29 @@ if (args.k and not args.c) or (not args.k and args.c):
|
|||||||
if args.k == "":
|
if args.k == "":
|
||||||
keyfilepath = ""
|
keyfilepath = ""
|
||||||
|
|
||||||
# handle and check ipadress argument
|
# handle and check ip address argument
|
||||||
ipadress = args.i or None
|
ip_address = args.i or None
|
||||||
if ipadress:
|
if ip_address:
|
||||||
try:
|
try:
|
||||||
# try to parse the given ip address with socket
|
# try to parse the given ip address with socket
|
||||||
if hasattr(socket, 'inet_pton'):
|
if hasattr(socket, 'inet_pton'):
|
||||||
if ':' in ipadress:
|
if ':' in ip_address:
|
||||||
socket.inet_pton(socket.AF_INET6, ipadress)
|
socket.inet_pton(socket.AF_INET6, ip_address)
|
||||||
else:
|
else:
|
||||||
socket.inet_pton(socket.AF_INET, ipadress)
|
socket.inet_pton(socket.AF_INET, ip_address)
|
||||||
else:
|
else:
|
||||||
# on windows python < 3.4, inet_pton is not available
|
# on windows python < 3.4, inet_pton is not available
|
||||||
# inet_atom only handles IPv4 addresses
|
# inet_atom only handles IPv4 addresses
|
||||||
socket.inet_aton(ipadress)
|
socket.inet_aton(ip_address)
|
||||||
except socket.error as err:
|
except socket.error as err:
|
||||||
print(ipadress, ':', err)
|
print(ip_address, ':', err)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# handle and check user password argument
|
# handle and check user password argument
|
||||||
user_password = args.s or None
|
user_credentials = args.s or None
|
||||||
|
if user_credentials and ":" not in user_credentials:
|
||||||
|
print("No valid 'username:password' format")
|
||||||
|
sys.exit(3)
|
||||||
|
|
||||||
|
# Handles enabling of filepicker
|
||||||
|
filepicker = args.f or None
|
||||||
|
|||||||
@@ -26,80 +26,109 @@ from .constants import BookMeta
|
|||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from wand.image import Image
|
||||||
|
use_IM = True
|
||||||
|
except (ImportError, RuntimeError) as e:
|
||||||
|
use_IM = False
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from comicapi.comicarchive import ComicArchive, MetaDataStyle
|
from comicapi.comicarchive import ComicArchive, MetaDataStyle
|
||||||
use_comic_meta = True
|
use_comic_meta = True
|
||||||
except ImportError as e:
|
try:
|
||||||
log.debug('cannot import comicapi, extracting comic metadata will not work: %s', e)
|
from comicapi import __version__ as comic_version
|
||||||
|
except ImportError:
|
||||||
|
comic_version = ''
|
||||||
|
except (ImportError, LookupError) as e:
|
||||||
|
log.debug('Cannot import comicapi, extracting comic metadata will not work: %s', e)
|
||||||
import zipfile
|
import zipfile
|
||||||
import tarfile
|
import tarfile
|
||||||
try:
|
try:
|
||||||
import rarfile
|
import rarfile
|
||||||
use_rarfile = True
|
use_rarfile = True
|
||||||
except ImportError as e:
|
except (ImportError, SyntaxError) as e:
|
||||||
log.debug('cannot import rarfile, extracting cover files from rar files will not work: %s', e)
|
log.debug('Cannot import rarfile, extracting cover files from rar files will not work: %s', e)
|
||||||
use_rarfile = False
|
use_rarfile = False
|
||||||
use_comic_meta = False
|
use_comic_meta = False
|
||||||
|
|
||||||
|
NO_JPEG_EXTENSIONS = ['.png', '.webp', '.bmp']
|
||||||
|
COVER_EXTENSIONS = ['.png', '.webp', '.bmp', '.jpg', '.jpeg']
|
||||||
|
|
||||||
def _extractCover(tmp_file_name, original_file_extension, rarExceutable):
|
def _cover_processing(tmp_file_name, img, extension):
|
||||||
cover_data = extension = None
|
tmp_cover_name = os.path.join(os.path.dirname(tmp_file_name), 'cover.jpg')
|
||||||
if use_comic_meta:
|
if use_IM:
|
||||||
archive = ComicArchive(tmp_file_name)
|
# convert to jpg because calibre only supports jpg
|
||||||
for index, name in enumerate(archive.getPageNameList()):
|
if extension in NO_JPEG_EXTENSIONS:
|
||||||
|
with Image(filename=tmp_file_name) as imgc:
|
||||||
|
imgc.format = 'jpeg'
|
||||||
|
imgc.transform_colorspace('rgb')
|
||||||
|
imgc.save(tmp_cover_name)
|
||||||
|
return tmp_cover_name
|
||||||
|
|
||||||
|
if not img:
|
||||||
|
return None
|
||||||
|
|
||||||
|
with open(tmp_cover_name, 'wb') as f:
|
||||||
|
f.write(img)
|
||||||
|
return tmp_cover_name
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_Cover_from_archive(original_file_extension, tmp_file_name, rarExecutable):
|
||||||
|
cover_data = None
|
||||||
|
if original_file_extension.upper() == '.CBZ':
|
||||||
|
cf = zipfile.ZipFile(tmp_file_name)
|
||||||
|
for name in cf.namelist():
|
||||||
ext = os.path.splitext(name)
|
ext = os.path.splitext(name)
|
||||||
if len(ext) > 1:
|
if len(ext) > 1:
|
||||||
extension = ext[1].lower()
|
extension = ext[1].lower()
|
||||||
if extension == '.jpg' or extension == '.jpeg':
|
if extension in COVER_EXTENSIONS:
|
||||||
cover_data = archive.getPage(index)
|
cover_data = cf.read(name)
|
||||||
break
|
break
|
||||||
else:
|
elif original_file_extension.upper() == '.CBT':
|
||||||
if original_file_extension.upper() == '.CBZ':
|
cf = tarfile.TarFile(tmp_file_name)
|
||||||
cf = zipfile.ZipFile(tmp_file_name)
|
for name in cf.getnames():
|
||||||
for name in cf.namelist():
|
ext = os.path.splitext(name)
|
||||||
ext = os.path.splitext(name)
|
if len(ext) > 1:
|
||||||
if len(ext) > 1:
|
extension = ext[1].lower()
|
||||||
extension = ext[1].lower()
|
if extension in COVER_EXTENSIONS:
|
||||||
if extension == '.jpg' or extension == '.jpeg':
|
cover_data = cf.extractfile(name).read()
|
||||||
cover_data = cf.read(name)
|
break
|
||||||
break
|
elif original_file_extension.upper() == '.CBR' and use_rarfile:
|
||||||
elif original_file_extension.upper() == '.CBT':
|
try:
|
||||||
cf = tarfile.TarFile(tmp_file_name)
|
rarfile.UNRAR_TOOL = rarExecutable
|
||||||
|
cf = rarfile.RarFile(tmp_file_name)
|
||||||
for name in cf.getnames():
|
for name in cf.getnames():
|
||||||
ext = os.path.splitext(name)
|
ext = os.path.splitext(name)
|
||||||
if len(ext) > 1:
|
if len(ext) > 1:
|
||||||
extension = ext[1].lower()
|
extension = ext[1].lower()
|
||||||
if extension == '.jpg' or extension == '.jpeg':
|
if extension in COVER_EXTENSIONS:
|
||||||
cover_data = cf.extractfile(name).read()
|
cover_data = cf.read(name)
|
||||||
break
|
break
|
||||||
elif original_file_extension.upper() == '.CBR' and use_rarfile:
|
except Exception as ex:
|
||||||
try:
|
log.debug('Rarfile failed with error: %s', ex)
|
||||||
rarfile.UNRAR_TOOL = rarExceutable
|
return cover_data
|
||||||
cf = rarfile.RarFile(tmp_file_name)
|
|
||||||
for name in cf.getnames():
|
|
||||||
ext = os.path.splitext(name)
|
|
||||||
if len(ext) > 1:
|
|
||||||
extension = ext[1].lower()
|
|
||||||
if extension == '.jpg' or extension == '.jpeg':
|
|
||||||
cover_data = cf.read(name)
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
log.debug('Rarfile failed with error: %s', e)
|
|
||||||
|
|
||||||
prefix = os.path.dirname(tmp_file_name)
|
|
||||||
if cover_data:
|
|
||||||
tmp_cover_name = prefix + '/cover' + extension
|
|
||||||
image = open(tmp_cover_name, 'wb')
|
|
||||||
image.write(cover_data)
|
|
||||||
image.close()
|
|
||||||
else:
|
|
||||||
tmp_cover_name = None
|
|
||||||
return tmp_cover_name
|
|
||||||
|
|
||||||
|
|
||||||
def get_comic_info(tmp_file_path, original_file_name, original_file_extension, rarExceutable):
|
def _extractCover(tmp_file_name, original_file_extension, rarExecutable):
|
||||||
|
cover_data = extension = None
|
||||||
if use_comic_meta:
|
if use_comic_meta:
|
||||||
archive = ComicArchive(tmp_file_path, rar_exe_path=rarExceutable)
|
archive = ComicArchive(tmp_file_name, rar_exe_path=rarExecutable)
|
||||||
|
for index, name in enumerate(archive.getPageNameList()):
|
||||||
|
ext = os.path.splitext(name)
|
||||||
|
if len(ext) > 1:
|
||||||
|
extension = ext[1].lower()
|
||||||
|
if extension in COVER_EXTENSIONS:
|
||||||
|
cover_data = archive.getPage(index)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
cover_data = _extract_Cover_from_archive(original_file_extension, tmp_file_name, rarExecutable)
|
||||||
|
return _cover_processing(tmp_file_name, cover_data, extension)
|
||||||
|
|
||||||
|
|
||||||
|
def get_comic_info(tmp_file_path, original_file_name, original_file_extension, rarExecutable):
|
||||||
|
if use_comic_meta:
|
||||||
|
archive = ComicArchive(tmp_file_path, rar_exe_path=rarExecutable)
|
||||||
if archive.seemsToBeAComicArchive():
|
if archive.seemsToBeAComicArchive():
|
||||||
if archive.hasMetadata(MetaDataStyle.CIX):
|
if archive.hasMetadata(MetaDataStyle.CIX):
|
||||||
style = MetaDataStyle.CIX
|
style = MetaDataStyle.CIX
|
||||||
@@ -111,35 +140,32 @@ def get_comic_info(tmp_file_path, original_file_name, original_file_extension, r
|
|||||||
# if style is not None:
|
# if style is not None:
|
||||||
loadedMetadata = archive.readMetadata(style)
|
loadedMetadata = archive.readMetadata(style)
|
||||||
|
|
||||||
lang = loadedMetadata.language
|
lang = loadedMetadata.language or ""
|
||||||
if lang:
|
loadedMetadata.language = isoLanguages.get_lang3(lang)
|
||||||
if len(lang) == 2:
|
|
||||||
loadedMetadata.language = isoLanguages.get(part1=lang).name
|
|
||||||
elif len(lang) == 3:
|
|
||||||
loadedMetadata.language = isoLanguages.get(part3=lang).name
|
|
||||||
else:
|
|
||||||
loadedMetadata.language = ""
|
|
||||||
|
|
||||||
return BookMeta(
|
return BookMeta(
|
||||||
file_path=tmp_file_path,
|
file_path=tmp_file_path,
|
||||||
extension=original_file_extension,
|
extension=original_file_extension,
|
||||||
title=loadedMetadata.title or original_file_name,
|
title=loadedMetadata.title or original_file_name,
|
||||||
author=" & ".join([credit["person"] for credit in loadedMetadata.credits if credit["role"] == "Writer"]) or u'Unknown',
|
author=" & ".join([credit["person"]
|
||||||
cover=_extractCover(tmp_file_path, original_file_extension, rarExceutable),
|
for credit in loadedMetadata.credits if credit["role"] == "Writer"]) or u'Unknown',
|
||||||
|
cover=_extractCover(tmp_file_path, original_file_extension, rarExecutable),
|
||||||
description=loadedMetadata.comments or "",
|
description=loadedMetadata.comments or "",
|
||||||
tags="",
|
tags="",
|
||||||
series=loadedMetadata.series or "",
|
series=loadedMetadata.series or "",
|
||||||
series_id=loadedMetadata.issue or "",
|
series_id=loadedMetadata.issue or "",
|
||||||
languages=loadedMetadata.language)
|
languages=loadedMetadata.language,
|
||||||
|
publisher="")
|
||||||
|
|
||||||
return BookMeta(
|
return BookMeta(
|
||||||
file_path=tmp_file_path,
|
file_path=tmp_file_path,
|
||||||
extension=original_file_extension,
|
extension=original_file_extension,
|
||||||
title=original_file_name,
|
title=original_file_name,
|
||||||
author=u'Unknown',
|
author=u'Unknown',
|
||||||
cover=_extractCover(tmp_file_path, original_file_extension, rarExceutable),
|
cover=_extractCover(tmp_file_path, original_file_extension, rarExecutable),
|
||||||
description="",
|
description="",
|
||||||
tags="",
|
tags="",
|
||||||
series="",
|
series="",
|
||||||
series_id="",
|
series_id="",
|
||||||
languages="")
|
languages="",
|
||||||
|
publisher="")
|
||||||
|
|||||||
@@ -19,13 +19,19 @@
|
|||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
from __future__ import division, print_function, unicode_literals
|
||||||
import os
|
import os
|
||||||
import json
|
|
||||||
import sys
|
import sys
|
||||||
|
import json
|
||||||
|
|
||||||
from sqlalchemy import exc, Column, String, Integer, SmallInteger, Boolean, BLOB
|
from sqlalchemy import Column, String, Integer, SmallInteger, Boolean, BLOB, JSON
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.exc import OperationalError
|
||||||
|
from sqlalchemy.sql.expression import text
|
||||||
|
try:
|
||||||
|
# Compatibility with sqlalchemy 2.0
|
||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
except ImportError:
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
|
||||||
from . import constants, cli, logger, ub
|
from . import constants, cli, logger
|
||||||
|
|
||||||
|
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
@@ -35,7 +41,7 @@ class _Flask_Settings(_Base):
|
|||||||
__tablename__ = 'flask_settings'
|
__tablename__ = 'flask_settings'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
flask_session_key = Column(BLOB, default="")
|
flask_session_key = Column(BLOB, default=b"")
|
||||||
|
|
||||||
def __init__(self, key):
|
def __init__(self, key):
|
||||||
self.flask_session_key = key
|
self.flask_session_key = key
|
||||||
@@ -53,9 +59,13 @@ class _Settings(_Base):
|
|||||||
mail_login = Column(String, default='mail@example.com')
|
mail_login = Column(String, default='mail@example.com')
|
||||||
mail_password = Column(String, default='mypassword')
|
mail_password = Column(String, default='mypassword')
|
||||||
mail_from = Column(String, default='automailer <mail@example.com>')
|
mail_from = Column(String, default='automailer <mail@example.com>')
|
||||||
|
mail_size = Column(Integer, default=25*1024*1024)
|
||||||
|
mail_server_type = Column(SmallInteger, default=0)
|
||||||
|
mail_gmail_token = Column(JSON, default={})
|
||||||
|
|
||||||
config_calibre_dir = Column(String)
|
config_calibre_dir = Column(String)
|
||||||
config_port = Column(Integer, default=constants.DEFAULT_PORT)
|
config_port = Column(Integer, default=constants.DEFAULT_PORT)
|
||||||
|
config_external_port = Column(Integer, default=constants.DEFAULT_PORT)
|
||||||
config_certfile = Column(String)
|
config_certfile = Column(String)
|
||||||
config_keyfile = Column(String)
|
config_keyfile = Column(String)
|
||||||
|
|
||||||
@@ -64,7 +74,7 @@ class _Settings(_Base):
|
|||||||
config_random_books = Column(Integer, default=4)
|
config_random_books = Column(Integer, default=4)
|
||||||
config_authors_max = Column(Integer, default=0)
|
config_authors_max = Column(Integer, default=0)
|
||||||
config_read_column = Column(Integer, default=0)
|
config_read_column = Column(Integer, default=0)
|
||||||
config_title_regex = Column(String, default=u'^(A|The|An|Der|Die|Das|Den|Ein|Eine|Einen|Dem|Des|Einem|Eines)\s+')
|
config_title_regex = Column(String, default=r'^(A|The|An|Der|Die|Das|Den|Ein|Eine|Einen|Dem|Des|Einem|Eines)\s+')
|
||||||
config_mature_content_tags = Column(String, default='')
|
config_mature_content_tags = Column(String, default='')
|
||||||
config_theme = Column(Integer, default=0)
|
config_theme = Column(Integer, default=0)
|
||||||
|
|
||||||
@@ -91,35 +101,38 @@ class _Settings(_Base):
|
|||||||
|
|
||||||
config_use_google_drive = Column(Boolean, default=False)
|
config_use_google_drive = Column(Boolean, default=False)
|
||||||
config_google_drive_folder = Column(String)
|
config_google_drive_folder = Column(String)
|
||||||
config_google_drive_watch_changes_response = Column(String)
|
config_google_drive_watch_changes_response = Column(JSON, default={})
|
||||||
|
|
||||||
config_use_goodreads = Column(Boolean, default=False)
|
config_use_goodreads = Column(Boolean, default=False)
|
||||||
config_goodreads_api_key = Column(String)
|
config_goodreads_api_key = Column(String)
|
||||||
config_goodreads_api_secret = Column(String)
|
config_goodreads_api_secret = Column(String)
|
||||||
|
config_register_email = Column(Boolean, default=False)
|
||||||
config_login_type = Column(Integer, default=0)
|
config_login_type = Column(Integer, default=0)
|
||||||
|
|
||||||
config_kobo_proxy = Column(Boolean, default=False)
|
config_kobo_proxy = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
|
||||||
config_ldap_provider_url = Column(String, default='example.org')
|
config_ldap_provider_url = Column(String, default='example.org')
|
||||||
config_ldap_port = Column(SmallInteger, default=389)
|
config_ldap_port = Column(SmallInteger, default=389)
|
||||||
config_ldap_authentication = Column(SmallInteger, default=constants.LDAP_AUTH_SIMPLE)
|
config_ldap_authentication = Column(SmallInteger, default=constants.LDAP_AUTH_SIMPLE)
|
||||||
config_ldap_serv_username = Column(String, default='cn=admin,dc=example,dc=org')
|
config_ldap_serv_username = Column(String, default='cn=admin,dc=example,dc=org')
|
||||||
config_ldap_serv_password = Column(String, default="")
|
config_ldap_serv_password = Column(String, default="")
|
||||||
config_ldap_encryption = Column(SmallInteger, default=0)
|
config_ldap_encryption = Column(SmallInteger, default=0)
|
||||||
|
config_ldap_cacert_path = Column(String, default="")
|
||||||
config_ldap_cert_path = Column(String, default="")
|
config_ldap_cert_path = Column(String, default="")
|
||||||
|
config_ldap_key_path = Column(String, default="")
|
||||||
config_ldap_dn = Column(String, default='dc=example,dc=org')
|
config_ldap_dn = Column(String, default='dc=example,dc=org')
|
||||||
config_ldap_user_object = Column(String, default='uid=%s')
|
config_ldap_user_object = Column(String, default='uid=%s')
|
||||||
|
config_ldap_member_user_object = Column(String, default='') #
|
||||||
config_ldap_openldap = Column(Boolean, default=True)
|
config_ldap_openldap = Column(Boolean, default=True)
|
||||||
config_ldap_group_object_filter = Column(String, default='(&(objectclass=posixGroup)(cn=%s))')
|
config_ldap_group_object_filter = Column(String, default='(&(objectclass=posixGroup)(cn=%s))')
|
||||||
config_ldap_group_members_field = Column(String, default='memberUid')
|
config_ldap_group_members_field = Column(String, default='memberUid')
|
||||||
config_ldap_group_name = Column(String, default='calibreweb')
|
config_ldap_group_name = Column(String, default='calibreweb')
|
||||||
|
|
||||||
config_ebookconverter = Column(Integer, default=0)
|
config_kepubifypath = Column(String, default=None)
|
||||||
config_converterpath = Column(String)
|
config_converterpath = Column(String, default=None)
|
||||||
config_calibre = Column(String)
|
config_calibre = Column(String)
|
||||||
config_rarfile_location = Column(String)
|
config_rarfile_location = Column(String, default=None)
|
||||||
|
config_upload_formats = Column(String, default=','.join(constants.EXTENSIONS_UPLOAD))
|
||||||
|
|
||||||
config_updatechannel = Column(Integer, default=constants.UPDATE_STABLE)
|
config_updatechannel = Column(Integer, default=constants.UPDATE_STABLE)
|
||||||
|
|
||||||
@@ -140,6 +153,23 @@ class _ConfigSQL(object):
|
|||||||
self.config_calibre_dir = None
|
self.config_calibre_dir = None
|
||||||
self.load()
|
self.load()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
change = True
|
||||||
|
self.config_kepubifypath = autodetect_kepubify_binary()
|
||||||
|
|
||||||
|
if self.config_rarfile_location == None: # pylint: disable=access-member-before-definition
|
||||||
|
change = True
|
||||||
|
self.config_rarfile_location = autodetect_unrar_binary()
|
||||||
|
if change:
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
def _read_from_storage(self):
|
def _read_from_storage(self):
|
||||||
if self._settings is None:
|
if self._settings is None:
|
||||||
log.debug("_ConfigSQL._read_from_storage")
|
log.debug("_ConfigSQL._read_from_storage")
|
||||||
@@ -160,8 +190,9 @@ class _ConfigSQL(object):
|
|||||||
return None
|
return None
|
||||||
return self.config_keyfile
|
return self.config_keyfile
|
||||||
|
|
||||||
def get_config_ipaddress(self):
|
@staticmethod
|
||||||
return cli.ipadress or ""
|
def get_config_ipaddress():
|
||||||
|
return cli.ip_address or ""
|
||||||
|
|
||||||
def _has_role(self, role_flag):
|
def _has_role(self, role_flag):
|
||||||
return constants.has_flag(self.config_default_role, role_flag)
|
return constants.has_flag(self.config_default_role, role_flag)
|
||||||
@@ -197,20 +228,20 @@ class _ConfigSQL(object):
|
|||||||
return self.show_element_new_user(constants.DETAIL_RANDOM)
|
return self.show_element_new_user(constants.DETAIL_RANDOM)
|
||||||
|
|
||||||
def list_denied_tags(self):
|
def list_denied_tags(self):
|
||||||
mct = self.config_denied_tags.split(",")
|
mct = self.config_denied_tags or ""
|
||||||
return [t.strip() for t in mct]
|
return [t.strip() for t in mct.split(",")]
|
||||||
|
|
||||||
def list_allowed_tags(self):
|
def list_allowed_tags(self):
|
||||||
mct = self.config_allowed_tags.split(",")
|
mct = self.config_allowed_tags or ""
|
||||||
return [t.strip() for t in mct]
|
return [t.strip() for t in mct.split(",")]
|
||||||
|
|
||||||
def list_denied_column_values(self):
|
def list_denied_column_values(self):
|
||||||
mct = self.config_denied_column_value.split(",")
|
mct = self.config_denied_column_value or ""
|
||||||
return [t.strip() for t in mct]
|
return [t.strip() for t in mct.split(",")]
|
||||||
|
|
||||||
def list_allowed_column_values(self):
|
def list_allowed_column_values(self):
|
||||||
mct = self.config_allowed_column_value.split(",")
|
mct = self.config_allowed_column_value or ""
|
||||||
return [t.strip() for t in mct]
|
return [t.strip() for t in mct.split(",")]
|
||||||
|
|
||||||
def get_log_level(self):
|
def get_log_level(self):
|
||||||
return logger.get_level_name(self.config_log_level)
|
return logger.get_level_name(self.config_log_level)
|
||||||
@@ -219,18 +250,18 @@ class _ConfigSQL(object):
|
|||||||
return {k:v for k, v in self.__dict__.items() if k.startswith('mail_')}
|
return {k:v for k, v in self.__dict__.items() if k.startswith('mail_')}
|
||||||
|
|
||||||
def get_mail_server_configured(self):
|
def get_mail_server_configured(self):
|
||||||
return not bool(self.mail_server == constants.DEFAULT_MAIL_SERVER)
|
return bool((self.mail_server != constants.DEFAULT_MAIL_SERVER and self.mail_server_type == 0)
|
||||||
|
or (self.mail_gmail_token != {} and self.mail_server_type == 1))
|
||||||
|
|
||||||
|
|
||||||
def set_from_dictionary(self, dictionary, field, convertor=None, default=None, encode=None):
|
def set_from_dictionary(self, dictionary, field, convertor=None, default=None, encode=None):
|
||||||
'''Possibly updates a field of this object.
|
"""Possibly updates a field of this object.
|
||||||
The new value, if present, is grabbed from the given dictionary, and optionally passed through a convertor.
|
The new value, if present, is grabbed from the given dictionary, and optionally passed through a convertor.
|
||||||
|
|
||||||
:returns: `True` if the field has changed value
|
:returns: `True` if the field has changed value
|
||||||
'''
|
"""
|
||||||
new_value = dictionary.get(field, default)
|
new_value = dictionary.get(field, default)
|
||||||
if new_value is None:
|
if new_value is None:
|
||||||
# log.debug("_ConfigSQL set_from_dictionary field '%s' not found", field)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if field not in self.__dict__:
|
if field not in self.__dict__:
|
||||||
@@ -247,10 +278,17 @@ class _ConfigSQL(object):
|
|||||||
if current_value == new_value:
|
if current_value == new_value:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# log.debug("_ConfigSQL set_from_dictionary '%s' = %r (was %r)", field, new_value, current_value)
|
|
||||||
setattr(self, field, new_value)
|
setattr(self, field, new_value)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def toDict(self):
|
||||||
|
storage = {}
|
||||||
|
for k, v in self.__dict__.items():
|
||||||
|
if k[0] != '_' and not k.endswith("password") and not k.endswith("secret"):
|
||||||
|
storage[k] = v
|
||||||
|
return storage
|
||||||
|
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
'''Load all configuration values from the underlying storage.'''
|
'''Load all configuration values from the underlying storage.'''
|
||||||
s = self._read_from_storage() # type: _Settings
|
s = self._read_from_storage() # type: _Settings
|
||||||
@@ -263,17 +301,27 @@ class _ConfigSQL(object):
|
|||||||
v = column.default.arg
|
v = column.default.arg
|
||||||
setattr(self, k, v)
|
setattr(self, k, v)
|
||||||
|
|
||||||
if self.config_google_drive_watch_changes_response:
|
|
||||||
self.config_google_drive_watch_changes_response = json.loads(self.config_google_drive_watch_changes_response)
|
|
||||||
|
|
||||||
have_metadata_db = bool(self.config_calibre_dir)
|
have_metadata_db = bool(self.config_calibre_dir)
|
||||||
if have_metadata_db:
|
if have_metadata_db:
|
||||||
if not self.config_use_google_drive:
|
if not self.config_use_google_drive:
|
||||||
db_file = os.path.join(self.config_calibre_dir, 'metadata.db')
|
db_file = os.path.join(self.config_calibre_dir, 'metadata.db')
|
||||||
have_metadata_db = os.path.isfile(db_file)
|
have_metadata_db = os.path.isfile(db_file)
|
||||||
self.db_configured = have_metadata_db
|
self.db_configured = have_metadata_db
|
||||||
|
constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')]
|
||||||
logger.setup(self.config_logfile, self.config_log_level)
|
if os.environ.get('FLASK_DEBUG'):
|
||||||
|
logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG)
|
||||||
|
else:
|
||||||
|
# pylint: disable=access-member-before-definition
|
||||||
|
logfile = logger.setup(self.config_logfile, self.config_log_level)
|
||||||
|
if logfile != self.config_logfile:
|
||||||
|
log.warning("Log path %s not valid, falling back to default", self.config_logfile)
|
||||||
|
self.config_logfile = logfile
|
||||||
|
self._session.merge(s)
|
||||||
|
try:
|
||||||
|
self._session.commit()
|
||||||
|
except OperationalError as e:
|
||||||
|
log.error('Database error: %s', e)
|
||||||
|
self._session.rollback()
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
'''Apply all configuration values to the underlying storage.'''
|
'''Apply all configuration values to the underlying storage.'''
|
||||||
@@ -287,7 +335,11 @@ class _ConfigSQL(object):
|
|||||||
|
|
||||||
log.debug("_ConfigSQL updating storage")
|
log.debug("_ConfigSQL updating storage")
|
||||||
self._session.merge(s)
|
self._session.merge(s)
|
||||||
self._session.commit()
|
try:
|
||||||
|
self._session.commit()
|
||||||
|
except OperationalError as e:
|
||||||
|
log.error('Database error: %s', e)
|
||||||
|
self._session.rollback()
|
||||||
self.load()
|
self.load()
|
||||||
|
|
||||||
def invalidate(self, error=None):
|
def invalidate(self, error=None):
|
||||||
@@ -306,7 +358,7 @@ def _migrate_table(session, orm_class):
|
|||||||
if column_name[0] != '_':
|
if column_name[0] != '_':
|
||||||
try:
|
try:
|
||||||
session.query(column).first()
|
session.query(column).first()
|
||||||
except exc.OperationalError as err:
|
except OperationalError as err:
|
||||||
log.debug("%s: %s", column_name, err.args[0])
|
log.debug("%s: %s", column_name, err.args[0])
|
||||||
if column.default is not None:
|
if column.default is not None:
|
||||||
if sys.version_info < (3, 0):
|
if sys.version_info < (3, 0):
|
||||||
@@ -316,31 +368,65 @@ def _migrate_table(session, orm_class):
|
|||||||
column_default = ""
|
column_default = ""
|
||||||
else:
|
else:
|
||||||
if isinstance(column.default.arg, bool):
|
if isinstance(column.default.arg, bool):
|
||||||
column_default = ("DEFAULT %r" % int(column.default.arg))
|
column_default = "DEFAULT {}".format(int(column.default.arg))
|
||||||
else:
|
else:
|
||||||
column_default = ("DEFAULT %r" % column.default.arg)
|
column_default = "DEFAULT `{}`".format(column.default.arg)
|
||||||
alter_table = "ALTER TABLE %s ADD COLUMN `%s` %s %s" % (orm_class.__tablename__,
|
if isinstance(column.type, JSON):
|
||||||
|
column_type = "JSON"
|
||||||
|
else:
|
||||||
|
column_type = column.type
|
||||||
|
alter_table = text("ALTER TABLE %s ADD COLUMN `%s` %s %s" % (orm_class.__tablename__,
|
||||||
column_name,
|
column_name,
|
||||||
column.type,
|
column_type,
|
||||||
column_default)
|
column_default))
|
||||||
log.debug(alter_table)
|
log.debug(alter_table)
|
||||||
session.execute(alter_table)
|
session.execute(alter_table)
|
||||||
changed = True
|
changed = True
|
||||||
|
except json.decoder.JSONDecodeError as e:
|
||||||
|
log.error("Database corrupt column: {}".format(column_name))
|
||||||
|
log.debug(e)
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
session.commit()
|
try:
|
||||||
|
session.commit()
|
||||||
|
except OperationalError:
|
||||||
|
session.rollback()
|
||||||
|
|
||||||
|
|
||||||
def autodetect_calibre_binary():
|
def autodetect_calibre_binary():
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
calibre_path = ["C:\\program files\calibre\calibre-convert.exe",
|
calibre_path = ["C:\\program files\\calibre\\ebook-convert.exe",
|
||||||
"C:\\program files(x86)\calibre\calibre-convert.exe"]
|
"C:\\program files(x86)\\calibre\\ebook-convert.exe",
|
||||||
|
"C:\\program files(x86)\\calibre2\\ebook-convert.exe",
|
||||||
|
"C:\\program files\\calibre2\\ebook-convert.exe"]
|
||||||
else:
|
else:
|
||||||
calibre_path = ["/opt/calibre/ebook-convert"]
|
calibre_path = ["/opt/calibre/ebook-convert"]
|
||||||
for element in calibre_path:
|
for element in calibre_path:
|
||||||
if os.path.isfile(element) and os.access(element, os.X_OK):
|
if os.path.isfile(element) and os.access(element, os.X_OK):
|
||||||
return element
|
return element
|
||||||
return None
|
return ""
|
||||||
|
|
||||||
|
def autodetect_unrar_binary():
|
||||||
|
if sys.platform == "win32":
|
||||||
|
calibre_path = ["C:\\program files\\WinRar\\unRAR.exe",
|
||||||
|
"C:\\program files(x86)\\WinRar\\unRAR.exe"]
|
||||||
|
else:
|
||||||
|
calibre_path = ["/usr/bin/unrar"]
|
||||||
|
for element in calibre_path:
|
||||||
|
if os.path.isfile(element) and os.access(element, os.X_OK):
|
||||||
|
return element
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def autodetect_kepubify_binary():
|
||||||
|
if sys.platform == "win32":
|
||||||
|
calibre_path = ["C:\\program files\\kepubify\\kepubify-windows-64Bit.exe",
|
||||||
|
"C:\\program files(x86)\\kepubify\\kepubify-windows-64Bit.exe"]
|
||||||
|
else:
|
||||||
|
calibre_path = ["/opt/kepubify/kepubify-linux-64bit", "/opt/kepubify/kepubify-linux-32bit"]
|
||||||
|
for element in calibre_path:
|
||||||
|
if os.path.isfile(element) and os.access(element, os.X_OK):
|
||||||
|
return element
|
||||||
|
return ""
|
||||||
|
|
||||||
def _migrate_database(session):
|
def _migrate_database(session):
|
||||||
# make sure the table is created, if it does not exist
|
# make sure the table is created, if it does not exist
|
||||||
@@ -357,12 +443,12 @@ def load_configuration(session):
|
|||||||
session.commit()
|
session.commit()
|
||||||
conf = _ConfigSQL(session)
|
conf = _ConfigSQL(session)
|
||||||
# Migrate from global restrictions to user based restrictions
|
# Migrate from global restrictions to user based restrictions
|
||||||
if bool(conf.config_default_show & constants.MATURE_CONTENT) and conf.config_denied_tags == "":
|
#if bool(conf.config_default_show & constants.MATURE_CONTENT) and conf.config_denied_tags == "":
|
||||||
conf.config_denied_tags = conf.config_mature_content_tags
|
# conf.config_denied_tags = conf.config_mature_content_tags
|
||||||
conf.save()
|
# conf.save()
|
||||||
session.query(ub.User).filter(ub.User.mature_content != True). \
|
# session.query(ub.User).filter(ub.User.mature_content != True). \
|
||||||
update({"denied_tags": conf.config_mature_content_tags}, synchronize_session=False)
|
# update({"denied_tags": conf.config_mature_content_tags}, synchronize_session=False)
|
||||||
session.commit()
|
# session.commit()
|
||||||
return conf
|
return conf
|
||||||
|
|
||||||
def get_flask_session_key(session):
|
def get_flask_session_key(session):
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ import sys
|
|||||||
import os
|
import os
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
|
||||||
HOME_CONFIG = False
|
# if installed via pip this variable is set to true (empty file with name .HOMEDIR present)
|
||||||
|
HOME_CONFIG = os.path.isfile(os.path.join(os.path.dirname(os.path.abspath(__file__)), '.HOMEDIR'))
|
||||||
|
|
||||||
|
#In executables updater is not available, so variable is set to False there
|
||||||
|
UPDATER_AVAILABLE = True
|
||||||
|
|
||||||
# Base dir is parent of current file, necessary if called from different folder
|
# Base dir is parent of current file, necessary if called from different folder
|
||||||
if sys.version_info < (3, 0):
|
if sys.version_info < (3, 0):
|
||||||
@@ -40,7 +44,7 @@ if HOME_CONFIG:
|
|||||||
os.makedirs(home_dir)
|
os.makedirs(home_dir)
|
||||||
CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', home_dir)
|
CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', home_dir)
|
||||||
else:
|
else:
|
||||||
CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', BASE_DIR)
|
CONFIG_DIR = os.environ.get('CALIBRE_DBPATH', BASE_DIR)
|
||||||
|
|
||||||
|
|
||||||
ROLE_USER = 0 << 0
|
ROLE_USER = 0 << 0
|
||||||
@@ -81,9 +85,31 @@ SIDEBAR_PUBLISHER = 1 << 12
|
|||||||
SIDEBAR_RATING = 1 << 13
|
SIDEBAR_RATING = 1 << 13
|
||||||
SIDEBAR_FORMAT = 1 << 14
|
SIDEBAR_FORMAT = 1 << 14
|
||||||
SIDEBAR_ARCHIVED = 1 << 15
|
SIDEBAR_ARCHIVED = 1 << 15
|
||||||
|
SIDEBAR_DOWNLOAD = 1 << 16
|
||||||
|
SIDEBAR_LIST = 1 << 17
|
||||||
|
|
||||||
|
sidebar_settings = {
|
||||||
|
"detail_random": DETAIL_RANDOM,
|
||||||
|
"sidebar_language": SIDEBAR_LANGUAGE,
|
||||||
|
"sidebar_series": SIDEBAR_SERIES,
|
||||||
|
"sidebar_category": SIDEBAR_CATEGORY,
|
||||||
|
"sidebar_random": SIDEBAR_RANDOM,
|
||||||
|
"sidebar_author": SIDEBAR_AUTHOR,
|
||||||
|
"sidebar_best_rated": SIDEBAR_BEST_RATED,
|
||||||
|
"sidebar_read_and_unread": SIDEBAR_READ_AND_UNREAD,
|
||||||
|
"sidebar_recent": SIDEBAR_RECENT,
|
||||||
|
"sidebar_sorted": SIDEBAR_SORTED,
|
||||||
|
"sidebar_publisher": SIDEBAR_PUBLISHER,
|
||||||
|
"sidebar_rating": SIDEBAR_RATING,
|
||||||
|
"sidebar_format": SIDEBAR_FORMAT,
|
||||||
|
"sidebar_archived": SIDEBAR_ARCHIVED,
|
||||||
|
"sidebar_download": SIDEBAR_DOWNLOAD,
|
||||||
|
"sidebar_list": SIDEBAR_LIST,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
ADMIN_USER_ROLES = sum(r for r in ALL_ROLES.values()) & ~ROLE_ANONYMOUS
|
ADMIN_USER_ROLES = sum(r for r in ALL_ROLES.values()) & ~ROLE_ANONYMOUS
|
||||||
ADMIN_USER_SIDEBAR = (SIDEBAR_ARCHIVED << 1) - 1
|
ADMIN_USER_SIDEBAR = (SIDEBAR_LIST << 1) - 1
|
||||||
|
|
||||||
UPDATE_STABLE = 0 << 0
|
UPDATE_STABLE = 0 << 0
|
||||||
AUTO_UPDATE_STABLE = 1 << 0
|
AUTO_UPDATE_STABLE = 1 << 0
|
||||||
@@ -100,7 +126,7 @@ LDAP_AUTH_SIMPLE = 0
|
|||||||
|
|
||||||
DEFAULT_MAIL_SERVER = "mail.example.org"
|
DEFAULT_MAIL_SERVER = "mail.example.org"
|
||||||
|
|
||||||
DEFAULT_PASSWORD = "admin123"
|
DEFAULT_PASSWORD = "admin123" # nosec
|
||||||
DEFAULT_PORT = 8083
|
DEFAULT_PORT = 8083
|
||||||
env_CALIBRE_PORT = os.environ.get("CALIBRE_PORT", DEFAULT_PORT)
|
env_CALIBRE_PORT = os.environ.get("CALIBRE_PORT", DEFAULT_PORT)
|
||||||
try:
|
try:
|
||||||
@@ -110,12 +136,11 @@ except ValueError:
|
|||||||
del env_CALIBRE_PORT
|
del env_CALIBRE_PORT
|
||||||
|
|
||||||
|
|
||||||
EXTENSIONS_AUDIO = {'mp3', 'm4a', 'm4b'}
|
EXTENSIONS_AUDIO = {'mp3', 'mp4', 'ogg', 'opus', 'wav', 'flac', 'm4a', 'm4b'}
|
||||||
EXTENSIONS_CONVERT = {'pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf', 'txt', 'htmlz', 'rtf', 'odt'}
|
EXTENSIONS_CONVERT_FROM = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf', 'txt', 'htmlz', 'rtf', 'odt','cbz','cbr']
|
||||||
EXTENSIONS_UPLOAD = {'txt', 'pdf', 'epub', 'mobi', 'azw', 'azw3', 'cbr', 'cbz', 'cbt', 'djvu', 'prc', 'doc', 'docx',
|
EXTENSIONS_CONVERT_TO = ['pdf', 'epub', 'mobi', 'azw3', 'docx', 'rtf', 'fb2', 'lit', 'lrf', 'txt', 'htmlz', 'rtf', 'odt']
|
||||||
'fb2', 'html', 'rtf', 'lit', 'odt', 'mp3', 'm4a', 'm4b'}
|
EXTENSIONS_UPLOAD = {'txt', 'pdf', 'epub', 'kepub', 'mobi', 'azw', 'azw3', 'cbr', 'cbz', 'cbt', 'djvu', 'prc', 'doc', 'docx',
|
||||||
# EXTENSIONS_READER = set(['txt', 'pdf', 'epub', 'zip', 'cbz', 'tar', 'cbt'] +
|
'fb2', 'html', 'rtf', 'lit', 'odt', 'mp3', 'mp4', 'ogg', 'opus', 'wav', 'flac', 'm4a', 'm4b'}
|
||||||
# (['rar','cbr'] if feature_support['rar'] else []))
|
|
||||||
|
|
||||||
|
|
||||||
def has_flag(value, bit_flag):
|
def has_flag(value, bit_flag):
|
||||||
@@ -127,9 +152,9 @@ def selected_roles(dictionary):
|
|||||||
|
|
||||||
# :rtype: BookMeta
|
# :rtype: BookMeta
|
||||||
BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, description, tags, series, '
|
BookMeta = namedtuple('BookMeta', 'file_path, extension, title, author, cover, description, tags, series, '
|
||||||
'series_id, languages')
|
'series_id, languages, publisher')
|
||||||
|
|
||||||
STABLE_VERSION = {'version': '0.6.7'}
|
STABLE_VERSION = {'version': '0.6.12'}
|
||||||
|
|
||||||
NIGHTLY_VERSION = {}
|
NIGHTLY_VERSION = {}
|
||||||
NIGHTLY_VERSION[0] = '$Format:%H$'
|
NIGHTLY_VERSION[0] = '$Format:%H$'
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ log = logger.create()
|
|||||||
|
|
||||||
# _() necessary to make babel aware of string for translation
|
# _() necessary to make babel aware of string for translation
|
||||||
_NOT_CONFIGURED = _('not configured')
|
_NOT_CONFIGURED = _('not configured')
|
||||||
_NOT_INSTALLED = 'not installed'
|
_NOT_INSTALLED = _('not installed')
|
||||||
_EXECUTION_ERROR = 'Execution permissions missing'
|
_EXECUTION_ERROR = _('Execution permissions missing')
|
||||||
|
|
||||||
|
|
||||||
def _get_command_version(path, pattern, argument=None):
|
def _get_command_version(path, pattern, argument=None):
|
||||||
@@ -48,10 +48,15 @@ def _get_command_version(path, pattern, argument=None):
|
|||||||
return _NOT_INSTALLED
|
return _NOT_INSTALLED
|
||||||
|
|
||||||
|
|
||||||
def get_version():
|
def get_calibre_version():
|
||||||
version = None
|
return _get_command_version(config.config_converterpath, r'ebook-convert.*\(calibre', '--version') \
|
||||||
if config.config_ebookconverter == 1:
|
or _NOT_CONFIGURED
|
||||||
version = _get_command_version(config.config_converterpath, r'Amazon kindlegen\(')
|
|
||||||
elif config.config_ebookconverter == 2:
|
|
||||||
version = _get_command_version(config.config_converterpath, r'ebook-convert.*\(calibre', '--version')
|
def get_unrar_version():
|
||||||
return version or _NOT_CONFIGURED
|
return _get_command_version(config.config_rarfile_location, r'UNRAR.*\d') or _NOT_CONFIGURED
|
||||||
|
|
||||||
|
def get_kepubify_version():
|
||||||
|
return _get_command_version(config.config_kepubifypath, r'kepubify\s','--version') or _NOT_CONFIGURED
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,59 +22,86 @@ import sys
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import ast
|
import ast
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy import Table, Column, ForeignKey
|
from sqlalchemy import Table, Column, ForeignKey, CheckConstraint
|
||||||
from sqlalchemy import String, Integer, Boolean, TIMESTAMP, Float, DateTime
|
from sqlalchemy import String, Integer, Boolean, TIMESTAMP, Float
|
||||||
from sqlalchemy.orm import relationship, sessionmaker, scoped_session
|
from sqlalchemy.orm import relationship, sessionmaker, scoped_session
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.orm.collections import InstrumentedList
|
||||||
|
from sqlalchemy.ext.declarative import DeclarativeMeta
|
||||||
|
from sqlalchemy.exc import OperationalError
|
||||||
|
try:
|
||||||
|
# Compatibility with sqlalchemy 2.0
|
||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
except ImportError:
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
from sqlalchemy.sql.expression import and_, true, false, text, func, or_
|
||||||
|
from sqlalchemy.ext.associationproxy import association_proxy
|
||||||
|
from flask_login import current_user
|
||||||
|
from babel import Locale as LC
|
||||||
|
from babel.core import UnknownLocaleError
|
||||||
|
from flask_babel import gettext as _
|
||||||
|
from flask import flash
|
||||||
|
|
||||||
|
from . import logger, ub, isoLanguages
|
||||||
|
from .pagination import Pagination
|
||||||
|
|
||||||
session = None
|
from weakref import WeakSet
|
||||||
cc_exceptions = ['datetime', 'comments', 'composite', 'series']
|
|
||||||
|
try:
|
||||||
|
import unidecode
|
||||||
|
use_unidecode = True
|
||||||
|
except ImportError:
|
||||||
|
use_unidecode = False
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
cc_exceptions = ['composite', 'series']
|
||||||
cc_classes = {}
|
cc_classes = {}
|
||||||
engine = None
|
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
books_authors_link = Table('books_authors_link', Base.metadata,
|
books_authors_link = Table('books_authors_link', Base.metadata,
|
||||||
Column('book', Integer, ForeignKey('books.id'), primary_key=True),
|
Column('book', Integer, ForeignKey('books.id'), primary_key=True),
|
||||||
Column('author', Integer, ForeignKey('authors.id'), primary_key=True)
|
Column('author', Integer, ForeignKey('authors.id'), primary_key=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
books_tags_link = Table('books_tags_link', Base.metadata,
|
books_tags_link = Table('books_tags_link', Base.metadata,
|
||||||
Column('book', Integer, ForeignKey('books.id'), primary_key=True),
|
Column('book', Integer, ForeignKey('books.id'), primary_key=True),
|
||||||
Column('tag', Integer, ForeignKey('tags.id'), primary_key=True)
|
Column('tag', Integer, ForeignKey('tags.id'), primary_key=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
books_series_link = Table('books_series_link', Base.metadata,
|
books_series_link = Table('books_series_link', Base.metadata,
|
||||||
Column('book', Integer, ForeignKey('books.id'), primary_key=True),
|
Column('book', Integer, ForeignKey('books.id'), primary_key=True),
|
||||||
Column('series', Integer, ForeignKey('series.id'), primary_key=True)
|
Column('series', Integer, ForeignKey('series.id'), primary_key=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
books_ratings_link = Table('books_ratings_link', Base.metadata,
|
books_ratings_link = Table('books_ratings_link', Base.metadata,
|
||||||
Column('book', Integer, ForeignKey('books.id'), primary_key=True),
|
Column('book', Integer, ForeignKey('books.id'), primary_key=True),
|
||||||
Column('rating', Integer, ForeignKey('ratings.id'), primary_key=True)
|
Column('rating', Integer, ForeignKey('ratings.id'), primary_key=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
books_languages_link = Table('books_languages_link', Base.metadata,
|
books_languages_link = Table('books_languages_link', Base.metadata,
|
||||||
Column('book', Integer, ForeignKey('books.id'), primary_key=True),
|
Column('book', Integer, ForeignKey('books.id'), primary_key=True),
|
||||||
Column('lang_code', Integer, ForeignKey('languages.id'), primary_key=True)
|
Column('lang_code', Integer, ForeignKey('languages.id'), primary_key=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
books_publishers_link = Table('books_publishers_link', Base.metadata,
|
books_publishers_link = Table('books_publishers_link', Base.metadata,
|
||||||
Column('book', Integer, ForeignKey('books.id'), primary_key=True),
|
Column('book', Integer, ForeignKey('books.id'), primary_key=True),
|
||||||
Column('publisher', Integer, ForeignKey('publishers.id'), primary_key=True)
|
Column('publisher', Integer, ForeignKey('publishers.id'), primary_key=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Identifiers(Base):
|
class Identifiers(Base):
|
||||||
__tablename__ = 'identifiers'
|
__tablename__ = 'identifiers'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
type = Column(String)
|
type = Column(String(collation='NOCASE'), nullable=False, default="isbn")
|
||||||
val = Column(String)
|
val = Column(String(collation='NOCASE'), nullable=False)
|
||||||
book = Column(Integer, ForeignKey('books.id'))
|
book = Column(Integer, ForeignKey('books.id'), nullable=False)
|
||||||
|
|
||||||
def __init__(self, val, id_type, book):
|
def __init__(self, val, id_type, book):
|
||||||
self.val = val
|
self.val = val
|
||||||
@@ -82,57 +109,82 @@ class Identifiers(Base):
|
|||||||
self.book = book
|
self.book = book
|
||||||
|
|
||||||
def formatType(self):
|
def formatType(self):
|
||||||
if self.type == "amazon":
|
format_type = self.type.lower()
|
||||||
|
if format_type == 'amazon':
|
||||||
return u"Amazon"
|
return u"Amazon"
|
||||||
elif self.type == "isbn":
|
elif format_type.startswith("amazon_"):
|
||||||
|
return u"Amazon.{0}".format(format_type[7:])
|
||||||
|
elif format_type == "isbn":
|
||||||
return u"ISBN"
|
return u"ISBN"
|
||||||
elif self.type == "doi":
|
elif format_type == "doi":
|
||||||
return u"DOI"
|
return u"DOI"
|
||||||
elif self.type == "goodreads":
|
elif format_type == "douban":
|
||||||
|
return u"Douban"
|
||||||
|
elif format_type == "goodreads":
|
||||||
return u"Goodreads"
|
return u"Goodreads"
|
||||||
elif self.type == "google":
|
elif format_type == "babelio":
|
||||||
|
return u"Babelio"
|
||||||
|
elif format_type == "google":
|
||||||
return u"Google Books"
|
return u"Google Books"
|
||||||
elif self.type == "kobo":
|
elif format_type == "kobo":
|
||||||
return u"Kobo"
|
return u"Kobo"
|
||||||
if self.type == "lubimyczytac":
|
elif format_type == "litres":
|
||||||
|
return u"ЛитРес"
|
||||||
|
elif format_type == "issn":
|
||||||
|
return u"ISSN"
|
||||||
|
elif format_type == "isfdb":
|
||||||
|
return u"ISFDB"
|
||||||
|
if format_type == "lubimyczytac":
|
||||||
return u"Lubimyczytac"
|
return u"Lubimyczytac"
|
||||||
else:
|
else:
|
||||||
return self.type
|
return self.type
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
if self.type == "amazon":
|
format_type = self.type.lower()
|
||||||
return u"https://amzn.com/{0}".format(self.val)
|
if format_type == "amazon" or format_type == "asin":
|
||||||
elif self.type == "isbn":
|
return u"https://amazon.com/dp/{0}".format(self.val)
|
||||||
return u"http://www.worldcat.org/isbn/{0}".format(self.val)
|
elif format_type.startswith('amazon_'):
|
||||||
elif self.type == "doi":
|
return u"https://amazon.{0}/dp/{1}".format(format_type[7:], self.val)
|
||||||
return u"http://dx.doi.org/{0}".format(self.val)
|
elif format_type == "isbn":
|
||||||
elif self.type == "goodreads":
|
return u"https://www.worldcat.org/isbn/{0}".format(self.val)
|
||||||
return u"http://www.goodreads.com/book/show/{0}".format(self.val)
|
elif format_type == "doi":
|
||||||
elif self.type == "douban":
|
return u"https://dx.doi.org/{0}".format(self.val)
|
||||||
|
elif format_type == "goodreads":
|
||||||
|
return u"https://www.goodreads.com/book/show/{0}".format(self.val)
|
||||||
|
elif format_type == "babelio":
|
||||||
|
return u"https://www.babelio.com/livres/titre/{0}".format(self.val)
|
||||||
|
elif format_type == "douban":
|
||||||
return u"https://book.douban.com/subject/{0}".format(self.val)
|
return u"https://book.douban.com/subject/{0}".format(self.val)
|
||||||
elif self.type == "google":
|
elif format_type == "google":
|
||||||
return u"https://books.google.com/books?id={0}".format(self.val)
|
return u"https://books.google.com/books?id={0}".format(self.val)
|
||||||
elif self.type == "kobo":
|
elif format_type == "kobo":
|
||||||
return u"https://www.kobo.com/ebook/{0}".format(self.val)
|
return u"https://www.kobo.com/ebook/{0}".format(self.val)
|
||||||
elif self.type == "lubimyczytac":
|
elif format_type == "lubimyczytac":
|
||||||
return u" http://lubimyczytac.pl/ksiazka/{0}".format(self.val)
|
return u"https://lubimyczytac.pl/ksiazka/{0}/ksiazka".format(self.val)
|
||||||
elif self.type == "url":
|
elif format_type == "litres":
|
||||||
return u"{0}".format(self.val)
|
return u"https://www.litres.ru/{0}".format(self.val)
|
||||||
|
elif format_type == "issn":
|
||||||
|
return u"https://portal.issn.org/resource/ISSN/{0}".format(self.val)
|
||||||
|
elif format_type == "isfdb":
|
||||||
|
return u"http://www.isfdb.org/cgi-bin/pl.cgi?{0}".format(self.val)
|
||||||
else:
|
else:
|
||||||
return u""
|
return u"{0}".format(self.val)
|
||||||
|
|
||||||
|
|
||||||
class Comments(Base):
|
class Comments(Base):
|
||||||
__tablename__ = 'comments'
|
__tablename__ = 'comments'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
text = Column(String)
|
text = Column(String(collation='NOCASE'), nullable=False)
|
||||||
book = Column(Integer, ForeignKey('books.id'))
|
book = Column(Integer, ForeignKey('books.id'), nullable=False)
|
||||||
|
|
||||||
def __init__(self, text, book):
|
def __init__(self, text, book):
|
||||||
self.text = text
|
self.text = text
|
||||||
self.book = book
|
self.book = book
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
return self.text
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return u"<Comments({0})>".format(self.text)
|
return u"<Comments({0})>".format(self.text)
|
||||||
|
|
||||||
@@ -141,11 +193,14 @@ class Tags(Base):
|
|||||||
__tablename__ = 'tags'
|
__tablename__ = 'tags'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
name = Column(String)
|
name = Column(String(collation='NOCASE'), unique=True, nullable=False)
|
||||||
|
|
||||||
def __init__(self, name):
|
def __init__(self, name):
|
||||||
self.name = name
|
self.name = name
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return u"<Tags('{0})>".format(self.name)
|
return u"<Tags('{0})>".format(self.name)
|
||||||
|
|
||||||
@@ -154,15 +209,18 @@ class Authors(Base):
|
|||||||
__tablename__ = 'authors'
|
__tablename__ = 'authors'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
name = Column(String)
|
name = Column(String(collation='NOCASE'), unique=True, nullable=False)
|
||||||
sort = Column(String)
|
sort = Column(String(collation='NOCASE'))
|
||||||
link = Column(String)
|
link = Column(String, nullable=False, default="")
|
||||||
|
|
||||||
def __init__(self, name, sort, link):
|
def __init__(self, name, sort, link):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.sort = sort
|
self.sort = sort
|
||||||
self.link = link
|
self.link = link
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return u"<Authors('{0},{1}{2}')>".format(self.name, self.sort, self.link)
|
return u"<Authors('{0},{1}{2}')>".format(self.name, self.sort, self.link)
|
||||||
|
|
||||||
@@ -171,13 +229,16 @@ class Series(Base):
|
|||||||
__tablename__ = 'series'
|
__tablename__ = 'series'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
name = Column(String)
|
name = Column(String(collation='NOCASE'), unique=True, nullable=False)
|
||||||
sort = Column(String)
|
sort = Column(String(collation='NOCASE'))
|
||||||
|
|
||||||
def __init__(self, name, sort):
|
def __init__(self, name, sort):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.sort = sort
|
self.sort = sort
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return u"<Series('{0},{1}')>".format(self.name, self.sort)
|
return u"<Series('{0},{1}')>".format(self.name, self.sort)
|
||||||
|
|
||||||
@@ -186,11 +247,14 @@ class Ratings(Base):
|
|||||||
__tablename__ = 'ratings'
|
__tablename__ = 'ratings'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
rating = Column(Integer)
|
rating = Column(Integer, CheckConstraint('rating>-1 AND rating<11'), unique=True)
|
||||||
|
|
||||||
def __init__(self, rating):
|
def __init__(self, rating):
|
||||||
self.rating = rating
|
self.rating = rating
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
return self.rating
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return u"<Ratings('{0}')>".format(self.rating)
|
return u"<Ratings('{0}')>".format(self.rating)
|
||||||
|
|
||||||
@@ -199,11 +263,17 @@ class Languages(Base):
|
|||||||
__tablename__ = 'languages'
|
__tablename__ = 'languages'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
lang_code = Column(String)
|
lang_code = Column(String(collation='NOCASE'), nullable=False, unique=True)
|
||||||
|
|
||||||
def __init__(self, lang_code):
|
def __init__(self, lang_code):
|
||||||
self.lang_code = lang_code
|
self.lang_code = lang_code
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
if self.language_name:
|
||||||
|
return self.language_name
|
||||||
|
else:
|
||||||
|
return self.lang_code
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return u"<Languages('{0}')>".format(self.lang_code)
|
return u"<Languages('{0}')>".format(self.lang_code)
|
||||||
|
|
||||||
@@ -212,25 +282,29 @@ class Publishers(Base):
|
|||||||
__tablename__ = 'publishers'
|
__tablename__ = 'publishers'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
name = Column(String)
|
name = Column(String(collation='NOCASE'), nullable=False, unique=True)
|
||||||
sort = Column(String)
|
sort = Column(String(collation='NOCASE'))
|
||||||
|
|
||||||
def __init__(self, name, sort):
|
def __init__(self, name, sort):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.sort = sort
|
self.sort = sort
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return u"<Publishers('{0},{1}')>".format(self.name, self.sort)
|
return u"<Publishers('{0},{1}')>".format(self.name, self.sort)
|
||||||
|
|
||||||
|
|
||||||
class Data(Base):
|
class Data(Base):
|
||||||
__tablename__ = 'data'
|
__tablename__ = 'data'
|
||||||
|
__table_args__ = {'schema': 'calibre'}
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
book = Column(Integer, ForeignKey('books.id'))
|
book = Column(Integer, ForeignKey('books.id'), nullable=False)
|
||||||
format = Column(String)
|
format = Column(String(collation='NOCASE'), nullable=False)
|
||||||
uncompressed_size = Column(Integer)
|
uncompressed_size = Column(Integer, nullable=False)
|
||||||
name = Column(String)
|
name = Column(String, nullable=False)
|
||||||
|
|
||||||
def __init__(self, book, book_format, uncompressed_size, name):
|
def __init__(self, book, book_format, uncompressed_size, name):
|
||||||
self.book = book
|
self.book = book
|
||||||
@@ -238,6 +312,10 @@ class Data(Base):
|
|||||||
self.uncompressed_size = uncompressed_size
|
self.uncompressed_size = uncompressed_size
|
||||||
self.name = name
|
self.name = name
|
||||||
|
|
||||||
|
# ToDo: Check
|
||||||
|
def get(self):
|
||||||
|
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 u"<Data('{0},{1}{2}{3}')>".format(self.book, self.format, self.uncompressed_size, self.name)
|
||||||
|
|
||||||
@@ -245,22 +323,24 @@ class Data(Base):
|
|||||||
class Books(Base):
|
class Books(Base):
|
||||||
__tablename__ = 'books'
|
__tablename__ = 'books'
|
||||||
|
|
||||||
DEFAULT_PUBDATE = "0101-01-01 00:00:00+00:00"
|
DEFAULT_PUBDATE = datetime(101, 1, 1, 0, 0, 0, 0) # ("0101-01-01 00:00:00+00:00")
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
title = Column(String)
|
title = Column(String(collation='NOCASE'), nullable=False, default='Unknown')
|
||||||
sort = Column(String)
|
sort = Column(String(collation='NOCASE'))
|
||||||
author_sort = Column(String)
|
author_sort = Column(String(collation='NOCASE'))
|
||||||
timestamp = Column(TIMESTAMP)
|
timestamp = Column(TIMESTAMP, default=datetime.utcnow)
|
||||||
pubdate = Column(String)
|
pubdate = Column(TIMESTAMP, default=DEFAULT_PUBDATE)
|
||||||
series_index = Column(String)
|
series_index = Column(String, nullable=False, default="1.0")
|
||||||
last_modified = Column(TIMESTAMP)
|
last_modified = Column(TIMESTAMP, default=datetime.utcnow)
|
||||||
path = Column(String)
|
path = Column(String, default="", nullable=False)
|
||||||
has_cover = Column(Integer)
|
has_cover = Column(Integer, default=0)
|
||||||
uuid = Column(String)
|
uuid = Column(String)
|
||||||
|
isbn = Column(String(collation='NOCASE'), default="")
|
||||||
|
flags = Column(Integer, nullable=False, default=1)
|
||||||
|
|
||||||
authors = relationship('Authors', secondary=books_authors_link, backref='books')
|
authors = relationship('Authors', secondary=books_authors_link, backref='books')
|
||||||
tags = relationship('Tags', secondary=books_tags_link, backref='books',order_by="Tags.name")
|
tags = relationship('Tags', secondary=books_tags_link, backref='books', order_by="Tags.name")
|
||||||
comments = relationship('Comments', backref='books')
|
comments = relationship('Comments', backref='books')
|
||||||
data = relationship('Data', backref='books')
|
data = relationship('Data', backref='books')
|
||||||
series = relationship('Series', secondary=books_series_link, backref='books')
|
series = relationship('Series', secondary=books_series_link, backref='books')
|
||||||
@@ -279,7 +359,8 @@ 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
|
self.has_cover = (has_cover != 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 u"<Books('{0},{1}{2}{3}{4}{5}{6}{7}{8}')>".format(self.title, self.sort, self.author_sort,
|
||||||
@@ -290,6 +371,7 @@ class Books(Base):
|
|||||||
def atom_timestamp(self):
|
def atom_timestamp(self):
|
||||||
return (self.timestamp.strftime('%Y-%m-%dT%H:%M:%S+00:00') or '')
|
return (self.timestamp.strftime('%Y-%m-%dT%H:%M:%S+00:00') or '')
|
||||||
|
|
||||||
|
|
||||||
class Custom_Columns(Base):
|
class Custom_Columns(Base):
|
||||||
__tablename__ = 'custom_columns'
|
__tablename__ = 'custom_columns'
|
||||||
|
|
||||||
@@ -310,131 +392,449 @@ class Custom_Columns(Base):
|
|||||||
return display_dict
|
return display_dict
|
||||||
|
|
||||||
|
|
||||||
def update_title_sort(config, conn=None):
|
class AlchemyEncoder(json.JSONEncoder):
|
||||||
# user defined sort function for calibre databases (Series, etc.)
|
|
||||||
def _title_sort(title):
|
|
||||||
# calibre sort stuff
|
|
||||||
title_pat = re.compile(config.config_title_regex, re.IGNORECASE)
|
|
||||||
match = title_pat.search(title)
|
|
||||||
if match:
|
|
||||||
prep = match.group(1)
|
|
||||||
title = title.replace(prep, '') + ', ' + prep
|
|
||||||
return title.strip()
|
|
||||||
|
|
||||||
conn = conn or session.connection().connection.connection
|
def default(self, o):
|
||||||
conn.create_function("title_sort", 1, _title_sort)
|
if isinstance(o.__class__, DeclarativeMeta):
|
||||||
|
# an SQLAlchemy class
|
||||||
|
fields = {}
|
||||||
|
for field in [x for x in dir(o) if not x.startswith('_') and x != 'metadata' and x!="password"]:
|
||||||
|
if field == 'books':
|
||||||
|
continue
|
||||||
|
data = o.__getattribute__(field)
|
||||||
|
try:
|
||||||
|
if isinstance(data, str):
|
||||||
|
data = data.replace("'", "\'")
|
||||||
|
elif isinstance(data, InstrumentedList):
|
||||||
|
el = list()
|
||||||
|
for ele in data:
|
||||||
|
if ele.get:
|
||||||
|
el.append(ele.get())
|
||||||
|
else:
|
||||||
|
el.append(json.dumps(ele, cls=AlchemyEncoder))
|
||||||
|
if field == 'authors':
|
||||||
|
data = " & ".join(el)
|
||||||
|
else:
|
||||||
|
data = ",".join(el)
|
||||||
|
if data == '[]':
|
||||||
|
data = ""
|
||||||
|
else:
|
||||||
|
json.dumps(data)
|
||||||
|
fields[field] = data
|
||||||
|
except Exception:
|
||||||
|
fields[field] = ""
|
||||||
|
# a json-encodable dict
|
||||||
|
return fields
|
||||||
|
|
||||||
|
return json.JSONEncoder.default(self, o)
|
||||||
|
|
||||||
|
|
||||||
def setup_db(config):
|
class CalibreDB():
|
||||||
dispose()
|
_init = False
|
||||||
global engine
|
engine = None
|
||||||
|
config = None
|
||||||
|
session_factory = None
|
||||||
|
# This is a WeakSet so that references here don't keep other CalibreDB
|
||||||
|
# instances alive once they reach the end of their respective scopes
|
||||||
|
instances = WeakSet()
|
||||||
|
|
||||||
if not config.config_calibre_dir:
|
def __init__(self, expire_on_commit=True):
|
||||||
config.invalidate()
|
""" Initialize a new CalibreDB session
|
||||||
return False
|
"""
|
||||||
|
self.session = None
|
||||||
|
if self._init:
|
||||||
|
self.initSession(expire_on_commit)
|
||||||
|
|
||||||
dbpath = os.path.join(config.config_calibre_dir, "metadata.db")
|
self.instances.add(self)
|
||||||
if not os.path.exists(dbpath):
|
|
||||||
config.invalidate()
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
def initSession(self, expire_on_commit=True):
|
||||||
engine = create_engine('sqlite:///{0}'.format(dbpath),
|
self.session = self.session_factory()
|
||||||
echo=False,
|
self.session.expire_on_commit = expire_on_commit
|
||||||
isolation_level="SERIALIZABLE",
|
self.update_title_sort(self.config)
|
||||||
connect_args={'check_same_thread': False})
|
|
||||||
conn = engine.connect()
|
|
||||||
# conn.text_factory = lambda b: b.decode(errors = 'ignore') possible fix for #1302
|
|
||||||
except Exception as e:
|
|
||||||
config.invalidate(e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
config.db_configured = True
|
|
||||||
update_title_sort(config, conn.connection)
|
|
||||||
|
|
||||||
if not cc_classes:
|
|
||||||
cc = conn.execute("SELECT id, datatype FROM custom_columns")
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_db_cc_classes(self, cc):
|
||||||
cc_ids = []
|
cc_ids = []
|
||||||
books_custom_column_links = {}
|
books_custom_column_links = {}
|
||||||
for row in cc:
|
for row in cc:
|
||||||
if row.datatype not in cc_exceptions:
|
if row.datatype not in cc_exceptions:
|
||||||
books_custom_column_links[row.id] = Table('books_custom_column_' + str(row.id) + '_link', Base.metadata,
|
if row.datatype == 'series':
|
||||||
Column('book', Integer, ForeignKey('books.id'),
|
dicttable = {'__tablename__': 'books_custom_column_' + str(row.id) + '_link',
|
||||||
primary_key=True),
|
'id': Column(Integer, primary_key=True),
|
||||||
Column('value', Integer,
|
'book': Column(Integer, ForeignKey('books.id'),
|
||||||
ForeignKey('custom_column_' + str(row.id) + '.id'),
|
primary_key=True),
|
||||||
primary_key=True)
|
'map_value': Column('value', Integer,
|
||||||
)
|
ForeignKey('custom_column_' +
|
||||||
|
str(row.id) + '.id'),
|
||||||
|
primary_key=True),
|
||||||
|
'extra': Column(Float),
|
||||||
|
'asoc': relationship('custom_column_' + str(row.id), uselist=False),
|
||||||
|
'value': association_proxy('asoc', 'value')
|
||||||
|
}
|
||||||
|
books_custom_column_links[row.id] = type(str('books_custom_column_' + str(row.id) + '_link'),
|
||||||
|
(Base,), dicttable)
|
||||||
|
if row.datatype in ['rating', 'text', 'enumeration']:
|
||||||
|
books_custom_column_links[row.id] = Table('books_custom_column_' + str(row.id) + '_link',
|
||||||
|
Base.metadata,
|
||||||
|
Column('book', Integer, ForeignKey('books.id'),
|
||||||
|
primary_key=True),
|
||||||
|
Column('value', Integer,
|
||||||
|
ForeignKey('custom_column_' +
|
||||||
|
str(row.id) + '.id'),
|
||||||
|
primary_key=True)
|
||||||
|
)
|
||||||
cc_ids.append([row.id, row.datatype])
|
cc_ids.append([row.id, row.datatype])
|
||||||
if row.datatype == 'bool':
|
|
||||||
ccdict = {'__tablename__': 'custom_column_' + str(row.id),
|
ccdict = {'__tablename__': 'custom_column_' + str(row.id),
|
||||||
'id': Column(Integer, primary_key=True),
|
'id': Column(Integer, primary_key=True)}
|
||||||
'book': Column(Integer, ForeignKey('books.id')),
|
if row.datatype == 'float':
|
||||||
'value': Column(Boolean)}
|
ccdict['value'] = Column(Float)
|
||||||
elif row.datatype == 'int':
|
elif row.datatype == 'int':
|
||||||
ccdict = {'__tablename__': 'custom_column_' + str(row.id),
|
ccdict['value'] = Column(Integer)
|
||||||
'id': Column(Integer, primary_key=True),
|
elif row.datatype == 'datetime':
|
||||||
'book': Column(Integer, ForeignKey('books.id')),
|
ccdict['value'] = Column(TIMESTAMP)
|
||||||
'value': Column(Integer)}
|
elif row.datatype == 'bool':
|
||||||
elif row.datatype == 'float':
|
ccdict['value'] = Column(Boolean)
|
||||||
ccdict = {'__tablename__': 'custom_column_' + str(row.id),
|
|
||||||
'id': Column(Integer, primary_key=True),
|
|
||||||
'book': Column(Integer, ForeignKey('books.id')),
|
|
||||||
'value': Column(Float)}
|
|
||||||
else:
|
else:
|
||||||
ccdict = {'__tablename__': 'custom_column_' + str(row.id),
|
ccdict['value'] = Column(String)
|
||||||
'id': Column(Integer, primary_key=True),
|
if row.datatype in ['float', 'int', 'bool', 'datetime', 'comments']:
|
||||||
'value': Column(String)}
|
ccdict['book'] = Column(Integer, ForeignKey('books.id'))
|
||||||
cc_classes[row.id] = type(str('Custom_Column_' + str(row.id)), (Base,), ccdict)
|
cc_classes[row.id] = type(str('custom_column_' + str(row.id)), (Base,), ccdict)
|
||||||
|
|
||||||
for cc_id in cc_ids:
|
for cc_id in cc_ids:
|
||||||
if (cc_id[1] == 'bool') or (cc_id[1] == 'int') or (cc_id[1] == 'float'):
|
if cc_id[1] in ['bool', 'int', 'float', 'datetime', 'comments']:
|
||||||
setattr(Books, 'custom_column_' + str(cc_id[0]), relationship(cc_classes[cc_id[0]],
|
setattr(Books,
|
||||||
primaryjoin=(
|
'custom_column_' + str(cc_id[0]),
|
||||||
Books.id == cc_classes[cc_id[0]].book),
|
relationship(cc_classes[cc_id[0]],
|
||||||
backref='books'))
|
primaryjoin=(
|
||||||
|
Books.id == cc_classes[cc_id[0]].book),
|
||||||
|
backref='books'))
|
||||||
|
elif cc_id[1] == 'series':
|
||||||
|
setattr(Books,
|
||||||
|
'custom_column_' + str(cc_id[0]),
|
||||||
|
relationship(books_custom_column_links[cc_id[0]],
|
||||||
|
backref='books'))
|
||||||
else:
|
else:
|
||||||
setattr(Books, 'custom_column_' + str(cc_id[0]), relationship(cc_classes[cc_id[0]],
|
setattr(Books,
|
||||||
secondary=books_custom_column_links[cc_id[0]],
|
'custom_column_' + str(cc_id[0]),
|
||||||
backref='books'))
|
relationship(cc_classes[cc_id[0]],
|
||||||
|
secondary=books_custom_column_links[cc_id[0]],
|
||||||
|
backref='books'))
|
||||||
|
|
||||||
|
return cc_classes
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setup_db(cls, config, app_db_path):
|
||||||
|
cls.config = config
|
||||||
|
cls.dispose()
|
||||||
|
|
||||||
|
# toDo: if db changed -> delete shelfs, delete download books, delete read boks, kobo sync??
|
||||||
|
|
||||||
|
if not config.config_calibre_dir:
|
||||||
|
config.invalidate()
|
||||||
|
return False
|
||||||
|
|
||||||
|
dbpath = os.path.join(config.config_calibre_dir, "metadata.db")
|
||||||
|
if not os.path.exists(dbpath):
|
||||||
|
config.invalidate()
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
cls.engine = create_engine('sqlite://',
|
||||||
|
echo=False,
|
||||||
|
isolation_level="SERIALIZABLE",
|
||||||
|
connect_args={'check_same_thread': False},
|
||||||
|
poolclass=StaticPool)
|
||||||
|
with cls.engine.begin() as connection:
|
||||||
|
connection.execute(text("attach database '{}' as calibre;".format(dbpath)))
|
||||||
|
connection.execute(text("attach database '{}' as app_settings;".format(app_db_path)))
|
||||||
|
|
||||||
|
conn = cls.engine.connect()
|
||||||
|
# conn.text_factory = lambda b: b.decode(errors = 'ignore') possible fix for #1302
|
||||||
|
except Exception as ex:
|
||||||
|
config.invalidate(ex)
|
||||||
|
return False
|
||||||
|
|
||||||
|
config.db_configured = True
|
||||||
|
|
||||||
|
if not cc_classes:
|
||||||
|
try:
|
||||||
|
cc = conn.execute("SELECT id, datatype FROM custom_columns")
|
||||||
|
cls.setup_db_cc_classes(cc)
|
||||||
|
except OperationalError as e:
|
||||||
|
log.debug_or_exception(e)
|
||||||
|
|
||||||
|
cls.session_factory = scoped_session(sessionmaker(autocommit=False,
|
||||||
|
autoflush=True,
|
||||||
|
bind=cls.engine))
|
||||||
|
for inst in cls.instances:
|
||||||
|
inst.initSession()
|
||||||
|
|
||||||
|
cls._init = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_book(self, book_id):
|
||||||
|
return self.session.query(Books).filter(Books.id == book_id).first()
|
||||||
|
|
||||||
|
def get_filtered_book(self, book_id, allow_show_archived=False):
|
||||||
|
return self.session.query(Books).filter(Books.id == book_id). \
|
||||||
|
filter(self.common_filters(allow_show_archived)).first()
|
||||||
|
|
||||||
|
def get_book_by_uuid(self, book_uuid):
|
||||||
|
return self.session.query(Books).filter(Books.uuid == book_uuid).first()
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
# Language and content filters for displaying in the UI
|
||||||
|
def common_filters(self, allow_show_archived=False):
|
||||||
|
if not allow_show_archived:
|
||||||
|
archived_books = (
|
||||||
|
ub.session.query(ub.ArchivedBook)
|
||||||
|
.filter(ub.ArchivedBook.user_id == int(current_user.id))
|
||||||
|
.filter(ub.ArchivedBook.is_archived == True)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
archived_book_ids = [archived_book.book_id for archived_book in archived_books]
|
||||||
|
archived_filter = Books.id.notin_(archived_book_ids)
|
||||||
|
else:
|
||||||
|
archived_filter = true()
|
||||||
|
|
||||||
|
if current_user.filter_language() != "all":
|
||||||
|
lang_filter = Books.languages.any(Languages.lang_code == current_user.filter_language())
|
||||||
|
else:
|
||||||
|
lang_filter = true()
|
||||||
|
negtags_list = current_user.list_denied_tags()
|
||||||
|
postags_list = current_user.list_allowed_tags()
|
||||||
|
neg_content_tags_filter = false() if negtags_list == [''] else Books.tags.any(Tags.name.in_(negtags_list))
|
||||||
|
pos_content_tags_filter = true() if postags_list == [''] else Books.tags.any(Tags.name.in_(postags_list))
|
||||||
|
if self.config.config_restricted_column:
|
||||||
|
try:
|
||||||
|
pos_cc_list = current_user.allowed_column_value.split(',')
|
||||||
|
pos_content_cc_filter = true() if pos_cc_list == [''] else \
|
||||||
|
getattr(Books, 'custom_column_' + str(self.config.config_restricted_column)). \
|
||||||
|
any(cc_classes[self.config.config_restricted_column].value.in_(pos_cc_list))
|
||||||
|
neg_cc_list = current_user.denied_column_value.split(',')
|
||||||
|
neg_content_cc_filter = false() if neg_cc_list == [''] else \
|
||||||
|
getattr(Books, 'custom_column_' + str(self.config.config_restricted_column)). \
|
||||||
|
any(cc_classes[self.config.config_restricted_column].value.in_(neg_cc_list))
|
||||||
|
except (KeyError, AttributeError):
|
||||||
|
pos_content_cc_filter = false()
|
||||||
|
neg_content_cc_filter = true()
|
||||||
|
log.error(u"Custom Column No.%d is not existing in calibre database",
|
||||||
|
self.config.config_restricted_column)
|
||||||
|
flash(_("Custom Column No.%(column)d is not existing in calibre database",
|
||||||
|
column=self.config.config_restricted_column),
|
||||||
|
category="error")
|
||||||
|
|
||||||
|
else:
|
||||||
|
pos_content_cc_filter = true()
|
||||||
|
neg_content_cc_filter = false()
|
||||||
|
return and_(lang_filter, pos_content_tags_filter, ~neg_content_tags_filter,
|
||||||
|
pos_content_cc_filter, ~neg_content_cc_filter, archived_filter)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_checkbox_sorted(inputlist, state, offset, limit, order):
|
||||||
|
outcome = list()
|
||||||
|
elementlist = {ele.id: ele for ele in inputlist}
|
||||||
|
for entry in state:
|
||||||
|
try:
|
||||||
|
outcome.append(elementlist[entry])
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
del elementlist[entry]
|
||||||
|
for entry in elementlist:
|
||||||
|
outcome.append(elementlist[entry])
|
||||||
|
if order == "asc":
|
||||||
|
outcome.reverse()
|
||||||
|
return outcome[offset:offset + limit]
|
||||||
|
|
||||||
|
# Fill indexpage with all requested data from database
|
||||||
|
def fill_indexpage(self, page, pagesize, database, db_filter, order, *join):
|
||||||
|
return self.fill_indexpage_with_archived_books(page, pagesize, database, db_filter, order, False, *join)
|
||||||
|
|
||||||
|
def fill_indexpage_with_archived_books(self, page, pagesize, database, db_filter, order, allow_show_archived,
|
||||||
|
*join):
|
||||||
|
pagesize = pagesize or self.config.config_books_per_page
|
||||||
|
if current_user.show_detail_random():
|
||||||
|
randm = self.session.query(Books) \
|
||||||
|
.filter(self.common_filters(allow_show_archived)) \
|
||||||
|
.order_by(func.random()) \
|
||||||
|
.limit(self.config.config_random_books).all()
|
||||||
|
else:
|
||||||
|
randm = false()
|
||||||
|
off = int(int(pagesize) * (page - 1))
|
||||||
|
query = self.session.query(database)
|
||||||
|
if len(join) == 3:
|
||||||
|
query = query.outerjoin(join[0], join[1]).outerjoin(join[2])
|
||||||
|
elif len(join) == 2:
|
||||||
|
query = query.outerjoin(join[0], join[1])
|
||||||
|
elif len(join) == 1:
|
||||||
|
query = query.outerjoin(join[0])
|
||||||
|
query = query.filter(db_filter)\
|
||||||
|
.filter(self.common_filters(allow_show_archived))
|
||||||
|
entries = list()
|
||||||
|
pagination = list()
|
||||||
|
try:
|
||||||
|
pagination = Pagination(page, pagesize,
|
||||||
|
len(query.all()))
|
||||||
|
entries = query.order_by(*order).offset(off).limit(pagesize).all()
|
||||||
|
except Exception as ex:
|
||||||
|
log.debug_or_exception(ex)
|
||||||
|
#for book in entries:
|
||||||
|
# book = self.order_authors(book)
|
||||||
|
return entries, randm, pagination
|
||||||
|
|
||||||
|
# Orders all Authors in the list according to authors sort
|
||||||
|
def order_authors(self, entry):
|
||||||
|
sort_authors = entry.author_sort.split('&')
|
||||||
|
authors_ordered = list()
|
||||||
|
error = False
|
||||||
|
ids = [a.id for a in entry.authors]
|
||||||
|
for auth in sort_authors:
|
||||||
|
results = self.session.query(Authors).filter(Authors.sort == auth.lstrip().strip()).all()
|
||||||
|
# ToDo: How to handle not found authorname
|
||||||
|
if not len(results):
|
||||||
|
error = True
|
||||||
|
break
|
||||||
|
for r in results:
|
||||||
|
if r.id in ids:
|
||||||
|
authors_ordered.append(r)
|
||||||
|
if not error:
|
||||||
|
entry.authors = authors_ordered
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def get_typeahead(self, database, query, replace=('', ''), tag_filter=true()):
|
||||||
|
query = query or ''
|
||||||
|
self.session.connection().connection.connection.create_function("lower", 1, lcase)
|
||||||
|
entries = self.session.query(database).filter(tag_filter). \
|
||||||
|
filter(func.lower(database.name).ilike("%" + query + "%")).all()
|
||||||
|
json_dumps = json.dumps([dict(name=r.name.replace(*replace)) for r in entries])
|
||||||
|
return json_dumps
|
||||||
|
|
||||||
|
def check_exists_book(self, authr, title):
|
||||||
|
self.session.connection().connection.connection.create_function("lower", 1, lcase)
|
||||||
|
q = list()
|
||||||
|
authorterms = re.split(r'\s*&\s*', authr)
|
||||||
|
for authorterm in authorterms:
|
||||||
|
q.append(Books.authors.any(func.lower(Authors.name).ilike("%" + authorterm + "%")))
|
||||||
|
|
||||||
|
return self.session.query(Books) \
|
||||||
|
.filter(and_(Books.authors.any(and_(*q)), func.lower(Books.title).ilike("%" + title + "%"))).first()
|
||||||
|
|
||||||
|
def search_query(self, term, *join):
|
||||||
|
term.strip().lower()
|
||||||
|
self.session.connection().connection.connection.create_function("lower", 1, lcase)
|
||||||
|
q = list()
|
||||||
|
authorterms = re.split("[, ]+", term)
|
||||||
|
for authorterm in authorterms:
|
||||||
|
q.append(Books.authors.any(func.lower(Authors.name).ilike("%" + authorterm + "%")))
|
||||||
|
query = self.session.query(Books)
|
||||||
|
if len(join) == 3:
|
||||||
|
query = query.outerjoin(join[0], join[1]).outerjoin(join[2])
|
||||||
|
elif len(join) == 2:
|
||||||
|
query = query.outerjoin(join[0], join[1])
|
||||||
|
elif len(join) == 1:
|
||||||
|
query = query.outerjoin(join[0])
|
||||||
|
return query.filter(self.common_filters(True)).filter(
|
||||||
|
or_(Books.tags.any(func.lower(Tags.name).ilike("%" + term + "%")),
|
||||||
|
Books.series.any(func.lower(Series.name).ilike("%" + term + "%")),
|
||||||
|
Books.authors.any(and_(*q)),
|
||||||
|
Books.publishers.any(func.lower(Publishers.name).ilike("%" + term + "%")),
|
||||||
|
func.lower(Books.title).ilike("%" + term + "%")
|
||||||
|
))
|
||||||
|
|
||||||
|
# read search results from calibre-database and return it (function is used for feed and simple search
|
||||||
|
def get_search_results(self, term, offset=None, order=None, limit=None, *join):
|
||||||
|
order = order or [Books.sort]
|
||||||
|
pagination = None
|
||||||
|
result = self.search_query(term, *join).order_by(*order).all()
|
||||||
|
result_count = len(result)
|
||||||
|
if offset != None and limit != None:
|
||||||
|
offset = int(offset)
|
||||||
|
limit_all = offset + int(limit)
|
||||||
|
pagination = Pagination((offset / (int(limit)) + 1), limit, result_count)
|
||||||
|
else:
|
||||||
|
offset = 0
|
||||||
|
limit_all = result_count
|
||||||
|
|
||||||
|
ub.store_ids(result)
|
||||||
|
return result[offset:limit_all], result_count, pagination
|
||||||
|
|
||||||
|
# Creates for all stored languages a translated speaking name in the array for the UI
|
||||||
|
def speaking_language(self, languages=None):
|
||||||
|
from . import get_locale
|
||||||
|
|
||||||
|
if not languages:
|
||||||
|
languages = self.session.query(Languages) \
|
||||||
|
.join(books_languages_link) \
|
||||||
|
.join(Books) \
|
||||||
|
.filter(self.common_filters()) \
|
||||||
|
.group_by(text('books_languages_link.lang_code')).all()
|
||||||
|
for lang in languages:
|
||||||
|
try:
|
||||||
|
cur_l = LC.parse(lang.lang_code)
|
||||||
|
lang.name = cur_l.get_language_name(get_locale())
|
||||||
|
except UnknownLocaleError:
|
||||||
|
lang.name = _(isoLanguages.get(part3=lang.lang_code).name)
|
||||||
|
return languages
|
||||||
|
|
||||||
|
def update_title_sort(self, config, conn=None):
|
||||||
|
# user defined sort function for calibre databases (Series, etc.)
|
||||||
|
def _title_sort(title):
|
||||||
|
# calibre sort stuff
|
||||||
|
title_pat = re.compile(config.config_title_regex, re.IGNORECASE)
|
||||||
|
match = title_pat.search(title)
|
||||||
|
if match:
|
||||||
|
prep = match.group(1)
|
||||||
|
title = title[len(prep):] + ', ' + prep
|
||||||
|
return title.strip()
|
||||||
|
|
||||||
|
conn = conn or self.session.connection().connection.connection
|
||||||
|
conn.create_function("title_sort", 1, _title_sort)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def dispose(cls):
|
||||||
|
# global session
|
||||||
|
|
||||||
|
for inst in cls.instances:
|
||||||
|
old_session = inst.session
|
||||||
|
inst.session = None
|
||||||
|
if old_session:
|
||||||
|
try:
|
||||||
|
old_session.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if old_session.bind:
|
||||||
|
try:
|
||||||
|
old_session.bind.dispose()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for attr in list(Books.__dict__.keys()):
|
||||||
|
if attr.startswith("custom_column_"):
|
||||||
|
setattr(Books, attr, None)
|
||||||
|
|
||||||
|
for db_class in cc_classes.values():
|
||||||
|
Base.metadata.remove(db_class.__table__)
|
||||||
|
cc_classes.clear()
|
||||||
|
|
||||||
|
for table in reversed(Base.metadata.sorted_tables):
|
||||||
|
name = table.key
|
||||||
|
if name.startswith("custom_column_") or name.startswith("books_custom_column_"):
|
||||||
|
if table is not None:
|
||||||
|
Base.metadata.remove(table)
|
||||||
|
|
||||||
|
def reconnect_db(self, config, app_db_path):
|
||||||
|
self.dispose()
|
||||||
|
self.engine.dispose()
|
||||||
|
self.setup_db(config, app_db_path)
|
||||||
|
|
||||||
|
|
||||||
global session
|
def lcase(s):
|
||||||
Session = scoped_session(sessionmaker(autocommit=False,
|
try:
|
||||||
autoflush=False,
|
return unidecode.unidecode(s.lower())
|
||||||
bind=engine))
|
except Exception as ex:
|
||||||
session = Session()
|
log = logger.create()
|
||||||
return True
|
log.debug_or_exception(ex)
|
||||||
|
return s.lower()
|
||||||
|
|
||||||
def dispose():
|
|
||||||
global session
|
|
||||||
|
|
||||||
old_session = session
|
|
||||||
session = None
|
|
||||||
if old_session:
|
|
||||||
try: old_session.close()
|
|
||||||
except: pass
|
|
||||||
if old_session.bind:
|
|
||||||
try: old_session.bind.dispose()
|
|
||||||
except Exception: pass
|
|
||||||
|
|
||||||
for attr in list(Books.__dict__.keys()):
|
|
||||||
if attr.startswith("custom_column_"):
|
|
||||||
setattr(Books, attr, None)
|
|
||||||
|
|
||||||
for db_class in cc_classes.values():
|
|
||||||
Base.metadata.remove(db_class.__table__)
|
|
||||||
cc_classes.clear()
|
|
||||||
|
|
||||||
for table in reversed(Base.metadata.sorted_tables):
|
|
||||||
name = table.key
|
|
||||||
if name.startswith("custom_column_") or name.startswith("books_custom_column_"):
|
|
||||||
if table is not None:
|
|
||||||
Base.metadata.remove(table)
|
|
||||||
|
|
||||||
def reconnect_db(config):
|
|
||||||
session.close()
|
|
||||||
engine.dispose()
|
|
||||||
setup_db(config)
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2012-2019 cervinko, idalin, SiphonSquirrel, ouzklcn, akushsky,
|
||||||
|
# OzzieIsaacs, bodybybuddha, jkrehm, matthazinski, janeczku
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import glob
|
||||||
|
import zipfile
|
||||||
|
import json
|
||||||
|
from io import BytesIO
|
||||||
|
try:
|
||||||
|
from StringIO import StringIO
|
||||||
|
except ImportError:
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from flask import send_file
|
||||||
|
|
||||||
|
from . import logger, config
|
||||||
|
from .about import collect_stats
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
def assemble_logfiles(file_name):
|
||||||
|
log_list = sorted(glob.glob(file_name + '*'), reverse=True)
|
||||||
|
wfd = StringIO()
|
||||||
|
for f in log_list:
|
||||||
|
with open(f, 'r') as fd:
|
||||||
|
shutil.copyfileobj(fd, wfd)
|
||||||
|
wfd.seek(0)
|
||||||
|
return send_file(wfd,
|
||||||
|
as_attachment=True,
|
||||||
|
attachment_filename=os.path.basename(file_name))
|
||||||
|
|
||||||
|
def send_debug():
|
||||||
|
file_list = glob.glob(logger.get_logfile(config.config_logfile) + '*')
|
||||||
|
file_list.extend(glob.glob(logger.get_accesslogfile(config.config_access_logfile) + '*'))
|
||||||
|
for element in [logger.LOG_TO_STDOUT, logger.LOG_TO_STDERR]:
|
||||||
|
if element in file_list:
|
||||||
|
file_list.remove(element)
|
||||||
|
memory_zip = BytesIO()
|
||||||
|
with zipfile.ZipFile(memory_zip, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr('settings.txt', json.dumps(config.toDict()))
|
||||||
|
zf.writestr('libs.txt', json.dumps(collect_stats()))
|
||||||
|
for fp in file_list:
|
||||||
|
zf.write(fp, os.path.basename(fp))
|
||||||
|
memory_zip.seek(0)
|
||||||
|
return send_file(memory_zip,
|
||||||
|
as_attachment=True,
|
||||||
|
attachment_filename="Calibre-Web-debug-pack.zip")
|
||||||
@@ -22,9 +22,11 @@ import zipfile
|
|||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
from . import isoLanguages
|
from . import isoLanguages
|
||||||
|
from .helper import split_authors
|
||||||
from .constants import BookMeta
|
from .constants import BookMeta
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def extractCover(zipFile, coverFile, coverpath, tmp_file_name):
|
def extractCover(zipFile, coverFile, coverpath, tmp_file_name):
|
||||||
if coverFile is None:
|
if coverFile is None:
|
||||||
return None
|
return None
|
||||||
@@ -64,11 +66,11 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
|
|||||||
tmp = p.xpath('dc:%s/text()' % s, namespaces=ns)
|
tmp = p.xpath('dc:%s/text()' % s, namespaces=ns)
|
||||||
if len(tmp) > 0:
|
if len(tmp) > 0:
|
||||||
if s == 'creator':
|
if s == 'creator':
|
||||||
epub_metadata[s] = ' & '.join(p.xpath('dc:%s/text()' % s, namespaces=ns))
|
epub_metadata[s] = ' & '.join(split_authors(tmp))
|
||||||
elif s == 'subject':
|
elif s == 'subject':
|
||||||
epub_metadata[s] = ', '.join(p.xpath('dc:%s/text()' % s, namespaces=ns))
|
epub_metadata[s] = ', '.join(tmp)
|
||||||
else:
|
else:
|
||||||
epub_metadata[s] = p.xpath('dc:%s/text()' % s, namespaces=ns)[0]
|
epub_metadata[s] = tmp[0]
|
||||||
else:
|
else:
|
||||||
epub_metadata[s] = u'Unknown'
|
epub_metadata[s] = u'Unknown'
|
||||||
|
|
||||||
@@ -82,29 +84,32 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
|
|||||||
else:
|
else:
|
||||||
epub_metadata['description'] = ""
|
epub_metadata['description'] = ""
|
||||||
|
|
||||||
if epub_metadata['language'] == u'Unknown':
|
lang = epub_metadata['language'].split('-', 1)[0].lower()
|
||||||
epub_metadata['language'] = ""
|
epub_metadata['language'] = isoLanguages.get_lang3(lang)
|
||||||
else:
|
|
||||||
lang = epub_metadata['language'].split('-', 1)[0].lower()
|
|
||||||
if len(lang) == 2:
|
|
||||||
epub_metadata['language'] = isoLanguages.get(part1=lang).name
|
|
||||||
elif len(lang) == 3:
|
|
||||||
epub_metadata['language'] = isoLanguages.get(part3=lang).name
|
|
||||||
else:
|
|
||||||
epub_metadata['language'] = ""
|
|
||||||
|
|
||||||
series = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series']/@content", namespaces=ns)
|
epub_metadata = parse_epbub_series(ns, tree, epub_metadata)
|
||||||
if len(series) > 0:
|
|
||||||
epub_metadata['series'] = series[0]
|
|
||||||
else:
|
|
||||||
epub_metadata['series'] = ''
|
|
||||||
|
|
||||||
series_id = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series_index']/@content", namespaces=ns)
|
coverfile = parse_ebpub_cover(ns, tree, epubZip, coverpath, tmp_file_path)
|
||||||
if len(series_id) > 0:
|
|
||||||
epub_metadata['series_id'] = series_id[0]
|
|
||||||
else:
|
|
||||||
epub_metadata['series_id'] = '1'
|
|
||||||
|
|
||||||
|
if not epub_metadata['title']:
|
||||||
|
title = original_file_name
|
||||||
|
else:
|
||||||
|
title = epub_metadata['title']
|
||||||
|
|
||||||
|
return BookMeta(
|
||||||
|
file_path=tmp_file_path,
|
||||||
|
extension=original_file_extension,
|
||||||
|
title=title.encode('utf-8').decode('utf-8'),
|
||||||
|
author=epub_metadata['creator'].encode('utf-8').decode('utf-8'),
|
||||||
|
cover=coverfile,
|
||||||
|
description=epub_metadata['description'],
|
||||||
|
tags=epub_metadata['subject'].encode('utf-8').decode('utf-8'),
|
||||||
|
series=epub_metadata['series'].encode('utf-8').decode('utf-8'),
|
||||||
|
series_id=epub_metadata['series_id'].encode('utf-8').decode('utf-8'),
|
||||||
|
languages=epub_metadata['language'],
|
||||||
|
publisher="")
|
||||||
|
|
||||||
|
def parse_ebpub_cover(ns, tree, epubZip, coverpath, tmp_file_path):
|
||||||
coversection = tree.xpath("/pkg:package/pkg:manifest/pkg:item[@id='cover-image']/@href", namespaces=ns)
|
coversection = tree.xpath("/pkg:package/pkg:manifest/pkg:item[@id='cover-image']/@href", namespaces=ns)
|
||||||
coverfile = None
|
coverfile = None
|
||||||
if len(coversection) > 0:
|
if len(coversection) > 0:
|
||||||
@@ -122,25 +127,28 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension):
|
|||||||
markupTree = etree.fromstring(markup)
|
markupTree = etree.fromstring(markup)
|
||||||
# no matter xhtml or html with no namespace
|
# no matter xhtml or html with no namespace
|
||||||
imgsrc = markupTree.xpath("//*[local-name() = 'img']/@src")
|
imgsrc = markupTree.xpath("//*[local-name() = 'img']/@src")
|
||||||
# imgsrc maybe startwith "../"" so fullpath join then relpath to cwd
|
# Alternative image source
|
||||||
filename = os.path.relpath(os.path.join(os.path.dirname(os.path.join(coverpath, coversection[0])), imgsrc[0]))
|
if not len(imgsrc):
|
||||||
coverfile = extractCover(epubZip, filename, "", tmp_file_path)
|
imgsrc = markupTree.xpath("//attribute::*[contains(local-name(), 'href')]")
|
||||||
|
if len(imgsrc):
|
||||||
|
# imgsrc maybe startwith "../"" so fullpath join then relpath to cwd
|
||||||
|
filename = os.path.relpath(os.path.join(os.path.dirname(os.path.join(coverpath, coversection[0])),
|
||||||
|
imgsrc[0]))
|
||||||
|
coverfile = extractCover(epubZip, filename, "", tmp_file_path)
|
||||||
else:
|
else:
|
||||||
coverfile = extractCover(epubZip, coversection[0], coverpath, tmp_file_path)
|
coverfile = extractCover(epubZip, coversection[0], coverpath, tmp_file_path)
|
||||||
|
return coverfile
|
||||||
|
|
||||||
if not epub_metadata['title']:
|
def parse_epbub_series(ns, tree, epub_metadata):
|
||||||
title = original_file_name
|
series = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series']/@content", namespaces=ns)
|
||||||
|
if len(series) > 0:
|
||||||
|
epub_metadata['series'] = series[0]
|
||||||
else:
|
else:
|
||||||
title = epub_metadata['title']
|
epub_metadata['series'] = ''
|
||||||
|
|
||||||
return BookMeta(
|
series_id = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series_index']/@content", namespaces=ns)
|
||||||
file_path=tmp_file_path,
|
if len(series_id) > 0:
|
||||||
extension=original_file_extension,
|
epub_metadata['series_id'] = series_id[0]
|
||||||
title=title.encode('utf-8').decode('utf-8'),
|
else:
|
||||||
author=epub_metadata['creator'].encode('utf-8').decode('utf-8'),
|
epub_metadata['series_id'] = '1'
|
||||||
cover=coverfile,
|
return epub_metadata
|
||||||
description=epub_metadata['description'],
|
|
||||||
tags=epub_metadata['subject'].encode('utf-8').decode('utf-8'),
|
|
||||||
series=epub_metadata['series'].encode('utf-8').decode('utf-8'),
|
|
||||||
series_id=epub_metadata['series_id'].encode('utf-8').decode('utf-8'),
|
|
||||||
languages=epub_metadata['language'])
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2018-2020 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
from flask import render_template
|
||||||
|
from werkzeug.exceptions import default_exceptions
|
||||||
|
try:
|
||||||
|
from werkzeug.exceptions import FailedDependency
|
||||||
|
except ImportError:
|
||||||
|
from werkzeug.exceptions import UnprocessableEntity as FailedDependency
|
||||||
|
|
||||||
|
from . import config, app, logger, services
|
||||||
|
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
# custom error page
|
||||||
|
def error_http(error):
|
||||||
|
return render_template('http_error.html',
|
||||||
|
error_code="Error {0}".format(error.code),
|
||||||
|
error_name=error.name,
|
||||||
|
issue=False,
|
||||||
|
instance=config.config_calibre_web_title
|
||||||
|
), error.code
|
||||||
|
|
||||||
|
|
||||||
|
def internal_error(error):
|
||||||
|
return render_template('http_error.html',
|
||||||
|
error_code="Internal Server Error",
|
||||||
|
error_name=str(error),
|
||||||
|
issue=True,
|
||||||
|
error_stack=traceback.format_exc().split("\n"),
|
||||||
|
instance=config.config_calibre_web_title
|
||||||
|
), 500
|
||||||
|
|
||||||
|
def init_errorhandler():
|
||||||
|
# http error handling
|
||||||
|
for ex in default_exceptions:
|
||||||
|
if ex < 500:
|
||||||
|
app.register_error_handler(ex, error_http)
|
||||||
|
elif ex == 500:
|
||||||
|
app.register_error_handler(ex, internal_error)
|
||||||
|
|
||||||
|
|
||||||
|
if services.ldap:
|
||||||
|
# Only way of catching the LDAPException upon logging in with LDAP server down
|
||||||
|
@app.errorhandler(services.ldap.LDAPException)
|
||||||
|
# pylint: disable=unused-variable
|
||||||
|
def handle_exception(e):
|
||||||
|
log.debug('LDAP server not accessible while trying to login to opds feed')
|
||||||
|
return error_http(FailedDependency())
|
||||||
|
|
||||||
@@ -29,52 +29,53 @@ def get_fb2_info(tmp_file_path, original_file_extension):
|
|||||||
'l': 'http://www.w3.org/1999/xlink',
|
'l': 'http://www.w3.org/1999/xlink',
|
||||||
}
|
}
|
||||||
|
|
||||||
fb2_file = open(tmp_file_path)
|
fb2_file = open(tmp_file_path, encoding="utf-8")
|
||||||
tree = etree.fromstring(fb2_file.read())
|
tree = etree.fromstring(fb2_file.read().encode())
|
||||||
|
|
||||||
authors = tree.xpath('/fb:FictionBook/fb:description/fb:title-info/fb:author', namespaces=ns)
|
authors = tree.xpath('/fb:FictionBook/fb:description/fb:title-info/fb:author', namespaces=ns)
|
||||||
|
|
||||||
def get_author(element):
|
def get_author(element):
|
||||||
last_name = element.xpath('fb:last-name/text()', namespaces=ns)
|
last_name = element.xpath('fb:last-name/text()', namespaces=ns)
|
||||||
if len(last_name):
|
if len(last_name):
|
||||||
last_name = last_name[0].encode('utf-8')
|
last_name = last_name[0]
|
||||||
else:
|
else:
|
||||||
last_name = u''
|
last_name = u''
|
||||||
middle_name = element.xpath('fb:middle-name/text()', namespaces=ns)
|
middle_name = element.xpath('fb:middle-name/text()', namespaces=ns)
|
||||||
if len(middle_name):
|
if len(middle_name):
|
||||||
middle_name = middle_name[0].encode('utf-8')
|
middle_name = middle_name[0]
|
||||||
else:
|
else:
|
||||||
middle_name = u''
|
middle_name = u''
|
||||||
first_name = element.xpath('fb:first-name/text()', namespaces=ns)
|
first_name = element.xpath('fb:first-name/text()', namespaces=ns)
|
||||||
if len(first_name):
|
if len(first_name):
|
||||||
first_name = first_name[0].encode('utf-8')
|
first_name = first_name[0]
|
||||||
else:
|
else:
|
||||||
first_name = u''
|
first_name = u''
|
||||||
return (first_name.decode('utf-8') + u' '
|
return (first_name + u' '
|
||||||
+ middle_name.decode('utf-8') + u' '
|
+ middle_name + u' '
|
||||||
+ last_name.decode('utf-8')).encode('utf-8')
|
+ last_name)
|
||||||
|
|
||||||
author = str(", ".join(map(get_author, authors)))
|
author = str(", ".join(map(get_author, authors)))
|
||||||
|
|
||||||
title = tree.xpath('/fb:FictionBook/fb:description/fb:title-info/fb:book-title/text()', namespaces=ns)
|
title = tree.xpath('/fb:FictionBook/fb:description/fb:title-info/fb:book-title/text()', namespaces=ns)
|
||||||
if len(title):
|
if len(title):
|
||||||
title = str(title[0].encode('utf-8'))
|
title = str(title[0])
|
||||||
else:
|
else:
|
||||||
title = u''
|
title = u''
|
||||||
description = tree.xpath('/fb:FictionBook/fb:description/fb:publish-info/fb:book-name/text()', namespaces=ns)
|
description = tree.xpath('/fb:FictionBook/fb:description/fb:publish-info/fb:book-name/text()', namespaces=ns)
|
||||||
if len(description):
|
if len(description):
|
||||||
description = str(description[0].encode('utf-8'))
|
description = str(description[0])
|
||||||
else:
|
else:
|
||||||
description = u''
|
description = u''
|
||||||
|
|
||||||
return BookMeta(
|
return BookMeta(
|
||||||
file_path=tmp_file_path,
|
file_path=tmp_file_path,
|
||||||
extension=original_file_extension,
|
extension=original_file_extension,
|
||||||
title=title.decode('utf-8'),
|
title=title,
|
||||||
author=author.decode('utf-8'),
|
author=author,
|
||||||
cover=None,
|
cover=None,
|
||||||
description=description.decode('utf-8'),
|
description=description,
|
||||||
tags="",
|
tags="",
|
||||||
series="",
|
series="",
|
||||||
series_id="",
|
series_id="",
|
||||||
languages="")
|
languages="",
|
||||||
|
publisher="")
|
||||||
|
|||||||
@@ -34,24 +34,23 @@ from flask import Blueprint, flash, request, redirect, url_for, abort
|
|||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_login import login_required
|
from flask_login import login_required
|
||||||
|
|
||||||
|
from . import logger, gdriveutils, config, ub, calibre_db
|
||||||
|
from .admin import admin_required
|
||||||
|
|
||||||
|
gdrive = Blueprint('gdrive', __name__, url_prefix='/gdrive')
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from googleapiclient.errors import HttpError
|
from googleapiclient.errors import HttpError
|
||||||
except ImportError:
|
except ImportError as err:
|
||||||
pass
|
log.debug("Cannot import googleapiclient, using GDrive will not work: %s", err)
|
||||||
|
|
||||||
from . import logger, gdriveutils, config, db
|
|
||||||
from .web import admin_required
|
|
||||||
|
|
||||||
|
|
||||||
gdrive = Blueprint('gdrive', __name__)
|
|
||||||
log = logger.create()
|
|
||||||
|
|
||||||
current_milli_time = lambda: int(round(time() * 1000))
|
current_milli_time = lambda: int(round(time() * 1000))
|
||||||
|
|
||||||
gdrive_watch_callback_token = 'target=calibreweb-watch_files'
|
gdrive_watch_callback_token = 'target=calibreweb-watch_files' #nosec
|
||||||
|
|
||||||
|
|
||||||
@gdrive.route("/gdrive/authenticate")
|
@gdrive.route("/authenticate")
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def authenticate_google_drive():
|
def authenticate_google_drive():
|
||||||
@@ -64,7 +63,7 @@ def authenticate_google_drive():
|
|||||||
return redirect(authUrl)
|
return redirect(authUrl)
|
||||||
|
|
||||||
|
|
||||||
@gdrive.route("/gdrive/callback")
|
@gdrive.route("/callback")
|
||||||
def google_drive_callback():
|
def google_drive_callback():
|
||||||
auth_code = request.args.get('code')
|
auth_code = request.args.get('code')
|
||||||
if not auth_code:
|
if not auth_code:
|
||||||
@@ -73,41 +72,37 @@ def google_drive_callback():
|
|||||||
credentials = gdriveutils.Gauth.Instance().auth.flow.step2_exchange(auth_code)
|
credentials = gdriveutils.Gauth.Instance().auth.flow.step2_exchange(auth_code)
|
||||||
with open(gdriveutils.CREDENTIALS, 'w') as f:
|
with open(gdriveutils.CREDENTIALS, 'w') as f:
|
||||||
f.write(credentials.to_json())
|
f.write(credentials.to_json())
|
||||||
except ValueError as error:
|
except (ValueError, AttributeError) as error:
|
||||||
log.error(error)
|
log.error(error)
|
||||||
return redirect(url_for('admin.configuration'))
|
return redirect(url_for('admin.configuration'))
|
||||||
|
|
||||||
|
|
||||||
@gdrive.route("/gdrive/watch/subscribe")
|
@gdrive.route("/watch/subscribe")
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def watch_gdrive():
|
def watch_gdrive():
|
||||||
if not config.config_google_drive_watch_changes_response:
|
if not config.config_google_drive_watch_changes_response:
|
||||||
with open(gdriveutils.CLIENT_SECRETS, 'r') as settings:
|
with open(gdriveutils.CLIENT_SECRETS, 'r') as settings:
|
||||||
filedata = json.load(settings)
|
filedata = json.load(settings)
|
||||||
if filedata['web']['redirect_uris'][0].endswith('/'):
|
address = filedata['web']['redirect_uris'][0].rstrip('/').replace('/gdrive/callback', '/gdrive/watch/callback')
|
||||||
filedata['web']['redirect_uris'][0] = filedata['web']['redirect_uris'][0][:-((len('/gdrive/callback')+1))]
|
|
||||||
else:
|
|
||||||
filedata['web']['redirect_uris'][0] = filedata['web']['redirect_uris'][0][:-(len('/gdrive/callback'))]
|
|
||||||
address = '%s/gdrive/watch/callback' % filedata['web']['redirect_uris'][0]
|
|
||||||
notification_id = str(uuid4())
|
notification_id = str(uuid4())
|
||||||
try:
|
try:
|
||||||
result = gdriveutils.watchChange(gdriveutils.Gdrive.Instance().drive, notification_id,
|
result = gdriveutils.watchChange(gdriveutils.Gdrive.Instance().drive, notification_id,
|
||||||
'web_hook', address, gdrive_watch_callback_token, current_milli_time() + 604800*1000)
|
'web_hook', address, gdrive_watch_callback_token, current_milli_time() + 604800*1000)
|
||||||
config.config_google_drive_watch_changes_response = json.dumps(result)
|
config.config_google_drive_watch_changes_response = result
|
||||||
# after save(), config_google_drive_watch_changes_response will be a json object, not string
|
|
||||||
config.save()
|
config.save()
|
||||||
except HttpError as e:
|
except HttpError as e:
|
||||||
reason=json.loads(e.content)['error']['errors'][0]
|
reason=json.loads(e.content)['error']['errors'][0]
|
||||||
if reason['reason'] == u'push.webhookUrlUnauthorized':
|
if reason['reason'] == u'push.webhookUrlUnauthorized':
|
||||||
flash(_(u'Callback domain is not verified, please follow steps to verify domain in google developer console'), category="error")
|
flash(_(u'Callback domain is not verified, '
|
||||||
|
u'please follow steps to verify domain in google developer console'), category="error")
|
||||||
else:
|
else:
|
||||||
flash(reason['message'], category="error")
|
flash(reason['message'], category="error")
|
||||||
|
|
||||||
return redirect(url_for('admin.configuration'))
|
return redirect(url_for('admin.configuration'))
|
||||||
|
|
||||||
|
|
||||||
@gdrive.route("/gdrive/watch/revoke")
|
@gdrive.route("/watch/revoke")
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def revoke_watch_gdrive():
|
def revoke_watch_gdrive():
|
||||||
@@ -118,44 +113,48 @@ def revoke_watch_gdrive():
|
|||||||
last_watch_response['resourceId'])
|
last_watch_response['resourceId'])
|
||||||
except HttpError:
|
except HttpError:
|
||||||
pass
|
pass
|
||||||
config.config_google_drive_watch_changes_response = None
|
config.config_google_drive_watch_changes_response = {}
|
||||||
config.save()
|
config.save()
|
||||||
return redirect(url_for('admin.configuration'))
|
return redirect(url_for('admin.configuration'))
|
||||||
|
|
||||||
|
|
||||||
@gdrive.route("/gdrive/watch/callback", methods=['GET', 'POST'])
|
@gdrive.route("/watch/callback", methods=['GET', 'POST'])
|
||||||
def on_received_watch_confirmation():
|
def on_received_watch_confirmation():
|
||||||
|
if not config.config_google_drive_watch_changes_response:
|
||||||
|
return ''
|
||||||
|
if request.headers.get('X-Goog-Channel-Token') != gdrive_watch_callback_token \
|
||||||
|
or request.headers.get('X-Goog-Resource-State') != 'change' \
|
||||||
|
or not request.data:
|
||||||
|
return ''
|
||||||
|
|
||||||
log.debug('%r', request.headers)
|
log.debug('%r', request.headers)
|
||||||
if request.headers.get('X-Goog-Channel-Token') == gdrive_watch_callback_token \
|
log.debug('%r', request.data)
|
||||||
and request.headers.get('X-Goog-Resource-State') == 'change' \
|
log.info('Change received from gdrive')
|
||||||
and request.data:
|
|
||||||
|
|
||||||
data = request.data
|
try:
|
||||||
|
j = json.loads(request.data)
|
||||||
|
log.info('Getting change details')
|
||||||
|
response = gdriveutils.getChangeById(gdriveutils.Gdrive.Instance().drive, j['id'])
|
||||||
|
log.debug('%r', response)
|
||||||
|
if response:
|
||||||
|
if sys.version_info < (3, 0):
|
||||||
|
dbpath = os.path.join(config.config_calibre_dir, "metadata.db")
|
||||||
|
else:
|
||||||
|
dbpath = os.path.join(config.config_calibre_dir, "metadata.db").encode()
|
||||||
|
if not response['deleted'] and response['file']['title'] == 'metadata.db' \
|
||||||
|
and response['file']['md5Checksum'] != hashlib.md5(dbpath): # nosec
|
||||||
|
tmp_dir = os.path.join(tempfile.gettempdir(), 'calibre_web')
|
||||||
|
if not os.path.isdir(tmp_dir):
|
||||||
|
os.mkdir(tmp_dir)
|
||||||
|
|
||||||
def updateMetaData():
|
log.info('Database file updated')
|
||||||
log.info('Change received from gdrive')
|
copyfile(dbpath, os.path.join(tmp_dir, "metadata.db_" + str(current_milli_time())))
|
||||||
log.debug('%r', data)
|
log.info('Backing up existing and downloading updated metadata.db')
|
||||||
try:
|
gdriveutils.downloadFile(None, "metadata.db", os.path.join(tmp_dir, "tmp_metadata.db"))
|
||||||
j = json.loads(data)
|
log.info('Setting up new DB')
|
||||||
log.info('Getting change details')
|
# prevent error on windows, as os.rename does on existing files, also allow cross hdd move
|
||||||
response = gdriveutils.getChangeById(gdriveutils.Gdrive.Instance().drive, j['id'])
|
move(os.path.join(tmp_dir, "tmp_metadata.db"), dbpath)
|
||||||
log.debug('%r', response)
|
calibre_db.reconnect_db(config, ub.app_DB_path)
|
||||||
if response:
|
except Exception as ex:
|
||||||
if sys.version_info < (3, 0):
|
log.debug_or_exception(ex)
|
||||||
dbpath = os.path.join(config.config_calibre_dir, "metadata.db")
|
|
||||||
else:
|
|
||||||
dbpath = os.path.join(config.config_calibre_dir, "metadata.db").encode()
|
|
||||||
if not response['deleted'] and response['file']['title'] == 'metadata.db' and response['file']['md5Checksum'] != hashlib.md5(dbpath):
|
|
||||||
tmpDir = tempfile.gettempdir()
|
|
||||||
log.info('Database file updated')
|
|
||||||
copyfile(dbpath, os.path.join(tmpDir, "metadata.db_" + str(current_milli_time())))
|
|
||||||
log.info('Backing up existing and downloading updated metadata.db')
|
|
||||||
gdriveutils.downloadFile(None, "metadata.db", os.path.join(tmpDir, "tmp_metadata.db"))
|
|
||||||
log.info('Setting up new DB')
|
|
||||||
# prevent error on windows, as os.rename does on exisiting files
|
|
||||||
move(os.path.join(tmpDir, "tmp_metadata.db"), dbpath)
|
|
||||||
db.setup_db(config)
|
|
||||||
except Exception as e:
|
|
||||||
log.exception(e)
|
|
||||||
updateMetaData()
|
|
||||||
return ''
|
return ''
|
||||||
|
|||||||
@@ -20,22 +20,41 @@ from __future__ import division, print_function, unicode_literals
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
|
import chardet
|
||||||
|
import ssl
|
||||||
|
|
||||||
from flask import Response, stream_with_context
|
from flask import Response, stream_with_context
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy import Column, UniqueConstraint
|
from sqlalchemy import Column, UniqueConstraint
|
||||||
from sqlalchemy import String, Integer
|
from sqlalchemy import String, Integer
|
||||||
from sqlalchemy.orm import sessionmaker, scoped_session
|
from sqlalchemy.orm import sessionmaker, scoped_session
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
try:
|
||||||
|
# Compatibility with sqlalchemy 2.0
|
||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
except ImportError:
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.exc import OperationalError, InvalidRequestError
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from pydrive.auth import GoogleAuth
|
|
||||||
from pydrive.drive import GoogleDrive
|
|
||||||
from pydrive.auth import RefreshError
|
|
||||||
from apiclient import errors
|
from apiclient import errors
|
||||||
|
from httplib2 import ServerNotFoundError
|
||||||
|
importError = None
|
||||||
gdrive_support = True
|
gdrive_support = True
|
||||||
except ImportError:
|
except ImportError as e:
|
||||||
|
importError = e
|
||||||
gdrive_support = False
|
gdrive_support = False
|
||||||
|
try:
|
||||||
|
from pydrive2.auth import GoogleAuth
|
||||||
|
from pydrive2.drive import GoogleDrive
|
||||||
|
from pydrive2.auth import RefreshError
|
||||||
|
except ImportError as err:
|
||||||
|
try:
|
||||||
|
from pydrive.auth import GoogleAuth
|
||||||
|
from pydrive.drive import GoogleDrive
|
||||||
|
from pydrive.auth import RefreshError
|
||||||
|
except ImportError as err:
|
||||||
|
importError = err
|
||||||
|
gdrive_support = False
|
||||||
|
|
||||||
from . import logger, cli, config
|
from . import logger, cli, config
|
||||||
from .constants import CONFIG_DIR as _CONFIG_DIR
|
from .constants import CONFIG_DIR as _CONFIG_DIR
|
||||||
@@ -50,6 +69,8 @@ if gdrive_support:
|
|||||||
logger.get('googleapiclient.discovery_cache').setLevel(logger.logging.ERROR)
|
logger.get('googleapiclient.discovery_cache').setLevel(logger.logging.ERROR)
|
||||||
if not logger.is_debug_enabled():
|
if not logger.is_debug_enabled():
|
||||||
logger.get('googleapiclient.discovery').setLevel(logger.logging.ERROR)
|
logger.get('googleapiclient.discovery').setLevel(logger.logging.ERROR)
|
||||||
|
else:
|
||||||
|
log.debug("Cannot import pydrive,httplib2, using gdrive will not work: %s", importError)
|
||||||
|
|
||||||
|
|
||||||
class Singleton:
|
class Singleton:
|
||||||
@@ -83,7 +104,7 @@ class Singleton:
|
|||||||
except AttributeError:
|
except AttributeError:
|
||||||
self._instance = self._decorated()
|
self._instance = self._decorated()
|
||||||
return self._instance
|
return self._instance
|
||||||
except ImportError as e:
|
except (ImportError, NameError) as e:
|
||||||
log.debug(e)
|
log.debug(e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -97,7 +118,11 @@ class Singleton:
|
|||||||
@Singleton
|
@Singleton
|
||||||
class Gauth:
|
class Gauth:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.auth = GoogleAuth(settings_file=SETTINGS_YAML)
|
try:
|
||||||
|
self.auth = GoogleAuth(settings_file=SETTINGS_YAML)
|
||||||
|
except NameError as error:
|
||||||
|
log.error(error)
|
||||||
|
self.auth = None
|
||||||
|
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -177,8 +202,8 @@ def getDrive(drive=None, gauth=None):
|
|||||||
gauth.Refresh()
|
gauth.Refresh()
|
||||||
except RefreshError as e:
|
except RefreshError as e:
|
||||||
log.error("Google Drive error: %s", e)
|
log.error("Google Drive error: %s", e)
|
||||||
except Exception as e:
|
except Exception as ex:
|
||||||
log.exception(e)
|
log.debug_or_exception(ex)
|
||||||
else:
|
else:
|
||||||
# Initialize the saved creds
|
# Initialize the saved creds
|
||||||
gauth.Authorize()
|
gauth.Authorize()
|
||||||
@@ -192,14 +217,18 @@ def getDrive(drive=None, gauth=None):
|
|||||||
return drive
|
return drive
|
||||||
|
|
||||||
def listRootFolders():
|
def listRootFolders():
|
||||||
drive = getDrive(Gdrive.Instance().drive)
|
try:
|
||||||
folder = "'root' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false"
|
drive = getDrive(Gdrive.Instance().drive)
|
||||||
fileList = drive.ListFile({'q': folder}).GetList()
|
folder = "'root' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false"
|
||||||
|
fileList = drive.ListFile({'q': folder}).GetList()
|
||||||
|
except (ServerNotFoundError, ssl.SSLError, RefreshError) as e:
|
||||||
|
log.info("GDrive Error %s" % e)
|
||||||
|
fileList = []
|
||||||
return fileList
|
return fileList
|
||||||
|
|
||||||
|
|
||||||
def getEbooksFolder(drive):
|
def getEbooksFolder(drive):
|
||||||
return getFolderInFolder('root',config.config_google_drive_folder,drive)
|
return getFolderInFolder('root', config.config_google_drive_folder, drive)
|
||||||
|
|
||||||
|
|
||||||
def getFolderInFolder(parentId, folderName, drive):
|
def getFolderInFolder(parentId, folderName, drive):
|
||||||
@@ -228,8 +257,13 @@ def getEbooksFolderId(drive=None):
|
|||||||
log.error('Error gDrive, root ID not found')
|
log.error('Error gDrive, root ID not found')
|
||||||
gDriveId.path = '/'
|
gDriveId.path = '/'
|
||||||
session.merge(gDriveId)
|
session.merge(gDriveId)
|
||||||
session.commit()
|
try:
|
||||||
return
|
session.commit()
|
||||||
|
except OperationalError as ex:
|
||||||
|
log.error("gdrive.db DB is not Writeable")
|
||||||
|
log.debug('Database error: %s', ex)
|
||||||
|
session.rollback()
|
||||||
|
return gDriveId.gdrive_id
|
||||||
|
|
||||||
|
|
||||||
def getFile(pathId, fileName, drive):
|
def getFile(pathId, fileName, drive):
|
||||||
@@ -243,37 +277,42 @@ def getFile(pathId, fileName, drive):
|
|||||||
|
|
||||||
def getFolderId(path, drive):
|
def getFolderId(path, drive):
|
||||||
# drive = getDrive(drive)
|
# drive = getDrive(drive)
|
||||||
currentFolderId = getEbooksFolderId(drive)
|
try:
|
||||||
sqlCheckPath = path if path[-1] == '/' else path + '/'
|
currentFolderId = getEbooksFolderId(drive)
|
||||||
storedPathName = session.query(GdriveId).filter(GdriveId.path == sqlCheckPath).first()
|
sqlCheckPath = path if path[-1] == '/' else path + '/'
|
||||||
|
storedPathName = session.query(GdriveId).filter(GdriveId.path == sqlCheckPath).first()
|
||||||
|
|
||||||
if not storedPathName:
|
if not storedPathName:
|
||||||
dbChange = False
|
dbChange = False
|
||||||
s = path.split('/')
|
s = path.split('/')
|
||||||
for i, x in enumerate(s):
|
for i, x in enumerate(s):
|
||||||
if len(x) > 0:
|
if len(x) > 0:
|
||||||
currentPath = "/".join(s[:i+1])
|
currentPath = "/".join(s[:i+1])
|
||||||
if currentPath[-1] != '/':
|
if currentPath[-1] != '/':
|
||||||
currentPath = currentPath + '/'
|
currentPath = currentPath + '/'
|
||||||
storedPathName = session.query(GdriveId).filter(GdriveId.path == currentPath).first()
|
storedPathName = session.query(GdriveId).filter(GdriveId.path == currentPath).first()
|
||||||
if storedPathName:
|
if storedPathName:
|
||||||
currentFolderId = storedPathName.gdrive_id
|
currentFolderId = storedPathName.gdrive_id
|
||||||
else:
|
|
||||||
currentFolder = getFolderInFolder(currentFolderId, x, drive)
|
|
||||||
if currentFolder:
|
|
||||||
gDriveId = GdriveId()
|
|
||||||
gDriveId.gdrive_id = currentFolder['id']
|
|
||||||
gDriveId.path = currentPath
|
|
||||||
session.merge(gDriveId)
|
|
||||||
dbChange = True
|
|
||||||
currentFolderId = currentFolder['id']
|
|
||||||
else:
|
else:
|
||||||
currentFolderId = None
|
currentFolder = getFolderInFolder(currentFolderId, x, drive)
|
||||||
break
|
if currentFolder:
|
||||||
if dbChange:
|
gDriveId = GdriveId()
|
||||||
session.commit()
|
gDriveId.gdrive_id = currentFolder['id']
|
||||||
else:
|
gDriveId.path = currentPath
|
||||||
currentFolderId = storedPathName.gdrive_id
|
session.merge(gDriveId)
|
||||||
|
dbChange = True
|
||||||
|
currentFolderId = currentFolder['id']
|
||||||
|
else:
|
||||||
|
currentFolderId = None
|
||||||
|
break
|
||||||
|
if dbChange:
|
||||||
|
session.commit()
|
||||||
|
else:
|
||||||
|
currentFolderId = storedPathName.gdrive_id
|
||||||
|
except OperationalError as ex:
|
||||||
|
log.error("gdrive.db DB is not Writeable")
|
||||||
|
log.debug('Database error: %s', ex)
|
||||||
|
session.rollback()
|
||||||
return currentFolderId
|
return currentFolderId
|
||||||
|
|
||||||
|
|
||||||
@@ -317,7 +356,7 @@ def moveGdriveFolderRemote(origin_file, target_folder):
|
|||||||
addParents=gFileTargetDir['id'],
|
addParents=gFileTargetDir['id'],
|
||||||
removeParents=previous_parents,
|
removeParents=previous_parents,
|
||||||
fields='id, parents').execute()
|
fields='id, parents').execute()
|
||||||
# if previous_parents has no childs anymore, delete original fileparent
|
# if previous_parents has no children anymore, delete original fileparent
|
||||||
if len(children['items']) == 1:
|
if len(children['items']) == 1:
|
||||||
deleteDatabaseEntry(previous_parents)
|
deleteDatabaseEntry(previous_parents)
|
||||||
drive.auth.service.files().delete(fileId=previous_parents).execute()
|
drive.auth.service.files().delete(fileId=previous_parents).execute()
|
||||||
@@ -369,7 +408,8 @@ def uploadFileToEbooksFolder(destFile, f):
|
|||||||
if len(existingFiles) > 0:
|
if len(existingFiles) > 0:
|
||||||
driveFile = existingFiles[0]
|
driveFile = existingFiles[0]
|
||||||
else:
|
else:
|
||||||
driveFile = drive.CreateFile({'title': x, 'parents': [{"kind": "drive#fileLink", 'id': parent['id']}],})
|
driveFile = drive.CreateFile({'title': x,
|
||||||
|
'parents': [{"kind": "drive#fileLink", 'id': parent['id']}], })
|
||||||
driveFile.SetContentFile(f)
|
driveFile.SetContentFile(f)
|
||||||
driveFile.Upload()
|
driveFile.Upload()
|
||||||
else:
|
else:
|
||||||
@@ -467,15 +507,21 @@ def getChangeById (drive, change_id):
|
|||||||
except (errors.HttpError) as error:
|
except (errors.HttpError) as error:
|
||||||
log.error(error)
|
log.error(error)
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as ex:
|
||||||
log.error(e)
|
log.error(ex)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Deletes the local hashes database to force search for new folder names
|
# Deletes the local hashes database to force search for new folder names
|
||||||
def deleteDatabaseOnChange():
|
def deleteDatabaseOnChange():
|
||||||
session.query(GdriveId).delete()
|
try:
|
||||||
session.commit()
|
session.query(GdriveId).delete()
|
||||||
|
session.commit()
|
||||||
|
except (OperationalError, InvalidRequestError) as ex:
|
||||||
|
session.rollback()
|
||||||
|
log.debug('Database error: %s', ex)
|
||||||
|
log.error(u"GDrive DB is not Writeable")
|
||||||
|
|
||||||
|
|
||||||
def updateGdriveCalibreFromLocal():
|
def updateGdriveCalibreFromLocal():
|
||||||
copyToDrive(Gdrive.Instance().drive, config.config_calibre_dir, False, True)
|
copyToDrive(Gdrive.Instance().drive, config.config_calibre_dir, False, True)
|
||||||
@@ -489,13 +535,23 @@ def updateDatabaseOnEdit(ID,newPath):
|
|||||||
storedPathName = session.query(GdriveId).filter(GdriveId.gdrive_id == ID).first()
|
storedPathName = session.query(GdriveId).filter(GdriveId.gdrive_id == ID).first()
|
||||||
if storedPathName:
|
if storedPathName:
|
||||||
storedPathName.path = sqlCheckPath
|
storedPathName.path = sqlCheckPath
|
||||||
session.commit()
|
try:
|
||||||
|
session.commit()
|
||||||
|
except OperationalError as ex:
|
||||||
|
log.error("gdrive.db DB is not Writeable")
|
||||||
|
log.debug('Database error: %s', ex)
|
||||||
|
session.rollback()
|
||||||
|
|
||||||
|
|
||||||
# Deletes the hashes in database of deleted book
|
# Deletes the hashes in database of deleted book
|
||||||
def deleteDatabaseEntry(ID):
|
def deleteDatabaseEntry(ID):
|
||||||
session.query(GdriveId).filter(GdriveId.gdrive_id == ID).delete()
|
session.query(GdriveId).filter(GdriveId.gdrive_id == ID).delete()
|
||||||
session.commit()
|
try:
|
||||||
|
session.commit()
|
||||||
|
except OperationalError as ex:
|
||||||
|
log.error("gdrive.db DB is not Writeable")
|
||||||
|
log.debug('Database error: %s', ex)
|
||||||
|
session.rollback()
|
||||||
|
|
||||||
|
|
||||||
# Gets cover file from gdrive
|
# Gets cover file from gdrive
|
||||||
@@ -512,7 +568,12 @@ def get_cover_via_gdrive(cover_path):
|
|||||||
permissionAdded = PermissionAdded()
|
permissionAdded = PermissionAdded()
|
||||||
permissionAdded.gdrive_id = df['id']
|
permissionAdded.gdrive_id = df['id']
|
||||||
session.add(permissionAdded)
|
session.add(permissionAdded)
|
||||||
session.commit()
|
try:
|
||||||
|
session.commit()
|
||||||
|
except OperationalError as ex:
|
||||||
|
log.error("gdrive.db DB is not Writeable")
|
||||||
|
log.debug('Database error: %s', ex)
|
||||||
|
session.rollback()
|
||||||
return df.metadata.get('webContentLink')
|
return df.metadata.get('webContentLink')
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
@@ -526,21 +587,24 @@ def partial(total_byte_len, part_size_limit):
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
# downloads files in chunks from gdrive
|
# downloads files in chunks from gdrive
|
||||||
def do_gdrive_download(df, headers):
|
def do_gdrive_download(df, headers, convert_encoding=False):
|
||||||
total_size = int(df.metadata.get('fileSize'))
|
total_size = int(df.metadata.get('fileSize'))
|
||||||
download_url = df.metadata.get('downloadUrl')
|
download_url = df.metadata.get('downloadUrl')
|
||||||
s = partial(total_size, 1024 * 1024) # I'm downloading BIG files, so 100M chunk size is fine for me
|
s = partial(total_size, 1024 * 1024) # I'm downloading BIG files, so 100M chunk size is fine for me
|
||||||
|
|
||||||
def stream():
|
def stream(convert_encoding):
|
||||||
for byte in s:
|
for byte in s:
|
||||||
headers = {"Range": 'bytes=%s-%s' % (byte[0], byte[1])}
|
headers = {"Range": 'bytes=%s-%s' % (byte[0], byte[1])}
|
||||||
resp, content = df.auth.Get_Http_Object().request(download_url, headers=headers)
|
resp, content = df.auth.Get_Http_Object().request(download_url, headers=headers)
|
||||||
if resp.status == 206:
|
if resp.status == 206:
|
||||||
|
if convert_encoding:
|
||||||
|
result = chardet.detect(content)
|
||||||
|
content = content.decode(result['encoding']).encode('utf-8')
|
||||||
yield content
|
yield content
|
||||||
else:
|
else:
|
||||||
log.warning('An error occurred: %s', resp)
|
log.warning('An error occurred: %s', resp)
|
||||||
return
|
return
|
||||||
return Response(stream_with_context(stream()), headers=headers)
|
return Response(stream_with_context(stream(convert_encoding)), headers=headers)
|
||||||
|
|
||||||
|
|
||||||
_SETTINGS_YAML_TEMPLATE = """
|
_SETTINGS_YAML_TEMPLATE = """
|
||||||
@@ -583,8 +647,12 @@ def get_error_text(client_secrets=None):
|
|||||||
if not os.path.isfile(CLIENT_SECRETS):
|
if not os.path.isfile(CLIENT_SECRETS):
|
||||||
return 'client_secrets.json is missing or not readable'
|
return 'client_secrets.json is missing or not readable'
|
||||||
|
|
||||||
with open(CLIENT_SECRETS, 'r') as settings:
|
try:
|
||||||
filedata = json.load(settings)
|
with open(CLIENT_SECRETS, 'r') as settings:
|
||||||
|
filedata = json.load(settings)
|
||||||
|
except PermissionError:
|
||||||
|
return 'client_secrets.json is missing or not readable'
|
||||||
|
|
||||||
if 'web' not in filedata:
|
if 'web' not in filedata:
|
||||||
return 'client_secrets.json is not configured for web application'
|
return 'client_secrets.json is not configured for web application'
|
||||||
if 'redirect_uris' not in filedata['web']:
|
if 'redirect_uris' not in filedata['web']:
|
||||||
|
|||||||
@@ -57,12 +57,39 @@ def get_language_name(locale, lang_code):
|
|||||||
|
|
||||||
def get_language_codes(locale, language_names, remainder=None):
|
def get_language_codes(locale, language_names, remainder=None):
|
||||||
language_names = set(x.strip().lower() for x in language_names if x)
|
language_names = set(x.strip().lower() for x in language_names if x)
|
||||||
|
lang = list()
|
||||||
for k, v in get_language_names(locale).items():
|
for k, v in get_language_names(locale).items():
|
||||||
v = v.lower()
|
v = v.lower()
|
||||||
if v in language_names:
|
if v in language_names:
|
||||||
|
lang.append(k)
|
||||||
language_names.remove(v)
|
language_names.remove(v)
|
||||||
yield k
|
if remainder is not None and language_names:
|
||||||
|
|
||||||
if remainder is not None:
|
|
||||||
remainder.extend(language_names)
|
remainder.extend(language_names)
|
||||||
|
return lang
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_valid_language_codes(locale, language_names, remainder=None):
|
||||||
|
lang = list()
|
||||||
|
if "" in language_names:
|
||||||
|
language_names.remove("")
|
||||||
|
for k, __ in get_language_names(locale).items():
|
||||||
|
if k in language_names:
|
||||||
|
lang.append(k)
|
||||||
|
language_names.remove(k)
|
||||||
|
if remainder is not None and len(language_names):
|
||||||
|
remainder.extend(language_names)
|
||||||
|
return lang
|
||||||
|
|
||||||
|
|
||||||
|
def get_lang3(lang):
|
||||||
|
try:
|
||||||
|
if len(lang) == 2:
|
||||||
|
ret_value = get(part1=lang).part3
|
||||||
|
elif len(lang) == 3:
|
||||||
|
ret_value = lang
|
||||||
|
else:
|
||||||
|
ret_value = ""
|
||||||
|
except KeyError:
|
||||||
|
ret_value = lang
|
||||||
|
return ret_value
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
from __future__ import division, print_function, unicode_literals
|
from __future__ import division, print_function, unicode_literals
|
||||||
import datetime
|
import datetime
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
from uuid import uuid4
|
||||||
|
|
||||||
from babel.dates import format_date
|
from babel.dates import format_date
|
||||||
from flask import Blueprint, request, url_for
|
from flask import Blueprint, request, url_for
|
||||||
@@ -44,6 +44,8 @@ log = logger.create()
|
|||||||
def url_for_other_page(page):
|
def url_for_other_page(page):
|
||||||
args = request.view_args.copy()
|
args = request.view_args.copy()
|
||||||
args['page'] = page
|
args['page'] = page
|
||||||
|
for get, val in request.args.items():
|
||||||
|
args[get] = val
|
||||||
return url_for(request.endpoint, **args)
|
return url_for(request.endpoint, **args)
|
||||||
|
|
||||||
|
|
||||||
@@ -76,22 +78,18 @@ def mimetype_filter(val):
|
|||||||
@jinjia.app_template_filter('formatdate')
|
@jinjia.app_template_filter('formatdate')
|
||||||
def formatdate_filter(val):
|
def formatdate_filter(val):
|
||||||
try:
|
try:
|
||||||
conformed_timestamp = re.sub(r"[:]|([-](?!((\d{2}[:]\d{2})|(\d{4}))$))", '', val)
|
return format_date(val, format='medium', locale=get_locale())
|
||||||
formatdate = datetime.datetime.strptime(conformed_timestamp[:15], "%Y%m%d %H%M%S")
|
|
||||||
return format_date(formatdate, format='medium', locale=get_locale())
|
|
||||||
except AttributeError as e:
|
except AttributeError as e:
|
||||||
log.error('Babel error: %s, Current user locale: %s, Current User: %s', e,
|
log.error('Babel error: %s, Current user locale: %s, Current User: %s', e,
|
||||||
current_user.locale,
|
current_user.locale,
|
||||||
current_user.nickname
|
current_user.name
|
||||||
)
|
)
|
||||||
return formatdate
|
return val
|
||||||
|
|
||||||
|
|
||||||
@jinjia.app_template_filter('formatdateinput')
|
@jinjia.app_template_filter('formatdateinput')
|
||||||
def format_date_input(val):
|
def format_date_input(val):
|
||||||
conformed_timestamp = re.sub(r"[:]|([-](?!((\d{2}[:]\d{2})|(\d{4}))$))", '', val)
|
input_date = val.isoformat().split('T', 1)[0] # Hack to support dates <1900
|
||||||
date_obj = datetime.datetime.strptime(conformed_timestamp[:15], "%Y%m%d %H%M%S")
|
|
||||||
input_date = date_obj.isoformat().split('T', 1)[0] # Hack to support dates <1900
|
|
||||||
return '' if input_date == "0101-01-01" else input_date
|
return '' if input_date == "0101-01-01" else input_date
|
||||||
|
|
||||||
|
|
||||||
@@ -113,8 +111,28 @@ def yesno(value, yes, no):
|
|||||||
return yes if value else no
|
return yes if value else no
|
||||||
|
|
||||||
|
|
||||||
'''@jinjia.app_template_filter('canread')
|
@jinjia.app_template_filter('formatfloat')
|
||||||
def canread(ext):
|
def formatfloat(value, decimals=1):
|
||||||
if isinstance(ext, db.Data):
|
formatedstring = '%d' % value
|
||||||
ext = ext.format
|
if (value % 1) != 0:
|
||||||
return ext.lower() in EXTENSIONS_READER'''
|
formatedstring = ('%s.%d' % (formatedstring, (value % 1) * 10**decimals)).rstrip('0')
|
||||||
|
return formatedstring
|
||||||
|
|
||||||
|
|
||||||
|
@jinjia.app_template_filter('formatseriesindex')
|
||||||
|
def formatseriesindex_filter(series_index):
|
||||||
|
if series_index:
|
||||||
|
try:
|
||||||
|
if int(series_index) - series_index == 0:
|
||||||
|
return int(series_index)
|
||||||
|
else:
|
||||||
|
return series_index
|
||||||
|
except ValueError:
|
||||||
|
return series_index
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@jinjia.app_template_filter('uuidfilter')
|
||||||
|
def uuidfilter(var):
|
||||||
|
return uuid4()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,6 @@
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import datetime
|
import datetime
|
||||||
import itertools
|
|
||||||
import json
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
@@ -41,22 +39,25 @@ from flask import (
|
|||||||
redirect,
|
redirect,
|
||||||
abort
|
abort
|
||||||
)
|
)
|
||||||
from flask_login import current_user, login_required
|
from flask_login import current_user
|
||||||
from werkzeug.datastructures import Headers
|
from werkzeug.datastructures import Headers
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.sql.expression import and_, or_
|
from sqlalchemy.sql.expression import and_
|
||||||
from sqlalchemy.exc import StatementError
|
from sqlalchemy.exc import StatementError
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from . import config, logger, kobo_auth, db, helper, shelf as shelf_lib, ub
|
from . import config, logger, kobo_auth, db, calibre_db, helper, shelf as shelf_lib, ub
|
||||||
|
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
|
||||||
from .kobo_auth import requires_kobo_auth
|
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://kbimages1-a.akamaihd.net"
|
||||||
|
|
||||||
|
SYNC_ITEM_LIMIT = 100
|
||||||
|
|
||||||
kobo = Blueprint("kobo", __name__, url_prefix="/kobo/<auth_token>")
|
kobo = Blueprint("kobo", __name__, url_prefix="/kobo/<auth_token>")
|
||||||
kobo_auth.disable_failed_auth_redirect_for_blueprint(kobo)
|
kobo_auth.disable_failed_auth_redirect_for_blueprint(kobo)
|
||||||
kobo_auth.register_url_value_preprocessor(kobo)
|
kobo_auth.register_url_value_preprocessor(kobo)
|
||||||
@@ -121,7 +122,11 @@ def redirect_or_proxy_request():
|
|||||||
|
|
||||||
|
|
||||||
def convert_to_kobo_timestamp_string(timestamp):
|
def convert_to_kobo_timestamp_string(timestamp):
|
||||||
return timestamp.strftime("%Y-%m-%dT%H:%M:%SZ")
|
try:
|
||||||
|
return timestamp.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
except AttributeError as exc:
|
||||||
|
log.debug("Timestamp not valid: {}".format(exc))
|
||||||
|
return datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
@kobo.route("/v1/library/sync")
|
@kobo.route("/v1/library/sync")
|
||||||
@@ -131,7 +136,7 @@ def HandleSyncRequest():
|
|||||||
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.")
|
||||||
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 server port')
|
log.debug('Kobo: Received unproxied request, changed request port to external server port')
|
||||||
|
|
||||||
# TODO: Limit the number of books return per sync call, and rely on the sync-continuatation header
|
# TODO: Limit the number of books return per sync call, and rely on the sync-continuatation header
|
||||||
# instead so that the device triggers another sync.
|
# instead so that the device triggers another sync.
|
||||||
@@ -139,75 +144,93 @@ def HandleSyncRequest():
|
|||||||
new_books_last_modified = sync_token.books_last_modified
|
new_books_last_modified = sync_token.books_last_modified
|
||||||
new_books_last_created = sync_token.books_last_created
|
new_books_last_created = sync_token.books_last_created
|
||||||
new_reading_state_last_modified = sync_token.reading_state_last_modified
|
new_reading_state_last_modified = sync_token.reading_state_last_modified
|
||||||
|
new_archived_last_modified = datetime.datetime.min
|
||||||
sync_results = []
|
sync_results = []
|
||||||
|
|
||||||
# We reload the book database so that the user get's a fresh view of the library
|
# We reload the book database so that the user get's a fresh view of the library
|
||||||
# in case of external changes (e.g: adding a book through Calibre).
|
# in case of external changes (e.g: adding a book through Calibre).
|
||||||
db.reconnect_db(config)
|
calibre_db.reconnect_db(config, ub.app_DB_path)
|
||||||
|
|
||||||
archived_books = (
|
if sync_token.books_last_id > -1:
|
||||||
ub.session.query(ub.ArchivedBook)
|
changed_entries = (
|
||||||
.filter(ub.ArchivedBook.user_id == int(current_user.id))
|
calibre_db.session.query(db.Books, ub.ArchivedBook.last_modified, ub.ArchivedBook.is_archived)
|
||||||
.all()
|
.join(db.Data).outerjoin(ub.ArchivedBook, db.Books.id == ub.ArchivedBook.book_id)
|
||||||
)
|
.filter(db.Books.last_modified >= sync_token.books_last_modified)
|
||||||
|
.filter(db.Books.id>sync_token.books_last_id)
|
||||||
# We join-in books that have had their Archived bit recently modified in order to either:
|
.filter(db.Data.format.in_(KOBO_FORMATS))
|
||||||
# * Restore them to the user's device.
|
.filter(calibre_db.common_filters())
|
||||||
# * Delete them from the user's device.
|
.order_by(db.Books.last_modified)
|
||||||
# (Ideally we would use a join for this logic, however cross-database joins don't look trivial in SqlAlchemy.)
|
.order_by(db.Books.id)
|
||||||
recently_restored_or_archived_books = []
|
.limit(SYNC_ITEM_LIMIT)
|
||||||
archived_book_ids = {}
|
)
|
||||||
new_archived_last_modified = datetime.datetime.min
|
else:
|
||||||
for archived_book in archived_books:
|
changed_entries = (
|
||||||
if archived_book.last_modified > sync_token.archive_last_modified:
|
calibre_db.session.query(db.Books, ub.ArchivedBook.last_modified, ub.ArchivedBook.is_archived)
|
||||||
recently_restored_or_archived_books.append(archived_book.book_id)
|
.join(db.Data).outerjoin(ub.ArchivedBook, db.Books.id == ub.ArchivedBook.book_id)
|
||||||
if archived_book.is_archived:
|
.filter(db.Books.last_modified > sync_token.books_last_modified)
|
||||||
archived_book_ids[archived_book.book_id] = True
|
.filter(db.Data.format.in_(KOBO_FORMATS))
|
||||||
new_archived_last_modified = max(
|
.filter(calibre_db.common_filters())
|
||||||
new_archived_last_modified, archived_book.last_modified)
|
.order_by(db.Books.last_modified)
|
||||||
|
.order_by(db.Books.id)
|
||||||
# sqlite gives unexpected results when performing the last_modified comparison without the datetime cast.
|
.limit(SYNC_ITEM_LIMIT)
|
||||||
# It looks like it's treating the db.Books.last_modified field as a string and may fail
|
)
|
||||||
# the comparison because of the +00:00 suffix.
|
|
||||||
changed_entries = (
|
|
||||||
db.session.query(db.Books)
|
|
||||||
.join(db.Data)
|
|
||||||
.filter(or_(func.datetime(db.Books.last_modified) > sync_token.books_last_modified,
|
|
||||||
db.Books.id.in_(recently_restored_or_archived_books)))
|
|
||||||
.filter(db.Data.format.in_(KOBO_FORMATS))
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
reading_states_in_new_entitlements = []
|
reading_states_in_new_entitlements = []
|
||||||
for book in changed_entries:
|
for book in changed_entries:
|
||||||
kobo_reading_state = get_or_create_reading_state(book.id)
|
formats = [data.format for data in book.Books.data]
|
||||||
|
if not 'KEPUB' 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)
|
||||||
|
|
||||||
|
kobo_reading_state = get_or_create_reading_state(book.Books.id)
|
||||||
entitlement = {
|
entitlement = {
|
||||||
"BookEntitlement": create_book_entitlement(book, archived=(book.id in archived_book_ids)),
|
"BookEntitlement": create_book_entitlement(book.Books, archived=(book.is_archived == True)),
|
||||||
"BookMetadata": get_metadata(book),
|
"BookMetadata": get_metadata(book.Books),
|
||||||
}
|
}
|
||||||
|
|
||||||
if kobo_reading_state.last_modified > sync_token.reading_state_last_modified:
|
if kobo_reading_state.last_modified > sync_token.reading_state_last_modified:
|
||||||
entitlement["ReadingState"] = get_kobo_reading_state_response(book, kobo_reading_state)
|
entitlement["ReadingState"] = get_kobo_reading_state_response(book.Books, kobo_reading_state)
|
||||||
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.id)
|
reading_states_in_new_entitlements.append(book.Books.id)
|
||||||
|
|
||||||
if book.timestamp > sync_token.books_last_created:
|
if book.Books.timestamp > sync_token.books_last_created:
|
||||||
sync_results.append({"NewEntitlement": entitlement})
|
sync_results.append({"NewEntitlement": entitlement})
|
||||||
else:
|
else:
|
||||||
sync_results.append({"ChangedEntitlement": entitlement})
|
sync_results.append({"ChangedEntitlement": entitlement})
|
||||||
|
|
||||||
new_books_last_modified = max(
|
new_books_last_modified = max(
|
||||||
book.last_modified, new_books_last_modified
|
book.Books.last_modified, new_books_last_modified
|
||||||
)
|
)
|
||||||
new_books_last_created = max(book.timestamp, new_books_last_created)
|
new_books_last_created = max(book.Books.timestamp, new_books_last_created)
|
||||||
|
|
||||||
|
max_change = (changed_entries
|
||||||
|
.from_self()
|
||||||
|
.filter(ub.ArchivedBook.is_archived)
|
||||||
|
.order_by(func.datetime(ub.ArchivedBook.last_modified).desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if max_change:
|
||||||
|
max_change = max_change.last_modified
|
||||||
|
else:
|
||||||
|
max_change = new_archived_last_modified
|
||||||
|
new_archived_last_modified = max(new_archived_last_modified, max_change)
|
||||||
|
|
||||||
|
# no. of books returned
|
||||||
|
book_count = changed_entries.count()
|
||||||
|
|
||||||
|
# last entry:
|
||||||
|
if book_count:
|
||||||
|
books_last_id = changed_entries.all()[-1].Books.id or -1
|
||||||
|
else:
|
||||||
|
books_last_id = -1
|
||||||
|
|
||||||
|
# generate reading state data
|
||||||
changed_reading_states = (
|
changed_reading_states = (
|
||||||
ub.session.query(ub.KoboReadingState)
|
ub.session.query(ub.KoboReadingState)
|
||||||
.filter(and_(func.datetime(ub.KoboReadingState.last_modified) > sync_token.reading_state_last_modified,
|
.filter(and_(func.datetime(ub.KoboReadingState.last_modified) > sync_token.reading_state_last_modified,
|
||||||
ub.KoboReadingState.user_id == current_user.id,
|
ub.KoboReadingState.user_id == current_user.id,
|
||||||
ub.KoboReadingState.book_id.notin_(reading_states_in_new_entitlements))))
|
ub.KoboReadingState.book_id.notin_(reading_states_in_new_entitlements))))
|
||||||
for kobo_reading_state in changed_reading_states.all():
|
for kobo_reading_state in changed_reading_states.all():
|
||||||
book = db.session.query(db.Books).filter(db.Books.id == kobo_reading_state.book_id).one_or_none()
|
book = calibre_db.session.query(db.Books).filter(db.Books.id == kobo_reading_state.book_id).one_or_none()
|
||||||
if book:
|
if book:
|
||||||
sync_results.append({
|
sync_results.append({
|
||||||
"ChangedReadingState": {
|
"ChangedReadingState": {
|
||||||
@@ -222,11 +245,12 @@ def HandleSyncRequest():
|
|||||||
sync_token.books_last_modified = new_books_last_modified
|
sync_token.books_last_modified = new_books_last_modified
|
||||||
sync_token.archive_last_modified = new_archived_last_modified
|
sync_token.archive_last_modified = new_archived_last_modified
|
||||||
sync_token.reading_state_last_modified = new_reading_state_last_modified
|
sync_token.reading_state_last_modified = new_reading_state_last_modified
|
||||||
|
sync_token.books_last_id = books_last_id
|
||||||
|
|
||||||
return generate_sync_response(sync_token, sync_results)
|
return generate_sync_response(sync_token, sync_results, book_count)
|
||||||
|
|
||||||
|
|
||||||
def generate_sync_response(sync_token, sync_results):
|
def generate_sync_response(sync_token, sync_results, set_cont=False):
|
||||||
extra_headers = {}
|
extra_headers = {}
|
||||||
if config.config_kobo_proxy:
|
if config.config_kobo_proxy:
|
||||||
# Merge in sync results from the official Kobo store.
|
# Merge in sync results from the official Kobo store.
|
||||||
@@ -240,8 +264,10 @@ def generate_sync_response(sync_token, sync_results):
|
|||||||
extra_headers["x-kobo-sync-mode"] = store_response.headers.get("x-kobo-sync-mode")
|
extra_headers["x-kobo-sync-mode"] = store_response.headers.get("x-kobo-sync-mode")
|
||||||
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 e:
|
except Exception as ex:
|
||||||
log.error("Failed to receive or parse response from Kobo's sync endpoint: " + str(e))
|
log.error("Failed to receive or parse response from Kobo's sync endpoint: {}".format(ex))
|
||||||
|
if set_cont:
|
||||||
|
extra_headers["x-kobo-sync"] = "continue"
|
||||||
sync_token.to_headers(extra_headers)
|
sync_token.to_headers(extra_headers)
|
||||||
|
|
||||||
response = make_response(jsonify(sync_results), extra_headers)
|
response = make_response(jsonify(sync_results), extra_headers)
|
||||||
@@ -254,9 +280,9 @@ def generate_sync_response(sync_token, sync_results):
|
|||||||
@download_required
|
@download_required
|
||||||
def HandleMetadataRequest(book_uuid):
|
def HandleMetadataRequest(book_uuid):
|
||||||
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 server port')
|
log.debug('Kobo: Received unproxied request, changed request port to external server port')
|
||||||
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 = db.session.query(db.Books).filter(db.Books.uuid == book_uuid).first()
|
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(u"Book %s not found in database", book_uuid)
|
||||||
return redirect_or_proxy_request()
|
return redirect_or_proxy_request()
|
||||||
@@ -267,19 +293,22 @@ def HandleMetadataRequest(book_uuid):
|
|||||||
|
|
||||||
def get_download_url_for_book(book, book_format):
|
def get_download_url_for_book(book, 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])
|
||||||
else:
|
else:
|
||||||
host = request.host
|
host = request.host
|
||||||
return "{url_scheme}://{url_base}:{url_port}/download/{book_id}/{book_format}".format(
|
|
||||||
|
return "{url_scheme}://{url_base}:{url_port}/kobo/{auth_token}/download/{book_id}/{book_format}".format(
|
||||||
url_scheme=request.scheme,
|
url_scheme=request.scheme,
|
||||||
url_base=host,
|
url_base=host,
|
||||||
url_port=config.config_port,
|
url_port=config.config_external_port,
|
||||||
|
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(
|
||||||
"web.download_link",
|
"kobo.download_book",
|
||||||
|
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,
|
||||||
@@ -317,8 +346,15 @@ def get_description(book):
|
|||||||
# TODO handle multiple authors
|
# TODO handle multiple authors
|
||||||
def get_author(book):
|
def get_author(book):
|
||||||
if not book.authors:
|
if not book.authors:
|
||||||
return None
|
return {"Contributors": None}
|
||||||
return book.authors[0].name
|
if len(book.authors) > 1:
|
||||||
|
author_list = []
|
||||||
|
autor_roles = []
|
||||||
|
for author in book.authors:
|
||||||
|
autor_roles.append({"Name":author.name, "Role":"Author"})
|
||||||
|
author_list.append(author.name)
|
||||||
|
return {"ContributorRoles": autor_roles, "Contributors":author_list}
|
||||||
|
return {"ContributorRoles": [{"Name":book.authors[0].name, "Role":"Author"}], "Contributors": book.authors[0].name}
|
||||||
|
|
||||||
|
|
||||||
def get_publisher(book):
|
def get_publisher(book):
|
||||||
@@ -332,10 +368,15 @@ def get_series(book):
|
|||||||
return None
|
return None
|
||||||
return book.series[0].name
|
return book.series[0].name
|
||||||
|
|
||||||
|
def get_seriesindex(book):
|
||||||
|
return book.series_index or 1
|
||||||
|
|
||||||
|
|
||||||
def get_metadata(book):
|
def get_metadata(book):
|
||||||
download_urls = []
|
download_urls = []
|
||||||
for book_data in book.data:
|
kepub = [data for data in book.data if data.format == 'KEPUB']
|
||||||
|
|
||||||
|
for book_data in kepub if len(kepub) > 0 else book.data:
|
||||||
if book_data.format not in KOBO_FORMATS:
|
if book_data.format not in KOBO_FORMATS:
|
||||||
continue
|
continue
|
||||||
for kobo_format in KOBO_FORMATS[book_data.format]:
|
for kobo_format in KOBO_FORMATS[book_data.format]:
|
||||||
@@ -354,7 +395,7 @@ def get_metadata(book):
|
|||||||
book_uuid = book.uuid
|
book_uuid = book.uuid
|
||||||
metadata = {
|
metadata = {
|
||||||
"Categories": ["00000000-0000-0000-0000-000000000001",],
|
"Categories": ["00000000-0000-0000-0000-000000000001",],
|
||||||
"Contributors": get_author(book),
|
# "Contributors": get_author(book),
|
||||||
"CoverImageId": book_uuid,
|
"CoverImageId": book_uuid,
|
||||||
"CrossRevisionId": book_uuid,
|
"CrossRevisionId": book_uuid,
|
||||||
"CurrentDisplayPrice": {"CurrencyCode": "USD", "TotalAmount": 0},
|
"CurrentDisplayPrice": {"CurrencyCode": "USD", "TotalAmount": 0},
|
||||||
@@ -378,6 +419,7 @@ def get_metadata(book):
|
|||||||
"Title": book.title,
|
"Title": book.title,
|
||||||
"WorkId": book_uuid,
|
"WorkId": book_uuid,
|
||||||
}
|
}
|
||||||
|
metadata.update(get_author(book))
|
||||||
|
|
||||||
if get_series(book):
|
if get_series(book):
|
||||||
if sys.version_info < (3, 0):
|
if sys.version_info < (3, 0):
|
||||||
@@ -386,8 +428,8 @@ def get_metadata(book):
|
|||||||
name = get_series(book)
|
name = get_series(book)
|
||||||
metadata["Series"] = {
|
metadata["Series"] = {
|
||||||
"Name": get_series(book),
|
"Name": get_series(book),
|
||||||
"Number": book.series_index, # ToDo Check int() ?
|
"Number": get_seriesindex(book), # ToDo Check int() ?
|
||||||
"NumberFloat": float(book.series_index),
|
"NumberFloat": float(get_seriesindex(book)),
|
||||||
# Get a deterministic id based on the series name.
|
# Get a deterministic id based on the series name.
|
||||||
"Id": uuid.uuid3(uuid.NAMESPACE_DNS, name),
|
"Id": uuid.uuid3(uuid.NAMESPACE_DNS, name),
|
||||||
}
|
}
|
||||||
@@ -396,7 +438,7 @@ def get_metadata(book):
|
|||||||
|
|
||||||
|
|
||||||
@kobo.route("/v1/library/tags", methods=["POST", "DELETE"])
|
@kobo.route("/v1/library/tags", methods=["POST", "DELETE"])
|
||||||
@login_required
|
@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 handeld in the book delete handler
|
# catch delete requests, otherwise the are handeld in the book delete handler
|
||||||
@@ -425,12 +467,12 @@ def HandleTagCreate():
|
|||||||
items_unknown_to_calibre = add_items_to_shelf(items, shelf)
|
items_unknown_to_calibre = add_items_to_shelf(items, shelf)
|
||||||
if items_unknown_to_calibre:
|
if items_unknown_to_calibre:
|
||||||
log.debug("Received request to add unknown books to a collection. Silently ignoring items.")
|
log.debug("Received request to add unknown books to a collection. Silently ignoring items.")
|
||||||
ub.session.commit()
|
ub.session_commit()
|
||||||
|
|
||||||
return make_response(jsonify(str(shelf.uuid)), 201)
|
return make_response(jsonify(str(shelf.uuid)), 201)
|
||||||
|
|
||||||
|
|
||||||
@kobo.route("/v1/library/tags/<tag_id>", methods=["DELETE", "PUT"])
|
@kobo.route("/v1/library/tags/<tag_id>", methods=["DELETE", "PUT"])
|
||||||
|
@requires_kobo_auth
|
||||||
def HandleTagUpdate(tag_id):
|
def HandleTagUpdate(tag_id):
|
||||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.uuid == tag_id,
|
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.uuid == tag_id,
|
||||||
ub.Shelf.user_id == current_user.id).one_or_none()
|
ub.Shelf.user_id == current_user.id).one_or_none()
|
||||||
@@ -457,7 +499,7 @@ def HandleTagUpdate(tag_id):
|
|||||||
|
|
||||||
shelf.name = name
|
shelf.name = name
|
||||||
ub.session.merge(shelf)
|
ub.session.merge(shelf)
|
||||||
ub.session.commit()
|
ub.session_commit()
|
||||||
return make_response(' ', 200)
|
return make_response(' ', 200)
|
||||||
|
|
||||||
|
|
||||||
@@ -471,7 +513,7 @@ def add_items_to_shelf(items, shelf):
|
|||||||
items_unknown_to_calibre.append(item)
|
items_unknown_to_calibre.append(item)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
book = db.session.query(db.Books).filter(db.Books.uuid == item["RevisionId"]).one_or_none()
|
book = calibre_db.get_book_by_uuid(item["RevisionId"])
|
||||||
if not book:
|
if not book:
|
||||||
items_unknown_to_calibre.append(item)
|
items_unknown_to_calibre.append(item)
|
||||||
continue
|
continue
|
||||||
@@ -485,7 +527,7 @@ def add_items_to_shelf(items, shelf):
|
|||||||
|
|
||||||
|
|
||||||
@kobo.route("/v1/library/tags/<tag_id>/items", methods=["POST"])
|
@kobo.route("/v1/library/tags/<tag_id>/items", methods=["POST"])
|
||||||
@login_required
|
@requires_kobo_auth
|
||||||
def HandleTagAddItem(tag_id):
|
def HandleTagAddItem(tag_id):
|
||||||
items = None
|
items = None
|
||||||
try:
|
try:
|
||||||
@@ -509,13 +551,12 @@ def HandleTagAddItem(tag_id):
|
|||||||
log.debug("Received request to add an unknown book to a collection. Silently ignoring item.")
|
log.debug("Received request to add an unknown book to a collection. Silently ignoring item.")
|
||||||
|
|
||||||
ub.session.merge(shelf)
|
ub.session.merge(shelf)
|
||||||
ub.session.commit()
|
ub.session_commit()
|
||||||
|
|
||||||
return make_response('', 201)
|
return make_response('', 201)
|
||||||
|
|
||||||
|
|
||||||
@kobo.route("/v1/library/tags/<tag_id>/items/delete", methods=["POST"])
|
@kobo.route("/v1/library/tags/<tag_id>/items/delete", methods=["POST"])
|
||||||
@login_required
|
@requires_kobo_auth
|
||||||
def HandleTagRemoveItem(tag_id):
|
def HandleTagRemoveItem(tag_id):
|
||||||
items = None
|
items = None
|
||||||
try:
|
try:
|
||||||
@@ -542,7 +583,7 @@ def HandleTagRemoveItem(tag_id):
|
|||||||
items_unknown_to_calibre.append(item)
|
items_unknown_to_calibre.append(item)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
book = db.session.query(db.Books).filter(db.Books.uuid == item["RevisionId"]).one_or_none()
|
book = calibre_db.get_book_by_uuid(item["RevisionId"])
|
||||||
if not book:
|
if not book:
|
||||||
items_unknown_to_calibre.append(item)
|
items_unknown_to_calibre.append(item)
|
||||||
continue
|
continue
|
||||||
@@ -550,7 +591,7 @@ def HandleTagRemoveItem(tag_id):
|
|||||||
shelf.books.filter(ub.BookShelf.book_id == book.id).delete()
|
shelf.books.filter(ub.BookShelf.book_id == book.id).delete()
|
||||||
except KeyError:
|
except KeyError:
|
||||||
items_unknown_to_calibre.append(item)
|
items_unknown_to_calibre.append(item)
|
||||||
ub.session.commit()
|
ub.session_commit()
|
||||||
|
|
||||||
if items_unknown_to_calibre:
|
if items_unknown_to_calibre:
|
||||||
log.debug("Received request to remove an unknown book to a collecition. Silently ignoring item.")
|
log.debug("Received request to remove an unknown book to a collecition. Silently ignoring item.")
|
||||||
@@ -596,7 +637,7 @@ def sync_shelves(sync_token, sync_results):
|
|||||||
"ChangedTag": tag
|
"ChangedTag": tag
|
||||||
})
|
})
|
||||||
sync_token.tags_last_modified = new_tags_last_modified
|
sync_token.tags_last_modified = new_tags_last_modified
|
||||||
ub.session.commit()
|
ub.session_commit()
|
||||||
|
|
||||||
|
|
||||||
# Creates a Kobo "Tag" object from a ub.Shelf object
|
# Creates a Kobo "Tag" object from a ub.Shelf object
|
||||||
@@ -610,7 +651,7 @@ def create_kobo_tag(shelf):
|
|||||||
"Type": "UserTag"
|
"Type": "UserTag"
|
||||||
}
|
}
|
||||||
for book_shelf in shelf.books:
|
for book_shelf in shelf.books:
|
||||||
book = db.session.query(db.Books).filter(db.Books.id == book_shelf.book_id).one_or_none()
|
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(u"Book (id: %s) in BookShelf (id: %s) not found in book database", book_shelf.book_id, shelf.id)
|
||||||
continue
|
continue
|
||||||
@@ -624,9 +665,9 @@ def create_kobo_tag(shelf):
|
|||||||
|
|
||||||
|
|
||||||
@kobo.route("/v1/library/<book_uuid>/state", methods=["GET", "PUT"])
|
@kobo.route("/v1/library/<book_uuid>/state", methods=["GET", "PUT"])
|
||||||
@login_required
|
@requires_kobo_auth
|
||||||
def HandleStateRequest(book_uuid):
|
def HandleStateRequest(book_uuid):
|
||||||
book = db.session.query(db.Books).filter(db.Books.uuid == book_uuid).first()
|
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(u"Book %s not found in database", book_uuid)
|
||||||
return redirect_or_proxy_request()
|
return redirect_or_proxy_request()
|
||||||
@@ -677,7 +718,7 @@ def HandleStateRequest(book_uuid):
|
|||||||
abort(400, description="Malformed request data is missing 'ReadingStates' key")
|
abort(400, description="Malformed request data is missing 'ReadingStates' key")
|
||||||
|
|
||||||
ub.session.merge(kobo_reading_state)
|
ub.session.merge(kobo_reading_state)
|
||||||
ub.session.commit()
|
ub.session_commit()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"RequestResult": "Success",
|
"RequestResult": "Success",
|
||||||
"UpdateResults": [update_results_response],
|
"UpdateResults": [update_results_response],
|
||||||
@@ -715,7 +756,7 @@ def get_or_create_reading_state(book_id):
|
|||||||
kobo_reading_state.statistics = ub.KoboStatistics()
|
kobo_reading_state.statistics = ub.KoboStatistics()
|
||||||
book_read.kobo_reading_state = kobo_reading_state
|
book_read.kobo_reading_state = kobo_reading_state
|
||||||
ub.session.add(book_read)
|
ub.session.add(book_read)
|
||||||
ub.session.commit()
|
ub.session_commit()
|
||||||
return book_read.kobo_reading_state
|
return book_read.kobo_reading_state
|
||||||
|
|
||||||
|
|
||||||
@@ -798,10 +839,10 @@ def TopLevelEndpoint():
|
|||||||
|
|
||||||
|
|
||||||
@kobo.route("/v1/library/<book_uuid>", methods=["DELETE"])
|
@kobo.route("/v1/library/<book_uuid>", methods=["DELETE"])
|
||||||
@login_required
|
@requires_kobo_auth
|
||||||
def HandleBookDeletionRequest(book_uuid):
|
def HandleBookDeletionRequest(book_uuid):
|
||||||
log.info("Kobo book deletion request received for book %s" % book_uuid)
|
log.info("Kobo book deletion request received for book %s" % book_uuid)
|
||||||
book = db.session.query(db.Books).filter(db.Books.uuid == book_uuid).first()
|
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(u"Book %s not found in database", book_uuid)
|
||||||
return redirect_or_proxy_request()
|
return redirect_or_proxy_request()
|
||||||
@@ -818,8 +859,7 @@ def HandleBookDeletionRequest(book_uuid):
|
|||||||
archived_book.last_modified = datetime.datetime.utcnow()
|
archived_book.last_modified = datetime.datetime.utcnow()
|
||||||
|
|
||||||
ub.session.merge(archived_book)
|
ub.session.merge(archived_book)
|
||||||
ub.session.commit()
|
ub.session_commit()
|
||||||
|
|
||||||
return ("", 204)
|
return ("", 204)
|
||||||
|
|
||||||
|
|
||||||
@@ -845,6 +885,7 @@ def HandleUserRequest(dummy=None):
|
|||||||
@kobo.route("/v1/products/<dummy>/recommendations", methods=["GET", "POST"])
|
@kobo.route("/v1/products/<dummy>/recommendations", methods=["GET", "POST"])
|
||||||
@kobo.route("/v1/products/<dummy>/nextread", methods=["GET", "POST"])
|
@kobo.route("/v1/products/<dummy>/nextread", methods=["GET", "POST"])
|
||||||
@kobo.route("/v1/products/<dummy>/reviews", methods=["GET", "POST"])
|
@kobo.route("/v1/products/<dummy>/reviews", methods=["GET", "POST"])
|
||||||
|
@kobo.route("/v1/products/books/external/<dummy>", methods=["GET", "POST"])
|
||||||
@kobo.route("/v1/products/books/series/<dummy>", methods=["GET", "POST"])
|
@kobo.route("/v1/products/books/series/<dummy>", methods=["GET", "POST"])
|
||||||
@kobo.route("/v1/products/books/<dummy>", methods=["GET", "POST"])
|
@kobo.route("/v1/products/books/<dummy>", methods=["GET", "POST"])
|
||||||
@kobo.route("/v1/products/dailydeal", methods=["GET", "POST"])
|
@kobo.route("/v1/products/dailydeal", methods=["GET", "POST"])
|
||||||
@@ -854,17 +895,6 @@ def HandleProductsRequest(dummy=None):
|
|||||||
return redirect_or_proxy_request()
|
return redirect_or_proxy_request()
|
||||||
|
|
||||||
|
|
||||||
'''@kobo.errorhandler(404)
|
|
||||||
def handle_404(err):
|
|
||||||
# This handler acts as a catch-all for endpoints that we don't have an interest in
|
|
||||||
# implementing (e.g: v1/analytics/gettests, v1/user/recommendations, etc)
|
|
||||||
if err:
|
|
||||||
print('404')
|
|
||||||
return jsonify(error=str(err)), 404
|
|
||||||
log.debug("Unknown Request received: %s, method: %s, data: %s", request.base_url, request.method, request.data)
|
|
||||||
return redirect_or_proxy_request()'''
|
|
||||||
|
|
||||||
|
|
||||||
def make_calibre_web_auth_response():
|
def make_calibre_web_auth_response():
|
||||||
# As described in kobo_auth.py, CalibreWeb doesn't make use practical use of this auth/device API call for
|
# As described in kobo_auth.py, CalibreWeb doesn't make use practical use of this auth/device API call for
|
||||||
# authentation (nor for authorization). We return a dummy response just to keep the device happy.
|
# authentation (nor for authorization). We return a dummy response just to keep the device happy.
|
||||||
@@ -891,7 +921,7 @@ def HandleAuthRequest():
|
|||||||
if config.config_kobo_proxy:
|
if config.config_kobo_proxy:
|
||||||
try:
|
try:
|
||||||
return redirect_or_proxy_request()
|
return redirect_or_proxy_request()
|
||||||
except:
|
except Exception:
|
||||||
log.error("Failed to receive or parse response from Kobo's auth endpoint. Falling back to un-proxied mode.")
|
log.error("Failed to receive or parse response from Kobo's auth endpoint. Falling back to un-proxied mode.")
|
||||||
return make_calibre_web_auth_response()
|
return make_calibre_web_auth_response()
|
||||||
|
|
||||||
@@ -908,13 +938,13 @@ def HandleInitRequest():
|
|||||||
store_response_json = store_response.json()
|
store_response_json = store_response.json()
|
||||||
if "Resources" in store_response_json:
|
if "Resources" in store_response_json:
|
||||||
kobo_resources = store_response_json["Resources"]
|
kobo_resources = store_response_json["Resources"]
|
||||||
except:
|
except Exception:
|
||||||
log.error("Failed to receive or parse response from Kobo's init endpoint. Falling back to un-proxied mode.")
|
log.error("Failed to receive or parse response from Kobo's init endpoint. Falling back to un-proxied mode.")
|
||||||
if not kobo_resources:
|
if not kobo_resources:
|
||||||
kobo_resources = NATIVE_KOBO_RESOURCES()
|
kobo_resources = NATIVE_KOBO_RESOURCES()
|
||||||
|
|
||||||
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 server port')
|
log.debug('Kobo: Received unproxied request, changed request port to external server port')
|
||||||
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])
|
||||||
else:
|
else:
|
||||||
@@ -922,8 +952,9 @@ def HandleInitRequest():
|
|||||||
calibre_web_url = "{url_scheme}://{url_base}:{url_port}".format(
|
calibre_web_url = "{url_scheme}://{url_base}:{url_port}".format(
|
||||||
url_scheme=request.scheme,
|
url_scheme=request.scheme,
|
||||||
url_base=host,
|
url_base=host,
|
||||||
url_port=config.config_port
|
url_port=config.config_external_port
|
||||||
)
|
)
|
||||||
|
log.debug('Kobo: Received unproxied request, changed request url to %s', calibre_web_url)
|
||||||
kobo_resources["image_host"] = calibre_web_url
|
kobo_resources["image_host"] = calibre_web_url
|
||||||
kobo_resources["image_url_quality_template"] = unquote(calibre_web_url +
|
kobo_resources["image_url_quality_template"] = unquote(calibre_web_url +
|
||||||
url_for("kobo.HandleCoverImageRequest",
|
url_for("kobo.HandleCoverImageRequest",
|
||||||
@@ -932,16 +963,14 @@ def HandleInitRequest():
|
|||||||
width="{width}",
|
width="{width}",
|
||||||
height="{height}",
|
height="{height}",
|
||||||
Quality='{Quality}',
|
Quality='{Quality}',
|
||||||
isGreyscale='isGreyscale'
|
isGreyscale='isGreyscale'))
|
||||||
))
|
|
||||||
kobo_resources["image_url_template"] = unquote(calibre_web_url +
|
kobo_resources["image_url_template"] = unquote(calibre_web_url +
|
||||||
url_for("kobo.HandleCoverImageRequest",
|
url_for("kobo.HandleCoverImageRequest",
|
||||||
auth_token=kobo_auth.get_auth_token(),
|
auth_token=kobo_auth.get_auth_token(),
|
||||||
book_uuid="{ImageId}",
|
book_uuid="{ImageId}",
|
||||||
width="{width}",
|
width="{width}",
|
||||||
height="{height}",
|
height="{height}",
|
||||||
isGreyscale='false'
|
isGreyscale='false'))
|
||||||
))
|
|
||||||
else:
|
else:
|
||||||
kobo_resources["image_host"] = url_for("web.index", _external=True).strip("/")
|
kobo_resources["image_host"] = url_for("web.index", _external=True).strip("/")
|
||||||
kobo_resources["image_url_quality_template"] = unquote(url_for("kobo.HandleCoverImageRequest",
|
kobo_resources["image_url_quality_template"] = unquote(url_for("kobo.HandleCoverImageRequest",
|
||||||
@@ -960,13 +989,19 @@ def HandleInitRequest():
|
|||||||
isGreyscale='false',
|
isGreyscale='false',
|
||||||
_external=True))
|
_external=True))
|
||||||
|
|
||||||
|
|
||||||
response = make_response(jsonify({"Resources": kobo_resources}))
|
response = make_response(jsonify({"Resources": kobo_resources}))
|
||||||
response.headers["x-kobo-apitoken"] = "e30="
|
response.headers["x-kobo-apitoken"] = "e30="
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@kobo.route("/download/<book_id>/<book_format>")
|
||||||
|
@requires_kobo_auth
|
||||||
|
@download_required
|
||||||
|
def download_book(book_id, book_format):
|
||||||
|
return get_download_link(book_id, book_format, "kobo")
|
||||||
|
|
||||||
|
|
||||||
def NATIVE_KOBO_RESOURCES():
|
def NATIVE_KOBO_RESOURCES():
|
||||||
return {
|
return {
|
||||||
"account_page": "https://secure.kobobooks.com/profile",
|
"account_page": "https://secure.kobobooks.com/profile",
|
||||||
@@ -979,7 +1014,7 @@ def NATIVE_KOBO_RESOURCES():
|
|||||||
"blackstone_header": {"key": "x-amz-request-payer", "value": "requester"},
|
"blackstone_header": {"key": "x-amz-request-payer", "value": "requester"},
|
||||||
"book": "https://storeapi.kobo.com/v1/products/books/{ProductId}",
|
"book": "https://storeapi.kobo.com/v1/products/books/{ProductId}",
|
||||||
"book_detail_page": "https://store.kobobooks.com/{culture}/ebook/{slug}",
|
"book_detail_page": "https://store.kobobooks.com/{culture}/ebook/{slug}",
|
||||||
"book_detail_page_rakuten": "http://books.rakuten.co.jp/rk/{crossrevisionid}",
|
"book_detail_page_rakuten": "https://books.rakuten.co.jp/rk/{crossrevisionid}",
|
||||||
"book_landing_page": "https://store.kobobooks.com/ebooks",
|
"book_landing_page": "https://store.kobobooks.com/ebooks",
|
||||||
"book_subscription": "https://storeapi.kobo.com/v1/products/books/subscriptions",
|
"book_subscription": "https://storeapi.kobo.com/v1/products/books/subscriptions",
|
||||||
"categories": "https://storeapi.kobo.com/v1/categories",
|
"categories": "https://storeapi.kobo.com/v1/categories",
|
||||||
@@ -1017,7 +1052,7 @@ def NATIVE_KOBO_RESOURCES():
|
|||||||
"get_tests_request": "https://storeapi.kobo.com/v1/analytics/gettests",
|
"get_tests_request": "https://storeapi.kobo.com/v1/analytics/gettests",
|
||||||
"giftcard_epd_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem-ereader",
|
"giftcard_epd_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem-ereader",
|
||||||
"giftcard_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem",
|
"giftcard_redeem_url": "https://www.kobo.com/{storefront}/{language}/redeem",
|
||||||
"help_page": "http://www.kobo.com/help",
|
"help_page": "https://www.kobo.com/help",
|
||||||
"kobo_audiobooks_enabled": "False",
|
"kobo_audiobooks_enabled": "False",
|
||||||
"kobo_audiobooks_orange_deal_enabled": "False",
|
"kobo_audiobooks_orange_deal_enabled": "False",
|
||||||
"kobo_audiobooks_subscriptions_enabled": "False",
|
"kobo_audiobooks_subscriptions_enabled": "False",
|
||||||
|
|||||||
@@ -64,11 +64,11 @@ from datetime import datetime
|
|||||||
from os import urandom
|
from os import urandom
|
||||||
|
|
||||||
from flask import g, Blueprint, url_for, abort, request
|
from flask import g, Blueprint, url_for, abort, request
|
||||||
from flask_login import login_user, login_required
|
from flask_login import login_user, current_user, login_required
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
from . import logger, ub, lm
|
from . import logger, config, calibre_db, db, helper, ub, lm
|
||||||
from .web import render_title_template
|
from .render_template import render_title_template
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
@@ -81,6 +81,7 @@ log = logger.create()
|
|||||||
|
|
||||||
def register_url_value_preprocessor(kobo):
|
def register_url_value_preprocessor(kobo):
|
||||||
@kobo.url_value_preprocessor
|
@kobo.url_value_preprocessor
|
||||||
|
# pylint: disable=unused-variable
|
||||||
def pop_auth_token(__, values):
|
def pop_auth_token(__, values):
|
||||||
g.auth_token = values.pop("auth_token")
|
g.auth_token = values.pop("auth_token")
|
||||||
|
|
||||||
@@ -121,7 +122,11 @@ kobo_auth = Blueprint("kobo_auth", __name__, url_prefix="/kobo_auth")
|
|||||||
@kobo_auth.route("/generate_auth_token/<int:user_id>")
|
@kobo_auth.route("/generate_auth_token/<int:user_id>")
|
||||||
@login_required
|
@login_required
|
||||||
def generate_auth_token(user_id):
|
def generate_auth_token(user_id):
|
||||||
host = ':'.join(request.host.rsplit(':')[0:-1])
|
host_list = request.host.rsplit(':')
|
||||||
|
if len(host_list) == 1:
|
||||||
|
host = ':'.join(host_list)
|
||||||
|
else:
|
||||||
|
host = ':'.join(host_list[0:-1])
|
||||||
if host.startswith('127.') or host.lower() == 'localhost' or host.startswith('[::ffff:7f'):
|
if host.startswith('127.') or host.lower() == 'localhost' or host.startswith('[::ffff:7f'):
|
||||||
warning = _('PLease access calibre-web from non localhost to get valid api_endpoint for kobo device')
|
warning = _('PLease access calibre-web from non localhost to get valid api_endpoint for kobo device')
|
||||||
return render_title_template(
|
return render_title_template(
|
||||||
@@ -143,7 +148,15 @@ def generate_auth_token(user_id):
|
|||||||
auth_token.token_type = 1
|
auth_token.token_type = 1
|
||||||
|
|
||||||
ub.session.add(auth_token)
|
ub.session.add(auth_token)
|
||||||
ub.session.commit()
|
ub.session_commit()
|
||||||
|
|
||||||
|
books = calibre_db.session.query(db.Books).join(db.Data).all()
|
||||||
|
|
||||||
|
for book in books:
|
||||||
|
formats = [data.format for data in book.data]
|
||||||
|
if not 'KEPUB' in formats and config.config_kepubifypath and 'EPUB' in formats:
|
||||||
|
helper.convert_book_format(book.id, config.config_calibre_dir, 'EPUB', 'KEPUB', current_user.name)
|
||||||
|
|
||||||
return render_title_template(
|
return render_title_template(
|
||||||
"generate_kobo_auth_url.html",
|
"generate_kobo_auth_url.html",
|
||||||
title=_(u"Kobo Setup"),
|
title=_(u"Kobo Setup"),
|
||||||
@@ -160,5 +173,5 @@ def delete_auth_token(user_id):
|
|||||||
# Invalidate any prevously generated Kobo Auth token for this user.
|
# Invalidate any prevously generated Kobo Auth token for this user.
|
||||||
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\
|
ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.user_id == user_id)\
|
||||||
.filter(ub.RemoteAuthToken.token_type==1).delete()
|
.filter(ub.RemoteAuthToken.token_type==1).delete()
|
||||||
ub.session.commit()
|
|
||||||
return ""
|
return ub.session_commit()
|
||||||
|
|||||||
@@ -41,10 +41,37 @@ logging.addLevelName(logging.WARNING, "WARN")
|
|||||||
logging.addLevelName(logging.CRITICAL, "CRIT")
|
logging.addLevelName(logging.CRITICAL, "CRIT")
|
||||||
|
|
||||||
|
|
||||||
|
class _Logger(logging.Logger):
|
||||||
|
|
||||||
|
def debug_or_exception(self, message, *args, **kwargs):
|
||||||
|
if sys.version_info > (3, 7):
|
||||||
|
if is_debug_enabled():
|
||||||
|
self.exception(message, stacklevel=2, *args, **kwargs)
|
||||||
|
else:
|
||||||
|
self.error(message, stacklevel=2, *args, **kwargs)
|
||||||
|
elif sys.version_info > (3, 0):
|
||||||
|
if is_debug_enabled():
|
||||||
|
self.exception(message, stack_info=True, *args, **kwargs)
|
||||||
|
else:
|
||||||
|
self.error(message, *args, **kwargs)
|
||||||
|
else:
|
||||||
|
if is_debug_enabled():
|
||||||
|
self.exception(message, *args, **kwargs)
|
||||||
|
else:
|
||||||
|
self.error(message, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def debug_no_auth(self, message, *args, **kwargs):
|
||||||
|
message = message.strip("\r\n")
|
||||||
|
if message.startswith("send: AUTH"):
|
||||||
|
self.debug(message[:16], *args, **kwargs)
|
||||||
|
else:
|
||||||
|
self.debug(message, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def get(name=None):
|
def get(name=None):
|
||||||
return logging.getLogger(name)
|
return logging.getLogger(name)
|
||||||
|
|
||||||
|
|
||||||
def create():
|
def create():
|
||||||
parent_frame = inspect.stack(0)[1]
|
parent_frame = inspect.stack(0)[1]
|
||||||
if hasattr(parent_frame, 'frame'):
|
if hasattr(parent_frame, 'frame'):
|
||||||
@@ -54,7 +81,6 @@ def create():
|
|||||||
parent_module = inspect.getmodule(parent_frame)
|
parent_module = inspect.getmodule(parent_frame)
|
||||||
return get(parent_module.__name__)
|
return get(parent_module.__name__)
|
||||||
|
|
||||||
|
|
||||||
def is_debug_enabled():
|
def is_debug_enabled():
|
||||||
return logging.root.level <= logging.DEBUG
|
return logging.root.level <= logging.DEBUG
|
||||||
|
|
||||||
@@ -82,7 +108,6 @@ def _absolute_log_file(log_file, default_log_file):
|
|||||||
if not os.path.dirname(log_file):
|
if not os.path.dirname(log_file):
|
||||||
log_file = os.path.join(_CONFIG_DIR, log_file)
|
log_file = os.path.join(_CONFIG_DIR, log_file)
|
||||||
return os.path.abspath(log_file)
|
return os.path.abspath(log_file)
|
||||||
|
|
||||||
return default_log_file
|
return default_log_file
|
||||||
|
|
||||||
|
|
||||||
@@ -100,6 +125,7 @@ def setup(log_file, log_level=None):
|
|||||||
May be called multiple times.
|
May be called multiple times.
|
||||||
'''
|
'''
|
||||||
log_level = log_level or DEFAULT_LOG_LEVEL
|
log_level = log_level or DEFAULT_LOG_LEVEL
|
||||||
|
logging.setLoggerClass(_Logger)
|
||||||
logging.getLogger(__package__).setLevel(log_level)
|
logging.getLogger(__package__).setLevel(log_level)
|
||||||
|
|
||||||
r = logging.root
|
r = logging.root
|
||||||
@@ -115,7 +141,7 @@ def setup(log_file, log_level=None):
|
|||||||
if previous_handler:
|
if previous_handler:
|
||||||
# if the log_file has not changed, don't create a new handler
|
# if the log_file has not changed, don't create a new handler
|
||||||
if getattr(previous_handler, 'baseFilename', None) == log_file:
|
if getattr(previous_handler, 'baseFilename', None) == log_file:
|
||||||
return
|
return "" if log_file == DEFAULT_LOG_FILE else log_file
|
||||||
logging.debug("logging to %s level %s", log_file, r.level)
|
logging.debug("logging to %s level %s", log_file, r.level)
|
||||||
|
|
||||||
if log_file == LOG_TO_STDERR or log_file == LOG_TO_STDOUT:
|
if log_file == LOG_TO_STDERR or log_file == LOG_TO_STDOUT:
|
||||||
@@ -127,17 +153,19 @@ def setup(log_file, log_level=None):
|
|||||||
file_handler.baseFilename = log_file
|
file_handler.baseFilename = log_file
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
file_handler = RotatingFileHandler(log_file, maxBytes=50000, backupCount=2)
|
file_handler = RotatingFileHandler(log_file, maxBytes=100000, backupCount=2, encoding='utf-8')
|
||||||
except IOError:
|
except IOError:
|
||||||
if log_file == DEFAULT_LOG_FILE:
|
if log_file == DEFAULT_LOG_FILE:
|
||||||
raise
|
raise
|
||||||
file_handler = RotatingFileHandler(DEFAULT_LOG_FILE, maxBytes=50000, backupCount=2)
|
file_handler = RotatingFileHandler(DEFAULT_LOG_FILE, maxBytes=100000, backupCount=2, encoding='utf-8')
|
||||||
|
log_file = ""
|
||||||
file_handler.setFormatter(FORMATTER)
|
file_handler.setFormatter(FORMATTER)
|
||||||
|
|
||||||
for h in r.handlers:
|
for h in r.handlers:
|
||||||
r.removeHandler(h)
|
r.removeHandler(h)
|
||||||
h.close()
|
h.close()
|
||||||
r.addHandler(file_handler)
|
r.addHandler(file_handler)
|
||||||
|
return "" if log_file == DEFAULT_LOG_FILE else log_file
|
||||||
|
|
||||||
|
|
||||||
def create_access_log(log_file, log_name, formatter):
|
def create_access_log(log_file, log_name, formatter):
|
||||||
@@ -150,11 +178,18 @@ def create_access_log(log_file, log_name, formatter):
|
|||||||
access_log = logging.getLogger(log_name)
|
access_log = logging.getLogger(log_name)
|
||||||
access_log.propagate = False
|
access_log.propagate = False
|
||||||
access_log.setLevel(logging.INFO)
|
access_log.setLevel(logging.INFO)
|
||||||
|
try:
|
||||||
|
file_handler = RotatingFileHandler(log_file, maxBytes=50000, backupCount=2, encoding='utf-8')
|
||||||
|
except IOError:
|
||||||
|
if log_file == DEFAULT_ACCESS_LOG:
|
||||||
|
raise
|
||||||
|
file_handler = RotatingFileHandler(DEFAULT_ACCESS_LOG, maxBytes=50000, backupCount=2, encoding='utf-8')
|
||||||
|
log_file = ""
|
||||||
|
|
||||||
file_handler = RotatingFileHandler(log_file, maxBytes=50000, backupCount=2)
|
|
||||||
file_handler.setFormatter(formatter)
|
file_handler.setFormatter(formatter)
|
||||||
access_log.addHandler(file_handler)
|
access_log.addHandler(file_handler)
|
||||||
return access_log
|
return access_log, \
|
||||||
|
"" if _absolute_log_file(log_file, DEFAULT_ACCESS_LOG) == DEFAULT_ACCESS_LOG else log_file
|
||||||
|
|
||||||
|
|
||||||
# Enable logging of smtp lib debug output
|
# Enable logging of smtp lib debug output
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
from __future__ import division, print_function, unicode_literals
|
from __future__ import division, print_function, unicode_literals
|
||||||
from flask import session
|
from flask import session
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from flask_dance.consumer.backend.sqla import SQLAlchemyBackend, first, _get_real_user
|
from flask_dance.consumer.backend.sqla import SQLAlchemyBackend, first, _get_real_user
|
||||||
from sqlalchemy.orm.exc import NoResultFound
|
from sqlalchemy.orm.exc import NoResultFound
|
||||||
@@ -34,134 +33,131 @@ except ImportError:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
|
||||||
class OAuthBackend(SQLAlchemyBackend):
|
|
||||||
"""
|
|
||||||
Stores and retrieves OAuth tokens using a relational database through
|
|
||||||
the `SQLAlchemy`_ ORM.
|
|
||||||
|
|
||||||
.. _SQLAlchemy: http://www.sqlalchemy.org/
|
class OAuthBackend(SQLAlchemyBackend):
|
||||||
"""
|
"""
|
||||||
def __init__(self, model, session, provider_id,
|
Stores and retrieves OAuth tokens using a relational database through
|
||||||
user=None, user_id=None, user_required=None, anon_user=None,
|
the `SQLAlchemy`_ ORM.
|
||||||
cache=None):
|
|
||||||
self.provider_id = provider_id
|
|
||||||
super(OAuthBackend, self).__init__(model, session, user, user_id, user_required, anon_user, cache)
|
|
||||||
|
|
||||||
def get(self, blueprint, user=None, user_id=None):
|
.. _SQLAlchemy: https://www.sqlalchemy.org/
|
||||||
if self.provider_id + '_oauth_token' in session and session[self.provider_id + '_oauth_token'] != '':
|
"""
|
||||||
return session[self.provider_id + '_oauth_token']
|
def __init__(self, model, session, provider_id,
|
||||||
# check cache
|
user=None, user_id=None, user_required=None, anon_user=None,
|
||||||
cache_key = self.make_cache_key(blueprint=blueprint, user=user, user_id=user_id)
|
cache=None):
|
||||||
token = self.cache.get(cache_key)
|
self.provider_id = provider_id
|
||||||
if token:
|
super(OAuthBackend, self).__init__(model, session, user, user_id, user_required, anon_user, cache)
|
||||||
return token
|
|
||||||
|
|
||||||
# if not cached, make database queries
|
|
||||||
query = (
|
|
||||||
self.session.query(self.model)
|
|
||||||
.filter_by(provider=self.provider_id)
|
|
||||||
)
|
|
||||||
uid = first([user_id, self.user_id, blueprint.config.get("user_id")])
|
|
||||||
u = first(_get_real_user(ref, self.anon_user)
|
|
||||||
for ref in (user, self.user, blueprint.config.get("user")))
|
|
||||||
|
|
||||||
use_provider_user_id = False
|
|
||||||
if self.provider_id + '_oauth_user_id' in session and session[self.provider_id + '_oauth_user_id'] != '':
|
|
||||||
query = query.filter_by(provider_user_id=session[self.provider_id + '_oauth_user_id'])
|
|
||||||
use_provider_user_id = True
|
|
||||||
|
|
||||||
if self.user_required and not u and not uid and not use_provider_user_id:
|
|
||||||
# raise ValueError("Cannot get OAuth token without an associated user")
|
|
||||||
return None
|
|
||||||
# check for user ID
|
|
||||||
if hasattr(self.model, "user_id") and uid:
|
|
||||||
query = query.filter_by(user_id=uid)
|
|
||||||
# check for user (relationship property)
|
|
||||||
elif hasattr(self.model, "user") and u:
|
|
||||||
query = query.filter_by(user=u)
|
|
||||||
# if we have the property, but not value, filter by None
|
|
||||||
elif hasattr(self.model, "user_id"):
|
|
||||||
query = query.filter_by(user_id=None)
|
|
||||||
# run query
|
|
||||||
try:
|
|
||||||
token = query.one().token
|
|
||||||
except NoResultFound:
|
|
||||||
token = None
|
|
||||||
|
|
||||||
# cache the result
|
|
||||||
self.cache.set(cache_key, token)
|
|
||||||
|
|
||||||
|
def get(self, blueprint, user=None, user_id=None):
|
||||||
|
if self.provider_id + '_oauth_token' in session and session[self.provider_id + '_oauth_token'] != '':
|
||||||
|
return session[self.provider_id + '_oauth_token']
|
||||||
|
# check cache
|
||||||
|
cache_key = self.make_cache_key(blueprint=blueprint, user=user, user_id=user_id)
|
||||||
|
token = self.cache.get(cache_key)
|
||||||
|
if token:
|
||||||
return token
|
return token
|
||||||
|
|
||||||
def set(self, blueprint, token, user=None, user_id=None):
|
# if not cached, make database queries
|
||||||
uid = first([user_id, self.user_id, blueprint.config.get("user_id")])
|
query = (
|
||||||
u = first(_get_real_user(ref, self.anon_user)
|
self.session.query(self.model)
|
||||||
for ref in (user, self.user, blueprint.config.get("user")))
|
.filter_by(provider=self.provider_id)
|
||||||
|
)
|
||||||
|
uid = first([user_id, self.user_id, blueprint.config.get("user_id")])
|
||||||
|
u = first(_get_real_user(ref, self.anon_user)
|
||||||
|
for ref in (user, self.user, blueprint.config.get("user")))
|
||||||
|
|
||||||
if self.user_required and not u and not uid:
|
use_provider_user_id = False
|
||||||
raise ValueError("Cannot set OAuth token without an associated user")
|
if self.provider_id + '_oauth_user_id' in session and session[self.provider_id + '_oauth_user_id'] != '':
|
||||||
|
query = query.filter_by(provider_user_id=session[self.provider_id + '_oauth_user_id'])
|
||||||
|
use_provider_user_id = True
|
||||||
|
|
||||||
# if there was an existing model, delete it
|
if self.user_required and not u and not uid and not use_provider_user_id:
|
||||||
existing_query = (
|
# raise ValueError("Cannot get OAuth token without an associated user")
|
||||||
self.session.query(self.model)
|
return None
|
||||||
.filter_by(provider=self.provider_id)
|
# check for user ID
|
||||||
)
|
if hasattr(self.model, "user_id") and uid:
|
||||||
# check for user ID
|
query = query.filter_by(user_id=uid)
|
||||||
has_user_id = hasattr(self.model, "user_id")
|
# check for user (relationship property)
|
||||||
if has_user_id and uid:
|
elif hasattr(self.model, "user") and u:
|
||||||
existing_query = existing_query.filter_by(user_id=uid)
|
query = query.filter_by(user=u)
|
||||||
# check for user (relationship property)
|
# if we have the property, but not value, filter by None
|
||||||
has_user = hasattr(self.model, "user")
|
elif hasattr(self.model, "user_id"):
|
||||||
if has_user and u:
|
query = query.filter_by(user_id=None)
|
||||||
existing_query = existing_query.filter_by(user=u)
|
# run query
|
||||||
# queue up delete query -- won't be run until commit()
|
try:
|
||||||
existing_query.delete()
|
token = query.one().token
|
||||||
# create a new model for this token
|
except NoResultFound:
|
||||||
kwargs = {
|
token = None
|
||||||
"provider": self.provider_id,
|
|
||||||
"token": token,
|
|
||||||
}
|
|
||||||
if has_user_id and uid:
|
|
||||||
kwargs["user_id"] = uid
|
|
||||||
if has_user and u:
|
|
||||||
kwargs["user"] = u
|
|
||||||
self.session.add(self.model(**kwargs))
|
|
||||||
# commit to delete and add simultaneously
|
|
||||||
self.session.commit()
|
|
||||||
# invalidate cache
|
|
||||||
self.cache.delete(self.make_cache_key(
|
|
||||||
blueprint=blueprint, user=user, user_id=user_id
|
|
||||||
))
|
|
||||||
|
|
||||||
def delete(self, blueprint, user=None, user_id=None):
|
# cache the result
|
||||||
query = (
|
self.cache.set(cache_key, token)
|
||||||
self.session.query(self.model)
|
|
||||||
.filter_by(provider=self.provider_id)
|
|
||||||
)
|
|
||||||
uid = first([user_id, self.user_id, blueprint.config.get("user_id")])
|
|
||||||
u = first(_get_real_user(ref, self.anon_user)
|
|
||||||
for ref in (user, self.user, blueprint.config.get("user")))
|
|
||||||
|
|
||||||
if self.user_required and not u and not uid:
|
return token
|
||||||
raise ValueError("Cannot delete OAuth token without an associated user")
|
|
||||||
|
|
||||||
# check for user ID
|
def set(self, blueprint, token, user=None, user_id=None):
|
||||||
if hasattr(self.model, "user_id") and uid:
|
uid = first([user_id, self.user_id, blueprint.config.get("user_id")])
|
||||||
query = query.filter_by(user_id=uid)
|
u = first(_get_real_user(ref, self.anon_user)
|
||||||
# check for user (relationship property)
|
for ref in (user, self.user, blueprint.config.get("user")))
|
||||||
elif hasattr(self.model, "user") and u:
|
|
||||||
query = query.filter_by(user=u)
|
|
||||||
# if we have the property, but not value, filter by None
|
|
||||||
elif hasattr(self.model, "user_id"):
|
|
||||||
query = query.filter_by(user_id=None)
|
|
||||||
# run query
|
|
||||||
query.delete()
|
|
||||||
self.session.commit()
|
|
||||||
# invalidate cache
|
|
||||||
self.cache.delete(self.make_cache_key(
|
|
||||||
blueprint=blueprint, user=user, user_id=user_id,
|
|
||||||
))
|
|
||||||
|
|
||||||
except Exception:
|
if self.user_required and not u and not uid:
|
||||||
pass
|
raise ValueError("Cannot set OAuth token without an associated user")
|
||||||
|
|
||||||
|
# if there was an existing model, delete it
|
||||||
|
existing_query = (
|
||||||
|
self.session.query(self.model)
|
||||||
|
.filter_by(provider=self.provider_id)
|
||||||
|
)
|
||||||
|
# check for user ID
|
||||||
|
has_user_id = hasattr(self.model, "user_id")
|
||||||
|
if has_user_id and uid:
|
||||||
|
existing_query = existing_query.filter_by(user_id=uid)
|
||||||
|
# check for user (relationship property)
|
||||||
|
has_user = hasattr(self.model, "user")
|
||||||
|
if has_user and u:
|
||||||
|
existing_query = existing_query.filter_by(user=u)
|
||||||
|
# queue up delete query -- won't be run until commit()
|
||||||
|
existing_query.delete()
|
||||||
|
# create a new model for this token
|
||||||
|
kwargs = {
|
||||||
|
"provider": self.provider_id,
|
||||||
|
"token": token,
|
||||||
|
}
|
||||||
|
if has_user_id and uid:
|
||||||
|
kwargs["user_id"] = uid
|
||||||
|
if has_user and u:
|
||||||
|
kwargs["user"] = u
|
||||||
|
self.session.add(self.model(**kwargs))
|
||||||
|
# commit to delete and add simultaneously
|
||||||
|
self.session.commit()
|
||||||
|
# invalidate cache
|
||||||
|
self.cache.delete(self.make_cache_key(
|
||||||
|
blueprint=blueprint, user=user, user_id=user_id
|
||||||
|
))
|
||||||
|
|
||||||
|
def delete(self, blueprint, user=None, user_id=None):
|
||||||
|
query = (
|
||||||
|
self.session.query(self.model)
|
||||||
|
.filter_by(provider=self.provider_id)
|
||||||
|
)
|
||||||
|
uid = first([user_id, self.user_id, blueprint.config.get("user_id")])
|
||||||
|
u = first(_get_real_user(ref, self.anon_user)
|
||||||
|
for ref in (user, self.user, blueprint.config.get("user")))
|
||||||
|
|
||||||
|
if self.user_required and not u and not uid:
|
||||||
|
raise ValueError("Cannot delete OAuth token without an associated user")
|
||||||
|
|
||||||
|
# check for user ID
|
||||||
|
if hasattr(self.model, "user_id") and uid:
|
||||||
|
query = query.filter_by(user_id=uid)
|
||||||
|
# check for user (relationship property)
|
||||||
|
elif hasattr(self.model, "user") and u:
|
||||||
|
query = query.filter_by(user=u)
|
||||||
|
# if we have the property, but not value, filter by None
|
||||||
|
elif hasattr(self.model, "user_id"):
|
||||||
|
query = query.filter_by(user_id=None)
|
||||||
|
# run query
|
||||||
|
query.delete()
|
||||||
|
self.session.commit()
|
||||||
|
# invalidate cache
|
||||||
|
self.cache.delete(self.make_cache_key(
|
||||||
|
blueprint=blueprint, user=user, user_id=user_id,
|
||||||
|
))
|
||||||
|
|||||||
@@ -30,15 +30,20 @@ from flask_babel import gettext as _
|
|||||||
from flask_dance.consumer import oauth_authorized, oauth_error
|
from flask_dance.consumer import oauth_authorized, oauth_error
|
||||||
from flask_dance.contrib.github import make_github_blueprint, github
|
from flask_dance.contrib.github import make_github_blueprint, github
|
||||||
from flask_dance.contrib.google import make_google_blueprint, google
|
from flask_dance.contrib.google import make_google_blueprint, google
|
||||||
from flask_login import login_user, current_user
|
from oauthlib.oauth2 import TokenExpiredError, InvalidGrantError
|
||||||
|
from flask_login import login_user, current_user, login_required
|
||||||
from sqlalchemy.orm.exc import NoResultFound
|
from sqlalchemy.orm.exc import NoResultFound
|
||||||
|
|
||||||
from . import constants, logger, config, app, ub
|
from . import constants, logger, config, app, ub
|
||||||
from .web import login_required
|
|
||||||
from .oauth import OAuthBackend, backend_resultcode
|
try:
|
||||||
|
from .oauth import OAuthBackend, backend_resultcode
|
||||||
|
except NameError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
oauth_check = {}
|
oauth_check = {}
|
||||||
|
oauthblueprints = []
|
||||||
oauth = Blueprint('oauth', __name__)
|
oauth = Blueprint('oauth', __name__)
|
||||||
log = logger.create()
|
log = logger.create()
|
||||||
|
|
||||||
@@ -84,11 +89,7 @@ def register_user_with_oauth(user=None):
|
|||||||
except NoResultFound:
|
except NoResultFound:
|
||||||
# no found, return error
|
# no found, return error
|
||||||
return
|
return
|
||||||
try:
|
ub.session_commit("User {} with OAuth for provider {} registered".format(user.name, oauth_key))
|
||||||
ub.session.commit()
|
|
||||||
except Exception as e:
|
|
||||||
log.exception(e)
|
|
||||||
ub.session.rollback()
|
|
||||||
|
|
||||||
|
|
||||||
def logout_oauth_user():
|
def logout_oauth_user():
|
||||||
@@ -97,19 +98,122 @@ def logout_oauth_user():
|
|||||||
session.pop(str(oauth_key) + '_oauth_user_id')
|
session.pop(str(oauth_key) + '_oauth_user_id')
|
||||||
|
|
||||||
|
|
||||||
if ub.oauth_support:
|
def oauth_update_token(provider_id, token, provider_user_id):
|
||||||
oauthblueprints = []
|
session[provider_id + "_oauth_user_id"] = provider_user_id
|
||||||
|
session[provider_id + "_oauth_token"] = token
|
||||||
|
|
||||||
|
# Find this OAuth token in the database, or create it
|
||||||
|
query = ub.session.query(ub.OAuth).filter_by(
|
||||||
|
provider=provider_id,
|
||||||
|
provider_user_id=provider_user_id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
oauth_entry = query.one()
|
||||||
|
# update token
|
||||||
|
oauth_entry.token = token
|
||||||
|
except NoResultFound:
|
||||||
|
oauth_entry = ub.OAuth(
|
||||||
|
provider=provider_id,
|
||||||
|
provider_user_id=provider_user_id,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
ub.session.add(oauth_entry)
|
||||||
|
ub.session_commit()
|
||||||
|
|
||||||
|
# Disable Flask-Dance's default behavior for saving the OAuth token
|
||||||
|
# Value differrs depending on flask-dance version
|
||||||
|
return backend_resultcode
|
||||||
|
|
||||||
|
|
||||||
|
def bind_oauth_or_register(provider_id, provider_user_id, redirect_url, provider_name):
|
||||||
|
query = ub.session.query(ub.OAuth).filter_by(
|
||||||
|
provider=provider_id,
|
||||||
|
provider_user_id=provider_user_id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
oauth_entry = query.first()
|
||||||
|
# already bind with user, just login
|
||||||
|
if oauth_entry.user:
|
||||||
|
login_user(oauth_entry.user)
|
||||||
|
log.debug(u"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),
|
||||||
|
category="success")
|
||||||
|
return redirect(url_for('web.index'))
|
||||||
|
else:
|
||||||
|
# bind to current user
|
||||||
|
if current_user and current_user.is_authenticated:
|
||||||
|
oauth_entry.user = current_user
|
||||||
|
try:
|
||||||
|
ub.session.add(oauth_entry)
|
||||||
|
ub.session.commit()
|
||||||
|
flash(_(u"Link to %(oauth)s Succeeded", oauth=provider_name), category="success")
|
||||||
|
log.info("Link to {} Succeeded".format(provider_name))
|
||||||
|
return redirect(url_for('web.profile'))
|
||||||
|
except Exception as ex:
|
||||||
|
log.debug_or_exception(ex)
|
||||||
|
ub.session.rollback()
|
||||||
|
else:
|
||||||
|
flash(_(u"Login failed, No User Linked With OAuth Account"), category="error")
|
||||||
|
log.info('Login failed, No User Linked With OAuth Account')
|
||||||
|
return redirect(url_for('web.login'))
|
||||||
|
# return redirect(url_for('web.login'))
|
||||||
|
# if config.config_public_reg:
|
||||||
|
# return redirect(url_for('web.register'))
|
||||||
|
# else:
|
||||||
|
# flash(_(u"Public registration is not enabled"), category="error")
|
||||||
|
# return redirect(url_for(redirect_url))
|
||||||
|
except (NoResultFound, AttributeError):
|
||||||
|
return redirect(url_for(redirect_url))
|
||||||
|
|
||||||
|
|
||||||
|
def get_oauth_status():
|
||||||
|
status = []
|
||||||
|
query = ub.session.query(ub.OAuth).filter_by(
|
||||||
|
user_id=current_user.id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
oauths = query.all()
|
||||||
|
for oauth_entry in oauths:
|
||||||
|
status.append(int(oauth_entry.provider))
|
||||||
|
return status
|
||||||
|
except NoResultFound:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def unlink_oauth(provider):
|
||||||
|
if request.host_url + 'me' != request.referrer:
|
||||||
|
pass
|
||||||
|
query = ub.session.query(ub.OAuth).filter_by(
|
||||||
|
provider=provider,
|
||||||
|
user_id=current_user.id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
oauth_entry = query.one()
|
||||||
|
if current_user and current_user.is_authenticated:
|
||||||
|
oauth_entry.user = current_user
|
||||||
|
try:
|
||||||
|
ub.session.delete(oauth_entry)
|
||||||
|
ub.session.commit()
|
||||||
|
logout_oauth_user()
|
||||||
|
flash(_(u"Unlink to %(oauth)s Succeeded", oauth=oauth_check[provider]), category="success")
|
||||||
|
log.info("Unlink to {} Succeeded".format(oauth_check[provider]))
|
||||||
|
except Exception as ex:
|
||||||
|
log.debug_or_exception(ex)
|
||||||
|
ub.session.rollback()
|
||||||
|
flash(_(u"Unlink to %(oauth)s Failed", oauth=oauth_check[provider]), category="error")
|
||||||
|
except NoResultFound:
|
||||||
|
log.warning("oauth %s for user %d not found", provider, current_user.id)
|
||||||
|
flash(_(u"Not Linked to %(oauth)s", oauth=provider), category="error")
|
||||||
|
return redirect(url_for('web.profile'))
|
||||||
|
|
||||||
|
def generate_oauth_blueprints():
|
||||||
if not ub.session.query(ub.OAuthProvider).count():
|
if not ub.session.query(ub.OAuthProvider).count():
|
||||||
oauthProvider = ub.OAuthProvider()
|
for provider in ("github", "google"):
|
||||||
oauthProvider.provider_name = "github"
|
oauthProvider = ub.OAuthProvider()
|
||||||
oauthProvider.active = False
|
oauthProvider.provider_name = provider
|
||||||
ub.session.add(oauthProvider)
|
oauthProvider.active = False
|
||||||
ub.session.commit()
|
ub.session.add(oauthProvider)
|
||||||
oauthProvider = ub.OAuthProvider()
|
ub.session_commit("{} Blueprint Created".format(provider))
|
||||||
oauthProvider.provider_name = "google"
|
|
||||||
oauthProvider.active = False
|
|
||||||
ub.session.add(oauthProvider)
|
|
||||||
ub.session.commit()
|
|
||||||
|
|
||||||
oauth_ids = ub.session.query(ub.OAuthProvider).all()
|
oauth_ids = ub.session.query(ub.OAuthProvider).all()
|
||||||
ele1 = dict(provider_name='github',
|
ele1 = dict(provider_name='github',
|
||||||
@@ -122,10 +226,10 @@ if ub.oauth_support:
|
|||||||
ele2 = dict(provider_name='google',
|
ele2 = dict(provider_name='google',
|
||||||
id=oauth_ids[1].id,
|
id=oauth_ids[1].id,
|
||||||
active=oauth_ids[1].active,
|
active=oauth_ids[1].active,
|
||||||
scope=["https://www.googleapis.com/auth/plus.me", "https://www.googleapis.com/auth/userinfo.email"],
|
scope=["https://www.googleapis.com/auth/userinfo.email"],
|
||||||
oauth_client_id=oauth_ids[1].oauth_client_id,
|
oauth_client_id=oauth_ids[1].oauth_client_id,
|
||||||
oauth_client_secret=oauth_ids[1].oauth_client_secret,
|
oauth_client_secret=oauth_ids[1].oauth_client_secret,
|
||||||
obtain_link='https://github.com/settings/developers')
|
obtain_link='https://console.developers.google.com/apis/credentials')
|
||||||
oauthblueprints.append(ele1)
|
oauthblueprints.append(ele1)
|
||||||
oauthblueprints.append(ele2)
|
oauthblueprints.append(ele2)
|
||||||
|
|
||||||
@@ -146,17 +250,23 @@ if ub.oauth_support:
|
|||||||
app.register_blueprint(blueprint, url_prefix="/login")
|
app.register_blueprint(blueprint, url_prefix="/login")
|
||||||
if element['active']:
|
if element['active']:
|
||||||
register_oauth_blueprint(element['id'], element['provider_name'])
|
register_oauth_blueprint(element['id'], element['provider_name'])
|
||||||
|
return oauthblueprints
|
||||||
|
|
||||||
|
|
||||||
|
if ub.oauth_support:
|
||||||
|
oauthblueprints = generate_oauth_blueprints()
|
||||||
|
|
||||||
@oauth_authorized.connect_via(oauthblueprints[0]['blueprint'])
|
@oauth_authorized.connect_via(oauthblueprints[0]['blueprint'])
|
||||||
def github_logged_in(blueprint, token):
|
def github_logged_in(blueprint, token):
|
||||||
if not token:
|
if not token:
|
||||||
flash(_(u"Failed to log in with GitHub."), category="error")
|
flash(_(u"Failed to log in with GitHub."), category="error")
|
||||||
|
log.error("Failed to log in with GitHub")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
resp = blueprint.session.get("/user")
|
resp = blueprint.session.get("/user")
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
flash(_(u"Failed to fetch user info from GitHub."), category="error")
|
flash(_(u"Failed to fetch user info from GitHub."), category="error")
|
||||||
|
log.error("Failed to fetch user info from GitHub")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
github_info = resp.json()
|
github_info = resp.json()
|
||||||
@@ -168,11 +278,13 @@ if ub.oauth_support:
|
|||||||
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(_(u"Failed to log in with Google."), category="error")
|
||||||
|
log.error("Failed to log in with Google")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
resp = blueprint.session.get("/oauth2/v2/userinfo")
|
resp = blueprint.session.get("/oauth2/v2/userinfo")
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
flash(_(u"Failed to fetch user info from Google."), category="error")
|
flash(_(u"Failed to fetch user info from Google."), category="error")
|
||||||
|
log.error("Failed to fetch user info from Google")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
google_info = resp.json()
|
google_info = resp.json()
|
||||||
@@ -180,117 +292,6 @@ if ub.oauth_support:
|
|||||||
return oauth_update_token(str(oauthblueprints[1]['id']), token, google_user_id)
|
return oauth_update_token(str(oauthblueprints[1]['id']), token, google_user_id)
|
||||||
|
|
||||||
|
|
||||||
def oauth_update_token(provider_id, token, provider_user_id):
|
|
||||||
session[provider_id + "_oauth_user_id"] = provider_user_id
|
|
||||||
session[provider_id + "_oauth_token"] = token
|
|
||||||
|
|
||||||
# Find this OAuth token in the database, or create it
|
|
||||||
query = ub.session.query(ub.OAuth).filter_by(
|
|
||||||
provider=provider_id,
|
|
||||||
provider_user_id=provider_user_id,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
oauth_entry = query.one()
|
|
||||||
# update token
|
|
||||||
oauth_entry.token = token
|
|
||||||
except NoResultFound:
|
|
||||||
oauth_entry = ub.OAuth(
|
|
||||||
provider=provider_id,
|
|
||||||
provider_user_id=provider_user_id,
|
|
||||||
token=token,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
ub.session.add(oauth_entry)
|
|
||||||
ub.session.commit()
|
|
||||||
except Exception as e:
|
|
||||||
log.exception(e)
|
|
||||||
ub.session.rollback()
|
|
||||||
|
|
||||||
# Disable Flask-Dance's default behavior for saving the OAuth token
|
|
||||||
# Value differrs depending on flask-dance version
|
|
||||||
return backend_resultcode
|
|
||||||
|
|
||||||
|
|
||||||
def bind_oauth_or_register(provider_id, provider_user_id, redirect_url, provider_name):
|
|
||||||
query = ub.session.query(ub.OAuth).filter_by(
|
|
||||||
provider=provider_id,
|
|
||||||
provider_user_id=provider_user_id,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
oauth_entry = query.first()
|
|
||||||
# already bind with user, just login
|
|
||||||
if oauth_entry.user:
|
|
||||||
login_user(oauth_entry.user)
|
|
||||||
log.debug(u"You are now logged in as: '%s'", oauth_entry.user.nickname)
|
|
||||||
flash(_(u"you are now logged in as: '%(nickname)s'", nickname= oauth_entry.user.nickname),
|
|
||||||
category="success")
|
|
||||||
return redirect(url_for('web.index'))
|
|
||||||
else:
|
|
||||||
# bind to current user
|
|
||||||
if current_user and current_user.is_authenticated:
|
|
||||||
oauth_entry.user = current_user
|
|
||||||
try:
|
|
||||||
ub.session.add(oauth_entry)
|
|
||||||
ub.session.commit()
|
|
||||||
flash(_(u"Link to %(oauth)s Succeeded", oauth=provider_name), category="success")
|
|
||||||
return redirect(url_for('web.profile'))
|
|
||||||
except Exception as e:
|
|
||||||
log.exception(e)
|
|
||||||
ub.session.rollback()
|
|
||||||
else:
|
|
||||||
flash(_(u"Login failed, No User Linked With OAuth Account"), category="error")
|
|
||||||
log.info('Login failed, No User Linked With OAuth Account')
|
|
||||||
return redirect(url_for('web.login'))
|
|
||||||
# return redirect(url_for('web.login'))
|
|
||||||
# if config.config_public_reg:
|
|
||||||
# return redirect(url_for('web.register'))
|
|
||||||
# else:
|
|
||||||
# flash(_(u"Public registration is not enabled"), category="error")
|
|
||||||
# return redirect(url_for(redirect_url))
|
|
||||||
except (NoResultFound, AttributeError):
|
|
||||||
return redirect(url_for(redirect_url))
|
|
||||||
|
|
||||||
|
|
||||||
def get_oauth_status():
|
|
||||||
status = []
|
|
||||||
query = ub.session.query(ub.OAuth).filter_by(
|
|
||||||
user_id=current_user.id,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
oauths = query.all()
|
|
||||||
for oauth_entry in oauths:
|
|
||||||
status.append(int(oauth_entry.provider))
|
|
||||||
return status
|
|
||||||
except NoResultFound:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def unlink_oauth(provider):
|
|
||||||
if request.host_url + 'me' != request.referrer:
|
|
||||||
pass
|
|
||||||
query = ub.session.query(ub.OAuth).filter_by(
|
|
||||||
provider=provider,
|
|
||||||
user_id=current_user.id,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
oauth_entry = query.one()
|
|
||||||
if current_user and current_user.is_authenticated:
|
|
||||||
oauth_entry.user = current_user
|
|
||||||
try:
|
|
||||||
ub.session.delete(oauth_entry)
|
|
||||||
ub.session.commit()
|
|
||||||
logout_oauth_user()
|
|
||||||
flash(_(u"Unlink to %(oauth)s Succeeded", oauth=oauth_check[provider]), category="success")
|
|
||||||
except Exception as e:
|
|
||||||
log.exception(e)
|
|
||||||
ub.session.rollback()
|
|
||||||
flash(_(u"Unlink to %(oauth)s Failed", oauth=oauth_check[provider]), category="error")
|
|
||||||
except NoResultFound:
|
|
||||||
log.warning("oauth %s for user %d not found", provider, current_user.id)
|
|
||||||
flash(_(u"Not Linked to %(oauth)s.", oauth=oauth_check[provider]), category="error")
|
|
||||||
return redirect(url_for('web.profile'))
|
|
||||||
|
|
||||||
|
|
||||||
# notify on OAuth provider error
|
# notify on OAuth provider error
|
||||||
@oauth_error.connect_via(oauthblueprints[0]['blueprint'])
|
@oauth_error.connect_via(oauthblueprints[0]['blueprint'])
|
||||||
def github_error(blueprint, error, error_description=None, error_uri=None):
|
def github_error(blueprint, error, error_description=None, error_uri=None):
|
||||||
@@ -305,39 +306,6 @@ if ub.oauth_support:
|
|||||||
) # ToDo: Translate
|
) # ToDo: Translate
|
||||||
flash(msg, category="error")
|
flash(msg, category="error")
|
||||||
|
|
||||||
|
|
||||||
@oauth.route('/link/github')
|
|
||||||
@oauth_required
|
|
||||||
def github_login():
|
|
||||||
if not github.authorized:
|
|
||||||
return redirect(url_for('github.login'))
|
|
||||||
account_info = github.get('/user')
|
|
||||||
if account_info.ok:
|
|
||||||
account_info_json = account_info.json()
|
|
||||||
return bind_oauth_or_register(oauthblueprints[0]['id'], account_info_json['id'], 'github.login', 'github')
|
|
||||||
flash(_(u"GitHub Oauth error, please retry later."), category="error")
|
|
||||||
return redirect(url_for('web.login'))
|
|
||||||
|
|
||||||
|
|
||||||
@oauth.route('/unlink/github', methods=["GET"])
|
|
||||||
@login_required
|
|
||||||
def github_login_unlink():
|
|
||||||
return unlink_oauth(oauthblueprints[0]['id'])
|
|
||||||
|
|
||||||
|
|
||||||
@oauth.route('/link/google')
|
|
||||||
@oauth_required
|
|
||||||
def google_login():
|
|
||||||
if not google.authorized:
|
|
||||||
return redirect(url_for("google.login"))
|
|
||||||
resp = google.get("/oauth2/v2/userinfo")
|
|
||||||
if resp.ok:
|
|
||||||
account_info_json = resp.json()
|
|
||||||
return bind_oauth_or_register(oauthblueprints[1]['id'], account_info_json['id'], 'google.login', 'google')
|
|
||||||
flash(_(u"Google Oauth error, please retry later."), category="error")
|
|
||||||
return redirect(url_for('web.login'))
|
|
||||||
|
|
||||||
|
|
||||||
@oauth_error.connect_via(oauthblueprints[1]['blueprint'])
|
@oauth_error.connect_via(oauthblueprints[1]['blueprint'])
|
||||||
def google_error(blueprint, error, error_description=None, error_uri=None):
|
def google_error(blueprint, error, error_description=None, error_uri=None):
|
||||||
msg = (
|
msg = (
|
||||||
@@ -352,7 +320,49 @@ if ub.oauth_support:
|
|||||||
flash(msg, category="error")
|
flash(msg, category="error")
|
||||||
|
|
||||||
|
|
||||||
@oauth.route('/unlink/google', methods=["GET"])
|
@oauth.route('/link/github')
|
||||||
@login_required
|
@oauth_required
|
||||||
def google_login_unlink():
|
def github_login():
|
||||||
return unlink_oauth(oauthblueprints[1]['blueprint'].name)
|
if not github.authorized:
|
||||||
|
return redirect(url_for('github.login'))
|
||||||
|
try:
|
||||||
|
account_info = github.get('/user')
|
||||||
|
if account_info.ok:
|
||||||
|
account_info_json = account_info.json()
|
||||||
|
return bind_oauth_or_register(oauthblueprints[0]['id'], account_info_json['id'], 'github.login', 'github')
|
||||||
|
flash(_(u"GitHub Oauth error, please retry later."), category="error")
|
||||||
|
log.error("GitHub Oauth error, please retry later")
|
||||||
|
except (InvalidGrantError, TokenExpiredError) as e:
|
||||||
|
flash(_(u"GitHub Oauth error: {}").format(e), category="error")
|
||||||
|
log.error(e)
|
||||||
|
return redirect(url_for('web.login'))
|
||||||
|
|
||||||
|
|
||||||
|
@oauth.route('/unlink/github', methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def github_login_unlink():
|
||||||
|
return unlink_oauth(oauthblueprints[0]['id'])
|
||||||
|
|
||||||
|
|
||||||
|
@oauth.route('/link/google')
|
||||||
|
@oauth_required
|
||||||
|
def google_login():
|
||||||
|
if not google.authorized:
|
||||||
|
return redirect(url_for("google.login"))
|
||||||
|
try:
|
||||||
|
resp = google.get("/oauth2/v2/userinfo")
|
||||||
|
if resp.ok:
|
||||||
|
account_info_json = resp.json()
|
||||||
|
return bind_oauth_or_register(oauthblueprints[1]['id'], account_info_json['id'], 'google.login', 'google')
|
||||||
|
flash(_(u"Google Oauth error, please retry later."), category="error")
|
||||||
|
log.error("Google Oauth error, please retry later")
|
||||||
|
except (InvalidGrantError, TokenExpiredError) as e:
|
||||||
|
flash(_(u"Google Oauth error: {}").format(e), category="error")
|
||||||
|
log.error(e)
|
||||||
|
return redirect(url_for('web.login'))
|
||||||
|
|
||||||
|
|
||||||
|
@oauth.route('/unlink/google', methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def google_login_unlink():
|
||||||
|
return unlink_oauth(oauthblueprints[1]['id'])
|
||||||
|
|||||||
@@ -25,15 +25,16 @@ import sys
|
|||||||
import datetime
|
import datetime
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import Blueprint, request, render_template, Response, g, make_response
|
from flask import Blueprint, request, render_template, Response, g, make_response, abort
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
from sqlalchemy.sql.expression import func, text, or_, and_
|
from sqlalchemy.sql.expression import func, text, or_, and_, true
|
||||||
from werkzeug.security import check_password_hash
|
from werkzeug.security import check_password_hash
|
||||||
|
|
||||||
from . import constants, logger, config, db, ub, services, get_locale, isoLanguages
|
from . import constants, logger, config, db, calibre_db, ub, services, get_locale, isoLanguages
|
||||||
from .helper import fill_indexpage, get_download_link, get_book_cover, speaking_language
|
from .helper import get_download_link, get_book_cover
|
||||||
from .pagination import Pagination
|
from .pagination import Pagination
|
||||||
from .web import common_filters, get_search_results, render_read_books, download_required
|
from .web import render_read_books
|
||||||
|
from .usermanagement import load_user_from_request
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from babel import Locale as LC
|
from babel import Locale as LC
|
||||||
from babel.core import UnknownLocaleError
|
from babel.core import UnknownLocaleError
|
||||||
@@ -51,7 +52,7 @@ def requires_basic_auth_if_no_ano(f):
|
|||||||
if not auth or auth.type != 'basic' or not check_auth(auth.username, auth.password):
|
if not auth or auth.type != 'basic' or not check_auth(auth.username, auth.password):
|
||||||
return authenticate()
|
return authenticate()
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
if config.config_login_type == constants.LOGIN_LDAP and services.ldap:
|
if config.config_login_type == constants.LOGIN_LDAP and services.ldap and config.config_anonbrowse != 1:
|
||||||
return services.ldap.basic_auth_required(f)
|
return services.ldap.basic_auth_required(f)
|
||||||
return decorated
|
return decorated
|
||||||
|
|
||||||
@@ -93,22 +94,60 @@ def feed_cc_search(query):
|
|||||||
@opds.route("/opds/search", methods=["GET"])
|
@opds.route("/opds/search", methods=["GET"])
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_normal_search():
|
def feed_normal_search():
|
||||||
return feed_search(request.args.get("query").strip())
|
return feed_search(request.args.get("query", "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/books")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_booksindex():
|
||||||
|
shift = 0
|
||||||
|
off = int(request.args.get("offset") or 0)
|
||||||
|
entries = calibre_db.session.query(func.upper(func.substr(db.Books.sort, 1, 1)).label('id'))\
|
||||||
|
.filter(calibre_db.common_filters()).group_by(func.upper(func.substr(db.Books.sort, 1, 1))).all()
|
||||||
|
|
||||||
|
elements = []
|
||||||
|
if off == 0:
|
||||||
|
elements.append({'id': "00", 'name':_("All")})
|
||||||
|
shift = 1
|
||||||
|
for entry in entries[
|
||||||
|
off + shift - 1:
|
||||||
|
int(off + int(config.config_books_per_page) - shift)]:
|
||||||
|
elements.append({'id': entry.id, 'name': entry.id})
|
||||||
|
|
||||||
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
|
len(entries) + 1)
|
||||||
|
return render_xml_template('feed.xml',
|
||||||
|
letterelements=elements,
|
||||||
|
folder='opds.feed_letter_books',
|
||||||
|
pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/books/letter/<book_id>")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_letter_books(book_id):
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
letter = true() if book_id == "00" else func.upper(db.Books.sort).startswith(book_id)
|
||||||
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
|
db.Books,
|
||||||
|
letter,
|
||||||
|
[db.Books.sort])
|
||||||
|
|
||||||
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@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():
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
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()])
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@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():
|
||||||
entries = db.session.query(db.Books).filter(common_filters()).order_by(func.random())\
|
entries = calibre_db.session.query(db.Books).filter(calibre_db.common_filters()).order_by(func.random())\
|
||||||
.limit(config.config_books_per_page)
|
.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))
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
@@ -118,9 +157,9 @@ def feed_discover():
|
|||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_best_rated():
|
def feed_best_rated():
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
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),
|
||||||
[db.Books.timestamp.desc()])
|
[db.Books.timestamp.desc()])
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@@ -133,16 +172,13 @@ def feed_hot():
|
|||||||
hot_books = all_books.offset(off).limit(config.config_books_per_page)
|
hot_books = all_books.offset(off).limit(config.config_books_per_page)
|
||||||
entries = list()
|
entries = list()
|
||||||
for book in hot_books:
|
for book in hot_books:
|
||||||
downloadBook = db.session.query(db.Books).filter(db.Books.id == book.Downloads.book_id).first()
|
downloadBook = calibre_db.get_book(book.Downloads.book_id)
|
||||||
if downloadBook:
|
if downloadBook:
|
||||||
entries.append(
|
entries.append(
|
||||||
db.session.query(db.Books).filter(common_filters())
|
calibre_db.get_filtered_book(book.Downloads.book_id)
|
||||||
.filter(db.Books.id == book.Downloads.book_id).first()
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
ub.delete_download(book.Downloads.book_id)
|
ub.delete_download(book.Downloads.book_id)
|
||||||
# ub.session.query(ub.Downloads).filter(book.Downloads.book_id == ub.Downloads.book_id).delete()
|
|
||||||
# ub.session.commit()
|
|
||||||
numBooks = entries.__len__()
|
numBooks = entries.__len__()
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1),
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1),
|
||||||
config.config_books_per_page, numBooks)
|
config.config_books_per_page, numBooks)
|
||||||
@@ -152,12 +188,41 @@ 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():
|
||||||
off = request.args.get("offset") or 0
|
shift = 0
|
||||||
entries = db.session.query(db.Authors).join(db.books_authors_link).join(db.Books).filter(common_filters())\
|
off = int(request.args.get("offset") or 0)
|
||||||
.group_by(text('books_authors_link.author')).order_by(db.Authors.sort).limit(config.config_books_per_page)\
|
entries = calibre_db.session.query(func.upper(func.substr(db.Authors.sort, 1, 1)).label('id'))\
|
||||||
.offset(off)
|
.join(db.books_authors_link).join(db.Books).filter(calibre_db.common_filters())\
|
||||||
|
.group_by(func.upper(func.substr(db.Authors.sort, 1, 1))).all()
|
||||||
|
|
||||||
|
elements = []
|
||||||
|
if off == 0:
|
||||||
|
elements.append({'id': "00", 'name':_("All")})
|
||||||
|
shift = 1
|
||||||
|
for entry in entries[
|
||||||
|
off + shift - 1:
|
||||||
|
int(off + int(config.config_books_per_page) - shift)]:
|
||||||
|
elements.append({'id': entry.id, 'name': entry.id})
|
||||||
|
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
len(db.session.query(db.Authors).all()))
|
len(entries) + 1)
|
||||||
|
return render_xml_template('feed.xml',
|
||||||
|
letterelements=elements,
|
||||||
|
folder='opds.feed_letter_author',
|
||||||
|
pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@opds.route("/opds/author/letter/<book_id>")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_letter_author(book_id):
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
letter = true() if book_id == "00" else func.upper(db.Authors.sort).startswith(book_id)
|
||||||
|
entries = calibre_db.session.query(db.Authors).join(db.books_authors_link).join(db.Books)\
|
||||||
|
.filter(calibre_db.common_filters()).filter(letter)\
|
||||||
|
.group_by(text('books_authors_link.author'))\
|
||||||
|
.order_by(db.Authors.sort)
|
||||||
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
|
entries.count())
|
||||||
|
entries = entries.limit(config.config_books_per_page).offset(off).all()
|
||||||
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_author', pagination=pagination)
|
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_author', pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@@ -165,10 +230,10 @@ def feed_authorindex():
|
|||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_author(book_id):
|
def feed_author(book_id):
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
db.Books,
|
db.Books,
|
||||||
db.Books.authors.any(db.Authors.id == book_id),
|
db.Books.authors.any(db.Authors.id == book_id),
|
||||||
[db.Books.timestamp.desc()])
|
[db.Books.timestamp.desc()])
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@@ -176,11 +241,14 @@ def feed_author(book_id):
|
|||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_publisherindex():
|
def feed_publisherindex():
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries = db.session.query(db.Publishers).join(db.books_publishers_link).join(db.Books).filter(common_filters())\
|
entries = calibre_db.session.query(db.Publishers)\
|
||||||
.group_by(text('books_publishers_link.publisher')).order_by(db.Publishers.sort)\
|
.join(db.books_publishers_link)\
|
||||||
|
.join(db.Books).filter(calibre_db.common_filters())\
|
||||||
|
.group_by(text('books_publishers_link.publisher'))\
|
||||||
|
.order_by(db.Publishers.sort)\
|
||||||
.limit(config.config_books_per_page).offset(off)
|
.limit(config.config_books_per_page).offset(off)
|
||||||
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(db.session.query(db.Publishers).all()))
|
len(calibre_db.session.query(db.Publishers).all()))
|
||||||
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_publisher', pagination=pagination)
|
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_publisher', pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@@ -188,21 +256,51 @@ def feed_publisherindex():
|
|||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_publisher(book_id):
|
def feed_publisher(book_id):
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
db.Books,
|
db.Books,
|
||||||
db.Books.publishers.any(db.Publishers.id == book_id),
|
db.Books.publishers.any(db.Publishers.id == book_id),
|
||||||
[db.Books.timestamp.desc()])
|
[db.Books.timestamp.desc()])
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@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():
|
||||||
off = request.args.get("offset") or 0
|
shift = 0
|
||||||
entries = db.session.query(db.Tags).join(db.books_tags_link).join(db.Books).filter(common_filters())\
|
off = int(request.args.get("offset") or 0)
|
||||||
.group_by(text('books_tags_link.tag')).order_by(db.Tags.name).offset(off).limit(config.config_books_per_page)
|
entries = calibre_db.session.query(func.upper(func.substr(db.Tags.name, 1, 1)).label('id'))\
|
||||||
|
.join(db.books_tags_link).join(db.Books).filter(calibre_db.common_filters())\
|
||||||
|
.group_by(func.upper(func.substr(db.Tags.name, 1, 1))).all()
|
||||||
|
elements = []
|
||||||
|
if off == 0:
|
||||||
|
elements.append({'id': "00", 'name':_("All")})
|
||||||
|
shift = 1
|
||||||
|
for entry in entries[
|
||||||
|
off + shift - 1:
|
||||||
|
int(off + int(config.config_books_per_page) - shift)]:
|
||||||
|
elements.append({'id': entry.id, 'name': entry.id})
|
||||||
|
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
len(db.session.query(db.Tags).all()))
|
len(entries) + 1)
|
||||||
|
return render_xml_template('feed.xml',
|
||||||
|
letterelements=elements,
|
||||||
|
folder='opds.feed_letter_category',
|
||||||
|
pagination=pagination)
|
||||||
|
|
||||||
|
@opds.route("/opds/category/letter/<book_id>")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_letter_category(book_id):
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
letter = true() if book_id == "00" else func.upper(db.Tags.name).startswith(book_id)
|
||||||
|
entries = calibre_db.session.query(db.Tags)\
|
||||||
|
.join(db.books_tags_link)\
|
||||||
|
.join(db.Books)\
|
||||||
|
.filter(calibre_db.common_filters()).filter(letter)\
|
||||||
|
.group_by(text('books_tags_link.tag'))\
|
||||||
|
.order_by(db.Tags.name)
|
||||||
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
|
entries.count())
|
||||||
|
entries = entries.offset(off).limit(config.config_books_per_page).all()
|
||||||
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_category', pagination=pagination)
|
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_category', pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@@ -210,21 +308,50 @@ def feed_categoryindex():
|
|||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_category(book_id):
|
def feed_category(book_id):
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
db.Books,
|
db.Books,
|
||||||
db.Books.tags.any(db.Tags.id == book_id),
|
db.Books.tags.any(db.Tags.id == book_id),
|
||||||
[db.Books.timestamp.desc()])
|
[db.Books.timestamp.desc()])
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@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():
|
||||||
off = request.args.get("offset") or 0
|
shift = 0
|
||||||
entries = db.session.query(db.Series).join(db.books_series_link).join(db.Books).filter(common_filters())\
|
off = int(request.args.get("offset") or 0)
|
||||||
.group_by(text('books_series_link.series')).order_by(db.Series.sort).offset(off).all()
|
entries = calibre_db.session.query(func.upper(func.substr(db.Series.sort, 1, 1)).label('id'))\
|
||||||
|
.join(db.books_series_link).join(db.Books).filter(calibre_db.common_filters())\
|
||||||
|
.group_by(func.upper(func.substr(db.Series.sort, 1, 1))).all()
|
||||||
|
elements = []
|
||||||
|
if off == 0:
|
||||||
|
elements.append({'id': "00", 'name':_("All")})
|
||||||
|
shift = 1
|
||||||
|
for entry in entries[
|
||||||
|
off + shift - 1:
|
||||||
|
int(off + int(config.config_books_per_page) - shift)]:
|
||||||
|
elements.append({'id': entry.id, 'name': entry.id})
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
len(db.session.query(db.Series).all()))
|
len(entries) + 1)
|
||||||
|
return render_xml_template('feed.xml',
|
||||||
|
letterelements=elements,
|
||||||
|
folder='opds.feed_letter_series',
|
||||||
|
pagination=pagination)
|
||||||
|
|
||||||
|
@opds.route("/opds/series/letter/<book_id>")
|
||||||
|
@requires_basic_auth_if_no_ano
|
||||||
|
def feed_letter_series(book_id):
|
||||||
|
off = request.args.get("offset") or 0
|
||||||
|
letter = true() if book_id == "00" else func.upper(db.Series.sort).startswith(book_id)
|
||||||
|
entries = calibre_db.session.query(db.Series)\
|
||||||
|
.join(db.books_series_link)\
|
||||||
|
.join(db.Books)\
|
||||||
|
.filter(calibre_db.common_filters()).filter(letter)\
|
||||||
|
.group_by(text('books_series_link.series'))\
|
||||||
|
.order_by(db.Series.sort)
|
||||||
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
|
entries.count())
|
||||||
|
entries = entries.offset(off).limit(config.config_books_per_page).all()
|
||||||
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_series', pagination=pagination)
|
return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_series', pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@@ -232,10 +359,10 @@ def feed_seriesindex():
|
|||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_series(book_id):
|
def feed_series(book_id):
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
db.Books,
|
db.Books,
|
||||||
db.Books.series.any(db.Series.id == book_id),
|
db.Books.series.any(db.Series.id == book_id),
|
||||||
[db.Books.series_index])
|
[db.Books.series_index])
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@@ -243,16 +370,19 @@ def feed_series(book_id):
|
|||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_ratingindex():
|
def feed_ratingindex():
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries = 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')) \
|
||||||
.join(db.books_ratings_link).join(db.Books).filter(common_filters()) \
|
.join(db.books_ratings_link)\
|
||||||
.group_by(text('books_ratings_link.rating')).order_by(db.Ratings.rating).all()
|
.join(db.Books)\
|
||||||
|
.filter(calibre_db.common_filters()) \
|
||||||
|
.group_by(text('books_ratings_link.rating'))\
|
||||||
|
.order_by(db.Ratings.rating).all()
|
||||||
|
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
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[0].id, "{} Stars".format(entry.name)))
|
element.append(FeedObject(entry[0].id, _("{} Stars").format(entry.name)))
|
||||||
return render_xml_template('feed.xml', listelements=element, folder='opds.feed_ratings', pagination=pagination)
|
return render_xml_template('feed.xml', listelements=element, folder='opds.feed_ratings', pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@@ -260,10 +390,10 @@ def feed_ratingindex():
|
|||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_ratings(book_id):
|
def feed_ratings(book_id):
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
db.Books,
|
db.Books,
|
||||||
db.Books.ratings.any(db.Ratings.id == book_id),
|
db.Books.ratings.any(db.Ratings.id == book_id),
|
||||||
[db.Books.timestamp.desc()])
|
[db.Books.timestamp.desc()])
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@@ -271,8 +401,10 @@ def feed_ratings(book_id):
|
|||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_formatindex():
|
def feed_formatindex():
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries = db.session.query(db.Data).join(db.Books).filter(common_filters()) \
|
entries = calibre_db.session.query(db.Data).join(db.Books)\
|
||||||
.group_by(db.Data.format).order_by(db.Data.format).all()
|
.filter(calibre_db.common_filters()) \
|
||||||
|
.group_by(db.Data.format)\
|
||||||
|
.order_by(db.Data.format).all()
|
||||||
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page,
|
||||||
len(entries))
|
len(entries))
|
||||||
|
|
||||||
@@ -286,10 +418,10 @@ def feed_formatindex():
|
|||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_format(book_id):
|
def feed_format(book_id):
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
db.Books,
|
db.Books,
|
||||||
db.Books.data.any(db.Data.format == book_id.upper()),
|
db.Books.data.any(db.Data.format == book_id.upper()),
|
||||||
[db.Books.timestamp.desc()])
|
[db.Books.timestamp.desc()])
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@@ -299,13 +431,13 @@ def feed_format(book_id):
|
|||||||
def feed_languagesindex():
|
def feed_languagesindex():
|
||||||
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() == u"all":
|
||||||
languages = speaking_language()
|
languages = calibre_db.speaking_language()
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
cur_l = LC.parse(current_user.filter_language())
|
cur_l = LC.parse(current_user.filter_language())
|
||||||
except UnknownLocaleError:
|
except UnknownLocaleError:
|
||||||
cur_l = None
|
cur_l = None
|
||||||
languages = db.session.query(db.Languages).filter(
|
languages = calibre_db.session.query(db.Languages).filter(
|
||||||
db.Languages.lang_code == current_user.filter_language()).all()
|
db.Languages.lang_code == current_user.filter_language()).all()
|
||||||
if cur_l:
|
if cur_l:
|
||||||
languages[0].name = cur_l.get_language_name(get_locale())
|
languages[0].name = cur_l.get_language_name(get_locale())
|
||||||
@@ -320,10 +452,10 @@ def feed_languagesindex():
|
|||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def feed_languages(book_id):
|
def feed_languages(book_id):
|
||||||
off = request.args.get("offset") or 0
|
off = request.args.get("offset") or 0
|
||||||
entries, __, pagination = fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1),
|
entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0,
|
||||||
db.Books,
|
db.Books,
|
||||||
db.Books.languages.any(db.Languages.id == book_id),
|
db.Books.languages.any(db.Languages.id == book_id),
|
||||||
[db.Books.timestamp.desc()])
|
[db.Books.timestamp.desc()])
|
||||||
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
return render_xml_template('feed.xml', entries=entries, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@@ -356,7 +488,7 @@ def feed_shelf(book_id):
|
|||||||
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == book_id).order_by(
|
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == book_id).order_by(
|
||||||
ub.BookShelf.order.asc()).all()
|
ub.BookShelf.order.asc()).all()
|
||||||
for book in books_in_shelf:
|
for book in books_in_shelf:
|
||||||
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).first()
|
cur_book = calibre_db.get_book(book.book_id)
|
||||||
result.append(cur_book)
|
result.append(cur_book)
|
||||||
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(result))
|
len(result))
|
||||||
@@ -365,16 +497,25 @@ 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
|
||||||
@download_required
|
|
||||||
def opds_download_link(book_id, book_format):
|
def opds_download_link(book_id, book_format):
|
||||||
return get_download_link(book_id, book_format.lower())
|
# I gave up with this: With enabled ldap login, the user doesn't get logged in, therefore it's always guest
|
||||||
|
# workaround, loading the user from the request and checking it's 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)
|
||||||
|
if "Kobo" in request.headers.get('User-Agent'):
|
||||||
|
client = "kobo"
|
||||||
|
else:
|
||||||
|
client = ""
|
||||||
|
return get_download_link(book_id, book_format.lower(), client)
|
||||||
|
|
||||||
|
|
||||||
@opds.route("/ajax/book/<string:uuid>/<library>")
|
@opds.route("/ajax/book/<string:uuid>/<library>")
|
||||||
@opds.route("/ajax/book/<string:uuid>", defaults={'library': ""})
|
@opds.route("/ajax/book/<string:uuid>", defaults={'library': ""})
|
||||||
@requires_basic_auth_if_no_ano
|
@requires_basic_auth_if_no_ano
|
||||||
def get_metadata_calibre_companion(uuid, library):
|
def get_metadata_calibre_companion(uuid, library):
|
||||||
entry = db.session.query(db.Books).filter(db.Books.uuid.like("%" + uuid + "%")).first()
|
entry = calibre_db.session.query(db.Books).filter(db.Books.uuid.like("%" + uuid + "%")).first()
|
||||||
if entry is not None:
|
if entry is not None:
|
||||||
js = render_template('json.txt', entry=entry)
|
js = render_template('json.txt', entry=entry)
|
||||||
response = make_response(js)
|
response = make_response(js)
|
||||||
@@ -386,8 +527,7 @@ def get_metadata_calibre_companion(uuid, library):
|
|||||||
|
|
||||||
def feed_search(term):
|
def feed_search(term):
|
||||||
if term:
|
if term:
|
||||||
term = term.strip().lower()
|
entries, __, ___ = calibre_db.get_search_results(term)
|
||||||
entries = get_search_results(term)
|
|
||||||
entriescount = len(entries) if len(entries) > 0 else 1
|
entriescount = len(entries) if len(entries) > 0 else 1
|
||||||
pagination = Pagination(1, entriescount, entriescount)
|
pagination = Pagination(1, entriescount, entriescount)
|
||||||
return render_xml_template('feed.xml', searchterm=term, entries=entries, pagination=pagination)
|
return render_xml_template('feed.xml', searchterm=term, entries=entries, pagination=pagination)
|
||||||
@@ -397,10 +537,18 @@ def feed_search(term):
|
|||||||
|
|
||||||
def check_auth(username, password):
|
def check_auth(username, password):
|
||||||
if sys.version_info.major == 3:
|
if sys.version_info.major == 3:
|
||||||
username = username.encode('windows-1252')
|
try:
|
||||||
user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) ==
|
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()
|
username.decode('utf-8').lower()).first()
|
||||||
return bool(user and check_password_hash(str(user.password), password))
|
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():
|
def authenticate():
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2018-2019 OzzieIsaacs, cervinko, jkrehm, bodybybuddha, ok11,
|
||||||
|
# andy29485, idalin, Kyosfonica, wuqi, Kennyl, lemmsh,
|
||||||
|
# falgh1, grunjol, csitko, ytils, xybydy, trasba, vrabe,
|
||||||
|
# ruben-herold, marblepebble, JackED42, SiphonSquirrel,
|
||||||
|
# apetresc, nanu-c, mutschler
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask import Blueprint, request, make_response, abort, url_for, flash, redirect
|
||||||
|
from flask_login import login_required, current_user, login_user
|
||||||
|
from flask_babel import gettext as _
|
||||||
|
from sqlalchemy.sql.expression import true
|
||||||
|
|
||||||
|
from . import config, logger, ub
|
||||||
|
from .render_template import render_title_template
|
||||||
|
|
||||||
|
try:
|
||||||
|
from functools import wraps
|
||||||
|
except ImportError:
|
||||||
|
pass # We're not using Python 3
|
||||||
|
|
||||||
|
remotelogin = Blueprint('remotelogin', __name__)
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
|
def remote_login_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def inner(*args, **kwargs):
|
||||||
|
if config.config_remote_login:
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
data = {'status': 'error', 'message': 'Forbidden'}
|
||||||
|
response = make_response(json.dumps(data, ensure_ascii=False))
|
||||||
|
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||||
|
return response, 403
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
return inner
|
||||||
|
|
||||||
|
@remotelogin.route('/remote/login')
|
||||||
|
@remote_login_required
|
||||||
|
def remote_login():
|
||||||
|
auth_token = ub.RemoteAuthToken()
|
||||||
|
ub.session.add(auth_token)
|
||||||
|
ub.session_commit()
|
||||||
|
verify_url = url_for('remotelogin.verify_token', token=auth_token.auth_token, _external=true)
|
||||||
|
log.debug(u"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,
|
||||||
|
verify_url=verify_url, page="remotelogin")
|
||||||
|
|
||||||
|
|
||||||
|
@remotelogin.route('/verify/<token>')
|
||||||
|
@remote_login_required
|
||||||
|
@login_required
|
||||||
|
def verify_token(token):
|
||||||
|
auth_token = ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == token).first()
|
||||||
|
|
||||||
|
# Token not found
|
||||||
|
if auth_token is None:
|
||||||
|
flash(_(u"Token not found"), category="error")
|
||||||
|
log.error(u"Remote Login token not found")
|
||||||
|
return redirect(url_for('web.index'))
|
||||||
|
|
||||||
|
# Token expired
|
||||||
|
elif datetime.now() > auth_token.expiration:
|
||||||
|
ub.session.delete(auth_token)
|
||||||
|
ub.session_commit()
|
||||||
|
|
||||||
|
flash(_(u"Token has expired"), category="error")
|
||||||
|
log.error(u"Remote Login token expired")
|
||||||
|
return redirect(url_for('web.index'))
|
||||||
|
|
||||||
|
# Update token with user information
|
||||||
|
auth_token.user_id = current_user.id
|
||||||
|
auth_token.verified = True
|
||||||
|
ub.session_commit()
|
||||||
|
|
||||||
|
flash(_(u"Success! Please return to your device"), category="success")
|
||||||
|
log.debug(u"Remote Login token for userid %s verified", auth_token.user_id)
|
||||||
|
return redirect(url_for('web.index'))
|
||||||
|
|
||||||
|
|
||||||
|
@remotelogin.route('/ajax/verify_token', methods=['POST'])
|
||||||
|
@remote_login_required
|
||||||
|
def token_verified():
|
||||||
|
token = request.form['token']
|
||||||
|
auth_token = ub.session.query(ub.RemoteAuthToken).filter(ub.RemoteAuthToken.auth_token == token).first()
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
# Token not found
|
||||||
|
if auth_token is None:
|
||||||
|
data['status'] = 'error'
|
||||||
|
data['message'] = _(u"Token not found")
|
||||||
|
|
||||||
|
# Token expired
|
||||||
|
elif datetime.now() > auth_token.expiration:
|
||||||
|
ub.session.delete(auth_token)
|
||||||
|
ub.session_commit()
|
||||||
|
|
||||||
|
data['status'] = 'error'
|
||||||
|
data['message'] = _(u"Token has expired")
|
||||||
|
|
||||||
|
elif not auth_token.verified:
|
||||||
|
data['status'] = 'not_verified'
|
||||||
|
|
||||||
|
else:
|
||||||
|
user = ub.session.query(ub.User).filter(ub.User.id == auth_token.user_id).first()
|
||||||
|
login_user(user)
|
||||||
|
|
||||||
|
ub.session.delete(auth_token)
|
||||||
|
ub.session_commit("User {} logged in via remotelogin, token deleted".format(user.name))
|
||||||
|
|
||||||
|
data['status'] = 'success'
|
||||||
|
log.debug(u"Remote Login for userid %s succeded", user.id)
|
||||||
|
flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.name), category="success")
|
||||||
|
|
||||||
|
response = make_response(json.dumps(data, ensure_ascii=False))
|
||||||
|
response.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||||
|
|
||||||
|
return response
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||||
|
# Copyright (C) 2018-2020 OzzieIsaacs
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from flask import render_template
|
||||||
|
from flask_babel import gettext as _
|
||||||
|
from flask import g
|
||||||
|
from werkzeug.local import LocalProxy
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
from . import config, constants, ub, logger, db, calibre_db
|
||||||
|
from .ub import User
|
||||||
|
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
def get_sidebar_config(kwargs=None):
|
||||||
|
kwargs = kwargs or []
|
||||||
|
if 'content' in kwargs:
|
||||||
|
content = kwargs['content']
|
||||||
|
content = isinstance(content, (User, LocalProxy)) and not content.role_anonymous()
|
||||||
|
else:
|
||||||
|
content = 'conf' in kwargs
|
||||||
|
sidebar = list()
|
||||||
|
sidebar.append({"glyph": "glyphicon-book", "text": _('Books'), "link": 'web.index', "id": "new",
|
||||||
|
"visibility": constants.SIDEBAR_RECENT, 'public': True, "page": "root",
|
||||||
|
"show_text": _('Show recent books'), "config_show":False})
|
||||||
|
sidebar.append({"glyph": "glyphicon-fire", "text": _('Hot Books'), "link": 'web.books_list', "id": "hot",
|
||||||
|
"visibility": constants.SIDEBAR_HOT, 'public': True, "page": "hot",
|
||||||
|
"show_text": _('Show Hot Books'), "config_show": True})
|
||||||
|
if current_user.role_admin():
|
||||||
|
sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.download_list',
|
||||||
|
"id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not g.user.is_anonymous),
|
||||||
|
"page": "download", "show_text": _('Show Downloaded Books'),
|
||||||
|
"config_show": content})
|
||||||
|
else:
|
||||||
|
sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.books_list',
|
||||||
|
"id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not g.user.is_anonymous),
|
||||||
|
"page": "download", "show_text": _('Show Downloaded Books'),
|
||||||
|
"config_show": content})
|
||||||
|
sidebar.append(
|
||||||
|
{"glyph": "glyphicon-star", "text": _('Top Rated Books'), "link": 'web.books_list', "id": "rated",
|
||||||
|
"visibility": constants.SIDEBAR_BEST_RATED, 'public': True, "page": "rated",
|
||||||
|
"show_text": _('Show Top Rated Books'), "config_show": True})
|
||||||
|
sidebar.append({"glyph": "glyphicon-eye-open", "text": _('Read Books'), "link": 'web.books_list', "id": "read",
|
||||||
|
"visibility": constants.SIDEBAR_READ_AND_UNREAD, 'public': (not g.user.is_anonymous),
|
||||||
|
"page": "read", "show_text": _('Show read and unread'), "config_show": content})
|
||||||
|
sidebar.append(
|
||||||
|
{"glyph": "glyphicon-eye-close", "text": _('Unread Books'), "link": 'web.books_list', "id": "unread",
|
||||||
|
"visibility": constants.SIDEBAR_READ_AND_UNREAD, 'public': (not g.user.is_anonymous), "page": "unread",
|
||||||
|
"show_text": _('Show unread'), "config_show": False})
|
||||||
|
sidebar.append({"glyph": "glyphicon-random", "text": _('Discover'), "link": 'web.books_list', "id": "rand",
|
||||||
|
"visibility": constants.SIDEBAR_RANDOM, 'public': True, "page": "discover",
|
||||||
|
"show_text": _('Show Random Books'), "config_show": True})
|
||||||
|
sidebar.append({"glyph": "glyphicon-inbox", "text": _('Categories'), "link": 'web.category_list', "id": "cat",
|
||||||
|
"visibility": constants.SIDEBAR_CATEGORY, 'public': True, "page": "category",
|
||||||
|
"show_text": _('Show category selection'), "config_show": True})
|
||||||
|
sidebar.append({"glyph": "glyphicon-bookmark", "text": _('Series'), "link": 'web.series_list', "id": "serie",
|
||||||
|
"visibility": constants.SIDEBAR_SERIES, 'public': True, "page": "series",
|
||||||
|
"show_text": _('Show series selection'), "config_show": True})
|
||||||
|
sidebar.append({"glyph": "glyphicon-user", "text": _('Authors'), "link": 'web.author_list', "id": "author",
|
||||||
|
"visibility": constants.SIDEBAR_AUTHOR, 'public': True, "page": "author",
|
||||||
|
"show_text": _('Show author selection'), "config_show": True})
|
||||||
|
sidebar.append(
|
||||||
|
{"glyph": "glyphicon-text-size", "text": _('Publishers'), "link": 'web.publisher_list', "id": "publisher",
|
||||||
|
"visibility": constants.SIDEBAR_PUBLISHER, 'public': True, "page": "publisher",
|
||||||
|
"show_text": _('Show publisher selection'), "config_show":True})
|
||||||
|
sidebar.append({"glyph": "glyphicon-flag", "text": _('Languages'), "link": 'web.language_overview', "id": "lang",
|
||||||
|
"visibility": constants.SIDEBAR_LANGUAGE, 'public': (g.user.filter_language() == 'all'),
|
||||||
|
"page": "language",
|
||||||
|
"show_text": _('Show language selection'), "config_show": True})
|
||||||
|
sidebar.append({"glyph": "glyphicon-star-empty", "text": _('Ratings'), "link": 'web.ratings_list', "id": "rate",
|
||||||
|
"visibility": constants.SIDEBAR_RATING, 'public': True,
|
||||||
|
"page": "rating", "show_text": _('Show ratings selection'), "config_show": True})
|
||||||
|
sidebar.append({"glyph": "glyphicon-file", "text": _('File formats'), "link": 'web.formats_list', "id": "format",
|
||||||
|
"visibility": constants.SIDEBAR_FORMAT, 'public': True,
|
||||||
|
"page": "format", "show_text": _('Show file formats selection'), "config_show": True})
|
||||||
|
sidebar.append(
|
||||||
|
{"glyph": "glyphicon-trash", "text": _('Archived Books'), "link": 'web.books_list', "id": "archived",
|
||||||
|
"visibility": constants.SIDEBAR_ARCHIVED, 'public': (not g.user.is_anonymous), "page": "archived",
|
||||||
|
"show_text": _('Show archived books'), "config_show": content})
|
||||||
|
sidebar.append(
|
||||||
|
{"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",
|
||||||
|
"show_text": _('Show Books List'), "config_show": content})
|
||||||
|
|
||||||
|
return sidebar
|
||||||
|
|
||||||
|
def get_readbooks_ids():
|
||||||
|
if not config.config_read_column:
|
||||||
|
readBooks = ub.session.query(ub.ReadBook).filter(ub.ReadBook.user_id == int(current_user.id))\
|
||||||
|
.filter(ub.ReadBook.read_status == ub.ReadBook.STATUS_FINISHED).all()
|
||||||
|
return frozenset([x.book_id for x in readBooks])
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
readBooks = calibre_db.session.query(db.cc_classes[config.config_read_column])\
|
||||||
|
.filter(db.cc_classes[config.config_read_column].value == True).all()
|
||||||
|
return frozenset([x.book for x in readBooks])
|
||||||
|
except (KeyError, AttributeError):
|
||||||
|
log.error("Custom Column No.%d is not existing in calibre database", config.config_read_column)
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Returns the template for rendering and includes the instance name
|
||||||
|
def render_title_template(*args, **kwargs):
|
||||||
|
sidebar = get_sidebar_config(kwargs)
|
||||||
|
return render_template(instance=config.config_calibre_web_title, sidebar=sidebar,
|
||||||
|
accept=constants.EXTENSIONS_UPLOAD, read_book_ids=get_readbooks_ids(),
|
||||||
|
*args, **kwargs)
|
||||||
@@ -77,6 +77,7 @@ class ReverseProxied(object):
|
|||||||
servr = environ.get('HTTP_X_FORWARDED_HOST', '')
|
servr = environ.get('HTTP_X_FORWARDED_HOST', '')
|
||||||
if servr:
|
if servr:
|
||||||
environ['HTTP_HOST'] = servr
|
environ['HTTP_HOST'] = servr
|
||||||
|
self.proxied = True
|
||||||
return self.app(environ, start_response)
|
return self.app(environ, start_response)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -22,11 +22,14 @@ import os
|
|||||||
import errno
|
import errno
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
|
import subprocess # nosec
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from gevent.pywsgi import WSGIServer
|
from gevent.pywsgi import WSGIServer
|
||||||
from gevent.pool import Pool
|
from gevent.pool import Pool
|
||||||
from gevent import __version__ as _version
|
from gevent import __version__ as _version
|
||||||
|
from greenlet import GreenletExit
|
||||||
|
import ssl
|
||||||
VERSION = 'Gevent ' + _version
|
VERSION = 'Gevent ' + _version
|
||||||
_GEVENT = True
|
_GEVENT = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -72,7 +75,11 @@ class WebServer(object):
|
|||||||
if config.config_access_log:
|
if config.config_access_log:
|
||||||
log_name = "gevent.access" if _GEVENT else "tornado.access"
|
log_name = "gevent.access" if _GEVENT else "tornado.access"
|
||||||
formatter = logger.ACCESS_FORMATTER_GEVENT if _GEVENT else logger.ACCESS_FORMATTER_TORNADO
|
formatter = logger.ACCESS_FORMATTER_GEVENT if _GEVENT else logger.ACCESS_FORMATTER_TORNADO
|
||||||
self.access_logger = logger.create_access_log(config.config_access_logfile, log_name, formatter)
|
self.access_logger, logfile = logger.create_access_log(config.config_access_logfile, log_name, formatter)
|
||||||
|
if logfile != config.config_access_logfile:
|
||||||
|
log.warning("Accesslog path %s not valid, falling back to default", config.config_access_logfile)
|
||||||
|
config.config_access_logfile = logfile
|
||||||
|
config.save()
|
||||||
else:
|
else:
|
||||||
if not _GEVENT:
|
if not _GEVENT:
|
||||||
logger.get('tornado.access').disabled = True
|
logger.get('tornado.access').disabled = True
|
||||||
@@ -130,6 +137,64 @@ class WebServer(object):
|
|||||||
|
|
||||||
return sock, _readable_listen_address(*address)
|
return sock, _readable_listen_address(*address)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_args_for_reloading():
|
||||||
|
"""Determine how the script was executed, and return the args needed
|
||||||
|
to execute it again in a new process.
|
||||||
|
Code from https://github.com/pyload/pyload. Author GammaC0de, voulter
|
||||||
|
"""
|
||||||
|
rv = [sys.executable]
|
||||||
|
py_script = sys.argv[0]
|
||||||
|
args = sys.argv[1:]
|
||||||
|
# Need to look at main module to determine how it was executed.
|
||||||
|
__main__ = sys.modules["__main__"]
|
||||||
|
|
||||||
|
# The value of __package__ indicates how Python was called. It may
|
||||||
|
# not exist if a setuptools script is installed as an egg. It may be
|
||||||
|
# set incorrectly for entry points created with pip on Windows.
|
||||||
|
if getattr(__main__, "__package__", None) is None or (
|
||||||
|
os.name == "nt"
|
||||||
|
and __main__.__package__ == ""
|
||||||
|
and not os.path.exists(py_script)
|
||||||
|
and os.path.exists("{}.exe".format(py_script))
|
||||||
|
):
|
||||||
|
# Executed a file, like "python app.py".
|
||||||
|
py_script = os.path.abspath(py_script)
|
||||||
|
|
||||||
|
if os.name == "nt":
|
||||||
|
# Windows entry points have ".exe" extension and should be
|
||||||
|
# called directly.
|
||||||
|
if not os.path.exists(py_script) and os.path.exists("{}.exe".format(py_script)):
|
||||||
|
py_script += ".exe"
|
||||||
|
|
||||||
|
if (
|
||||||
|
os.path.splitext(sys.executable)[1] == ".exe"
|
||||||
|
and os.path.splitext(py_script)[1] == ".exe"
|
||||||
|
):
|
||||||
|
rv.pop(0)
|
||||||
|
|
||||||
|
rv.append(py_script)
|
||||||
|
else:
|
||||||
|
# Executed a module, like "python -m module".
|
||||||
|
if sys.argv[0] == "-m":
|
||||||
|
args = sys.argv
|
||||||
|
else:
|
||||||
|
if os.path.isfile(py_script):
|
||||||
|
# Rewritten by Python from "-m script" to "/path/to/script.py".
|
||||||
|
py_module = __main__.__package__
|
||||||
|
name = os.path.splitext(os.path.basename(py_script))[0]
|
||||||
|
|
||||||
|
if name != "__main__":
|
||||||
|
py_module += ".{}".format(name)
|
||||||
|
else:
|
||||||
|
# Incorrectly rewritten by pydevd debugger from "-m script" to "script".
|
||||||
|
py_module = py_script
|
||||||
|
|
||||||
|
rv.extend(("-m", py_module.lstrip(".")))
|
||||||
|
|
||||||
|
rv.extend(args)
|
||||||
|
return rv
|
||||||
|
|
||||||
def _start_gevent(self):
|
def _start_gevent(self):
|
||||||
ssl_args = self.ssl_args or {}
|
ssl_args = self.ssl_args or {}
|
||||||
|
|
||||||
@@ -139,6 +204,16 @@ class WebServer(object):
|
|||||||
output = _readable_listen_address(self.listen_address, self.listen_port)
|
output = _readable_listen_address(self.listen_address, self.listen_port)
|
||||||
log.info('Starting Gevent server on %s', output)
|
log.info('Starting Gevent server on %s', output)
|
||||||
self.wsgiserver = WSGIServer(sock, self.app, log=self.access_logger, spawn=Pool(), **ssl_args)
|
self.wsgiserver = WSGIServer(sock, self.app, log=self.access_logger, spawn=Pool(), **ssl_args)
|
||||||
|
if ssl_args:
|
||||||
|
wrap_socket = self.wsgiserver.wrap_socket
|
||||||
|
def my_wrap_socket(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return wrap_socket(*args, **kwargs)
|
||||||
|
except (ssl.SSLError, OSError) as ex:
|
||||||
|
log.warning('Gevent SSL Error: %s', ex)
|
||||||
|
raise GreenletExit
|
||||||
|
|
||||||
|
self.wsgiserver.wrap_socket = my_wrap_socket
|
||||||
self.wsgiserver.serve_forever()
|
self.wsgiserver.serve_forever()
|
||||||
finally:
|
finally:
|
||||||
if self.unix_socket_file:
|
if self.unix_socket_file:
|
||||||
@@ -176,21 +251,19 @@ class WebServer(object):
|
|||||||
finally:
|
finally:
|
||||||
self.wsgiserver = None
|
self.wsgiserver = None
|
||||||
|
|
||||||
|
# prevent irritating log of pending tasks message from asyncio
|
||||||
|
logger.get('asyncio').setLevel(logger.logging.CRITICAL)
|
||||||
|
|
||||||
if not self.restart:
|
if not self.restart:
|
||||||
log.info("Performing shutdown of Calibre-Web")
|
log.info("Performing shutdown of Calibre-Web")
|
||||||
# prevent irritiating log of pending tasks message from asyncio
|
|
||||||
logger.get('asyncio').setLevel(logger.logging.CRITICAL)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
log.info("Performing restart of Calibre-Web")
|
log.info("Performing restart of Calibre-Web")
|
||||||
arguments = list(sys.argv)
|
args = self._get_args_for_reloading()
|
||||||
arguments.insert(0, sys.executable)
|
subprocess.call(args, close_fds=True) # nosec
|
||||||
if os.name == 'nt':
|
|
||||||
arguments = ["\"%s\"" % a for a in arguments]
|
|
||||||
os.execv(sys.executable, arguments)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _killServer(self, ignored_signum, ignored_frame):
|
def _killServer(self, __, ___):
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
def stop(self, restart=False):
|
def stop(self, restart=False):
|
||||||
|
|||||||
@@ -22,12 +22,16 @@ from base64 import b64decode, b64encode
|
|||||||
from jsonschema import validate, exceptions, __version__
|
from jsonschema import validate, exceptions, __version__
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
try:
|
try:
|
||||||
|
# pylint: disable=unused-import
|
||||||
from urllib import unquote
|
from urllib import unquote
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from flask import json
|
from flask import json
|
||||||
from .. import logger as log
|
from .. import logger
|
||||||
|
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
|
||||||
def b64encode_json(json_data):
|
def b64encode_json(json_data):
|
||||||
@@ -45,7 +49,8 @@ def to_epoch_timestamp(datetime_object):
|
|||||||
def get_datetime_from_json(json_object, field_name):
|
def get_datetime_from_json(json_object, field_name):
|
||||||
try:
|
try:
|
||||||
return datetime.utcfromtimestamp(json_object[field_name])
|
return datetime.utcfromtimestamp(json_object[field_name])
|
||||||
except KeyError:
|
except (KeyError, OSError, OverflowError):
|
||||||
|
# OSError is thrown on Windows if timestamp is <1970 or >2038
|
||||||
return datetime.min
|
return datetime.min
|
||||||
|
|
||||||
|
|
||||||
@@ -60,7 +65,7 @@ class SyncToken:
|
|||||||
books_last_modified: Datetime representing the last modified book that the device knows about.
|
books_last_modified: Datetime representing the last modified book that the device knows about.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
SYNC_TOKEN_HEADER = "x-kobo-synctoken"
|
SYNC_TOKEN_HEADER = "x-kobo-synctoken" # nosec
|
||||||
VERSION = "1-1-0"
|
VERSION = "1-1-0"
|
||||||
LAST_MODIFIED_ADDED_VERSION = "1-1-0"
|
LAST_MODIFIED_ADDED_VERSION = "1-1-0"
|
||||||
MIN_VERSION = "1-0-0"
|
MIN_VERSION = "1-0-0"
|
||||||
@@ -81,6 +86,7 @@ class SyncToken:
|
|||||||
"archive_last_modified": {"type": "string"},
|
"archive_last_modified": {"type": "string"},
|
||||||
"reading_state_last_modified": {"type": "string"},
|
"reading_state_last_modified": {"type": "string"},
|
||||||
"tags_last_modified": {"type": "string"},
|
"tags_last_modified": {"type": "string"},
|
||||||
|
"books_last_id": {"type": "integer", "optional": True}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,18 +98,20 @@ class SyncToken:
|
|||||||
archive_last_modified=datetime.min,
|
archive_last_modified=datetime.min,
|
||||||
reading_state_last_modified=datetime.min,
|
reading_state_last_modified=datetime.min,
|
||||||
tags_last_modified=datetime.min,
|
tags_last_modified=datetime.min,
|
||||||
):
|
books_last_id=-1
|
||||||
|
): # nosec
|
||||||
self.raw_kobo_store_token = raw_kobo_store_token
|
self.raw_kobo_store_token = raw_kobo_store_token
|
||||||
self.books_last_created = books_last_created
|
self.books_last_created = books_last_created
|
||||||
self.books_last_modified = books_last_modified
|
self.books_last_modified = books_last_modified
|
||||||
self.archive_last_modified = archive_last_modified
|
self.archive_last_modified = archive_last_modified
|
||||||
self.reading_state_last_modified = reading_state_last_modified
|
self.reading_state_last_modified = reading_state_last_modified
|
||||||
self.tags_last_modified = tags_last_modified
|
self.tags_last_modified = tags_last_modified
|
||||||
|
self.books_last_id = books_last_id
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_headers(headers):
|
def from_headers(headers):
|
||||||
sync_token_header = headers.get(SyncToken.SYNC_TOKEN_HEADER, "")
|
sync_token_header = headers.get(SyncToken.SYNC_TOKEN_HEADER, "")
|
||||||
if sync_token_header == "":
|
if sync_token_header == "": # nosec
|
||||||
return SyncToken()
|
return SyncToken()
|
||||||
|
|
||||||
# On the first sync from a Kobo device, we may receive the SyncToken
|
# On the first sync from a Kobo device, we may receive the SyncToken
|
||||||
@@ -133,9 +141,12 @@ class SyncToken:
|
|||||||
archive_last_modified = get_datetime_from_json(data_json, "archive_last_modified")
|
archive_last_modified = get_datetime_from_json(data_json, "archive_last_modified")
|
||||||
reading_state_last_modified = get_datetime_from_json(data_json, "reading_state_last_modified")
|
reading_state_last_modified = get_datetime_from_json(data_json, "reading_state_last_modified")
|
||||||
tags_last_modified = get_datetime_from_json(data_json, "tags_last_modified")
|
tags_last_modified = get_datetime_from_json(data_json, "tags_last_modified")
|
||||||
|
books_last_id = data_json["books_last_id"]
|
||||||
except TypeError:
|
except TypeError:
|
||||||
log.error("SyncToken timestamps don't parse to a datetime.")
|
log.error("SyncToken timestamps don't parse to a datetime.")
|
||||||
return SyncToken(raw_kobo_store_token=raw_kobo_store_token)
|
return SyncToken(raw_kobo_store_token=raw_kobo_store_token)
|
||||||
|
except KeyError:
|
||||||
|
books_last_id = -1
|
||||||
|
|
||||||
return SyncToken(
|
return SyncToken(
|
||||||
raw_kobo_store_token=raw_kobo_store_token,
|
raw_kobo_store_token=raw_kobo_store_token,
|
||||||
@@ -143,7 +154,8 @@ class SyncToken:
|
|||||||
books_last_modified=books_last_modified,
|
books_last_modified=books_last_modified,
|
||||||
archive_last_modified=archive_last_modified,
|
archive_last_modified=archive_last_modified,
|
||||||
reading_state_last_modified=reading_state_last_modified,
|
reading_state_last_modified=reading_state_last_modified,
|
||||||
tags_last_modified=tags_last_modified
|
tags_last_modified=tags_last_modified,
|
||||||
|
books_last_id=books_last_id
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_kobo_store_header(self, store_headers):
|
def set_kobo_store_header(self, store_headers):
|
||||||
@@ -166,7 +178,8 @@ class SyncToken:
|
|||||||
"books_last_created": to_epoch_timestamp(self.books_last_created),
|
"books_last_created": to_epoch_timestamp(self.books_last_created),
|
||||||
"archive_last_modified": to_epoch_timestamp(self.archive_last_modified),
|
"archive_last_modified": to_epoch_timestamp(self.archive_last_modified),
|
||||||
"reading_state_last_modified": to_epoch_timestamp(self.reading_state_last_modified),
|
"reading_state_last_modified": to_epoch_timestamp(self.reading_state_last_modified),
|
||||||
"tags_last_modified": to_epoch_timestamp(self.tags_last_modified)
|
"tags_last_modified": to_epoch_timestamp(self.tags_last_modified),
|
||||||
|
"books_last_id":self.books_last_id
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return b64encode_json(token)
|
return b64encode_json(token)
|
||||||
|
|||||||
@@ -45,3 +45,9 @@ except ImportError as err:
|
|||||||
log.debug("Cannot import SyncToken, syncing books with Kobo Devices will not work: %s", err)
|
log.debug("Cannot import SyncToken, syncing books with Kobo Devices will not work: %s", err)
|
||||||
kobo = None
|
kobo = None
|
||||||
SyncToken = None
|
SyncToken = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from . import gmail
|
||||||
|
except ImportError as err:
|
||||||
|
log.debug("Cannot import gmail, sending books via Gmail Oauth2 Verification will not work: %s", err)
|
||||||
|
gmail = None
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import print_function
|
||||||
|
import os.path
|
||||||
|
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||||
|
from google.auth.transport.requests import Request
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
from google.oauth2.credentials import Credentials
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
import base64
|
||||||
|
from flask_babel import gettext as _
|
||||||
|
from ..constants import BASE_DIR
|
||||||
|
from .. import logger
|
||||||
|
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
SCOPES = ['openid', 'https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/userinfo.email']
|
||||||
|
|
||||||
|
def setup_gmail(token):
|
||||||
|
# If there are no (valid) credentials available, let the user log in.
|
||||||
|
creds = None
|
||||||
|
if "token" in token:
|
||||||
|
creds = Credentials(
|
||||||
|
token=token['token'],
|
||||||
|
refresh_token=token['refresh_token'],
|
||||||
|
token_uri=token['token_uri'],
|
||||||
|
client_id=token['client_id'],
|
||||||
|
client_secret=token['client_secret'],
|
||||||
|
scopes=token['scopes'],
|
||||||
|
)
|
||||||
|
creds.expiry = datetime.fromisoformat(token['expiry'])
|
||||||
|
|
||||||
|
if not creds or not creds.valid:
|
||||||
|
# don't forget to dump one more time after the refresh
|
||||||
|
# also, some file-locking routines wouldn't be needless
|
||||||
|
if creds and creds.expired and creds.refresh_token:
|
||||||
|
creds.refresh(Request())
|
||||||
|
else:
|
||||||
|
cred_file = os.path.join(BASE_DIR, 'gmail.json')
|
||||||
|
if not os.path.exists(cred_file):
|
||||||
|
raise Exception(_("Found no valid gmail.json file with OAuth information"))
|
||||||
|
flow = InstalledAppFlow.from_client_secrets_file(
|
||||||
|
os.path.join(BASE_DIR, 'gmail.json'), SCOPES)
|
||||||
|
creds = flow.run_local_server(port=0)
|
||||||
|
user_info = get_user_info(creds)
|
||||||
|
return {
|
||||||
|
'token': creds.token,
|
||||||
|
'refresh_token': creds.refresh_token,
|
||||||
|
'token_uri': creds.token_uri,
|
||||||
|
'client_id': creds.client_id,
|
||||||
|
'client_secret': creds.client_secret,
|
||||||
|
'scopes': creds.scopes,
|
||||||
|
'expiry': creds.expiry.isoformat(),
|
||||||
|
'email': user_info
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_user_info(credentials):
|
||||||
|
user_info_service = build(serviceName='oauth2', version='v2',credentials=credentials)
|
||||||
|
user_info = user_info_service.userinfo().get().execute()
|
||||||
|
return user_info.get('email', "")
|
||||||
|
|
||||||
|
def send_messsage(token, msg):
|
||||||
|
log.debug("Start sending email via Gmail")
|
||||||
|
creds = Credentials(
|
||||||
|
token=token['token'],
|
||||||
|
refresh_token=token['refresh_token'],
|
||||||
|
token_uri=token['token_uri'],
|
||||||
|
client_id=token['client_id'],
|
||||||
|
client_secret=token['client_secret'],
|
||||||
|
scopes=token['scopes'],
|
||||||
|
)
|
||||||
|
creds.expiry = datetime.fromisoformat(token['expiry'])
|
||||||
|
if creds and creds.expired and creds.refresh_token:
|
||||||
|
creds.refresh(Request())
|
||||||
|
service = build('gmail', 'v1', credentials=creds)
|
||||||
|
message_as_bytes = msg.as_bytes() # the message should converted from string to bytes.
|
||||||
|
message_as_base64 = base64.urlsafe_b64encode(message_as_bytes) # encode in base64 (printable letters coding)
|
||||||
|
raw = message_as_base64.decode() # convert to something JSON serializable
|
||||||
|
body = {'raw': raw}
|
||||||
|
|
||||||
|
(service.users().messages().send(userId='me', body=body).execute())
|
||||||
|
log.debug("Email send successfully via Gmail")
|
||||||
@@ -20,7 +20,10 @@ from __future__ import division, print_function, unicode_literals
|
|||||||
import time
|
import time
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
|
||||||
from goodreads.client import GoodreadsClient
|
try:
|
||||||
|
from goodreads.client import GoodreadsClient
|
||||||
|
except ImportError:
|
||||||
|
from betterreads.client import GoodreadsClient
|
||||||
|
|
||||||
try: import Levenshtein
|
try: import Levenshtein
|
||||||
except ImportError: Levenshtein = False
|
except ImportError: Levenshtein = False
|
||||||
@@ -69,7 +72,7 @@ def get_author_info(author_name):
|
|||||||
author_info = _client.find_author(author_name=author_name)
|
author_info = _client.find_author(author_name=author_name)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
# Skip goodreads, if site is down/inaccessible
|
# Skip goodreads, if site is down/inaccessible
|
||||||
log.warning('Goodreads website is down/inaccessible? %s', ex)
|
log.warning('Goodreads website is down/inaccessible? %s', ex.__str__())
|
||||||
return
|
return
|
||||||
|
|
||||||
if author_info:
|
if author_info:
|
||||||
@@ -95,8 +98,12 @@ def get_other_books(author_info, library_books=None):
|
|||||||
for book in author_info.books:
|
for book in author_info.books:
|
||||||
if book.isbn in identifiers:
|
if book.isbn in identifiers:
|
||||||
continue
|
continue
|
||||||
if book.gid["#text"] in identifiers:
|
if isinstance(book.gid, int):
|
||||||
continue
|
if book.gid in identifiers:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if book.gid["#text"] in identifiers:
|
||||||
|
continue
|
||||||
|
|
||||||
if Levenshtein and library_titles:
|
if Levenshtein and library_titles:
|
||||||
goodreads_title = book._book_dict['title_without_series']
|
goodreads_title = book._book_dict['title_without_series']
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from __future__ import division, print_function, unicode_literals
|
|||||||
import base64
|
import base64
|
||||||
|
|
||||||
from flask_simpleldap import LDAP, LDAPException
|
from flask_simpleldap import LDAP, LDAPException
|
||||||
|
from flask_simpleldap import ldap as pyLDAP
|
||||||
from .. import constants, logger
|
from .. import constants, logger
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -38,6 +38,7 @@ def init_app(app, config):
|
|||||||
|
|
||||||
app.config['LDAP_HOST'] = config.config_ldap_provider_url
|
app.config['LDAP_HOST'] = config.config_ldap_provider_url
|
||||||
app.config['LDAP_PORT'] = config.config_ldap_port
|
app.config['LDAP_PORT'] = config.config_ldap_port
|
||||||
|
app.config['LDAP_CUSTOM_OPTIONS'] = {pyLDAP.OPT_REFERRALS: 0}
|
||||||
if config.config_ldap_encryption == 2:
|
if config.config_ldap_encryption == 2:
|
||||||
app.config['LDAP_SCHEMA'] = 'ldaps'
|
app.config['LDAP_SCHEMA'] = 'ldaps'
|
||||||
else:
|
else:
|
||||||
@@ -54,8 +55,14 @@ def init_app(app, config):
|
|||||||
app.config['LDAP_USERNAME'] = ""
|
app.config['LDAP_USERNAME'] = ""
|
||||||
app.config['LDAP_PASSWORD'] = base64.b64decode("")
|
app.config['LDAP_PASSWORD'] = base64.b64decode("")
|
||||||
if bool(config.config_ldap_cert_path):
|
if bool(config.config_ldap_cert_path):
|
||||||
app.config['LDAP_REQUIRE_CERT'] = True
|
app.config['LDAP_CUSTOM_OPTIONS'].update({
|
||||||
app.config['LDAP_CERT_PATH'] = config.config_ldap_cert_path
|
pyLDAP.OPT_X_TLS_REQUIRE_CERT: pyLDAP.OPT_X_TLS_DEMAND,
|
||||||
|
pyLDAP.OPT_X_TLS_CACERTFILE: config.config_ldap_cacert_path,
|
||||||
|
pyLDAP.OPT_X_TLS_CERTFILE: config.config_ldap_cert_path,
|
||||||
|
pyLDAP.OPT_X_TLS_KEYFILE: config.config_ldap_key_path,
|
||||||
|
pyLDAP.OPT_X_TLS_NEWCTX: 0
|
||||||
|
})
|
||||||
|
|
||||||
app.config['LDAP_BASE_DN'] = config.config_ldap_dn
|
app.config['LDAP_BASE_DN'] = config.config_ldap_dn
|
||||||
app.config['LDAP_USER_OBJECT_FILTER'] = config.config_ldap_user_object
|
app.config['LDAP_USER_OBJECT_FILTER'] = config.config_ldap_user_object
|
||||||
|
|
||||||
@@ -64,13 +71,22 @@ def init_app(app, config):
|
|||||||
app.config['LDAP_OPENLDAP'] = bool(config.config_ldap_openldap)
|
app.config['LDAP_OPENLDAP'] = bool(config.config_ldap_openldap)
|
||||||
app.config['LDAP_GROUP_OBJECT_FILTER'] = config.config_ldap_group_object_filter
|
app.config['LDAP_GROUP_OBJECT_FILTER'] = config.config_ldap_group_object_filter
|
||||||
app.config['LDAP_GROUP_MEMBERS_FIELD'] = config.config_ldap_group_members_field
|
app.config['LDAP_GROUP_MEMBERS_FIELD'] = config.config_ldap_group_members_field
|
||||||
# app.config['LDAP_CUSTOM_OPTIONS'] = {'OPT_NETWORK_TIMEOUT': 10}
|
|
||||||
|
|
||||||
_ldap.init_app(app)
|
try:
|
||||||
|
_ldap.init_app(app)
|
||||||
|
except ValueError:
|
||||||
|
if bool(config.config_ldap_cert_path):
|
||||||
|
app.config['LDAP_CUSTOM_OPTIONS'].pop(pyLDAP.OPT_X_TLS_NEWCTX)
|
||||||
|
try:
|
||||||
|
_ldap.init_app(app)
|
||||||
|
except RuntimeError as e:
|
||||||
|
log.error(e)
|
||||||
|
except RuntimeError as e:
|
||||||
|
log.error(e)
|
||||||
|
|
||||||
|
|
||||||
def get_object_details(user=None, group=None, query_filter=None, dn_only=False):
|
def get_object_details(user=None,query_filter=None):
|
||||||
return _ldap.get_object_details(user, group, query_filter, dn_only)
|
return _ldap.get_object_details(user, query_filter=query_filter)
|
||||||
|
|
||||||
|
|
||||||
def bind():
|
def bind():
|
||||||
@@ -101,7 +117,7 @@ def bind_user(username, password):
|
|||||||
return None, error
|
return None, error
|
||||||
except LDAPException as ex:
|
except LDAPException as ex:
|
||||||
if ex.message == 'Invalid credentials':
|
if ex.message == 'Invalid credentials':
|
||||||
error = ("LDAP admin login failed")
|
error = "LDAP admin login failed"
|
||||||
return None, error
|
return None, error
|
||||||
if ex.message == "Can't contact LDAP server":
|
if ex.message == "Can't contact LDAP server":
|
||||||
# log.warning('LDAP Server down: %s', ex)
|
# log.warning('LDAP Server down: %s', ex)
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
|
||||||
|
from __future__ import division, print_function, unicode_literals
|
||||||
|
import threading
|
||||||
|
import abc
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
|
||||||
|
try:
|
||||||
|
import queue
|
||||||
|
except ImportError:
|
||||||
|
import Queue as queue
|
||||||
|
from datetime import datetime
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
|
from cps import logger
|
||||||
|
|
||||||
|
log = logger.create()
|
||||||
|
|
||||||
|
# task 'status' consts
|
||||||
|
STAT_WAITING = 0
|
||||||
|
STAT_FAIL = 1
|
||||||
|
STAT_STARTED = 2
|
||||||
|
STAT_FINISH_SUCCESS = 3
|
||||||
|
|
||||||
|
# Only retain this many tasks in dequeued list
|
||||||
|
TASK_CLEANUP_TRIGGER = 20
|
||||||
|
|
||||||
|
QueuedTask = namedtuple('QueuedTask', 'num, user, added, task')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_main_thread():
|
||||||
|
for t in threading.enumerate():
|
||||||
|
if t.__class__.__name__ == '_MainThread':
|
||||||
|
return t
|
||||||
|
raise Exception("main thread not found?!")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ImprovedQueue(queue.Queue):
|
||||||
|
def to_list(self):
|
||||||
|
"""
|
||||||
|
Returns a copy of all items in the queue without removing them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
with self.mutex:
|
||||||
|
return list(self.queue)
|
||||||
|
|
||||||
|
# Class for all worker tasks in the background
|
||||||
|
class WorkerThread(threading.Thread):
|
||||||
|
_instance = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def getInstance(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = WorkerThread()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
threading.Thread.__init__(self)
|
||||||
|
|
||||||
|
self.dequeued = list()
|
||||||
|
|
||||||
|
self.doLock = threading.Lock()
|
||||||
|
self.queue = ImprovedQueue()
|
||||||
|
self.num = 0
|
||||||
|
self.start()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add(cls, user, task):
|
||||||
|
ins = cls.getInstance()
|
||||||
|
ins.num += 1
|
||||||
|
log.debug("Add Task for user: {}: {}".format(user, task))
|
||||||
|
ins.queue.put(QueuedTask(
|
||||||
|
num=ins.num,
|
||||||
|
user=user,
|
||||||
|
added=datetime.now(),
|
||||||
|
task=task,
|
||||||
|
))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tasks(self):
|
||||||
|
with self.doLock:
|
||||||
|
tasks = self.queue.to_list() + self.dequeued
|
||||||
|
return sorted(tasks, key=lambda x: x.num)
|
||||||
|
|
||||||
|
def cleanup_tasks(self):
|
||||||
|
with self.doLock:
|
||||||
|
dead = []
|
||||||
|
alive = []
|
||||||
|
for x in self.dequeued:
|
||||||
|
(dead if x.task.dead else alive).append(x)
|
||||||
|
|
||||||
|
# if the ones that we need to keep are within the trigger, do nothing else
|
||||||
|
delta = len(self.dequeued) - len(dead)
|
||||||
|
if delta > TASK_CLEANUP_TRIGGER:
|
||||||
|
ret = alive
|
||||||
|
else:
|
||||||
|
# otherwise, lop off the oldest dead tasks until we hit the target trigger
|
||||||
|
ret = sorted(dead, key=lambda x: x.task.end_time)[-TASK_CLEANUP_TRIGGER:] + alive
|
||||||
|
|
||||||
|
self.dequeued = sorted(ret, key=lambda x: x.num)
|
||||||
|
|
||||||
|
# Main thread loop starting the different tasks
|
||||||
|
def run(self):
|
||||||
|
main_thread = _get_main_thread()
|
||||||
|
while main_thread.is_alive():
|
||||||
|
try:
|
||||||
|
# this blocks until something is available. This can cause issues when the main thread dies - this
|
||||||
|
# thread will remain alive. We implement a timeout to unblock every second which allows us to check if
|
||||||
|
# the main thread is still alive.
|
||||||
|
# We don't use a daemon here because we don't want the tasks to just be abruptly halted, leading to
|
||||||
|
# possible file / database corruption
|
||||||
|
item = self.queue.get(timeout=1)
|
||||||
|
except queue.Empty:
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
with self.doLock:
|
||||||
|
# add to list so that in-progress tasks show up
|
||||||
|
self.dequeued.append(item)
|
||||||
|
|
||||||
|
# once we hit our trigger, start cleaning up dead tasks
|
||||||
|
if len(self.dequeued) > TASK_CLEANUP_TRIGGER:
|
||||||
|
self.cleanup_tasks()
|
||||||
|
|
||||||
|
# sometimes tasks (like Upload) don't actually have work to do and are created as already finished
|
||||||
|
if item.task.stat is STAT_WAITING:
|
||||||
|
# CalibreTask.start() should wrap all exceptions in it's own error handling
|
||||||
|
item.task.start(self)
|
||||||
|
|
||||||
|
self.queue.task_done()
|
||||||
|
|
||||||
|
|
||||||
|
class CalibreTask:
|
||||||
|
__metaclass__ = abc.ABCMeta
|
||||||
|
|
||||||
|
def __init__(self, message):
|
||||||
|
self._progress = 0
|
||||||
|
self.stat = STAT_WAITING
|
||||||
|
self.error = None
|
||||||
|
self.start_time = None
|
||||||
|
self.end_time = None
|
||||||
|
self.message = message
|
||||||
|
self.id = uuid.uuid4()
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def run(self, worker_thread):
|
||||||
|
"""Provides the caller some human-readable name for this class"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def name(self):
|
||||||
|
"""Provides the caller some human-readable name for this class"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def start(self, *args):
|
||||||
|
self.start_time = datetime.now()
|
||||||
|
self.stat = STAT_STARTED
|
||||||
|
|
||||||
|
# catch any unhandled exceptions in a task and automatically fail it
|
||||||
|
try:
|
||||||
|
self.run(*args)
|
||||||
|
except Exception as ex:
|
||||||
|
self._handleError(str(ex))
|
||||||
|
log.debug_or_exception(ex)
|
||||||
|
|
||||||
|
self.end_time = datetime.now()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stat(self):
|
||||||
|
return self._stat
|
||||||
|
|
||||||
|
@stat.setter
|
||||||
|
def stat(self, x):
|
||||||
|
self._stat = x
|
||||||
|
|
||||||
|
@property
|
||||||
|
def progress(self):
|
||||||
|
return self._progress
|
||||||
|
|
||||||
|
@progress.setter
|
||||||
|
def progress(self, x):
|
||||||
|
if not 0 <= x <= 1:
|
||||||
|
raise ValueError("Task progress should within [0, 1] range")
|
||||||
|
self._progress = x
|
||||||
|
|
||||||
|
@property
|
||||||
|
def error(self):
|
||||||
|
return self._error
|
||||||
|
|
||||||
|
@error.setter
|
||||||
|
def error(self, x):
|
||||||
|
self._error = x
|
||||||
|
|
||||||
|
@property
|
||||||
|
def runtime(self):
|
||||||
|
return (self.end_time or datetime.now()) - self.start_time
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dead(self):
|
||||||
|
"""Determines whether or not this task can be garbage collected
|
||||||
|
|
||||||
|
We have a separate dictating this because there may be certain tasks that want to override this
|
||||||
|
"""
|
||||||
|
# By default, we're good to clean a task if it's "Done"
|
||||||
|
return self.stat in (STAT_FINISH_SUCCESS, STAT_FAIL)
|
||||||
|
|
||||||
|
@progress.setter
|
||||||
|
def progress(self, x):
|
||||||
|
# todo: throw error if outside of [0,1]
|
||||||
|
self._progress = x
|
||||||
|
|
||||||
|
def _handleError(self, error_message):
|
||||||
|
self.stat = STAT_FAIL
|
||||||
|
self.progress = 1
|
||||||
|
self.error = error_message
|
||||||
|
|
||||||
|
def _handleSuccess(self):
|
||||||
|
self.stat = STAT_FINISH_SUCCESS
|
||||||
|
self.progress = 1
|
||||||
@@ -22,15 +22,17 @@
|
|||||||
|
|
||||||
from __future__ import division, print_function, unicode_literals
|
from __future__ import division, print_function, unicode_literals
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import sys
|
||||||
|
|
||||||
from flask import Blueprint, request, flash, redirect, url_for
|
from flask import Blueprint, request, flash, redirect, url_for
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from sqlalchemy.sql.expression import func
|
from sqlalchemy.sql.expression import func, true
|
||||||
|
from sqlalchemy.exc import OperationalError, InvalidRequestError
|
||||||
|
|
||||||
from . import logger, ub, searched_ids, db
|
from . import logger, ub, calibre_db, db
|
||||||
from .web import render_title_template
|
from .render_template import render_title_template
|
||||||
from .helper import common_filters
|
from .usermanagement import login_required_if_no_ano
|
||||||
|
|
||||||
|
|
||||||
shelf = Blueprint('shelf', __name__)
|
shelf = Blueprint('shelf', __name__)
|
||||||
@@ -92,9 +94,19 @@ def add_to_shelf(shelf_id, book_id):
|
|||||||
|
|
||||||
shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book_id, order=maxOrder + 1))
|
shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book_id, order=maxOrder + 1))
|
||||||
shelf.last_modified = datetime.utcnow()
|
shelf.last_modified = datetime.utcnow()
|
||||||
ub.session.merge(shelf)
|
try:
|
||||||
ub.session.commit()
|
ub.session.merge(shelf)
|
||||||
|
ub.session.commit()
|
||||||
|
except (OperationalError, InvalidRequestError):
|
||||||
|
ub.session.rollback()
|
||||||
|
log.error("Settings DB is not Writeable")
|
||||||
|
flash(_(u"Settings DB is not Writeable"), category="error")
|
||||||
|
if "HTTP_REFERER" in request.environ:
|
||||||
|
return redirect(request.environ["HTTP_REFERER"])
|
||||||
|
else:
|
||||||
|
return redirect(url_for('web.index'))
|
||||||
if not xhr:
|
if not xhr:
|
||||||
|
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(_(u"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"])
|
||||||
@@ -113,41 +125,44 @@ def search_to_shelf(shelf_id):
|
|||||||
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 the shelf: {}".format(shelf.name))
|
||||||
flash(_(u"You are not allowed to add a book to the the shelf: %(name)s", name=shelf.name), category="error")
|
flash(_(u"You are not allowed to add a book to the the shelf: %(name)s", name=shelf.name), category="error")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
|
|
||||||
if current_user.id in searched_ids and searched_ids[current_user.id]:
|
if current_user.id in ub.searched_ids and ub.searched_ids[current_user.id]:
|
||||||
books_for_shelf = list()
|
books_for_shelf = list()
|
||||||
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).all()
|
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).all()
|
||||||
if books_in_shelf:
|
if books_in_shelf:
|
||||||
book_ids = list()
|
book_ids = list()
|
||||||
for book_id in books_in_shelf:
|
for book_id in books_in_shelf:
|
||||||
book_ids.append(book_id.book_id)
|
book_ids.append(book_id.book_id)
|
||||||
for searchid in searched_ids[current_user.id]:
|
for searchid in ub.searched_ids[current_user.id]:
|
||||||
if searchid not in book_ids:
|
if searchid not in book_ids:
|
||||||
books_for_shelf.append(searchid)
|
books_for_shelf.append(searchid)
|
||||||
else:
|
else:
|
||||||
books_for_shelf = searched_ids[current_user.id]
|
books_for_shelf = ub.searched_ids[current_user.id]
|
||||||
|
|
||||||
if not books_for_shelf:
|
if not books_for_shelf:
|
||||||
log.error("Books are already part of %s", shelf)
|
log.error("Books are already part of {}".format(shelf.name))
|
||||||
flash(_(u"Books are already part of the shelf: %(name)s", name=shelf.name), category="error")
|
flash(_(u"Books are already part of the shelf: %(name)s", name=shelf.name), category="error")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
|
|
||||||
maxOrder = ub.session.query(func.max(ub.BookShelf.order)).filter(ub.BookShelf.shelf == shelf_id).first()
|
maxOrder = ub.session.query(func.max(ub.BookShelf.order)).filter(ub.BookShelf.shelf == shelf_id).first()[0] or 0
|
||||||
if maxOrder[0] is None:
|
|
||||||
maxOrder = 0
|
|
||||||
else:
|
|
||||||
maxOrder = maxOrder[0]
|
|
||||||
|
|
||||||
for book in books_for_shelf:
|
for book in books_for_shelf:
|
||||||
maxOrder = maxOrder + 1
|
maxOrder += 1
|
||||||
shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book, order=maxOrder))
|
shelf.books.append(ub.BookShelf(shelf=shelf.id, book_id=book, order=maxOrder))
|
||||||
shelf.last_modified = datetime.utcnow()
|
shelf.last_modified = datetime.utcnow()
|
||||||
ub.session.merge(shelf)
|
try:
|
||||||
ub.session.commit()
|
ub.session.merge(shelf)
|
||||||
flash(_(u"Books have been added to shelf: %(sname)s", sname=shelf.name), category="success")
|
ub.session.commit()
|
||||||
|
flash(_(u"Books have been added to shelf: %(sname)s", sname=shelf.name), category="success")
|
||||||
|
except (OperationalError, InvalidRequestError):
|
||||||
|
ub.session.rollback()
|
||||||
|
log.error("Settings DB is not Writeable")
|
||||||
|
flash(_("Settings DB is not Writeable"), category="error")
|
||||||
else:
|
else:
|
||||||
|
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(_(u"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'))
|
||||||
|
|
||||||
@@ -158,7 +173,7 @@ def remove_from_shelf(shelf_id, book_id):
|
|||||||
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||||
if shelf is None:
|
if shelf is None:
|
||||||
log.error("Invalid shelf specified: %s", shelf_id)
|
log.error("Invalid shelf specified: {}".format(shelf_id))
|
||||||
if not xhr:
|
if not xhr:
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
return "Invalid shelf specified", 400
|
return "Invalid shelf specified", 400
|
||||||
@@ -181,10 +196,18 @@ def remove_from_shelf(shelf_id, book_id):
|
|||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
return "Book already removed from shelf", 410
|
return "Book already removed from shelf", 410
|
||||||
|
|
||||||
ub.session.delete(book_shelf)
|
try:
|
||||||
shelf.last_modified = datetime.utcnow()
|
ub.session.delete(book_shelf)
|
||||||
ub.session.commit()
|
shelf.last_modified = datetime.utcnow()
|
||||||
|
ub.session.commit()
|
||||||
|
except (OperationalError, InvalidRequestError):
|
||||||
|
ub.session.rollback()
|
||||||
|
log.error("Settings DB is not Writeable")
|
||||||
|
flash(_("Settings DB is not Writeable"), category="error")
|
||||||
|
if "HTTP_REFERER" in request.environ:
|
||||||
|
return redirect(request.environ["HTTP_REFERER"])
|
||||||
|
else:
|
||||||
|
return redirect(url_for('web.index'))
|
||||||
if not xhr:
|
if not xhr:
|
||||||
flash(_(u"Book has been removed from shelf: %(sname)s", sname=shelf.name), category="success")
|
flash(_(u"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:
|
||||||
@@ -194,6 +217,7 @@ def remove_from_shelf(shelf_id, book_id):
|
|||||||
return "", 204
|
return "", 204
|
||||||
else:
|
else:
|
||||||
if not xhr:
|
if not xhr:
|
||||||
|
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: %(sname)s", sname=shelf.name),
|
flash(_(u"Sorry you are not allowed to remove a book from this shelf: %(sname)s", sname=shelf.name),
|
||||||
category="error")
|
category="error")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
@@ -204,88 +228,79 @@ def remove_from_shelf(shelf_id, book_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def create_shelf():
|
def create_shelf():
|
||||||
shelf = ub.Shelf()
|
shelf = ub.Shelf()
|
||||||
if request.method == "POST":
|
return create_edit_shelf(shelf, title=_(u"Create a Shelf"), page="shelfcreate")
|
||||||
to_save = request.form.to_dict()
|
|
||||||
if "is_public" in to_save:
|
|
||||||
shelf.is_public = 1
|
|
||||||
shelf.name = to_save["title"]
|
|
||||||
shelf.user_id = int(current_user.id)
|
|
||||||
|
|
||||||
is_shelf_name_unique = False
|
|
||||||
if shelf.is_public == 1:
|
|
||||||
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
|
||||||
.filter((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 1)) \
|
|
||||||
.first() is None
|
|
||||||
|
|
||||||
if not is_shelf_name_unique:
|
|
||||||
flash(_(u"A public shelf with the name '%(title)s' already exists.", title=to_save["title"]),
|
|
||||||
category="error")
|
|
||||||
else:
|
|
||||||
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
|
||||||
.filter((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 0) &
|
|
||||||
(ub.Shelf.user_id == int(current_user.id)))\
|
|
||||||
.first() is None
|
|
||||||
|
|
||||||
if not is_shelf_name_unique:
|
|
||||||
flash(_(u"A private shelf with the name '%(title)s' already exists.", title=to_save["title"]),
|
|
||||||
category="error")
|
|
||||||
|
|
||||||
if is_shelf_name_unique:
|
|
||||||
try:
|
|
||||||
ub.session.add(shelf)
|
|
||||||
ub.session.commit()
|
|
||||||
flash(_(u"Shelf %(title)s created", title=to_save["title"]), category="success")
|
|
||||||
return redirect(url_for('shelf.show_shelf', shelf_id=shelf.id))
|
|
||||||
except Exception:
|
|
||||||
flash(_(u"There was an error"), category="error")
|
|
||||||
return render_title_template('shelf_edit.html', shelf=shelf, title=_(u"Create a Shelf"), page="shelfcreate")
|
|
||||||
else:
|
|
||||||
return render_title_template('shelf_edit.html', shelf=shelf, title=_(u"Create a Shelf"), page="shelfcreate")
|
|
||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/edit/<int:shelf_id>", methods=["GET", "POST"])
|
@shelf.route("/shelf/edit/<int:shelf_id>", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def edit_shelf(shelf_id):
|
def edit_shelf(shelf_id):
|
||||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||||
|
return create_edit_shelf(shelf, title=_(u"Edit a shelf"), page="shelfedit", shelf_id=shelf_id)
|
||||||
|
|
||||||
|
|
||||||
|
# if shelf ID is set, we are editing a shelf
|
||||||
|
def create_edit_shelf(shelf, 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 "is_public" in to_save:
|
||||||
is_shelf_name_unique = False
|
shelf.is_public = 1
|
||||||
if shelf.is_public == 1:
|
|
||||||
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
|
||||||
.filter((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 1)) \
|
|
||||||
.filter(ub.Shelf.id != shelf_id) \
|
|
||||||
.first() is None
|
|
||||||
|
|
||||||
if not is_shelf_name_unique:
|
|
||||||
flash(_(u"A public shelf with the name '%(title)s' already exists.", title=to_save["title"]),
|
|
||||||
category="error")
|
|
||||||
else:
|
else:
|
||||||
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
shelf.is_public = 0
|
||||||
.filter((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 0) &
|
if check_shelf_is_unique(shelf, to_save, shelf_id):
|
||||||
(ub.Shelf.user_id == int(current_user.id)))\
|
|
||||||
.filter(ub.Shelf.id != shelf_id)\
|
|
||||||
.first() is None
|
|
||||||
|
|
||||||
if not is_shelf_name_unique:
|
|
||||||
flash(_(u"A private shelf with the name '%(title)s' already exists.", title=to_save["title"]),
|
|
||||||
category="error")
|
|
||||||
|
|
||||||
if is_shelf_name_unique:
|
|
||||||
shelf.name = to_save["title"]
|
shelf.name = to_save["title"]
|
||||||
shelf.last_modified = datetime.utcnow()
|
# shelf.last_modified = datetime.utcnow()
|
||||||
if "is_public" in to_save:
|
if not shelf_id:
|
||||||
shelf.is_public = 1
|
shelf.user_id = int(current_user.id)
|
||||||
|
ub.session.add(shelf)
|
||||||
|
shelf_action = "created"
|
||||||
|
flash_text = _(u"Shelf %(title)s created", title=to_save["title"])
|
||||||
else:
|
else:
|
||||||
shelf.is_public = 0
|
shelf_action = "changed"
|
||||||
|
flash_text = _(u"Shelf %(title)s changed", title=to_save["title"])
|
||||||
try:
|
try:
|
||||||
ub.session.commit()
|
ub.session.commit()
|
||||||
flash(_(u"Shelf %(title)s changed", title=to_save["title"]), category="success")
|
log.info(u"Shelf {} {}".format(to_save["title"], shelf_action))
|
||||||
except Exception:
|
flash(flash_text, category="success")
|
||||||
|
return redirect(url_for('shelf.show_shelf', shelf_id=shelf.id))
|
||||||
|
except (OperationalError, InvalidRequestError) as ex:
|
||||||
|
ub.session.rollback()
|
||||||
|
log.debug_or_exception(ex)
|
||||||
|
log.error("Settings DB is not Writeable")
|
||||||
|
flash(_("Settings DB is not Writeable"), category="error")
|
||||||
|
except Exception as ex:
|
||||||
|
ub.session.rollback()
|
||||||
|
log.debug_or_exception(ex)
|
||||||
flash(_(u"There was an error"), category="error")
|
flash(_(u"There was an error"), category="error")
|
||||||
return render_title_template('shelf_edit.html', shelf=shelf, title=_(u"Edit a shelf"), page="shelfedit")
|
return render_title_template('shelf_edit.html', shelf=shelf, title=title, page=page)
|
||||||
|
|
||||||
|
|
||||||
|
def check_shelf_is_unique(shelf, to_save, shelf_id=False):
|
||||||
|
if shelf_id:
|
||||||
|
ident = ub.Shelf.id != shelf_id
|
||||||
else:
|
else:
|
||||||
return render_title_template('shelf_edit.html', shelf=shelf, title=_(u"Edit a shelf"), page="shelfedit")
|
ident = true()
|
||||||
|
if shelf.is_public == 1:
|
||||||
|
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
||||||
|
.filter((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 1)) \
|
||||||
|
.filter(ident) \
|
||||||
|
.first() is None
|
||||||
|
|
||||||
|
if not is_shelf_name_unique:
|
||||||
|
log.error("A public shelf with the name '{}' already exists.".format(to_save["title"]))
|
||||||
|
flash(_(u"A public shelf with the name '%(title)s' already exists.", title=to_save["title"]),
|
||||||
|
category="error")
|
||||||
|
else:
|
||||||
|
is_shelf_name_unique = ub.session.query(ub.Shelf) \
|
||||||
|
.filter((ub.Shelf.name == to_save["title"]) & (ub.Shelf.is_public == 0) &
|
||||||
|
(ub.Shelf.user_id == int(current_user.id))) \
|
||||||
|
.filter(ident) \
|
||||||
|
.first() is None
|
||||||
|
|
||||||
|
if not is_shelf_name_unique:
|
||||||
|
log.error("A private shelf with the name '{}' already exists.".format(to_save["title"]))
|
||||||
|
flash(_(u"A private shelf with the name '%(title)s' already exists.", title=to_save["title"]),
|
||||||
|
category="error")
|
||||||
|
return is_shelf_name_unique
|
||||||
|
|
||||||
|
|
||||||
def delete_shelf_helper(cur_shelf):
|
def delete_shelf_helper(cur_shelf):
|
||||||
@@ -295,45 +310,34 @@ def delete_shelf_helper(cur_shelf):
|
|||||||
ub.session.delete(cur_shelf)
|
ub.session.delete(cur_shelf)
|
||||||
ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).delete()
|
ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id).delete()
|
||||||
ub.session.add(ub.ShelfArchive(uuid=cur_shelf.uuid, user_id=cur_shelf.user_id))
|
ub.session.add(ub.ShelfArchive(uuid=cur_shelf.uuid, user_id=cur_shelf.user_id))
|
||||||
ub.session.commit()
|
ub.session_commit("successfully deleted Shelf {}".format(cur_shelf.name))
|
||||||
log.info("successfully deleted %s", cur_shelf)
|
|
||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/delete/<int:shelf_id>")
|
@shelf.route("/shelf/delete/<int:shelf_id>")
|
||||||
@login_required
|
@login_required
|
||||||
def delete_shelf(shelf_id):
|
def delete_shelf(shelf_id):
|
||||||
cur_shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
cur_shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||||
delete_shelf_helper(cur_shelf)
|
try:
|
||||||
|
delete_shelf_helper(cur_shelf)
|
||||||
|
except InvalidRequestError:
|
||||||
|
ub.session.rollback()
|
||||||
|
log.error("Settings DB is not Writeable")
|
||||||
|
flash(_("Settings DB is not Writeable"), category="error")
|
||||||
return redirect(url_for('web.index'))
|
return redirect(url_for('web.index'))
|
||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/<int:shelf_id>", defaults={'shelf_type': 1})
|
@shelf.route("/simpleshelf/<int:shelf_id>")
|
||||||
@shelf.route("/shelf/<int:shelf_id>/<int:shelf_type>")
|
@login_required_if_no_ano
|
||||||
def show_shelf(shelf_type, shelf_id):
|
def show_simpleshelf(shelf_id):
|
||||||
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
return render_show_shelf(2, shelf_id, 1, None)
|
||||||
|
|
||||||
result = list()
|
|
||||||
# user is allowed to access shelf
|
|
||||||
if shelf and check_shelf_view_permissions(shelf):
|
|
||||||
page = "shelf.html" if shelf_type == 1 else 'shelfdown.html'
|
|
||||||
|
|
||||||
books_in_shelf = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id)\
|
@shelf.route("/shelf/<int:shelf_id>", defaults={"sort_param": "order", 'page': 1})
|
||||||
.order_by(ub.BookShelf.order.asc()).all()
|
@shelf.route("/shelf/<int:shelf_id>/<sort_param>", defaults={'page': 1})
|
||||||
for book in books_in_shelf:
|
@shelf.route("/shelf/<int:shelf_id>/<sort_param>/<int:page>")
|
||||||
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).filter(common_filters()).first()
|
@login_required_if_no_ano
|
||||||
if cur_book:
|
def show_shelf(shelf_id, sort_param, page):
|
||||||
result.append(cur_book)
|
return render_show_shelf(1, shelf_id, page, sort_param)
|
||||||
else:
|
|
||||||
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).first()
|
|
||||||
if not cur_book:
|
|
||||||
log.info('Not existing book %s in %s deleted', book.book_id, shelf)
|
|
||||||
ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == book.book_id).delete()
|
|
||||||
ub.session.commit()
|
|
||||||
return render_title_template(page, entries=result, title=_(u"Shelf: '%(name)s'", name=shelf.name),
|
|
||||||
shelf=shelf, page="shelf")
|
|
||||||
else:
|
|
||||||
flash(_(u"Error opening shelf. Shelf does not exist or is not accessible"), category="error")
|
|
||||||
return redirect(url_for("web.index"))
|
|
||||||
|
|
||||||
|
|
||||||
@shelf.route("/shelf/order/<int:shelf_id>", methods=["GET", "POST"])
|
@shelf.route("/shelf/order/<int:shelf_id>", methods=["GET", "POST"])
|
||||||
@@ -348,27 +352,90 @@ def order_shelf(shelf_id):
|
|||||||
setattr(book, 'order', to_save[str(book.book_id)])
|
setattr(book, 'order', to_save[str(book.book_id)])
|
||||||
counter += 1
|
counter += 1
|
||||||
# if order diffrent from before -> shelf.last_modified = datetime.utcnow()
|
# if order diffrent from before -> shelf.last_modified = datetime.utcnow()
|
||||||
ub.session.commit()
|
try:
|
||||||
|
ub.session.commit()
|
||||||
|
except (OperationalError, InvalidRequestError):
|
||||||
|
ub.session.rollback()
|
||||||
|
log.error("Settings DB is not Writeable")
|
||||||
|
flash(_("Settings DB is not Writeable"), category="error")
|
||||||
|
|
||||||
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()
|
||||||
result = list()
|
result = list()
|
||||||
if shelf and check_shelf_view_permissions(shelf):
|
if shelf and check_shelf_view_permissions(shelf):
|
||||||
books_in_shelf2 = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id) \
|
result = calibre_db.session.query(db.Books)\
|
||||||
.order_by(ub.BookShelf.order.asc()).all()
|
.join(ub.BookShelf,ub.BookShelf.book_id == db.Books.id , isouter=True) \
|
||||||
for book in books_in_shelf2:
|
.add_columns(calibre_db.common_filters().label("visible")) \
|
||||||
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).filter(common_filters()).first()
|
.filter(ub.BookShelf.shelf == shelf_id).order_by(ub.BookShelf.order.asc()).all()
|
||||||
if cur_book:
|
|
||||||
result.append({'title': cur_book.title,
|
|
||||||
'id': cur_book.id,
|
|
||||||
'author': cur_book.authors,
|
|
||||||
'series': cur_book.series,
|
|
||||||
'series_index': cur_book.series_index})
|
|
||||||
else:
|
|
||||||
cur_book = db.session.query(db.Books).filter(db.Books.id == book.book_id).first()
|
|
||||||
result.append({'title': _('Hidden Book'),
|
|
||||||
'id': cur_book.id,
|
|
||||||
'author': [],
|
|
||||||
'series': []})
|
|
||||||
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=_(u"Change order of Shelf: '%(name)s'", name=shelf.name),
|
||||||
shelf=shelf, page="shelforder")
|
shelf=shelf, page="shelforder")
|
||||||
|
|
||||||
|
|
||||||
|
def change_shelf_order(shelf_id, order):
|
||||||
|
result = calibre_db.session.query(db.Books).join(ub.BookShelf,ub.BookShelf.book_id == db.Books.id)\
|
||||||
|
.filter(ub.BookShelf.shelf == shelf_id).order_by(*order).all()
|
||||||
|
for index, entry in enumerate(result):
|
||||||
|
book = ub.session.query(ub.BookShelf).filter(ub.BookShelf.shelf == shelf_id) \
|
||||||
|
.filter(ub.BookShelf.book_id == entry.id).first()
|
||||||
|
book.order = index
|
||||||
|
ub.session_commit("Shelf-id:{} - Order changed".format(shelf_id))
|
||||||
|
|
||||||
|
|
||||||
|
def render_show_shelf(shelf_type, shelf_id, page_no, sort_param):
|
||||||
|
shelf = ub.session.query(ub.Shelf).filter(ub.Shelf.id == shelf_id).first()
|
||||||
|
|
||||||
|
# check user is allowed to access shelf
|
||||||
|
if shelf and check_shelf_view_permissions(shelf):
|
||||||
|
|
||||||
|
if shelf_type == 1:
|
||||||
|
# order = [ub.BookShelf.order.asc()]
|
||||||
|
if sort_param == 'pubnew':
|
||||||
|
change_shelf_order(shelf_id, [db.Books.pubdate.desc()])
|
||||||
|
if sort_param == 'pubold':
|
||||||
|
change_shelf_order(shelf_id, [db.Books.pubdate])
|
||||||
|
if sort_param == 'abc':
|
||||||
|
change_shelf_order(shelf_id, [db.Books.sort])
|
||||||
|
if sort_param == 'zyx':
|
||||||
|
change_shelf_order(shelf_id, [db.Books.sort.desc()])
|
||||||
|
if sort_param == 'new':
|
||||||
|
change_shelf_order(shelf_id, [db.Books.timestamp.desc()])
|
||||||
|
if sort_param == 'old':
|
||||||
|
change_shelf_order(shelf_id, [db.Books.timestamp])
|
||||||
|
if sort_param == 'authaz':
|
||||||
|
change_shelf_order(shelf_id, [db.Books.author_sort.asc()])
|
||||||
|
if sort_param == 'authza':
|
||||||
|
change_shelf_order(shelf_id, [db.Books.author_sort.desc()])
|
||||||
|
page = "shelf.html"
|
||||||
|
pagesize = 0
|
||||||
|
else:
|
||||||
|
pagesize = sys.maxsize
|
||||||
|
page = 'shelfdown.html'
|
||||||
|
|
||||||
|
result, __, pagination = calibre_db.fill_indexpage(page_no, pagesize,
|
||||||
|
db.Books,
|
||||||
|
ub.BookShelf.shelf == shelf_id,
|
||||||
|
[ub.BookShelf.order.asc()],
|
||||||
|
ub.BookShelf,ub.BookShelf.book_id == db.Books.id)
|
||||||
|
# delete chelf entries where book is not existent anymore, can happen if book is deleted outside calibre-web
|
||||||
|
wrong_entries = calibre_db.session.query(ub.BookShelf)\
|
||||||
|
.join(db.Books, ub.BookShelf.book_id == db.Books.id, isouter=True)\
|
||||||
|
.filter(db.Books.id == None).all()
|
||||||
|
for entry in wrong_entries:
|
||||||
|
log.info('Not existing book {} in {} deleted'.format(entry.book_id, shelf))
|
||||||
|
try:
|
||||||
|
ub.session.query(ub.BookShelf).filter(ub.BookShelf.book_id == entry.book_id).delete()
|
||||||
|
ub.session.commit()
|
||||||
|
except (OperationalError, InvalidRequestError):
|
||||||
|
ub.session.rollback()
|
||||||
|
log.error("Settings DB is not Writeable")
|
||||||
|
flash(_("Settings DB is not Writeable"), category="error")
|
||||||
|
|
||||||
|
return render_title_template(page,
|
||||||
|
entries=result,
|
||||||
|
pagination=pagination,
|
||||||
|
title=_(u"Shelf: '%(name)s'", name=shelf.name),
|
||||||
|
shelf=shelf,
|
||||||
|
page="shelf")
|
||||||
|
else:
|
||||||
|
flash(_(u"Error opening shelf. Shelf does not exist or is not accessible"), category="error")
|
||||||
|
return redirect(url_for("web.index"))
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
body.serieslist.grid-view div.container-fluid > div > div.col-sm-10::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover .badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
color: #fff;
|
||||||
|
background-color: #cc7b19;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0 8px;
|
||||||
|
box-shadow: 0 0 4px rgba(0, 0, 0, 0.6);
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
box-shadow: 0 0 4px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover .read {
|
||||||
|
padding: 0 0;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 509 B |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -33,7 +33,6 @@ body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
|
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +44,7 @@ body {
|
|||||||
|
|
||||||
#sidebar a.active,
|
#sidebar a.active,
|
||||||
#sidebar a.active img + span {
|
#sidebar a.active img + span {
|
||||||
background-color: #45B29D;
|
background-color: #45b29d;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar li img {
|
#sidebar li img {
|
||||||
@@ -85,21 +84,30 @@ body {
|
|||||||
#progress .bar-load,
|
#progress .bar-load,
|
||||||
#progress .bar-read {
|
#progress .bar-read {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: flex-end;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
transition: width 150ms ease-in-out;
|
transition: width 150ms ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#progress .from-left {
|
||||||
|
left: 0;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
#progress .from-right {
|
||||||
|
right: 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
#progress .bar-load {
|
#progress .bar-load {
|
||||||
color: #000;
|
color: #000;
|
||||||
background-color: #ccc;
|
background-color: #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
#progress .bar-read {
|
#progress .bar-read {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background-color: #45b29d;
|
background-color: #45b29d;
|
||||||
}
|
}
|
||||||
@@ -219,3 +227,14 @@ th {
|
|||||||
.dark-theme .overlay {
|
.dark-theme .overlay {
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
|
.disabled-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar for IE, Edge and Firefox */
|
||||||
|
.disabled-scrollbar {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
@@ -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" 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>
|
||||||
|
After Width: | Height: | Size: 461 B |
|
Before Width: | Height: | Size: 199 B |
|
Before Width: | Height: | Size: 304 B |
|
Before Width: | Height: | Size: 193 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<!-- 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"><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>
|
||||||
|
After Width: | Height: | Size: 434 B |
|
Before Width: | Height: | Size: 296 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- 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>
|
||||||
|
After Width: | Height: | Size: 458 B |
|
Before Width: | Height: | Size: 193 B |
|
Before Width: | Height: | Size: 296 B |
|
Before Width: | Height: | Size: 199 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<!-- 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"><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>
|
||||||
|
After Width: | Height: | Size: 431 B |
|
Before Width: | Height: | Size: 304 B |
@@ -0,0 +1,24 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 16 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16" 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>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
<!-- 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>
|
||||||
|
After Width: | Height: | Size: 557 B |
|
Before Width: | Height: | Size: 403 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<!-- 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">
|
||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 530 B |
|
Before Width: | Height: | Size: 933 B |
@@ -0,0 +1,2 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 255 B |
|
Before Width: | Height: | Size: 179 B |
@@ -0,0 +1 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 228 B |
|
Before Width: | Height: | Size: 266 B |
@@ -0,0 +1,2 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 339 B |
|
Before Width: | Height: | Size: 301 B |
@@ -0,0 +1 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 312 B |
|
Before Width: | Height: | Size: 583 B |
@@ -0,0 +1,2 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 256 B |
|
Before Width: | Height: | Size: 175 B |
@@ -0,0 +1 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 229 B |
|
Before Width: | Height: | Size: 276 B |
@@ -0,0 +1,2 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 231 B |
|
Before Width: | Height: | Size: 360 B |
@@ -0,0 +1 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 204 B |
|
Before Width: | Height: | Size: 731 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- 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>
|
||||||
|
After Width: | Height: | Size: 521 B |
|
Before Width: | Height: | Size: 359 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<!-- 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"><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>
|
||||||
|
After Width: | Height: | Size: 494 B |
|
Before Width: | Height: | Size: 714 B |
@@ -0,0 +1,2 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 302 B |
|
Before Width: | Height: | Size: 218 B |
@@ -0,0 +1 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 275 B |
|
Before Width: | Height: | Size: 332 B |
@@ -0,0 +1,2 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 307 B |
|
Before Width: | Height: | Size: 228 B |
@@ -0,0 +1 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 280 B |