mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-08-05 18:58:34 -05:00
Compare commits
628 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
2368fd15cb | ||
![]() |
cc35b321c5 | ||
![]() |
1ddbc31c59 | ||
![]() |
6ae8ab4af3 | ||
![]() |
37dc791301 | ||
![]() |
5fed311ffc | ||
![]() |
e74f182662 | ||
![]() |
3f094c88bd | ||
![]() |
631eaa2109 | ||
![]() |
27fadf6963 | ||
![]() |
d53b300a7f | ||
![]() |
6cc626fd86 | ||
![]() |
8b8f1af513 | ||
![]() |
4226a1bddc | ||
![]() |
289a7a2348 | ||
![]() |
702ab1b77a | ||
![]() |
e22ccbda26 | ||
![]() |
56d296f04b | ||
![]() |
11cfa0871e | ||
![]() |
159344f033 | ||
![]() |
d8cfed5f5e | ||
![]() |
94fe7a9e3d | ||
![]() |
9c9b4effe2 | ||
![]() |
75f5007ede | ||
![]() |
d95baf4e6b | ||
![]() |
aac04e73b9 | ||
![]() |
3af3484a00 | ||
![]() |
90e68af6cf | ||
![]() |
eee08d389f | ||
![]() |
b3b0e95d2d | ||
![]() |
35907313e8 | ||
![]() |
d4a20c7e30 | ||
![]() |
3b1dffe0dc | ||
![]() |
fa0ab0de27 | ||
![]() |
3b2b4a9177 | ||
![]() |
3d030637ca | ||
![]() |
79092c27c5 | ||
![]() |
28fdb170bf | ||
![]() |
335c6c3820 | ||
![]() |
ad23cce2e6 | ||
![]() |
0d96cd03d5 | ||
![]() |
1888ee6a3f | ||
![]() |
605aa50b00 | ||
![]() |
149d770ad1 | ||
![]() |
b2e9f3195a | ||
![]() |
33e9990ed5 | ||
![]() |
e775b6346a | ||
![]() |
54e17f5b74 | ||
![]() |
ff1639d58b | ||
![]() |
7649903d3c | ||
![]() |
7a5d707fc0 | ||
![]() |
85e00aecb4 | ||
![]() |
53aa216a4a | ||
![]() |
2814cd110d | ||
![]() |
b9315b018a | ||
![]() |
27f7ba8cf8 | ||
![]() |
df9917b0f4 | ||
![]() |
86418f6e04 | ||
![]() |
69a6a12319 | ||
![]() |
b501d89846 | ||
![]() |
f6548e0e55 | ||
![]() |
a4b8bf1250 | ||
![]() |
1726aec989 | ||
![]() |
549312859e | ||
![]() |
544e9c4fe2 | ||
![]() |
0520db5e93 | ||
![]() |
1cb85d41f3 | ||
![]() |
fb94a5d377 | ||
![]() |
85e2081e40 | ||
![]() |
71e2565386 | ||
![]() |
82be90f7ff | ||
![]() |
f0ad073bb2 | ||
![]() |
61c804a6e3 | ||
![]() |
de95b296a0 | ||
![]() |
86a57838a8 | ||
![]() |
bddb9bfad8 | ||
![]() |
7098ec9bf5 | ||
![]() |
c2cfaaf8af | ||
![]() |
6292296876 | ||
![]() |
9f68e0f76a | ||
![]() |
4e849b545a | ||
![]() |
e43fee41cb | ||
![]() |
613f8a0065 | ||
![]() |
baf6484454 | ||
![]() |
9b84dc06b6 | ||
![]() |
cb617531bc | ||
![]() |
8e61a29137 | ||
![]() |
dfcecb3a5c | ||
![]() |
0a61b8e6fc | ||
![]() |
e78d758656 | ||
![]() |
073c42984a | ||
![]() |
2994f3a740 | ||
![]() |
2353f7c2db | ||
![]() |
dcc8d4046a | ||
![]() |
024b60638a | ||
![]() |
8dd355f6bf | ||
![]() |
cf3645c296 | ||
![]() |
facec317ef | ||
![]() |
95d1abd416 | ||
![]() |
7c11a37150 | ||
![]() |
e49ed58f1a | ||
![]() |
54293bedb1 | ||
![]() |
fc683e150a | ||
![]() |
b3487f1843 | ||
![]() |
f8d79b012f | ||
![]() |
2e3637d712 | ||
![]() |
74001bd0da | ||
![]() |
374a1ceb05 | ||
![]() |
59f726b2a2 | ||
![]() |
77bebc861d | ||
![]() |
85e57ede9b | ||
![]() |
a7424a7bfe | ||
![]() |
46b8e536a8 | ||
![]() |
2ab71137b9 | ||
![]() |
0b829cab32 | ||
![]() |
991c9b0ca4 | ||
![]() |
b9c1ba8a1d | ||
![]() |
c9e33a3401 | ||
![]() |
dd9b10bdf8 | ||
![]() |
546fd2740b | ||
![]() |
56e1365b4b | ||
![]() |
e6f59472e4 | ||
![]() |
5e687d9a93 | ||
![]() |
c92c3e224a | ||
![]() |
4adf20af1e | ||
![]() |
a9b7965dcf | ||
![]() |
d7ba6d98d3 | ||
![]() |
f6135f9ad0 | ||
![]() |
f06ff85b7d | ||
![]() |
1b7cacc877 | ||
![]() |
870d6ee782 | ||
![]() |
3aba68c09f | ||
![]() |
609fa9a212 | ||
![]() |
16069cde23 | ||
![]() |
a440c88b81 | ||
![]() |
97030a807f | ||
![]() |
4146b140d3 | ||
![]() |
fa6f013db5 | ||
![]() |
6192c15c4d | ||
![]() |
8aa35540b5 | ||
![]() |
e787055294 | ||
![]() |
045b62ca66 | ||
![]() |
3b7fdb2f37 | ||
![]() |
bd1f05df24 | ||
![]() |
eeeec498d4 | ||
![]() |
0af2b967e4 | ||
![]() |
4193401be7 | ||
![]() |
36df6fd3e5 | ||
![]() |
86a540e68e | ||
![]() |
fb3a881387 | ||
![]() |
8e555cce9e | ||
![]() |
4f8e59030e | ||
![]() |
5075d0bab0 | ||
![]() |
fb3a136b32 | ||
![]() |
66a8057e31 | ||
![]() |
cb6cf7f771 | ||
![]() |
9a7f95865f | ||
![]() |
0d1e0bc70e | ||
![]() |
bee963c23d | ||
![]() |
f1559b7108 | ||
![]() |
a64a182fc3 | ||
![]() |
aeb49898e5 | ||
![]() |
a2c8fcd46b | ||
![]() |
357ae92d88 | ||
![]() |
74330623b3 | ||
![]() |
3813dc2e18 | ||
![]() |
3df8be0bc7 | ||
![]() |
cc25cbc026 | ||
![]() |
e98d52830f | ||
![]() |
4903e4290d | ||
![]() |
e1ba1a1898 | ||
![]() |
b29c1e91d1 | ||
![]() |
a03c7701a5 | ||
![]() |
b8c9d1316c | ||
![]() |
a63ef26d38 | ||
![]() |
96b2884458 | ||
![]() |
8543202723 | ||
![]() |
a63904b7af | ||
![]() |
eb27bc9e7d | ||
![]() |
489b24ad65 | ||
![]() |
5349eb2302 | ||
![]() |
a8fd023398 | ||
![]() |
e34d48d913 | ||
![]() |
ee529c2276 | ||
![]() |
3d5e45c20a | ||
![]() |
b8283047ae | ||
![]() |
dad3a1ff28 | ||
![]() |
ce663398e6 | ||
![]() |
f5ec6de047 | ||
![]() |
807f788f92 | ||
![]() |
eaaaa575b8 | ||
![]() |
e21552e053 | ||
![]() |
6a7274c414 | ||
![]() |
35de04a2ce | ||
![]() |
a0c227fe55 | ||
![]() |
057ce29676 | ||
![]() |
982eeb0d24 | ||
![]() |
dfa25343a3 | ||
![]() |
dcfb4494c9 | ||
![]() |
5a1ef27224 | ||
![]() |
6f79ee9877 | ||
![]() |
bc0e420d67 | ||
![]() |
8e34756e6b | ||
![]() |
76951ea482 | ||
![]() |
b5e4aaa778 | ||
![]() |
39998cb34f | ||
![]() |
a771d2afd9 | ||
![]() |
dac3def6b9 | ||
![]() |
674b4a839c | ||
![]() |
037dcb6a11 | ||
![]() |
ad8c60d153 | ||
![]() |
3ea312e136 | ||
![]() |
d2a04743eb | ||
![]() |
b34f9c3b20 | ||
![]() |
fea7b0ec8c | ||
![]() |
0042e3eca4 | ||
![]() |
36db6f3d4b | ||
![]() |
3c633c2015 | ||
![]() |
dd8b51de67 | ||
![]() |
5fe846de1d | ||
![]() |
4711468598 | ||
![]() |
19dfaf1b94 | ||
![]() |
183ea24c9f | ||
![]() |
99693b6d30 | ||
![]() |
c0ad82b695 | ||
![]() |
fd36323d1c | ||
![]() |
4059c83a21 | ||
![]() |
b25c015516 | ||
![]() |
8fa52046e4 | ||
![]() |
15554322dd | ||
![]() |
0ee85aae21 | ||
![]() |
839fb34c8e | ||
![]() |
928580bf4f | ||
![]() |
9cca7aaa08 | ||
![]() |
38560cf13a | ||
![]() |
474ca08ef9 | ||
![]() |
d4fd529e49 | ||
![]() |
2260617447 | ||
![]() |
7c3ba3e518 | ||
![]() |
849e3a10ac | ||
![]() |
bdceeef3fb | ||
![]() |
8630e5f5b6 | ||
![]() |
2312eba5b6 | ||
![]() |
ad9d654886 | ||
![]() |
fa19a8975e | ||
![]() |
8987cd448f | ||
![]() |
a7536e3ebf | ||
![]() |
9c3bc2eb83 | ||
![]() |
d179efbd48 | ||
![]() |
801df5f7bd | ||
![]() |
d1c3ea7faa | ||
![]() |
dcfc53b7f2 | ||
![]() |
45002f8083 | ||
![]() |
722a2ca1e4 | ||
![]() |
2bd8b67d02 | ||
![]() |
637efd5cb3 | ||
![]() |
ad3dd76c2f | ||
![]() |
75aba12589 | ||
![]() |
ec33edb2f4 | ||
![]() |
4b706fa4dd | ||
![]() |
98799d9a69 | ||
![]() |
b80070ac0b | ||
![]() |
6b2e5559ca | ||
![]() |
82340ad6e3 | ||
![]() |
f45ab723ae | ||
![]() |
8e3ca37b05 | ||
![]() |
aef387ed69 | ||
![]() |
b93c970635 | ||
![]() |
a7d8b5c960 | ||
![]() |
56c9a3f270 | ||
![]() |
0c3dac45b5 | ||
![]() |
6965165c76 | ||
![]() |
73d33ff25a | ||
![]() |
df153be30e | ||
![]() |
9950ff2337 | ||
![]() |
9a9ab85baf | ||
![]() |
eaea42334b | ||
![]() |
de8ac013ee | ||
![]() |
9e2bf4820a | ||
![]() |
9af879a2bf | ||
![]() |
ff4203938b | ||
![]() |
a63f8809fa | ||
![]() |
edcde1f142 | ||
![]() |
6dc094f760 | ||
![]() |
186f520819 | ||
![]() |
0365fc5ac3 | ||
![]() |
61811a4bec | ||
![]() |
bb83c1eb0a | ||
![]() |
4ad4862641 | ||
![]() |
c03aa03ac2 | ||
![]() |
ada283441c | ||
![]() |
3cf73a77ac | ||
![]() |
71fedcb466 | ||
![]() |
1b9cf5121b | ||
![]() |
7fe76656f2 | ||
![]() |
0deb8a11d6 | ||
![]() |
064d384d97 | ||
![]() |
5045d06744 | ||
![]() |
c57aa81d15 | ||
![]() |
d35e350c79 | ||
![]() |
dd878c8d70 | ||
![]() |
9cd1945a89 | ||
![]() |
faab8a5560 | ||
![]() |
fcc9847bc3 | ||
![]() |
a64d457c30 | ||
![]() |
e799d757c2 | ||
![]() |
ac0ed0def8 | ||
![]() |
f01283c309 | ||
![]() |
a3c468a004 | ||
![]() |
3435ffd00c | ||
![]() |
4f1185c65d | ||
![]() |
2b1498cc6d | ||
![]() |
0643db4347 | ||
![]() |
29e6371cd1 | ||
![]() |
80c2d90e74 | ||
![]() |
f3cf608caa | ||
![]() |
c6d0557a3b | ||
![]() |
f3b7ae93f0 | ||
![]() |
e4265d0594 | ||
![]() |
deda49c204 | ||
![]() |
276abc1404 | ||
![]() |
6defe24ae7 | ||
![]() |
6ed5d11758 | ||
![]() |
9d34327a6d | ||
![]() |
63f164d099 | ||
![]() |
0f9710dc8f | ||
![]() |
cccba47bd7 | ||
![]() |
91585a1fa6 | ||
![]() |
3bb6a32ab9 | ||
![]() |
31f592453e | ||
![]() |
56f5f93c48 | ||
![]() |
e6aefd1063 | ||
![]() |
6187ee82af | ||
![]() |
a066ccff4f | ||
![]() |
f73be01897 | ||
![]() |
07ee25be06 | ||
![]() |
4347c87e92 | ||
![]() |
807f0f1345 | ||
![]() |
12857890cc | ||
![]() |
2ad0f8325c | ||
![]() |
6aae8bf440 | ||
![]() |
5c7522b423 | ||
![]() |
37e607abb9 | ||
![]() |
8045f3d58c | ||
![]() |
a796e58a94 | ||
![]() |
9d4e2d4652 | ||
![]() |
28db7e84e6 | ||
![]() |
22a6360edf | ||
![]() |
61485b0f1d | ||
![]() |
fa7a5451db | ||
![]() |
70069cd502 | ||
![]() |
9e8b96cd34 | ||
![]() |
d03058e539 | ||
![]() |
c929a18da2 | ||
![]() |
5bd248578a | ||
![]() |
ebfb72a691 | ||
![]() |
fc440d8317 | ||
![]() |
b6f6d524d6 | ||
![]() |
f225f72145 | ||
![]() |
d9002005b1 | ||
![]() |
6ddb62bf3f | ||
![]() |
d1ac15baa9 | ||
![]() |
81e4092f53 | ||
![]() |
d8c96b6e4a | ||
![]() |
3d6aa8a656 | ||
![]() |
6d2ae3df1f | ||
![]() |
de7c22e8d6 | ||
![]() |
74c44fe418 | ||
![]() |
a6407d64e9 | ||
![]() |
e553e872df | ||
![]() |
e9e3ec5597 | ||
![]() |
3dbf2e73f9 | ||
![]() |
33c0b692e6 | ||
![]() |
4abc185a13 | ||
![]() |
43ede21c35 | ||
![]() |
5fc6736666 | ||
![]() |
d2883b83c5 | ||
![]() |
085447e7c4 | ||
![]() |
04f52f553a | ||
![]() |
d558367642 | ||
![]() |
324a2aa1c6 | ||
![]() |
617bb30f29 | ||
![]() |
d6191d2f2b | ||
![]() |
f7347bdb69 | ||
![]() |
ce3d5b0065 | ||
![]() |
235b0a4c33 | ||
![]() |
fdf873ad6a | ||
![]() |
b996022003 | ||
![]() |
6d4897a1b8 | ||
![]() |
180b32651d | ||
![]() |
3d56a56eb8 | ||
![]() |
2b85e812f8 | ||
![]() |
e6b856e13f | ||
![]() |
02ebcd29ee | ||
![]() |
719f76060b | ||
![]() |
8abb0cd75d | ||
![]() |
c0c44b512c | ||
![]() |
3e62f13f96 | ||
![]() |
2116964f67 | ||
![]() |
f7ce32f471 | ||
![]() |
d87208be51 | ||
![]() |
bb8ee1e5fb | ||
![]() |
2a0c03eda0 | ||
![]() |
3061c59c06 | ||
![]() |
79067041dd | ||
![]() |
622f624132 | ||
![]() |
3facdefa40 | ||
![]() |
e283bbe5c2 | ||
![]() |
c4f9828a10 | ||
![]() |
cb160212d4 | ||
![]() |
3fa448ecb5 | ||
![]() |
019a255753 | ||
![]() |
50a6181e48 | ||
![]() |
5ff791e4c5 | ||
![]() |
317a9114eb | ||
![]() |
2597d312ed | ||
![]() |
0e95b0a64b | ||
![]() |
853c745039 | ||
![]() |
ed05b40ba4 | ||
![]() |
97eec44647 | ||
![]() |
45138a1881 | ||
![]() |
67565ea1ff | ||
![]() |
05a240b6ed | ||
![]() |
4c6faa698b | ||
![]() |
654685873a | ||
![]() |
2ac5407dd4 | ||
![]() |
76ddc09dba | ||
![]() |
f45daa9445 | ||
![]() |
953ba9160e | ||
![]() |
64de6b8571 | ||
![]() |
5455850168 | ||
![]() |
e91af06189 | ||
![]() |
779f091c04 | ||
![]() |
0627c7f43e | ||
![]() |
302bc9e9f6 | ||
![]() |
a1e4365ff2 | ||
![]() |
7983487430 | ||
![]() |
ac666df4ce | ||
![]() |
68ca27c27c | ||
![]() |
52350f8b51 | ||
![]() |
6fa3522618 | ||
![]() |
84c3e7893e | ||
![]() |
74b850423f | ||
![]() |
43a6e3985d | ||
![]() |
83e3f8efb8 | ||
![]() |
8c93d1db42 | ||
![]() |
5b8cd96f37 | ||
![]() |
5fec764018 | ||
![]() |
22c8d8ef2a | ||
![]() |
9b3a29cddd | ||
![]() |
d461dcbe29 | ||
![]() |
3e22f033c7 | ||
![]() |
e7a5ebc64c | ||
![]() |
e1f5edc0a1 | ||
![]() |
48092d47c5 | ||
![]() |
d4d0604da2 | ||
![]() |
f7db5f3821 | ||
![]() |
ddb65d371a | ||
![]() |
e17b91b87c | ||
![]() |
47ce797ee9 | ||
![]() |
f8057ed4f1 | ||
![]() |
d3ff0ff8e0 | ||
![]() |
6ea25a96a3 | ||
![]() |
caec0ed4d1 | ||
![]() |
ce08400f4e | ||
![]() |
076b5b1af5 | ||
![]() |
44ed78b442 | ||
![]() |
3bd6a6fcfa | ||
![]() |
4fa08a9c96 | ||
![]() |
8ea3259fe7 | ||
![]() |
fae2399e46 | ||
![]() |
0d49314593 | ||
![]() |
fda4742e86 | ||
![]() |
190b648c72 | ||
![]() |
22e88046bc | ||
![]() |
06447c72c5 | ||
![]() |
7a6fe2da7c | ||
![]() |
f04cf1a974 | ||
![]() |
1ebce6f3e0 | ||
![]() |
e07777e38a | ||
![]() |
2c69d0fd2e | ||
![]() |
b58c114a76 | ||
![]() |
9aee6f5a78 | ||
![]() |
54edad29ba | ||
![]() |
0bf711259a | ||
![]() |
1b6250ae24 | ||
![]() |
f2b3521e6c | ||
![]() |
c2944402fa | ||
![]() |
a0f1f6faa1 | ||
![]() |
eaec0014c5 | ||
![]() |
571f3444d1 | ||
![]() |
57032e234c | ||
![]() |
321adaeb8b | ||
![]() |
5d937cf639 | ||
![]() |
3e7656e1e1 | ||
![]() |
93555cf2e7 | ||
![]() |
f60c201eb9 | ||
![]() |
78af59ec17 | ||
![]() |
b305372ed1 | ||
![]() |
16b8b58533 | ||
![]() |
5802163a0e | ||
![]() |
b403b9d9d5 | ||
![]() |
c6e7d06bb7 | ||
![]() |
40289cd714 | ||
![]() |
2de9d1b7ae | ||
![]() |
39b57f695a | ||
![]() |
f503cd8758 | ||
![]() |
8d516c08f0 | ||
![]() |
8b4fc02955 | ||
![]() |
6c24686509 | ||
![]() |
7be7185418 | ||
![]() |
63e1f9f5d3 | ||
![]() |
bd4476d484 | ||
![]() |
7a0334f353 | ||
![]() |
d03e48ea88 | ||
![]() |
342e6d4679 | ||
![]() |
584f1361ad | ||
![]() |
05b1ff9738 | ||
![]() |
d65fcf70f3 | ||
![]() |
a5d3d51cc5 | ||
![]() |
f4489ca2e7 | ||
![]() |
e40893e74f | ||
![]() |
d002ae2e05 | ||
![]() |
bf430865b4 | ||
![]() |
a47d36f5e5 | ||
![]() |
4392628bd7 | ||
![]() |
6d25eb26a1 | ||
![]() |
95fd1ae879 | ||
![]() |
78f338484f | ||
![]() |
40db1065dc | ||
![]() |
c644e57533 | ||
![]() |
b720aa3cd1 | ||
![]() |
e837f1e85b | ||
![]() |
ea2012bc81 | ||
![]() |
8e39315586 | ||
![]() |
ea8127202d | ||
![]() |
f009d9868e | ||
![]() |
1bbcd0961b | ||
![]() |
4fa2b54aed | ||
![]() |
7281c110c6 | ||
![]() |
f812f2af4d | ||
![]() |
47b4a602a7 | ||
![]() |
21c7675f66 | ||
![]() |
ca73c0d1f3 | ||
![]() |
7f6a50be5b | ||
![]() |
10e10f9ff4 | ||
![]() |
95c24a50f7 | ||
![]() |
d06faa2fcb | ||
![]() |
bed66cced0 | ||
![]() |
ceaf60e6ad | ||
![]() |
9885ca5103 | ||
![]() |
2f22beaaee | ||
![]() |
fb2c6282a4 | ||
![]() |
8c5b5d3948 | ||
![]() |
4e5135fe70 | ||
![]() |
579c35a3fe | ||
![]() |
4aedcb856d | ||
![]() |
0b34e70f6c | ||
![]() |
7afc91e7b1 | ||
![]() |
56b17ce6a2 | ||
![]() |
954912cac3 | ||
![]() |
e46f6b1156 | ||
![]() |
1d85caa8d0 | ||
![]() |
622fcf96a0 | ||
![]() |
654cc05f0e | ||
![]() |
974dd24e69 | ||
![]() |
fe824e0faa | ||
![]() |
377d89ae06 | ||
![]() |
629e24e031 | ||
![]() |
38414025c8 | ||
![]() |
1dc5b7a707 | ||
![]() |
f076418c50 | ||
![]() |
bbaad2cdfb | ||
![]() |
ef01658335 | ||
![]() |
9f4a6c3b42 | ||
![]() |
5450bfb67b | ||
![]() |
ae2b302962 | ||
![]() |
957691c454 | ||
![]() |
6b17ba2934 | ||
![]() |
4d3616cda9 | ||
![]() |
c4a9697e02 | ||
![]() |
c57b7520b9 | ||
![]() |
46bd09227f | ||
![]() |
971f92a05c | ||
![]() |
2c43b06910 | ||
![]() |
0f8b2e69c9 | ||
![]() |
00b04c2e86 | ||
![]() |
b3c66cae06 | ||
![]() |
6a79d417b4 | ||
![]() |
98ef68f720 | ||
![]() |
ed3b7aa8f2 | ||
![]() |
e536600052 | ||
![]() |
bb820a2127 | ||
![]() |
129933ff30 | ||
![]() |
41fc11efff | ||
![]() |
a712bc72ca | ||
![]() |
fbe7acc6b0 | ||
![]() |
c4153b6fbf | ||
![]() |
1f355a22e0 | ||
![]() |
4af8070450 | ||
![]() |
d6d0071175 | ||
![]() |
ef51633b2c | ||
![]() |
d4963b9cbe | ||
![]() |
01dabf7c05 | ||
![]() |
fc68f79cc8 | ||
![]() |
ebe1479503 | ||
![]() |
8c9fe4da06 | ||
![]() |
b2ef51af55 | ||
![]() |
32b35d8e4b | ||
![]() |
c8bda18cf2 | ||
![]() |
0a944975cc | ||
![]() |
48eaa31ecf | ||
![]() |
1540e88a06 | ||
![]() |
b1aa57abcb | ||
![]() |
df359730fe | ||
![]() |
43ec154bc2 | ||
![]() |
2c4a664df4 | ||
![]() |
a196c14a58 | ||
![]() |
6f549506d6 | ||
![]() |
8d463e05ae | ||
![]() |
373c91911d | ||
![]() |
1d3ac99c02 | ||
![]() |
cda4c8f87e | ||
![]() |
ef4f589094 | ||
![]() |
3aeb45bf34 | ||
![]() |
b91da77a8a | ||
![]() |
33357a3fc2 | ||
![]() |
025001499d |
14
.codecov.yml
14
.codecov.yml
@@ -14,6 +14,9 @@ flag_management:
|
|||||||
# codecov will only comment if coverage changes
|
# codecov will only comment if coverage changes
|
||||||
comment:
|
comment:
|
||||||
require_changes: true
|
require_changes: true
|
||||||
|
# https://docs.codecov.com/docs/javascript-bundle-analysis
|
||||||
|
require_bundle_changes: true
|
||||||
|
bundle_change_threshold: "50Kb"
|
||||||
coverage:
|
coverage:
|
||||||
status:
|
status:
|
||||||
project:
|
project:
|
||||||
@@ -22,7 +25,12 @@ coverage:
|
|||||||
threshold: 1%
|
threshold: 1%
|
||||||
patch:
|
patch:
|
||||||
default:
|
default:
|
||||||
# For the changed lines only, target 75% covered, but
|
# For the changed lines only, target 100% covered, but
|
||||||
# allow as low as 50%
|
# allow as low as 75%
|
||||||
target: 75%
|
target: 100%
|
||||||
threshold: 25%
|
threshold: 25%
|
||||||
|
# https://docs.codecov.com/docs/javascript-bundle-analysis
|
||||||
|
bundle_analysis:
|
||||||
|
# Fail if the bundle size increases by more than 1MB
|
||||||
|
warning_threshold: "1MB"
|
||||||
|
status: true
|
||||||
|
@@ -1,3 +1,3 @@
|
|||||||
[codespell]
|
[codespell]
|
||||||
write-changes = True
|
write-changes = True
|
||||||
ignore-words-list = criterias,afterall,valeu,ureue,equest,ure
|
ignore-words-list = criterias,afterall,valeu,ureue,equest,ure,assertIn
|
||||||
|
180
.devcontainer/Dockerfile
Normal file
180
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM --platform=$BUILDPLATFORM docker.io/node:20-bookworm-slim as main-app
|
||||||
|
|
||||||
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Buildx provided, must be defined to use though
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
# Can be workflow provided, defaults set for manual building
|
||||||
|
ARG JBIG2ENC_VERSION=0.29
|
||||||
|
ARG QPDF_VERSION=11.9.0
|
||||||
|
ARG GS_VERSION=10.03.1
|
||||||
|
|
||||||
|
# Set Python environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
# Ignore warning from Whitenoise
|
||||||
|
PYTHONWARNINGS="ignore:::django.http.response:517" \
|
||||||
|
PNGX_CONTAINERIZED=1
|
||||||
|
|
||||||
|
#
|
||||||
|
# Begin installation and configuration
|
||||||
|
# Order the steps below from least often changed to most
|
||||||
|
#
|
||||||
|
|
||||||
|
# Packages need for running
|
||||||
|
ARG RUNTIME_PACKAGES="\
|
||||||
|
# General utils
|
||||||
|
curl \
|
||||||
|
# Docker specific
|
||||||
|
gosu \
|
||||||
|
# Timezones support
|
||||||
|
tzdata \
|
||||||
|
# fonts for text file thumbnail generation
|
||||||
|
fonts-liberation \
|
||||||
|
gettext \
|
||||||
|
ghostscript \
|
||||||
|
gnupg \
|
||||||
|
icc-profiles-free \
|
||||||
|
imagemagick \
|
||||||
|
# PostgreSQL
|
||||||
|
postgresql-client \
|
||||||
|
# MySQL / MariaDB
|
||||||
|
mariadb-client \
|
||||||
|
# OCRmyPDF dependencies
|
||||||
|
tesseract-ocr \
|
||||||
|
tesseract-ocr-eng \
|
||||||
|
tesseract-ocr-deu \
|
||||||
|
tesseract-ocr-fra \
|
||||||
|
tesseract-ocr-ita \
|
||||||
|
tesseract-ocr-spa \
|
||||||
|
unpaper \
|
||||||
|
pngquant \
|
||||||
|
jbig2dec \
|
||||||
|
# lxml
|
||||||
|
libxml2 \
|
||||||
|
libxslt1.1 \
|
||||||
|
# itself
|
||||||
|
qpdf \
|
||||||
|
# Mime type detection
|
||||||
|
file \
|
||||||
|
libmagic1 \
|
||||||
|
media-types \
|
||||||
|
zlib1g \
|
||||||
|
# Barcode splitter
|
||||||
|
libzbar0 \
|
||||||
|
poppler-utils \
|
||||||
|
htop \
|
||||||
|
sudo"
|
||||||
|
|
||||||
|
# Install basic runtime packages.
|
||||||
|
# These change very infrequently
|
||||||
|
RUN set -eux \
|
||||||
|
echo "Installing system packages" \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install --yes --quiet --no-install-recommends ${RUNTIME_PACKAGES}
|
||||||
|
|
||||||
|
ARG PYTHON_PACKAGES="\
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
python3-wheel \
|
||||||
|
pipenv \
|
||||||
|
ca-certificates"
|
||||||
|
|
||||||
|
RUN set -eux \
|
||||||
|
echo "Installing python packages" \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install --yes --quiet ${PYTHON_PACKAGES}
|
||||||
|
|
||||||
|
RUN set -eux \
|
||||||
|
&& echo "Installing pre-built updates" \
|
||||||
|
&& echo "Installing qpdf ${QPDF_VERSION}" \
|
||||||
|
&& curl --fail --silent --show-error --location \
|
||||||
|
--output libqpdf29_${QPDF_VERSION}-1_${TARGETARCH}.deb \
|
||||||
|
https://github.com/paperless-ngx/builder/releases/download/qpdf-${QPDF_VERSION}/libqpdf29_${QPDF_VERSION}-1_${TARGETARCH}.deb \
|
||||||
|
&& curl --fail --silent --show-error --location \
|
||||||
|
--output qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \
|
||||||
|
https://github.com/paperless-ngx/builder/releases/download/qpdf-${QPDF_VERSION}/qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \
|
||||||
|
&& dpkg --install ./libqpdf29_${QPDF_VERSION}-1_${TARGETARCH}.deb \
|
||||||
|
&& dpkg --install ./qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \
|
||||||
|
&& echo "Installing Ghostscript ${GS_VERSION}" \
|
||||||
|
&& curl --fail --silent --show-error --location \
|
||||||
|
--output libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
|
||||||
|
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
|
||||||
|
&& curl --fail --silent --show-error --location \
|
||||||
|
--output ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
|
||||||
|
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
|
||||||
|
&& curl --fail --silent --show-error --location \
|
||||||
|
--output libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
|
||||||
|
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
|
||||||
|
&& dpkg --install ./libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
|
||||||
|
&& dpkg --install ./libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
|
||||||
|
&& dpkg --install ./ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
|
||||||
|
&& echo "Installing jbig2enc" \
|
||||||
|
&& curl --fail --silent --show-error --location \
|
||||||
|
--output jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
|
||||||
|
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
|
||||||
|
&& dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb
|
||||||
|
|
||||||
|
# setup docker-specific things
|
||||||
|
# These change sometimes, but rarely
|
||||||
|
WORKDIR /usr/src/paperless/src/docker/
|
||||||
|
|
||||||
|
COPY [ \
|
||||||
|
"docker/imagemagick-policy.xml", \
|
||||||
|
"./" \
|
||||||
|
]
|
||||||
|
|
||||||
|
RUN set -eux \
|
||||||
|
&& echo "Configuring ImageMagick" \
|
||||||
|
&& mv imagemagick-policy.xml /etc/ImageMagick-6/policy.xml
|
||||||
|
|
||||||
|
# Packages needed only for building a few quick Python
|
||||||
|
# dependencies
|
||||||
|
ARG BUILD_PACKAGES="\
|
||||||
|
build-essential \
|
||||||
|
git \
|
||||||
|
# https://www.psycopg.org/docs/install.html#prerequisites
|
||||||
|
libpq-dev \
|
||||||
|
# https://github.com/PyMySQL/mysqlclient#linux
|
||||||
|
default-libmysqlclient-dev \
|
||||||
|
pkg-config \
|
||||||
|
pre-commit"
|
||||||
|
|
||||||
|
# hadolint ignore=DL3042
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \
|
||||||
|
set -eux \
|
||||||
|
&& echo "Installing build system packages" \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install --yes --quiet ${BUILD_PACKAGES}
|
||||||
|
|
||||||
|
RUN set -eux \
|
||||||
|
&& npm update npm -g
|
||||||
|
|
||||||
|
# add users, setup scripts
|
||||||
|
# Mount the compiled frontend to expected location
|
||||||
|
RUN set -eux \
|
||||||
|
&& echo "Setting up user/group" \
|
||||||
|
&& groupmod --new-name paperless node \
|
||||||
|
&& usermod --login paperless --home /usr/src/paperless node \
|
||||||
|
&& usermod -s /bin/bash paperless \
|
||||||
|
&& echo "paperless ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers \
|
||||||
|
&& echo "Creating volume directories" \
|
||||||
|
&& mkdir --parents --verbose /usr/src/paperless/paperless-ngx/data \
|
||||||
|
&& mkdir --parents --verbose /usr/src/paperless/paperless-ngx/media \
|
||||||
|
&& mkdir --parents --verbose /usr/src/paperless/paperless-ngx/consume \
|
||||||
|
&& mkdir --parents --verbose /usr/src/paperless/paperless-ngx/export \
|
||||||
|
&& mkdir --parents --verbose /usr/src/paperless/paperless-ngx/.venv \
|
||||||
|
&& echo "Adjusting all permissions" \
|
||||||
|
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless
|
||||||
|
# && echo "Collecting static files" \
|
||||||
|
# && gosu paperless python3 manage.py collectstatic --clear --no-input --link \
|
||||||
|
# && gosu paperless python3 manage.py compilemessages
|
||||||
|
|
||||||
|
VOLUME ["/usr/src/paperless/paperless-ngx/data", \
|
||||||
|
"/usr/src/paperless/paperless-ngx/media", \
|
||||||
|
"/usr/src/paperless/paperless-ngx/consume", \
|
||||||
|
"/usr/src/paperless/paperless-ngx/export", \
|
||||||
|
"/usr/src/paperless/paperless-ngx/.venv"]
|
117
.devcontainer/README.md
Normal file
117
.devcontainer/README.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# Paperless NGX Development Environment
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Welcome to the Paperless NGX development environment! This setup uses VSCode DevContainers to provide a consistent and seamless development experience.
|
||||||
|
|
||||||
|
### What are DevContainers?
|
||||||
|
|
||||||
|
DevContainers are a feature in VSCode that allows you to develop within a Docker container. This ensures that your development environment is consistent across different machines and setups. By defining a containerized environment, you can eliminate the "works on my machine" problem.
|
||||||
|
|
||||||
|
### Advantages of DevContainers
|
||||||
|
|
||||||
|
- **Consistency**: Same environment for all developers.
|
||||||
|
- **Isolation**: Separate development environment from your local machine.
|
||||||
|
- **Reproducibility**: Easily recreate the environment on any machine.
|
||||||
|
- **Pre-configured Tools**: Include all necessary tools and dependencies in the container.
|
||||||
|
|
||||||
|
## DevContainer Setup
|
||||||
|
|
||||||
|
The DevContainer configuration provides up all the necessary services for Paperless NGX, including:
|
||||||
|
|
||||||
|
- Redis
|
||||||
|
- Gotenberg
|
||||||
|
- Tika
|
||||||
|
|
||||||
|
Data is stored using Docker volumes to ensure persistence across container restarts.
|
||||||
|
|
||||||
|
## Configuration Files
|
||||||
|
|
||||||
|
The setup includes debugging configurations (`launch.json`) and tasks (`tasks.json`) to help you manage and debug various parts of the project:
|
||||||
|
|
||||||
|
- **Backend Debugging:**
|
||||||
|
- `manage.py runserver`
|
||||||
|
- `manage.py document-consumer`
|
||||||
|
- `celery`
|
||||||
|
- **Maintenance Tasks:**
|
||||||
|
- Create superuser
|
||||||
|
- Run migrations
|
||||||
|
- Recreate virtual environment (`.venv` with pipenv)
|
||||||
|
- Compile frontend assets
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Step 1: Running the DevContainer
|
||||||
|
|
||||||
|
To start the DevContainer:
|
||||||
|
|
||||||
|
1. Open VSCode.
|
||||||
|
2. Open the project folder.
|
||||||
|
3. Open the command palette:
|
||||||
|
- **Windows/Linux**: `Ctrl+Shift+P`
|
||||||
|
- **Mac**: `Cmd+Shift+P`
|
||||||
|
4. Type and select `Dev Containers: Rebuild and Reopen in Container`.
|
||||||
|
|
||||||
|
VSCode will build and start the DevContainer environment.
|
||||||
|
|
||||||
|
### Step 2: Initial Setup
|
||||||
|
|
||||||
|
Once the DevContainer is up and running, perform the following steps:
|
||||||
|
|
||||||
|
1. **Compile Frontend Assets**:
|
||||||
|
|
||||||
|
- Open the command palette:
|
||||||
|
- **Windows/Linux**: `Ctrl+Shift+P`
|
||||||
|
- **Mac**: `Cmd+Shift+P`
|
||||||
|
- Select `Tasks: Run Task`.
|
||||||
|
- Choose `Frontend Compile`.
|
||||||
|
|
||||||
|
2. **Run Database Migrations**:
|
||||||
|
|
||||||
|
- Open the command palette:
|
||||||
|
- **Windows/Linux**: `Ctrl+Shift+P`
|
||||||
|
- **Mac**: `Cmd+Shift+P`
|
||||||
|
- Select `Tasks: Run Task`.
|
||||||
|
- Choose `Migrate Database`.
|
||||||
|
|
||||||
|
3. **Create Superuser**:
|
||||||
|
- Open the command palette:
|
||||||
|
- **Windows/Linux**: `Ctrl+Shift+P`
|
||||||
|
- **Mac**: `Cmd+Shift+P`
|
||||||
|
- Select `Tasks: Run Task`.
|
||||||
|
- Choose `Create Superuser`.
|
||||||
|
|
||||||
|
### Debugging and Running Services
|
||||||
|
|
||||||
|
You can start and debug backend services either as debugging sessions via `launch.json` or as tasks.
|
||||||
|
|
||||||
|
#### Using `launch.json`:
|
||||||
|
|
||||||
|
1. Press `F5` or go to the **Run and Debug** view in VSCode.
|
||||||
|
2. Select the desired configuration:
|
||||||
|
- `Runserver`
|
||||||
|
- `Document Consumer`
|
||||||
|
- `Celery`
|
||||||
|
|
||||||
|
#### Using Tasks:
|
||||||
|
|
||||||
|
1. Open the command palette:
|
||||||
|
- **Windows/Linux**: `Ctrl+Shift+P`
|
||||||
|
- **Mac**: `Cmd+Shift+P`
|
||||||
|
2. Select `Tasks: Run Task`.
|
||||||
|
3. Choose the desired task:
|
||||||
|
- `Runserver`
|
||||||
|
- `Document Consumer`
|
||||||
|
- `Celery`
|
||||||
|
|
||||||
|
### Additional Maintenance Tasks
|
||||||
|
|
||||||
|
Additional tasks are available for common maintenance operations:
|
||||||
|
|
||||||
|
- **Recreate .venv**: For setting up the virtual environment using pipenv.
|
||||||
|
- **Migrate Database**: To apply database migrations.
|
||||||
|
- **Create Superuser**: To create an admin user for the application.
|
||||||
|
|
||||||
|
## Let's Get Started!
|
||||||
|
|
||||||
|
Follow the steps above to get your development environment up and running. Happy coding!
|
16
.devcontainer/devcontainer.json
Normal file
16
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "Paperless Development",
|
||||||
|
"dockerComposeFile": "docker-compose.devcontainer.sqlite-tika.yml",
|
||||||
|
"service": "paperless-development",
|
||||||
|
"workspaceFolder": "/usr/src/paperless/paperless-ngx",
|
||||||
|
"postCreateCommand": "/bin/bash -c pre-commit install && pipenv install --dev",
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"mhutchie.git-graph",
|
||||||
|
"ms-python.python"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"remoteUser": "paperless"
|
||||||
|
}
|
84
.devcontainer/docker-compose.devcontainer.sqlite-tika.yml
Normal file
84
.devcontainer/docker-compose.devcontainer.sqlite-tika.yml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Docker Compose file for developing Paperless NGX in VSCode DevContainers.
|
||||||
|
# This file contains everything Paperless NGX needs to run.
|
||||||
|
# Paperless supports amd64, arm, and arm64 hardware.
|
||||||
|
# All compose files of Paperless configure it in the following way:
|
||||||
|
#
|
||||||
|
# - Paperless is (re)started on system boot if it was running before shutdown.
|
||||||
|
# - Docker volumes for storing data are managed by Docker.
|
||||||
|
# - Folders for importing and exporting files are created in the same directory
|
||||||
|
# as this file and mounted to the correct folders inside the container.
|
||||||
|
# - Paperless listens on port 8000.
|
||||||
|
#
|
||||||
|
# SQLite is used as the database. The SQLite file is stored in the data volume.
|
||||||
|
#
|
||||||
|
# In addition, this Docker Compose file adds the following optional
|
||||||
|
# configurations:
|
||||||
|
#
|
||||||
|
# - Apache Tika and Gotenberg servers are started with Paperless NGX and Paperless
|
||||||
|
# is configured to use these services. These provide support for consuming
|
||||||
|
# Office documents (Word, Excel, PowerPoint, and their LibreOffice counterparts).
|
||||||
|
#
|
||||||
|
# This file is intended only to be used through VSCOde devcontainers. See README.md
|
||||||
|
# in the folder .devcontainer.
|
||||||
|
|
||||||
|
|
||||||
|
services:
|
||||||
|
broker:
|
||||||
|
image: docker.io/library/redis:7
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- redisdata:/data
|
||||||
|
|
||||||
|
# No ports need to be exposed; the VSCode DevContainer plugin manages them.
|
||||||
|
paperless-development:
|
||||||
|
image: paperless-ngx
|
||||||
|
build:
|
||||||
|
context: ../ # Dockerfile cannot access files from parent directories if context is not set.
|
||||||
|
dockerfile: ./.devcontainer/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- broker
|
||||||
|
- gotenberg
|
||||||
|
- tika
|
||||||
|
volumes:
|
||||||
|
- ..:/usr/src/paperless/paperless-ngx:delegated
|
||||||
|
- ../.devcontainer/vscode:/usr/src/paperless/paperless-ngx/.vscode:delegated # VSCode config files
|
||||||
|
- pipenv:/usr/src/paperless/paperless-ngx/.venv # Pipenv environment persisted in volume
|
||||||
|
- /usr/src/paperless/paperless-ngx/src/documents/static/frontend # Static frontend files exist only in container
|
||||||
|
- /usr/src/paperless/paperless-ngx/src/.pytest_cache
|
||||||
|
- /usr/src/paperless/paperless-ngx/.ruff_cache
|
||||||
|
- /usr/src/paperless/paperless-ngx/htmlcov
|
||||||
|
- /usr/src/paperless/paperless-ngx/.coverage
|
||||||
|
- data:/usr/src/paperless/paperless-ngx/data
|
||||||
|
- media:/usr/src/paperless/paperless-ngx/media
|
||||||
|
environment:
|
||||||
|
PAPERLESS_REDIS: redis://broker:6379
|
||||||
|
PAPERLESS_TIKA_ENABLED: 1
|
||||||
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
|
PAPERLESS_STATICDIR: ./src/documents/static
|
||||||
|
PAPERLESS_DEBUG: true
|
||||||
|
|
||||||
|
# Overrides default command so things don't shut down after the process ends.
|
||||||
|
command: /bin/sh -c "chown -R paperless:paperless /usr/src/paperless/paperless-ngx/src/documents/static/frontend && chown -R paperless:paperless /usr/src/paperless/paperless-ngx/.ruff_cache && while sleep 1000; do :; done"
|
||||||
|
|
||||||
|
gotenberg:
|
||||||
|
image: docker.io/gotenberg/gotenberg:7.10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# The Gotenberg Chromium route is used to convert .eml files. We do not
|
||||||
|
# want to allow external content like tracking pixels or even JavaScript.
|
||||||
|
command:
|
||||||
|
- "gotenberg"
|
||||||
|
- "--chromium-disable-javascript=true"
|
||||||
|
- "--chromium-allow-list=file:///tmp/.*"
|
||||||
|
|
||||||
|
tika:
|
||||||
|
image: docker.io/apache/tika:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
data:
|
||||||
|
media:
|
||||||
|
redisdata:
|
||||||
|
pipenv:
|
43
.devcontainer/vscode/launch.json
Normal file
43
.devcontainer/vscode/launch.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "manage.py runserver",
|
||||||
|
"type": "python",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "${workspaceFolder}/src/manage.py",
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"justMyCode": true,
|
||||||
|
"args": ["runserver"],
|
||||||
|
"django": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "manage.py document_consumer",
|
||||||
|
"type": "python",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "${workspaceFolder}/src/manage.py",
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"justMyCode": true,
|
||||||
|
"args": ["document_consumer"],
|
||||||
|
"django": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "celery",
|
||||||
|
"type": "python",
|
||||||
|
"cwd": "${workspaceFolder}/src",
|
||||||
|
"request": "launch",
|
||||||
|
"module": "celery",
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"env": {
|
||||||
|
"PYTHONPATH": "${workspaceFolder}/src"
|
||||||
|
},
|
||||||
|
"args": [
|
||||||
|
"-A",
|
||||||
|
"paperless",
|
||||||
|
"worker",
|
||||||
|
"-l",
|
||||||
|
"DEBUG"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
11
.devcontainer/vscode/settings.json
Normal file
11
.devcontainer/vscode/settings.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"python.testing.pytestArgs": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"python.testing.unittestEnabled": false,
|
||||||
|
"python.testing.pytestEnabled": true,
|
||||||
|
"files.watcherExclude": {
|
||||||
|
"**/.venv/**": true,
|
||||||
|
"**/pytest_cache/**": true
|
||||||
|
}
|
||||||
|
}
|
136
.devcontainer/vscode/tasks.json
Normal file
136
.devcontainer/vscode/tasks.json
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "manage.py document_consumer",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "pipenv run python manage.py document_consumer",
|
||||||
|
"group": "build",
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": false,
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": false,
|
||||||
|
"clear": true,
|
||||||
|
"revealProblems": "onProblem"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/src"
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "manage.py runserver",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "pipenv run python manage.py runserver",
|
||||||
|
"group": "build",
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": false,
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": false,
|
||||||
|
"clear": true,
|
||||||
|
"revealProblems": "onProblem"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/src"
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Maintenance: manage.py migrate",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "pipenv run python manage.py migrate",
|
||||||
|
"group": "none",
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": true,
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": false,
|
||||||
|
"clear": true,
|
||||||
|
"revealProblems": "onProblem"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Maintenance: manage.py createsuperuser",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "pipenv run python manage.py createsuperuser",
|
||||||
|
"group": "none",
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": true,
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": false,
|
||||||
|
"clear": true,
|
||||||
|
"revealProblems": "onProblem"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "compile frontend",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npm ci && ./node_modules/.bin/ng build --configuration production",
|
||||||
|
"group": "none",
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": true,
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": false,
|
||||||
|
"clear": true,
|
||||||
|
"revealProblems": "onProblem"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/src-ui"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Maintenance: recreate .venv",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "rm -R -v .venv/* || pipenv install --dev",
|
||||||
|
"group": "none",
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": true,
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": false,
|
||||||
|
"clear": true,
|
||||||
|
"revealProblems": "onProblem"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Celery Worker",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "pipenv run celery --app paperless worker -l DEBUG",
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": true
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": true,
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": false,
|
||||||
|
"clear": true,
|
||||||
|
"revealProblems": "onProblem"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/src"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
17
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
17
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -9,7 +9,7 @@ body:
|
|||||||
### ⚠️ Please remember: issues are for *bugs*
|
### ⚠️ Please remember: issues are for *bugs*
|
||||||
That is, something you believe affects every single user of Paperless-ngx, not just you. If you're not sure, start with one of the other options below.
|
That is, something you believe affects every single user of Paperless-ngx, not just you. If you're not sure, start with one of the other options below.
|
||||||
|
|
||||||
Also, note that **Paperless-ngx does not perform OCR itself**, that is handled by other tools. Problems with OCR of specific files should likely be raised 'upstream', see https://github.com/ocrmypdf/OCRmyPDF/issues or https://github.com/tesseract-ocr/tesseract/issues
|
Also, note that **Paperless-ngx does not perform OCR or archive file creation itself**, those are handled by other tools. Problems with OCR or archive versions of specific files should likely be raised 'upstream', see https://github.com/ocrmypdf/OCRmyPDF/issues or https://github.com/tesseract-ocr/tesseract/issues
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
@@ -86,22 +86,23 @@ body:
|
|||||||
description: Note there are significant differences from the official image and linuxserver.io, please check if your issue is specific to the third-party image.
|
description: Note there are significant differences from the official image and linuxserver.io, please check if your issue is specific to the third-party image.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: system-status
|
||||||
|
attributes:
|
||||||
|
label: System status
|
||||||
|
description: If available, copy & paste the system status output from Settings > System Status > Copy
|
||||||
|
render: json
|
||||||
- type: input
|
- type: input
|
||||||
id: browser
|
id: browser
|
||||||
attributes:
|
attributes:
|
||||||
label: Browser
|
label: Browser
|
||||||
description: Which browser you are using, if relevant.
|
description: Which browser you are using, if relevant.
|
||||||
placeholder: e.g. Chrome, Safari
|
placeholder: e.g. Chrome, Safari
|
||||||
- type: input
|
- type: textarea
|
||||||
id: config-changes
|
id: config-changes
|
||||||
attributes:
|
attributes:
|
||||||
label: Configuration changes
|
label: Configuration changes
|
||||||
description: Any configuration changes you made in `docker-compose.yml`, `docker-compose.env` or `paperless.conf`.
|
description: Any configuration changes you made in `docker-compose.yml`, `docker-compose.env` or `paperless.conf`.
|
||||||
- type: input
|
|
||||||
id: other
|
|
||||||
attributes:
|
|
||||||
label: Other
|
|
||||||
description: Any other relevant details.
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: required-checks
|
id: required-checks
|
||||||
attributes:
|
attributes:
|
||||||
@@ -109,6 +110,8 @@ body:
|
|||||||
options:
|
options:
|
||||||
- label: I believe this issue is a bug that affects all users of Paperless-ngx, not something specific to my installation.
|
- label: I believe this issue is a bug that affects all users of Paperless-ngx, not something specific to my installation.
|
||||||
required: true
|
required: true
|
||||||
|
- label: This issue is not about the OCR or archive creation of a specific file(s). Otherwise, please see above regarding OCR tools.
|
||||||
|
required: true
|
||||||
- label: I have already searched for relevant existing issues and discussions before opening this report.
|
- label: I have already searched for relevant existing issues and discussions before opening this report.
|
||||||
required: true
|
required: true
|
||||||
- label: I have updated the title field above with a concise description.
|
- label: I have updated the title field above with a concise description.
|
||||||
|
1
.github/dependabot.yml
vendored
1
.github/dependabot.yml
vendored
@@ -53,7 +53,6 @@ updates:
|
|||||||
development:
|
development:
|
||||||
patterns:
|
patterns:
|
||||||
- "*pytest*"
|
- "*pytest*"
|
||||||
- "black"
|
|
||||||
- "ruff"
|
- "ruff"
|
||||||
- "mkdocs-material"
|
- "mkdocs-material"
|
||||||
django:
|
django:
|
||||||
|
46
.github/workflows/ci.yml
vendored
46
.github/workflows/ci.yml
vendored
@@ -16,9 +16,9 @@ on:
|
|||||||
env:
|
env:
|
||||||
# This is the version of pipenv all the steps will use
|
# This is the version of pipenv all the steps will use
|
||||||
# If changing this, change Dockerfile
|
# If changing this, change Dockerfile
|
||||||
DEFAULT_PIP_ENV_VERSION: "2023.12.1"
|
DEFAULT_PIP_ENV_VERSION: "2024.0.3"
|
||||||
# This is the default version of Python to use in most steps which aren't specific
|
# This is the default version of Python to use in most steps which aren't specific
|
||||||
DEFAULT_PYTHON_VERSION: "3.10"
|
DEFAULT_PYTHON_VERSION: "3.11"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pre-commit:
|
pre-commit:
|
||||||
@@ -100,7 +100,7 @@ jobs:
|
|||||||
- pre-commit
|
- pre-commit
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ['3.9', '3.10', '3.11']
|
python-version: ['3.10', '3.11', '3.12']
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
@@ -260,7 +260,7 @@ jobs:
|
|||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
tests-coverage-upload:
|
tests-coverage-upload:
|
||||||
name: "Upload Coverage"
|
name: "Upload to Codecov"
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs:
|
needs:
|
||||||
- tests-backend
|
- tests-backend
|
||||||
@@ -306,6 +306,30 @@ jobs:
|
|||||||
# future expansion
|
# future expansion
|
||||||
flags: backend
|
flags: backend
|
||||||
directory: src/
|
directory: src/
|
||||||
|
-
|
||||||
|
name: Use Node.js 20
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20.x
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: 'src-ui/package-lock.json'
|
||||||
|
-
|
||||||
|
name: Cache frontend dependencies
|
||||||
|
id: cache-frontend-deps
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.npm
|
||||||
|
~/.cache
|
||||||
|
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/package-lock.json') }}
|
||||||
|
-
|
||||||
|
name: Re-link Angular cli
|
||||||
|
run: cd src-ui && npm link @angular/cli
|
||||||
|
-
|
||||||
|
name: Build frontend and upload analysis
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
run: cd src-ui && ng build --configuration=production
|
||||||
|
|
||||||
build-docker-image:
|
build-docker-image:
|
||||||
name: Build Docker image for ${{ github.ref_name }}
|
name: Build Docker image for ${{ github.ref_name }}
|
||||||
@@ -398,7 +422,7 @@ jobs:
|
|||||||
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
|
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
@@ -406,6 +430,8 @@ jobs:
|
|||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.docker-meta.outputs.tags }}
|
tags: ${{ steps.docker-meta.outputs.tags }}
|
||||||
labels: ${{ steps.docker-meta.outputs.labels }}
|
labels: ${{ steps.docker-meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
PNGX_TAG_VERSION=${{ steps.docker-meta.outputs.version }}
|
||||||
# Get cache layers from this branch, then dev
|
# Get cache layers from this branch, then dev
|
||||||
# This allows new branches to get at least some cache benefits, generally from dev
|
# This allows new branches to get at least some cache benefits, generally from dev
|
||||||
cache-from: |
|
cache-from: |
|
||||||
@@ -456,12 +482,6 @@ jobs:
|
|||||||
name: Install Python dependencies
|
name: Install Python dependencies
|
||||||
run: |
|
run: |
|
||||||
pipenv --python ${{ steps.setup-python.outputs.python-version }} sync --dev
|
pipenv --python ${{ steps.setup-python.outputs.python-version }} sync --dev
|
||||||
-
|
|
||||||
name: Patch whitenoise
|
|
||||||
run: |
|
|
||||||
curl --fail --silent --show-error --location --output 484.patch https://github.com/evansd/whitenoise/pull/484.patch
|
|
||||||
patch -d $(pipenv --venv)/lib/python3.10/site-packages --verbose -p2 < 484.patch
|
|
||||||
rm 484.patch
|
|
||||||
-
|
-
|
||||||
name: Install system dependencies
|
name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -629,7 +649,9 @@ jobs:
|
|||||||
git checkout ${{ needs.publish-release.outputs.version }}-changelog
|
git checkout ${{ needs.publish-release.outputs.version }}-changelog
|
||||||
echo -e "# Changelog\n\n${{ needs.publish-release.outputs.changelog }}\n" > changelog-new.md
|
echo -e "# Changelog\n\n${{ needs.publish-release.outputs.changelog }}\n" > changelog-new.md
|
||||||
echo "Manually linking usernames"
|
echo "Manually linking usernames"
|
||||||
sed -i -r 's|@(.+?) \(\[#|[@\1](https://github.com/\1) ([#|ig' changelog-new.md
|
sed -i -r 's|@([a-zA-Z0-9_]+) \(\[#|[@\1](https://github.com/\1) ([#|g' changelog-new.md
|
||||||
|
echo "Removing unneeded comment tags"
|
||||||
|
sed -i -r 's|@<!---->|@|g' changelog-new.md
|
||||||
CURRENT_CHANGELOG=`tail --lines +2 changelog.md`
|
CURRENT_CHANGELOG=`tail --lines +2 changelog.md`
|
||||||
echo -e "$CURRENT_CHANGELOG" >> changelog-new.md
|
echo -e "$CURRENT_CHANGELOG" >> changelog-new.md
|
||||||
mv changelog-new.md changelog.md
|
mv changelog-new.md changelog.md
|
||||||
|
4
.github/workflows/cleanup-tags.yml
vendored
4
.github/workflows/cleanup-tags.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Clean temporary images
|
name: Clean temporary images
|
||||||
if: "${{ env.TOKEN != '' }}"
|
if: "${{ env.TOKEN != '' }}"
|
||||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.5.0
|
uses: stumpylog/image-cleaner-action/ephemeral@v0.8.0
|
||||||
with:
|
with:
|
||||||
token: "${{ env.TOKEN }}"
|
token: "${{ env.TOKEN }}"
|
||||||
owner: "${{ github.repository_owner }}"
|
owner: "${{ github.repository_owner }}"
|
||||||
@@ -61,7 +61,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Clean untagged images
|
name: Clean untagged images
|
||||||
if: "${{ env.TOKEN != '' }}"
|
if: "${{ env.TOKEN != '' }}"
|
||||||
uses: stumpylog/image-cleaner-action/untagged@v0.5.0
|
uses: stumpylog/image-cleaner-action/untagged@v0.8.0
|
||||||
with:
|
with:
|
||||||
token: "${{ env.TOKEN }}"
|
token: "${{ env.TOKEN }}"
|
||||||
owner: "${{ github.repository_owner }}"
|
owner: "${{ github.repository_owner }}"
|
||||||
|
3
.github/workflows/crowdin.yml
vendored
3
.github/workflows/crowdin.yml
vendored
@@ -15,13 +15,14 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
synchronize-with-crowdin:
|
synchronize-with-crowdin:
|
||||||
name: Crowdin Sync
|
name: Crowdin Sync
|
||||||
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: crowdin action
|
- name: crowdin action
|
||||||
uses: crowdin/github-action@v1
|
uses: crowdin/github-action@v2
|
||||||
with:
|
with:
|
||||||
upload_translations: false
|
upload_translations: false
|
||||||
download_translations: true
|
download_translations: true
|
||||||
|
23
.github/workflows/repo-maintenance.yml
vendored
23
.github/workflows/repo-maintenance.yml
vendored
@@ -16,13 +16,14 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
stale:
|
stale:
|
||||||
name: 'Stale'
|
name: 'Stale'
|
||||||
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v9
|
- uses: actions/stale@v9
|
||||||
with:
|
with:
|
||||||
days-before-stale: 7
|
days-before-stale: 7
|
||||||
days-before-close: 14
|
days-before-close: 14
|
||||||
any-of-labels: 'cant-reproduce,not a bug'
|
any-of-labels: 'stale,cant-reproduce,not a bug'
|
||||||
stale-issue-label: stale
|
stale-issue-label: stale
|
||||||
stale-pr-label: stale
|
stale-pr-label: stale
|
||||||
stale-issue-message: >
|
stale-issue-message: >
|
||||||
@@ -31,6 +32,7 @@ jobs:
|
|||||||
for your contributions. See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.
|
for your contributions. See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.
|
||||||
lock-threads:
|
lock-threads:
|
||||||
name: 'Lock Old Threads'
|
name: 'Lock Old Threads'
|
||||||
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: dessant/lock-threads@v5
|
- uses: dessant/lock-threads@v5
|
||||||
@@ -56,6 +58,7 @@ jobs:
|
|||||||
See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.
|
See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.
|
||||||
close-answered-discussions:
|
close-answered-discussions:
|
||||||
name: 'Close Answered Discussions'
|
name: 'Close Answered Discussions'
|
||||||
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v7
|
- uses: actions/github-script@v7
|
||||||
@@ -112,6 +115,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
close-outdated-discussions:
|
close-outdated-discussions:
|
||||||
name: 'Close Outdated Discussions'
|
name: 'Close Outdated Discussions'
|
||||||
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v7
|
- uses: actions/github-script@v7
|
||||||
@@ -203,6 +207,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
close-unsupported-feature-requests:
|
close-unsupported-feature-requests:
|
||||||
name: 'Close Unsupported Feature Requests'
|
name: 'Close Unsupported Feature Requests'
|
||||||
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v7
|
- uses: actions/github-script@v7
|
||||||
@@ -212,15 +217,20 @@ jobs:
|
|||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CUTOFF_MAX_COUNT = 80;
|
||||||
const CUTOFF_1_DAYS = 180;
|
const CUTOFF_1_DAYS = 180;
|
||||||
const CUTOFF_1_COUNT = 5;
|
const CUTOFF_1_COUNT = 5;
|
||||||
const CUTOFF_2_DAYS = 365;
|
const CUTOFF_2_DAYS = 365;
|
||||||
const CUTOFF_2_COUNT = 10;
|
const CUTOFF_2_COUNT = 20;
|
||||||
|
const CUTOFF_3_DAYS = 730;
|
||||||
|
const CUTOFF_3_COUNT = 40;
|
||||||
|
|
||||||
const cutoff1Date = new Date();
|
const cutoff1Date = new Date();
|
||||||
cutoff1Date.setDate(cutoff1Date.getDate() - CUTOFF_1_DAYS);
|
cutoff1Date.setDate(cutoff1Date.getDate() - CUTOFF_1_DAYS);
|
||||||
const cutoff2Date = new Date();
|
const cutoff2Date = new Date();
|
||||||
cutoff2Date.setDate(cutoff2Date.getDate() - CUTOFF_2_DAYS);
|
cutoff2Date.setDate(cutoff2Date.getDate() - CUTOFF_2_DAYS);
|
||||||
|
const cutoff3Date = new Date();
|
||||||
|
cutoff3Date.setDate(cutoff3Date.getDate() - CUTOFF_3_DAYS);
|
||||||
|
|
||||||
const query = `query(
|
const query = `query(
|
||||||
$owner:String!,
|
$owner:String!,
|
||||||
@@ -250,9 +260,12 @@ jobs:
|
|||||||
const result = await github.graphql(query, variables);
|
const result = await github.graphql(query, variables);
|
||||||
|
|
||||||
for (const discussion of result.repository.discussions.nodes) {
|
for (const discussion of result.repository.discussions.nodes) {
|
||||||
const discussionDate = new Date(discussion.updatedAt);
|
const discussionUpdatedDate = new Date(discussion.updatedAt);
|
||||||
if ((discussionDate < cutoff1Date && discussion.upvoteCount < CUTOFF_1_COUNT) ||
|
const discussionCreatedDate = new Date(discussion.createdAt);
|
||||||
(discussionDate < cutoff2Date && discussion.upvoteCount < CUTOFF_2_COUNT)) {
|
if ((discussionUpdatedDate < cutoff1Date && discussion.upvoteCount < CUTOFF_MAX_COUNT) ||
|
||||||
|
(discussionCreatedDate < cutoff1Date && discussion.upvoteCount < CUTOFF_1_COUNT) ||
|
||||||
|
(discussionCreatedDate < cutoff2Date && discussion.upvoteCount < CUTOFF_2_COUNT) ||
|
||||||
|
(discussionCreatedDate < cutoff3Date && discussion.upvoteCount < CUTOFF_3_COUNT)) {
|
||||||
console.log(`Closing discussion #${discussion.number} (${discussion.id}), last updated at ${discussion.updatedAt} with votes ${discussion.upvoteCount}`);
|
console.log(`Closing discussion #${discussion.number} (${discussion.id}), last updated at ${discussion.updatedAt} with votes ${discussion.upvoteCount}`);
|
||||||
const addCommentMutation = `mutation($discussion:ID!, $body:String!) {
|
const addCommentMutation = `mutation($discussion:ID!, $body:String!) {
|
||||||
addDiscussionComment(input:{discussionId:$discussion, body:$body}) {
|
addDiscussionComment(input:{discussionId:$discussion, body:$body}) {
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,6 +22,7 @@ var/
|
|||||||
*.egg-info/
|
*.egg-info/
|
||||||
.installed.cfg
|
.installed.cfg
|
||||||
*.egg
|
*.egg
|
||||||
|
/src/paperless_mail/templates/node_modules
|
||||||
|
|
||||||
# PyInstaller
|
# PyInstaller
|
||||||
# Usually these files are written by a python script from a template
|
# Usually these files are written by a python script from a template
|
||||||
@@ -65,6 +66,8 @@ target/
|
|||||||
.vscode
|
.vscode
|
||||||
/src-ui/.vscode
|
/src-ui/.vscode
|
||||||
/docs/.vscode
|
/docs/.vscode
|
||||||
|
.vscode-server
|
||||||
|
*CommandMarker
|
||||||
|
|
||||||
# Other stuff that doesn't belong
|
# Other stuff that doesn't belong
|
||||||
.virtualenv
|
.virtualenv
|
||||||
|
@@ -5,7 +5,7 @@
|
|||||||
repos:
|
repos:
|
||||||
# General hooks
|
# General hooks
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.5.0
|
rev: v4.6.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-docstring-first
|
- id: check-docstring-first
|
||||||
- id: check-json
|
- id: check-json
|
||||||
@@ -29,15 +29,16 @@ repos:
|
|||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
- id: detect-private-key
|
- id: detect-private-key
|
||||||
- repo: https://github.com/codespell-project/codespell
|
- repo: https://github.com/codespell-project/codespell
|
||||||
rev: v2.2.6
|
rev: v2.3.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: codespell
|
- id: codespell
|
||||||
exclude: "(^src-ui/src/locale/)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)"
|
exclude: "(^src-ui/src/locale/)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)"
|
||||||
exclude_types:
|
exclude_types:
|
||||||
- pofile
|
- pofile
|
||||||
- json
|
- json
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
||||||
rev: 'v3.1.0'
|
- repo: https://github.com/rbubley/mirrors-prettier
|
||||||
|
rev: 'v3.3.3'
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
types_or:
|
types_or:
|
||||||
@@ -47,13 +48,10 @@ repos:
|
|||||||
exclude: "(^Pipfile\\.lock$)"
|
exclude: "(^Pipfile\\.lock$)"
|
||||||
# Python hooks
|
# Python hooks
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: 'v0.3.0'
|
rev: 'v0.6.8'
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
- repo: https://github.com/psf/black-pre-commit-mirror
|
- id: ruff-format
|
||||||
rev: 24.2.0
|
|
||||||
hooks:
|
|
||||||
- id: black
|
|
||||||
# Dockerfile hooks
|
# Dockerfile hooks
|
||||||
- repo: https://github.com/AleksaC/hadolint-py
|
- repo: https://github.com/AleksaC/hadolint-py
|
||||||
rev: v2.12.0.3
|
rev: v2.12.0.3
|
||||||
@@ -64,9 +62,11 @@ repos:
|
|||||||
rev: v6.2.1
|
rev: v6.2.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: beautysh
|
- id: beautysh
|
||||||
|
additional_dependencies:
|
||||||
|
- setuptools
|
||||||
args:
|
args:
|
||||||
- "--tab"
|
- "--tab"
|
||||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||||
rev: "v0.9.0.6"
|
rev: "v0.10.0.1"
|
||||||
hooks:
|
hooks:
|
||||||
- id: shellcheck
|
- id: shellcheck
|
||||||
|
@@ -7,9 +7,9 @@
|
|||||||
"trailingComma": "es5",
|
"trailingComma": "es5",
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": ["index.md", "administration.md"],
|
"files": ["docs/*.md"],
|
||||||
"options": {
|
"options": {
|
||||||
"tabWidth": 4
|
"tabWidth": 4,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@@ -1 +1 @@
|
|||||||
3.9.18
|
3.10.15
|
||||||
|
@@ -2,7 +2,7 @@ fix = true
|
|||||||
line-length = 88
|
line-length = 88
|
||||||
respect-gitignore = true
|
respect-gitignore = true
|
||||||
src = ["src"]
|
src = ["src"]
|
||||||
target-version = "py39"
|
target-version = "py310"
|
||||||
output-format = "grouped"
|
output-format = "grouped"
|
||||||
show-fixes = true
|
show-fixes = true
|
||||||
|
|
||||||
|
@@ -5,7 +5,7 @@
|
|||||||
We as members, contributors, and leaders pledge to make participation in our
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
community a harassment-free experience for everyone, regardless of age, body
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
identity and expression, level of experience, education, socio-economic status,
|
identity and expression, level of experience, education, socioeconomic status,
|
||||||
nationality, personal appearance, race, religion, or sexual identity
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
and orientation.
|
and orientation.
|
||||||
|
|
||||||
|
@@ -11,7 +11,7 @@ If you want to implement something big:
|
|||||||
|
|
||||||
## Python
|
## Python
|
||||||
|
|
||||||
Paperless supports python 3.9 - 3.11. We format Python code with [Black](https://github.com/psf/black).
|
Paperless supports python 3.10 - 3.12 at this time. We format Python code with [ruff](https://docs.astral.sh/ruff/formatter/).
|
||||||
|
|
||||||
## Branches
|
## Branches
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ community members. That said, in an effort to keep the repository organized and
|
|||||||
- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity.
|
- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity.
|
||||||
- Discussions with a marked answer will be automatically closed.
|
- Discussions with a marked answer will be automatically closed.
|
||||||
- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.
|
- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.
|
||||||
- Feature requests that do not meet the following thresholds will be closed: 5 "up-votes" after 180 days of inactivity or 10 "up-votes" after 365 days.
|
- Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity, < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 80 "up-votes" at 2 years.
|
||||||
|
|
||||||
In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns.
|
In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns.
|
||||||
Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features.
|
Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features.
|
||||||
|
50
Dockerfile
50
Dockerfile
@@ -13,6 +13,16 @@ WORKDIR /src/src-ui
|
|||||||
RUN set -eux \
|
RUN set -eux \
|
||||||
&& npm update npm -g \
|
&& npm update npm -g \
|
||||||
&& npm ci
|
&& npm ci
|
||||||
|
|
||||||
|
ARG PNGX_TAG_VERSION=
|
||||||
|
# Add the tag to the environment file if its a tagged dev build
|
||||||
|
RUN set -eux && \
|
||||||
|
case "${PNGX_TAG_VERSION}" in \
|
||||||
|
dev|beta|fix*|feature*) \
|
||||||
|
sed -i -E "s/version: '([0-9\.]+)'/version: '\1 #${PNGX_TAG_VERSION}'/g" /src/src-ui/src/environments/environment.prod.ts \
|
||||||
|
;; \
|
||||||
|
esac
|
||||||
|
|
||||||
RUN set -eux \
|
RUN set -eux \
|
||||||
&& ./node_modules/.bin/ng build --configuration production
|
&& ./node_modules/.bin/ng build --configuration production
|
||||||
|
|
||||||
@@ -21,7 +31,7 @@ RUN set -eux \
|
|||||||
# Comments:
|
# Comments:
|
||||||
# - pipenv dependencies are not left in the final image
|
# - pipenv dependencies are not left in the final image
|
||||||
# - pipenv can't touch the final image somehow
|
# - pipenv can't touch the final image somehow
|
||||||
FROM --platform=$BUILDPLATFORM docker.io/python:3.11-alpine as pipenv-base
|
FROM --platform=$BUILDPLATFORM docker.io/python:3.12-alpine AS pipenv-base
|
||||||
|
|
||||||
WORKDIR /usr/src/pipenv
|
WORKDIR /usr/src/pipenv
|
||||||
|
|
||||||
@@ -29,7 +39,7 @@ COPY Pipfile* ./
|
|||||||
|
|
||||||
RUN set -eux \
|
RUN set -eux \
|
||||||
&& echo "Installing pipenv" \
|
&& echo "Installing pipenv" \
|
||||||
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2023.12.1 \
|
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2024.0.3 \
|
||||||
&& echo "Generating requirement.txt" \
|
&& echo "Generating requirement.txt" \
|
||||||
&& pipenv requirements > requirements.txt
|
&& pipenv requirements > requirements.txt
|
||||||
|
|
||||||
@@ -37,7 +47,7 @@ RUN set -eux \
|
|||||||
# Purpose: The final image
|
# Purpose: The final image
|
||||||
# Comments:
|
# Comments:
|
||||||
# - Don't leave anything extra in here
|
# - Don't leave anything extra in here
|
||||||
FROM docker.io/python:3.11-slim-bookworm as main-app
|
FROM docker.io/python:3.12-slim-bookworm AS main-app
|
||||||
|
|
||||||
LABEL org.opencontainers.image.authors="paperless-ngx team <hello@paperless-ngx.com>"
|
LABEL org.opencontainers.image.authors="paperless-ngx team <hello@paperless-ngx.com>"
|
||||||
LABEL org.opencontainers.image.documentation="https://docs.paperless-ngx.com/"
|
LABEL org.opencontainers.image.documentation="https://docs.paperless-ngx.com/"
|
||||||
@@ -52,8 +62,8 @@ ARG TARGETARCH
|
|||||||
|
|
||||||
# Can be workflow provided, defaults set for manual building
|
# Can be workflow provided, defaults set for manual building
|
||||||
ARG JBIG2ENC_VERSION=0.29
|
ARG JBIG2ENC_VERSION=0.29
|
||||||
ARG QPDF_VERSION=11.6.4
|
ARG QPDF_VERSION=11.9.0
|
||||||
ARG GS_VERSION=10.02.1
|
ARG GS_VERSION=10.03.1
|
||||||
|
|
||||||
# Set Python environment variables
|
# Set Python environment variables
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
@@ -83,7 +93,6 @@ ARG RUNTIME_PACKAGES="\
|
|||||||
icc-profiles-free \
|
icc-profiles-free \
|
||||||
imagemagick \
|
imagemagick \
|
||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
libpq5 \
|
|
||||||
postgresql-client \
|
postgresql-client \
|
||||||
# MySQL / MariaDB
|
# MySQL / MariaDB
|
||||||
mariadb-client \
|
mariadb-client \
|
||||||
@@ -129,17 +138,17 @@ RUN set -eux \
|
|||||||
&& dpkg --install ./qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \
|
&& dpkg --install ./qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \
|
||||||
&& echo "Installing Ghostscript ${GS_VERSION}" \
|
&& echo "Installing Ghostscript ${GS_VERSION}" \
|
||||||
&& curl --fail --silent --show-error --location \
|
&& curl --fail --silent --show-error --location \
|
||||||
--output libgs10_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
|
--output libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
|
||||||
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
|
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
|
||||||
&& curl --fail --silent --show-error --location \
|
&& curl --fail --silent --show-error --location \
|
||||||
--output ghostscript_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
|
--output ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
|
||||||
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
|
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
|
||||||
&& curl --fail --silent --show-error --location \
|
&& curl --fail --silent --show-error --location \
|
||||||
--output libgs10-common_${GS_VERSION}.dfsg-2_all.deb \
|
--output libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
|
||||||
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
|
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
|
||||||
&& dpkg --install ./libgs10-common_${GS_VERSION}.dfsg-2_all.deb \
|
&& dpkg --install ./libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
|
||||||
&& dpkg --install ./libgs10_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
|
&& dpkg --install ./libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
|
||||||
&& dpkg --install ./ghostscript_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
|
&& dpkg --install ./ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
|
||||||
&& echo "Installing jbig2enc" \
|
&& echo "Installing jbig2enc" \
|
||||||
&& curl --fail --silent --show-error --location \
|
&& curl --fail --silent --show-error --location \
|
||||||
--output jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
|
--output jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
|
||||||
@@ -223,19 +232,22 @@ RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \
|
|||||||
&& apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \
|
&& apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \
|
||||||
&& python3 -m pip install --no-cache-dir --upgrade wheel \
|
&& python3 -m pip install --no-cache-dir --upgrade wheel \
|
||||||
&& echo "Installing Python requirements" \
|
&& echo "Installing Python requirements" \
|
||||||
&& python3 -m pip install --default-timeout=1000 --requirement requirements.txt \
|
&& curl --fail --silent --show-error --location \
|
||||||
&& echo "Patching whitenoise for compression speedup" \
|
--output psycopg_c-3.2.2-cp312-cp312-linux_x86_64.whl \
|
||||||
&& curl --fail --silent --show-error --location --output 484.patch https://github.com/evansd/whitenoise/pull/484.patch \
|
https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.2/psycopg_c-3.2.2-cp312-cp312-linux_x86_64.whl \
|
||||||
&& patch -d /usr/local/lib/python3.11/site-packages --verbose -p2 < 484.patch \
|
&& curl --fail --silent --show-error --location \
|
||||||
&& rm 484.patch \
|
--output psycopg_c-3.2.2-cp312-cp312-linux_aarch64.whl \
|
||||||
|
https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.2/psycopg_c-3.2.2-cp312-cp312-linux_aarch64.whl \
|
||||||
|
&& python3 -m pip install --default-timeout=1000 --find-links . --requirement requirements.txt \
|
||||||
&& echo "Installing NLTK data" \
|
&& echo "Installing NLTK data" \
|
||||||
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" snowball_data \
|
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" snowball_data \
|
||||||
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" stopwords \
|
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" stopwords \
|
||||||
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" punkt \
|
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" punkt_tab \
|
||||||
&& echo "Cleaning up image" \
|
&& echo "Cleaning up image" \
|
||||||
&& apt-get --yes purge ${BUILD_PACKAGES} \
|
&& apt-get --yes purge ${BUILD_PACKAGES} \
|
||||||
&& apt-get --yes autoremove --purge \
|
&& apt-get --yes autoremove --purge \
|
||||||
&& apt-get clean --yes \
|
&& apt-get clean --yes \
|
||||||
|
&& rm --recursive --force --verbose *.whl \
|
||||||
&& rm --recursive --force --verbose /var/lib/apt/lists/* \
|
&& rm --recursive --force --verbose /var/lib/apt/lists/* \
|
||||||
&& rm --recursive --force --verbose /tmp/* \
|
&& rm --recursive --force --verbose /tmp/* \
|
||||||
&& rm --recursive --force --verbose /var/tmp/* \
|
&& rm --recursive --force --verbose /var/tmp/* \
|
||||||
@@ -259,6 +271,8 @@ RUN set -eux \
|
|||||||
&& mkdir --parents --verbose /usr/src/paperless/media \
|
&& mkdir --parents --verbose /usr/src/paperless/media \
|
||||||
&& mkdir --parents --verbose /usr/src/paperless/consume \
|
&& mkdir --parents --verbose /usr/src/paperless/consume \
|
||||||
&& mkdir --parents --verbose /usr/src/paperless/export \
|
&& mkdir --parents --verbose /usr/src/paperless/export \
|
||||||
|
&& echo "Creating gnupg directory" \
|
||||||
|
&& mkdir -m700 --verbose /usr/src/paperless/.gnupg \
|
||||||
&& echo "Adjusting all permissions" \
|
&& echo "Adjusting all permissions" \
|
||||||
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \
|
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \
|
||||||
&& echo "Collecting static files" \
|
&& echo "Collecting static files" \
|
||||||
|
28
Pipfile
28
Pipfile
@@ -7,37 +7,40 @@ name = "pypi"
|
|||||||
dateparser = "~=1.2"
|
dateparser = "~=1.2"
|
||||||
# WARNING: django does not use semver.
|
# WARNING: django does not use semver.
|
||||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||||
django = "~=4.2.11"
|
django = "~=5.1.1"
|
||||||
django-allauth = "*"
|
django-allauth = {extras = ["socialaccount"], version = "*"}
|
||||||
django-auditlog = "*"
|
django-auditlog = "*"
|
||||||
django-celery-results = "*"
|
django-celery-results = "*"
|
||||||
django-compression-middleware = "*"
|
django-compression-middleware = "*"
|
||||||
django-cors-headers = "*"
|
django-cors-headers = "*"
|
||||||
django-extensions = "*"
|
django-extensions = "*"
|
||||||
django-filter = "~=23.5"
|
django-filter = "~=24.3"
|
||||||
django-guardian = "*"
|
django-guardian = "*"
|
||||||
django-multiselectfield = "*"
|
django-multiselectfield = "*"
|
||||||
djangorestframework = "~=3.14"
|
django-soft-delete = "*"
|
||||||
|
djangorestframework = "==3.15.2"
|
||||||
djangorestframework-guardian = "*"
|
djangorestframework-guardian = "*"
|
||||||
drf-writable-nested = "*"
|
drf-writable-nested = "*"
|
||||||
bleach = "*"
|
bleach = "*"
|
||||||
celery = {extras = ["redis"], version = "*"}
|
celery = {extras = ["redis"], version = "*"}
|
||||||
channels = "~=4.0"
|
channels = "~=4.1"
|
||||||
channels-redis = "*"
|
channels-redis = "*"
|
||||||
concurrent-log-handler = "*"
|
concurrent-log-handler = "*"
|
||||||
filelock = "*"
|
filelock = "*"
|
||||||
flower = "*"
|
flower = "*"
|
||||||
gotenberg-client = "*"
|
gotenberg-client = "*"
|
||||||
gunicorn = "*"
|
gunicorn = "*"
|
||||||
|
httpx-oauth = "*"
|
||||||
imap-tools = "*"
|
imap-tools = "*"
|
||||||
inotifyrecursive = "~=0.3"
|
inotifyrecursive = "~=0.3"
|
||||||
|
jinja2 = "~=3.1"
|
||||||
langdetect = "*"
|
langdetect = "*"
|
||||||
mysqlclient = "*"
|
mysqlclient = "*"
|
||||||
nltk = "*"
|
nltk = "*"
|
||||||
ocrmypdf = "~=15.4"
|
ocrmypdf = "~=16.5"
|
||||||
pathvalidate = "*"
|
pathvalidate = "*"
|
||||||
pdf2image = "*"
|
pdf2image = "*"
|
||||||
psycopg2 = "*"
|
psycopg = {version = "*", extras = ["c"]}
|
||||||
python-dateutil = "*"
|
python-dateutil = "*"
|
||||||
python-dotenv = "*"
|
python-dotenv = "*"
|
||||||
python-gnupg = "*"
|
python-gnupg = "*"
|
||||||
@@ -46,23 +49,24 @@ python-magic = "*"
|
|||||||
pyzbar = "*"
|
pyzbar = "*"
|
||||||
rapidfuzz = "*"
|
rapidfuzz = "*"
|
||||||
redis = {extras = ["hiredis"], version = "*"}
|
redis = {extras = ["hiredis"], version = "*"}
|
||||||
scikit-learn = "~=1.4"
|
scikit-learn = "~=1.5"
|
||||||
setproctitle = "*"
|
setproctitle = "*"
|
||||||
tika-client = "*"
|
tika-client = "*"
|
||||||
tqdm = "*"
|
tqdm = "*"
|
||||||
|
# See https://github.com/paperless-ngx/paperless-ngx/issues/5494
|
||||||
uvicorn = {extras = ["standard"], version = "==0.25.0"}
|
uvicorn = {extras = ["standard"], version = "==0.25.0"}
|
||||||
watchdog = "~=4.0"
|
watchdog = "~=4.0"
|
||||||
whitenoise = "~=6.6"
|
whitenoise = "~=6.8"
|
||||||
whoosh = "~=2.7"
|
whoosh = "~=2.7"
|
||||||
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
|
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
|
||||||
|
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
# Linting
|
# Linting
|
||||||
black = "*"
|
|
||||||
pre-commit = "*"
|
pre-commit = "*"
|
||||||
ruff = "*"
|
ruff = "*"
|
||||||
# Testing
|
|
||||||
factory-boy = "*"
|
factory-boy = "*"
|
||||||
|
# Testing
|
||||||
pytest = "*"
|
pytest = "*"
|
||||||
pytest-cov = "*"
|
pytest-cov = "*"
|
||||||
pytest-django = "*"
|
pytest-django = "*"
|
||||||
@@ -70,6 +74,7 @@ pytest-httpx = "*"
|
|||||||
pytest-env = "*"
|
pytest-env = "*"
|
||||||
pytest-sugar = "*"
|
pytest-sugar = "*"
|
||||||
pytest-xdist = "*"
|
pytest-xdist = "*"
|
||||||
|
pytest-mock = "*"
|
||||||
pytest-rerunfailures = "*"
|
pytest-rerunfailures = "*"
|
||||||
imagehash = "*"
|
imagehash = "*"
|
||||||
daphne = "*"
|
daphne = "*"
|
||||||
@@ -92,5 +97,4 @@ types-tqdm = "*"
|
|||||||
types-Markdown = "*"
|
types-Markdown = "*"
|
||||||
types-Pygments = "*"
|
types-Pygments = "*"
|
||||||
types-colorama = "*"
|
types-colorama = "*"
|
||||||
types-psycopg2 = "*"
|
|
||||||
types-setuptools = "*"
|
types-setuptools = "*"
|
||||||
|
4677
Pipfile.lock
generated
4677
Pipfile.lock
generated
File diff suppressed because it is too large
Load Diff
10
README.md
10
README.md
@@ -30,14 +30,14 @@ Thanks to the generous folks at [DigitalOcean](https://m.do.co/c/8d70b916d462),
|
|||||||
- [Translation](#translation)
|
- [Translation](#translation)
|
||||||
- [Feature Requests](#feature-requests)
|
- [Feature Requests](#feature-requests)
|
||||||
- [Bugs](#bugs)
|
- [Bugs](#bugs)
|
||||||
- [Affiliated Projects](#affiliated-projects)
|
- [Related Projects](#related-projects)
|
||||||
- [Important Note](#important-note)
|
- [Important Note](#important-note)
|
||||||
|
|
||||||
<p align="right">This project is supported by:<br/>
|
<p align="right">This project is supported by:<br/>
|
||||||
<a href="https://m.do.co/c/8d70b916d462" style="padding-top: 4px; display: block;">
|
<a href="https://m.do.co/c/8d70b916d462" style="padding-top: 4px; display: block;">
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_white.svg" width="140px">
|
<source media="(prefers-color-scheme: dark)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_white.svg" width="140px">
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_black_.svg" width="140px">
|
<source media="(prefers-color-scheme: light)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="140px">
|
||||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_black_.svg" width="140px">
|
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_black_.svg" width="140px">
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
@@ -63,7 +63,7 @@ If you'd like to jump right in, you can configure a `docker compose` environment
|
|||||||
bash -c "$(curl -L https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/install-paperless-ngx.sh)"
|
bash -c "$(curl -L https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/install-paperless-ngx.sh)"
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternatively, you can install the dependencies and setup apache and a database server yourself. The [documentation](https://docs.paperless-ngx.com/setup/#installation) has a step by step guide on how to do it.
|
More details and step-by-step guides for alternative installation methods can be found in [the documentation](https://docs.paperless-ngx.com/setup/#installation).
|
||||||
|
|
||||||
Migrating from Paperless-ng is easy, just drop in the new docker image! See the [documentation on migrating](https://docs.paperless-ngx.com/setup/#migrating-to-paperless-ngx) for more details.
|
Migrating from Paperless-ng is easy, just drop in the new docker image! See the [documentation on migrating](https://docs.paperless-ngx.com/setup/#migrating-to-paperless-ngx) for more details.
|
||||||
|
|
||||||
@@ -93,9 +93,9 @@ Feature requests can be submitted via [GitHub Discussions](https://github.com/pa
|
|||||||
|
|
||||||
For bugs please [open an issue](https://github.com/paperless-ngx/paperless-ngx/issues) or [start a discussion](https://github.com/paperless-ngx/paperless-ngx/discussions) if you have questions.
|
For bugs please [open an issue](https://github.com/paperless-ngx/paperless-ngx/issues) or [start a discussion](https://github.com/paperless-ngx/paperless-ngx/discussions) if you have questions.
|
||||||
|
|
||||||
# Affiliated Projects
|
# Related Projects
|
||||||
|
|
||||||
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Affiliated-Projects) for a user-maintained list of affiliated projects and software that is compatible with Paperless-ngx.
|
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects) for a user-maintained list of related projects and software that is compatible with Paperless-ngx.
|
||||||
|
|
||||||
# Important Note
|
# Important Note
|
||||||
|
|
||||||
|
@@ -3,10 +3,9 @@
|
|||||||
# Can be used locally or by the CI to start the necessary containers with the
|
# Can be used locally or by the CI to start the necessary containers with the
|
||||||
# correct networking for the tests
|
# correct networking for the tests
|
||||||
|
|
||||||
version: "3.7"
|
|
||||||
services:
|
services:
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:7.10
|
image: docker.io/gotenberg/gotenberg:8.7
|
||||||
hostname: gotenberg
|
hostname: gotenberg
|
||||||
container_name: gotenberg
|
container_name: gotenberg
|
||||||
network_mode: host
|
network_mode: host
|
||||||
@@ -20,7 +19,7 @@ services:
|
|||||||
- "--log-level=warn"
|
- "--log-level=warn"
|
||||||
- "--log-format=text"
|
- "--log-format=text"
|
||||||
tika:
|
tika:
|
||||||
image: ghcr.io/paperless-ngx/tika:latest
|
image: docker.io/apache/tika:latest
|
||||||
hostname: tika
|
hostname: tika
|
||||||
container_name: tika
|
container_name: tika
|
||||||
network_mode: host
|
network_mode: host
|
||||||
|
@@ -30,7 +30,6 @@
|
|||||||
# For more extensive installation and update instructions, refer to the
|
# For more extensive installation and update instructions, refer to the
|
||||||
# documentation.
|
# documentation.
|
||||||
|
|
||||||
version: "3.4"
|
|
||||||
services:
|
services:
|
||||||
broker:
|
broker:
|
||||||
image: docker.io/library/redis:7
|
image: docker.io/library/redis:7
|
||||||
@@ -39,7 +38,7 @@ services:
|
|||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/mariadb:10
|
image: docker.io/library/mariadb:11
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- dbdata:/var/lib/mysql
|
- dbdata:/var/lib/mysql
|
||||||
@@ -78,7 +77,7 @@ services:
|
|||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
|
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:7.10
|
image: docker.io/gotenberg/gotenberg:8.7
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
@@ -88,7 +87,7 @@ services:
|
|||||||
- "--chromium-allow-list=file:///tmp/.*"
|
- "--chromium-allow-list=file:///tmp/.*"
|
||||||
|
|
||||||
tika:
|
tika:
|
||||||
image: ghcr.io/paperless-ngx/tika:latest
|
image: docker.io/apache/tika:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
@@ -26,7 +26,6 @@
|
|||||||
# For more extensive installation and update instructions, refer to the
|
# For more extensive installation and update instructions, refer to the
|
||||||
# documentation.
|
# documentation.
|
||||||
|
|
||||||
version: "3.4"
|
|
||||||
services:
|
services:
|
||||||
broker:
|
broker:
|
||||||
image: docker.io/library/redis:7
|
image: docker.io/library/redis:7
|
||||||
@@ -35,7 +34,7 @@ services:
|
|||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/mariadb:10
|
image: docker.io/library/mariadb:11
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- dbdata:/var/lib/mysql
|
- dbdata:/var/lib/mysql
|
||||||
|
@@ -28,7 +28,6 @@
|
|||||||
# For more extensive installation and update instructions, refer to the
|
# For more extensive installation and update instructions, refer to the
|
||||||
# documentation.
|
# documentation.
|
||||||
|
|
||||||
version: "3.4"
|
|
||||||
services:
|
services:
|
||||||
broker:
|
broker:
|
||||||
image: docker.io/library/redis:7
|
image: docker.io/library/redis:7
|
||||||
@@ -37,7 +36,7 @@ services:
|
|||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/postgres:15
|
image: docker.io/library/postgres:16
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
@@ -30,7 +30,6 @@
|
|||||||
# For more extensive installation and update instructions, refer to the
|
# For more extensive installation and update instructions, refer to the
|
||||||
# documentation.
|
# documentation.
|
||||||
|
|
||||||
version: "3.4"
|
|
||||||
services:
|
services:
|
||||||
broker:
|
broker:
|
||||||
image: docker.io/library/redis:7
|
image: docker.io/library/redis:7
|
||||||
@@ -39,7 +38,7 @@ services:
|
|||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/postgres:15
|
image: docker.io/library/postgres:16
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
@@ -72,7 +71,7 @@ services:
|
|||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
|
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:7.10
|
image: docker.io/gotenberg/gotenberg:8.7
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
@@ -83,7 +82,7 @@ services:
|
|||||||
- "--chromium-allow-list=file:///tmp/.*"
|
- "--chromium-allow-list=file:///tmp/.*"
|
||||||
|
|
||||||
tika:
|
tika:
|
||||||
image: ghcr.io/paperless-ngx/tika:latest
|
image: docker.io/apache/tika:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
@@ -26,7 +26,6 @@
|
|||||||
# For more extensive installation and update instructions, refer to the
|
# For more extensive installation and update instructions, refer to the
|
||||||
# documentation.
|
# documentation.
|
||||||
|
|
||||||
version: "3.4"
|
|
||||||
services:
|
services:
|
||||||
broker:
|
broker:
|
||||||
image: docker.io/library/redis:7
|
image: docker.io/library/redis:7
|
||||||
@@ -35,7 +34,7 @@ services:
|
|||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/postgres:15
|
image: docker.io/library/postgres:16
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
@@ -30,7 +30,6 @@
|
|||||||
# For more extensive installation and update instructions, refer to the
|
# For more extensive installation and update instructions, refer to the
|
||||||
# documentation.
|
# documentation.
|
||||||
|
|
||||||
version: "3.4"
|
|
||||||
services:
|
services:
|
||||||
broker:
|
broker:
|
||||||
image: docker.io/library/redis:7
|
image: docker.io/library/redis:7
|
||||||
@@ -60,7 +59,7 @@ services:
|
|||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
|
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:7.10
|
image: docker.io/gotenberg/gotenberg:8.7
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
@@ -71,7 +70,7 @@ services:
|
|||||||
- "--chromium-allow-list=file:///tmp/.*"
|
- "--chromium-allow-list=file:///tmp/.*"
|
||||||
|
|
||||||
tika:
|
tika:
|
||||||
image: ghcr.io/paperless-ngx/tika:latest
|
image: docker.io/apache/tika:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
@@ -23,7 +23,6 @@
|
|||||||
# For more extensive installation and update instructions, refer to the
|
# For more extensive installation and update instructions, refer to the
|
||||||
# documentation.
|
# documentation.
|
||||||
|
|
||||||
version: "3.4"
|
|
||||||
services:
|
services:
|
||||||
broker:
|
broker:
|
||||||
image: docker.io/library/redis:7
|
image: docker.io/library/redis:7
|
||||||
|
@@ -10,8 +10,8 @@ map_uidgid() {
|
|||||||
local -r usermap_new_gid=${USERMAP_GID:-${usermap_original_gid:-$usermap_new_uid}}
|
local -r usermap_new_gid=${USERMAP_GID:-${usermap_original_gid:-$usermap_new_uid}}
|
||||||
if [[ ${usermap_new_uid} != "${usermap_original_uid}" || ${usermap_new_gid} != "${usermap_original_gid}" ]]; then
|
if [[ ${usermap_new_uid} != "${usermap_original_uid}" || ${usermap_new_gid} != "${usermap_original_gid}" ]]; then
|
||||||
echo "Mapping UID and GID for paperless:paperless to $usermap_new_uid:$usermap_new_gid"
|
echo "Mapping UID and GID for paperless:paperless to $usermap_new_uid:$usermap_new_gid"
|
||||||
usermod -o -u "${usermap_new_uid}" paperless
|
usermod --non-unique --uid "${usermap_new_uid}" paperless
|
||||||
groupmod -o -g "${usermap_new_gid}" paperless
|
groupmod --non-unique --gid "${usermap_new_gid}" paperless
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ custom_container_init() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Make sure custom init directory has files in it
|
# Make sure custom init directory has files in it
|
||||||
if [ -n "$(/bin/ls -A "${custom_script_dir}" 2>/dev/null)" ]; then
|
if [ -n "$(/bin/ls --almost-all "${custom_script_dir}" 2>/dev/null)" ]; then
|
||||||
echo "[custom-init] files found in ${custom_script_dir} executing"
|
echo "[custom-init] files found in ${custom_script_dir} executing"
|
||||||
# Loop over files in the directory
|
# Loop over files in the directory
|
||||||
for SCRIPT in "${custom_script_dir}"/*; do
|
for SCRIPT in "${custom_script_dir}"/*; do
|
||||||
@@ -86,13 +86,13 @@ initialize() {
|
|||||||
"${CONSUME_DIR}"; do
|
"${CONSUME_DIR}"; do
|
||||||
if [[ ! -d "${dir}" ]]; then
|
if [[ ! -d "${dir}" ]]; then
|
||||||
echo "Creating directory ${dir}"
|
echo "Creating directory ${dir}"
|
||||||
mkdir --parents "${dir}"
|
mkdir --parents --verbose "${dir}"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
local -r tmp_dir="${PAPERLESS_SCRATCH_DIR:=/tmp/paperless}"
|
local -r tmp_dir="${PAPERLESS_SCRATCH_DIR:=/tmp/paperless}"
|
||||||
echo "Creating directory scratch directory ${tmp_dir}"
|
echo "Creating directory scratch directory ${tmp_dir}"
|
||||||
mkdir --parents "${tmp_dir}"
|
mkdir --parents --verbose "${tmp_dir}"
|
||||||
|
|
||||||
set +e
|
set +e
|
||||||
echo "Adjusting permissions of paperless files. This may take a while."
|
echo "Adjusting permissions of paperless files. This may take a while."
|
||||||
@@ -102,7 +102,7 @@ initialize() {
|
|||||||
"${DATA_DIR}" \
|
"${DATA_DIR}" \
|
||||||
"${MEDIA_ROOT_DIR}" \
|
"${MEDIA_ROOT_DIR}" \
|
||||||
"${CONSUME_DIR}"; do
|
"${CONSUME_DIR}"; do
|
||||||
find "${dir}" -not \( -user paperless -and -group paperless \) -exec chown paperless:paperless {} +
|
find "${dir}" -not \( -user paperless -and -group paperless \) -exec chown --changes paperless:paperless {} +
|
||||||
done
|
done
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -122,33 +122,44 @@ install_languages() {
|
|||||||
if [ ${#langs[@]} -eq 0 ]; then
|
if [ ${#langs[@]} -eq 0 ]; then
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
apt-get update
|
|
||||||
|
|
||||||
|
# Build list of packages to install
|
||||||
|
to_install=()
|
||||||
for lang in "${langs[@]}"; do
|
for lang in "${langs[@]}"; do
|
||||||
pkg="tesseract-ocr-$lang"
|
pkg="tesseract-ocr-$lang"
|
||||||
|
|
||||||
if dpkg -s "$pkg" &>/dev/null; then
|
if dpkg --status "$pkg" &>/dev/null; then
|
||||||
echo "Package $pkg already installed!"
|
echo "Package $pkg already installed!"
|
||||||
continue
|
continue
|
||||||
|
else
|
||||||
|
to_install+=("$pkg")
|
||||||
fi
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Use apt only when we install packages
|
||||||
|
if [ ${#to_install[@]} -gt 0 ]; then
|
||||||
|
apt-get update
|
||||||
|
|
||||||
|
for pkg in "${to_install[@]}"; do
|
||||||
|
|
||||||
if ! apt-cache show "$pkg" &>/dev/null; then
|
if ! apt-cache show "$pkg" &>/dev/null; then
|
||||||
echo "Package $pkg not found! :("
|
echo "Skipped $pkg: Package not found! :("
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Installing package $pkg..."
|
echo "Installing package $pkg..."
|
||||||
if ! apt-get -y install "$pkg" &>/dev/null; then
|
if ! apt-get --assume-yes install "$pkg" &>/dev/null; then
|
||||||
echo "Could not install $pkg"
|
echo "Could not install $pkg"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "Paperless-ngx docker container starting..."
|
echo "Paperless-ngx docker container starting..."
|
||||||
|
|
||||||
gosu_cmd=(gosu paperless)
|
gosu_cmd=(gosu paperless)
|
||||||
if [ "$(id -u)" == "$(id -u paperless)" ]; then
|
if [ "$(id --user)" == "$(id --user paperless)" ]; then
|
||||||
gosu_cmd=()
|
gosu_cmd=()
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
@@ -13,7 +13,7 @@ wait_for_postgres() {
|
|||||||
|
|
||||||
# Disable warning, host and port can't have spaces
|
# Disable warning, host and port can't have spaces
|
||||||
# shellcheck disable=SC2086
|
# shellcheck disable=SC2086
|
||||||
while [ ! "$(pg_isready -h ${host} -p ${port})" ]; do
|
while [ ! "$(pg_isready --host ${host} --port ${port})" ]; do
|
||||||
|
|
||||||
if [ $attempt_num -eq $max_attempts ]; then
|
if [ $attempt_num -eq $max_attempts ]; then
|
||||||
echo "Unable to connect to database."
|
echo "Unable to connect to database."
|
||||||
@@ -25,6 +25,7 @@ wait_for_postgres() {
|
|||||||
attempt_num=$(("$attempt_num" + 1))
|
attempt_num=$(("$attempt_num" + 1))
|
||||||
sleep 5
|
sleep 5
|
||||||
done
|
done
|
||||||
|
echo "Connected to PostgreSQL"
|
||||||
}
|
}
|
||||||
|
|
||||||
wait_for_mariadb() {
|
wait_for_mariadb() {
|
||||||
@@ -51,6 +52,7 @@ wait_for_mariadb() {
|
|||||||
attempt_num=$(("$attempt_num" + 1))
|
attempt_num=$(("$attempt_num" + 1))
|
||||||
sleep 5
|
sleep 5
|
||||||
done
|
done
|
||||||
|
echo "Connected to MariaDB"
|
||||||
}
|
}
|
||||||
|
|
||||||
wait_for_redis() {
|
wait_for_redis() {
|
||||||
@@ -80,7 +82,7 @@ django_checks() {
|
|||||||
|
|
||||||
search_index() {
|
search_index() {
|
||||||
|
|
||||||
local -r index_version=8
|
local -r index_version=9
|
||||||
local -r index_version_file=${DATA_DIR}/.index_version
|
local -r index_version_file=${DATA_DIR}/.index_version
|
||||||
|
|
||||||
if [[ (! -f "${index_version_file}") || $(<"${index_version_file}") != "$index_version" ]]; then
|
if [[ (! -f "${index_version_file}") || $(<"${index_version_file}") != "$index_version" ]]; then
|
||||||
|
@@ -16,7 +16,7 @@ do
|
|||||||
# Check if it starts with "PAPERLESS_" and ends in "_FILE"
|
# Check if it starts with "PAPERLESS_" and ends in "_FILE"
|
||||||
if [[ ${env_name} == PAPERLESS_*_FILE ]]; then
|
if [[ ${env_name} == PAPERLESS_*_FILE ]]; then
|
||||||
# This should have been named different..
|
# This should have been named different..
|
||||||
if [[ ${env_name} == "PAPERLESS_OCR_SKIP_ARCHIVE_FILE" ]]; then
|
if [[ ${env_name} == "PAPERLESS_OCR_SKIP_ARCHIVE_FILE" || ${env_name} == "PAPERLESS_MODEL_FILE" ]]; then
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
# Extract the value of the environment
|
# Extract the value of the environment
|
||||||
|
@@ -14,7 +14,8 @@ for command in decrypt_documents \
|
|||||||
document_thumbnails \
|
document_thumbnails \
|
||||||
document_sanity_checker \
|
document_sanity_checker \
|
||||||
document_fuzzy_match \
|
document_fuzzy_match \
|
||||||
manage_superuser;
|
manage_superuser \
|
||||||
|
convert_mariadb_uuid;
|
||||||
do
|
do
|
||||||
echo "installing $command..."
|
echo "installing $command..."
|
||||||
sed "s/management_command/$command/g" management_script.sh > /usr/local/bin/$command
|
sed "s/management_command/$command/g" management_script.sh > /usr/local/bin/$command
|
||||||
|
@@ -4,6 +4,7 @@ Simple script which attempts to ping the Redis broker as set in the environment
|
|||||||
a certain number of times, waiting a little bit in between
|
a certain number of times, waiting a little bit in between
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
@@ -19,6 +19,8 @@ Options available to any installation of paperless:
|
|||||||
export. Therefore, incremental backups with `rsync` are entirely
|
export. Therefore, incremental backups with `rsync` are entirely
|
||||||
possible.
|
possible.
|
||||||
|
|
||||||
|
The exporter does not include API tokens and they will need to be re-generated after importing.
|
||||||
|
|
||||||
!!! caution
|
!!! caution
|
||||||
|
|
||||||
You cannot import the export generated with one version of paperless in
|
You cannot import the export generated with one version of paperless in
|
||||||
@@ -185,34 +187,12 @@ For PostgreSQL, refer to [Upgrading a PostgreSQL Cluster](https://www.postgresql
|
|||||||
|
|
||||||
For MariaDB, refer to [Upgrading MariaDB](https://mariadb.com/kb/en/upgrading/)
|
For MariaDB, refer to [Upgrading MariaDB](https://mariadb.com/kb/en/upgrading/)
|
||||||
|
|
||||||
## Downgrading Paperless {#downgrade-paperless}
|
You may also use the exporter and importer with the `--data-only` flag, after creating a new database with the updated version of PostgreSQL or MariaDB.
|
||||||
|
|
||||||
Downgrades are possible. However, some updates also contain database
|
!!! warning
|
||||||
migrations (these change the layout of the database and may move data).
|
|
||||||
In order to move back from a version that applied database migrations,
|
|
||||||
you'll have to revert the database migration _before_ downgrading, and
|
|
||||||
then downgrade paperless.
|
|
||||||
|
|
||||||
This table lists the compatible versions for each database migration
|
You should not change any settings, especially paths, when doing this or there is a
|
||||||
number.
|
risk of data loss
|
||||||
|
|
||||||
| Migration number | Version range |
|
|
||||||
| ---------------- | --------------- |
|
|
||||||
| 1011 | 1.0.0 |
|
|
||||||
| 1012 | 1.1.0 - 1.2.1 |
|
|
||||||
| 1014 | 1.3.0 - 1.3.1 |
|
|
||||||
| 1016 | 1.3.2 - current |
|
|
||||||
|
|
||||||
Execute the following management command to migrate your database:
|
|
||||||
|
|
||||||
```shell-session
|
|
||||||
$ python3 manage.py migrate documents <migration number>
|
|
||||||
```
|
|
||||||
|
|
||||||
!!! note
|
|
||||||
|
|
||||||
Some migrations cannot be undone. The command will issue errors if that
|
|
||||||
happens.
|
|
||||||
|
|
||||||
## Management utilities {#management-commands}
|
## Management utilities {#management-commands}
|
||||||
|
|
||||||
@@ -269,6 +249,9 @@ optional arguments:
|
|||||||
-sm, --split-manifest
|
-sm, --split-manifest
|
||||||
-z, --zip
|
-z, --zip
|
||||||
-zn, --zip-name
|
-zn, --zip-name
|
||||||
|
--data-only
|
||||||
|
--no-progress-bar
|
||||||
|
--passphrase
|
||||||
```
|
```
|
||||||
|
|
||||||
`target` is a folder to which the data gets written. This includes
|
`target` is a folder to which the data gets written. This includes
|
||||||
@@ -327,6 +310,16 @@ If `-z` or `--zip` is provided, the export will be a zip file
|
|||||||
in the target directory, named according to the current local date or the
|
in the target directory, named according to the current local date or the
|
||||||
value set in `-zn` or `--zip-name`.
|
value set in `-zn` or `--zip-name`.
|
||||||
|
|
||||||
|
If `--data-only` is provided, only the database will be exported. This option is intended
|
||||||
|
to facilitate database upgrades without needing to clean documents and thumbnails from the media directory.
|
||||||
|
|
||||||
|
If `--no-progress-bar` is provided, the progress bar will be hidden, rendering the
|
||||||
|
exporter quiet. This option is useful for scripting scenarios, such as when using the
|
||||||
|
exporter with `crontab`.
|
||||||
|
|
||||||
|
If `--passphrase` is provided, it will be used to encrypt certain fields in the export. This value
|
||||||
|
must be provided to import. If this value is lost, the export cannot be imported.
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
If exporting with the file name format, there may be errors due to
|
If exporting with the file name format, there may be errors due to
|
||||||
@@ -341,19 +334,34 @@ exporter](#exporter) and imports it into paperless.
|
|||||||
The importer works just like the exporter. You point it at a directory,
|
The importer works just like the exporter. You point it at a directory,
|
||||||
and the script does the rest of the work:
|
and the script does the rest of the work:
|
||||||
|
|
||||||
```
|
```shell
|
||||||
document_importer source
|
document_importer source
|
||||||
```
|
```
|
||||||
|
|
||||||
|
| Option | Required | Default | Description |
|
||||||
|
| ------------------- | -------- | ------- | ------------------------------------------------------------------------- |
|
||||||
|
| source | Yes | N/A | The directory containing an export |
|
||||||
|
| `--no-progress-bar` | No | False | If provided, the progress bar will be hidden |
|
||||||
|
| `--data-only` | No | False | If provided, only import data, do not import document files or thumbnails |
|
||||||
|
| `--passphrase` | No | N/A | If your export was encrypted with a passphrase, must be provided |
|
||||||
|
|
||||||
When you use the provided docker compose script, put the export inside
|
When you use the provided docker compose script, put the export inside
|
||||||
the `export` folder in your paperless source directory. Specify
|
the `export` folder in your paperless source directory. Specify
|
||||||
`../export` as the `source`.
|
`../export` as the `source`.
|
||||||
|
|
||||||
|
Note that .zip files (as can be generated from the exporter) are not supported. You must unzip them into
|
||||||
|
the target directory first.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
Importing from a previous version of Paperless may work, but for best
|
Importing from a previous version of Paperless may work, but for best
|
||||||
results it is suggested to match the versions.
|
results it is suggested to match the versions.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
The importer should be run against a completely empty installation (database and directories) of Paperless-ngx.
|
||||||
|
If using a data only import, only the database must be empty.
|
||||||
|
|
||||||
### Document retagger {#retagger}
|
### Document retagger {#retagger}
|
||||||
|
|
||||||
Say you've imported a few hundred documents and now want to introduce a
|
Say you've imported a few hundred documents and now want to introduce a
|
||||||
@@ -580,7 +588,7 @@ Enabling encryption is no longer supported.
|
|||||||
|
|
||||||
Basic usage to disable encryption of your document store:
|
Basic usage to disable encryption of your document store:
|
||||||
|
|
||||||
(Note: If [`PAPERLESS_PASSPHRASE`](configuration.md#PAPERLESS_PASSPHRASE) isn't set already, you need to specify
|
(Note: If `PAPERLESS_PASSPHRASE` isn't set already, you need to specify
|
||||||
it here)
|
it here)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
@@ -36,7 +36,7 @@ The following algorithms are available:
|
|||||||
- **Regular expression:** Parses the match as a regular expression and
|
- **Regular expression:** Parses the match as a regular expression and
|
||||||
tries to find a match within the document.
|
tries to find a match within the document.
|
||||||
- **Fuzzy match:** Uses a partial matching based on locating the tag text
|
- **Fuzzy match:** Uses a partial matching based on locating the tag text
|
||||||
inside the document, using a [partial ratio](https://maxbachmann.github.io/RapidFuzz/Usage/fuzz.html#partial-ratio)
|
inside the document, using a [partial ratio](https://rapidfuzz.github.io/RapidFuzz/Usage/fuzz.html#partial-ratio)
|
||||||
- **Auto:** Tries to automatically match new documents. This does not
|
- **Auto:** Tries to automatically match new documents. This does not
|
||||||
require you to set a match. See the [notes below](#automatic-matching).
|
require you to set a match. See the [notes below](#automatic-matching).
|
||||||
|
|
||||||
@@ -187,6 +187,7 @@ variables:
|
|||||||
| `DOCUMENT_THUMBNAIL_PATH` | Path to the generated thumbnail |
|
| `DOCUMENT_THUMBNAIL_PATH` | Path to the generated thumbnail |
|
||||||
| `DOCUMENT_DOWNLOAD_URL` | URL for document download |
|
| `DOCUMENT_DOWNLOAD_URL` | URL for document download |
|
||||||
| `DOCUMENT_THUMBNAIL_URL` | URL for the document thumbnail |
|
| `DOCUMENT_THUMBNAIL_URL` | URL for the document thumbnail |
|
||||||
|
| `DOCUMENT_OWNER` | Username of the document owner (if any) |
|
||||||
| `DOCUMENT_CORRESPONDENT` | Assigned correspondent (if any) |
|
| `DOCUMENT_CORRESPONDENT` | Assigned correspondent (if any) |
|
||||||
| `DOCUMENT_TAGS` | Comma separated list of tags applied (if any) |
|
| `DOCUMENT_TAGS` | Comma separated list of tags applied (if any) |
|
||||||
| `DOCUMENT_ORIGINAL_FILENAME` | Filename of original document |
|
| `DOCUMENT_ORIGINAL_FILENAME` | Filename of original document |
|
||||||
@@ -264,7 +265,7 @@ This variable allows you to configure the filename (folders are allowed)
|
|||||||
using placeholders. For example, configuring this to
|
using placeholders. For example, configuring this to
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PAPERLESS_FILENAME_FORMAT={created_year}/{correspondent}/{title}
|
PAPERLESS_FILENAME_FORMAT={{ created_year }}/{{ correspondent }}/{{ title }}
|
||||||
```
|
```
|
||||||
|
|
||||||
will create a directory structure as follows:
|
will create a directory structure as follows:
|
||||||
@@ -293,43 +294,43 @@ will create a directory structure as follows:
|
|||||||
!!! tip
|
!!! tip
|
||||||
|
|
||||||
Paperless checks the filename of a document whenever it is saved. Changing (or deleting)
|
Paperless checks the filename of a document whenever it is saved. Changing (or deleting)
|
||||||
a [storage paths](#storage-paths) will automatically be reflected in the file system. However,
|
a [storage path](#storage-paths) will automatically be reflected in the file system. However,
|
||||||
when changing `PAPERLESS_FILENAME_FORMAT` you will need to manually run the
|
when changing `PAPERLESS_FILENAME_FORMAT` you will need to manually run the
|
||||||
[`document renamer`](administration.md#renamer) to move any existing documents.
|
[`document renamer`](administration.md#renamer) to move any existing documents.
|
||||||
|
|
||||||
#### Placeholders
|
### Placeholders {#filename-format-variables}
|
||||||
|
|
||||||
Paperless provides the following placeholders within filenames:
|
Paperless provides the following variables for use within filenames:
|
||||||
|
|
||||||
- `{asn}`: The archive serial number of the document, or "none".
|
- `{{ asn }}`: The archive serial number of the document, or "none".
|
||||||
- `{correspondent}`: The name of the correspondent, or "none".
|
- `{{ correspondent }}`: The name of the correspondent, or "none".
|
||||||
- `{document_type}`: The name of the document type, or "none".
|
- `{{ document_type }}`: The name of the document type, or "none".
|
||||||
- `{tag_list}`: A comma separated list of all tags assigned to the
|
- `{{ tag_list }}`: A comma separated list of all tags assigned to the
|
||||||
document.
|
document.
|
||||||
- `{title}`: The title of the document.
|
- `{{ title }}`: The title of the document.
|
||||||
- `{created}`: The full date (ISO format) the document was created.
|
- `{{ created }}`: The full date (ISO format) the document was created.
|
||||||
- `{created_year}`: Year created only, formatted as the year with
|
- `{{ created_year }}`: Year created only, formatted as the year with
|
||||||
century.
|
century.
|
||||||
- `{created_year_short}`: Year created only, formatted as the year
|
- `{{ created_year_short }}`: Year created only, formatted as the year
|
||||||
without century, zero padded.
|
without century, zero padded.
|
||||||
- `{created_month}`: Month created only (number 01-12).
|
- `{{ created_month }}`: Month created only (number 01-12).
|
||||||
- `{created_month_name}`: Month created name, as per locale
|
- `{{ created_month_name }}`: Month created name, as per locale
|
||||||
- `{created_month_name_short}`: Month created abbreviated name, as per
|
- `{{ created_month_name_short }}`: Month created abbreviated name, as per
|
||||||
locale
|
locale
|
||||||
- `{created_day}`: Day created only (number 01-31).
|
- `{{ created_day }}`: Day created only (number 01-31).
|
||||||
- `{added}`: The full date (ISO format) the document was added to
|
- `{{ added }}`: The full date (ISO format) the document was added to
|
||||||
paperless.
|
paperless.
|
||||||
- `{added_year}`: Year added only.
|
- `{{ added_year }}`: Year added only.
|
||||||
- `{added_year_short}`: Year added only, formatted as the year without
|
- `{{ added_year_short }}`: Year added only, formatted as the year without
|
||||||
century, zero padded.
|
century, zero padded.
|
||||||
- `{added_month}`: Month added only (number 01-12).
|
- `{{ added_month }}`: Month added only (number 01-12).
|
||||||
- `{added_month_name}`: Month added name, as per locale
|
- `{{ added_month_name }}`: Month added name, as per locale
|
||||||
- `{added_month_name_short}`: Month added abbreviated name, as per
|
- `{{ added_month_name_short }}`: Month added abbreviated name, as per
|
||||||
locale
|
locale
|
||||||
- `{added_day}`: Day added only (number 01-31).
|
- `{{ added_day }}`: Day added only (number 01-31).
|
||||||
- `{owner_username}`: Username of document owner, if any, or "none"
|
- `{{ owner_username }}`: Username of document owner, if any, or "none"
|
||||||
- `{original_name}`: Document original filename, minus the extension, if any, or "none"
|
- `{{ original_name }}`: Document original filename, minus the extension, if any, or "none"
|
||||||
- `{doc_pk}`: The paperless identifier (primary key) for the document.
|
- `{{ doc_pk }}`: The paperless identifier (primary key) for the document.
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
@@ -337,6 +338,11 @@ Paperless provides the following placeholders within filenames:
|
|||||||
you may run into the limits of your operating system's maximum path lengths.
|
you may run into the limits of your operating system's maximum path lengths.
|
||||||
In that case, files will retain the previous path instead and the issue logged.
|
In that case, files will retain the previous path instead and the issue logged.
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
|
||||||
|
These variables are all simple strings, but the format can be a full template.
|
||||||
|
See [Filename Templates](#filename-templates) for even more advanced formatting.
|
||||||
|
|
||||||
Paperless will try to conserve the information from your database as
|
Paperless will try to conserve the information from your database as
|
||||||
much as possible. However, some characters that you can use in document
|
much as possible. However, some characters that you can use in document
|
||||||
titles and correspondent names (such as `: \ /` and a couple more) are
|
titles and correspondent names (such as `: \ /` and a couple more) are
|
||||||
@@ -362,7 +368,7 @@ paperless will fallback to using the default naming scheme instead.
|
|||||||
However, keep in mind that inside docker, if files get stored outside of
|
However, keep in mind that inside docker, if files get stored outside of
|
||||||
the predefined volumes, they will be lost after a restart.
|
the predefined volumes, they will be lost after a restart.
|
||||||
|
|
||||||
##### Empty placeholders
|
#### Empty placeholders
|
||||||
|
|
||||||
You can affect how empty placeholders are treated by changing the
|
You can affect how empty placeholders are treated by changing the
|
||||||
[`PAPERLESS_FILENAME_FORMAT_REMOVE_NONE`](configuration.md#PAPERLESS_FILENAME_FORMAT_REMOVE_NONE) setting.
|
[`PAPERLESS_FILENAME_FORMAT_REMOVE_NONE`](configuration.md#PAPERLESS_FILENAME_FORMAT_REMOVE_NONE) setting.
|
||||||
@@ -389,8 +395,8 @@ For example, you could define the following two storage paths:
|
|||||||
the correspondence.
|
the correspondence.
|
||||||
|
|
||||||
```
|
```
|
||||||
By Year = {created_year}/{correspondent}/{title}
|
By Year = {{ created_year }}/{{ correspondent }}/{{ title }}
|
||||||
Insurances = Insurances/{correspondent}/{created_year}-{created_month}-{created_day} {title}
|
Insurances = Insurances/{{ correspondent }}/{{ created_year }}-{{ created_month }}-{{ created_day }} {{ title }}
|
||||||
```
|
```
|
||||||
|
|
||||||
If you then map these storage paths to the documents, you might get the
|
If you then map these storage paths to the documents, you might get the
|
||||||
@@ -417,6 +423,97 @@ Insurances/ # Insurances
|
|||||||
Defining a storage path is optional. If no storage path is defined for a
|
Defining a storage path is optional. If no storage path is defined for a
|
||||||
document, the global [`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) is applied.
|
document, the global [`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) is applied.
|
||||||
|
|
||||||
|
### Filename Templates {#filename-templates}
|
||||||
|
|
||||||
|
The filename formatting uses [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/) to build the filename.
|
||||||
|
This allows for complex logic to be included in the format, including [logical structures](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures)
|
||||||
|
and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11) to manipulate the [variables](#filename-format-variables)
|
||||||
|
provided. The template is provided as a string, potentially multiline, and rendered into a single line.
|
||||||
|
|
||||||
|
In addition, the entire Document instance is available to be utilized in a more advanced way, as well as some variables which only make sense to be accessed
|
||||||
|
with more complex logic.
|
||||||
|
|
||||||
|
#### Additional Variables
|
||||||
|
|
||||||
|
- `{{ tag_name_list }}`: A list of tag names applied to the document, ordered by the tag name. Note this is a list, not a single string
|
||||||
|
- `{{ custom_fields }}`: A mapping of custom field names to their type and value. A user can access the mapping by field name or check if a field is applied by checking its existence in the variable.
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
|
||||||
|
To access a custom field which has a space in the name, use the `get_cf_value` filter. See the examples below.
|
||||||
|
This helps get fields by name and handle a default value if the named field is not attached to a Document.
|
||||||
|
|
||||||
|
#### Examples
|
||||||
|
|
||||||
|
This example will construct a path based on the archive serial number range:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
somepath/
|
||||||
|
{% if document.archive_serial_number >= 0 and document.archive_serial_number <= 200 %}
|
||||||
|
asn-000-200/{{title}}
|
||||||
|
{% elif document.archive_serial_number >= 201 and document.archive_serial_number <= 400 %}
|
||||||
|
asn-201-400
|
||||||
|
{% if document.archive_serial_number >= 201 and document.archive_serial_number < 300 %}
|
||||||
|
/asn-2xx
|
||||||
|
{% elif document.archive_serial_number >= 300 and document.archive_serial_number < 400 %}
|
||||||
|
/asn-3xx
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
/{{ title }}
|
||||||
|
```
|
||||||
|
|
||||||
|
For a document with an ASN of 205, it would result in `somepath/asn-201-400/asn-2xx/Title.pdf`, but
|
||||||
|
a document with an ASN of 355 would be placed in `somepath/asn-201-400/asn-3xx/Title.pdf`.
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
{% if document.mime_type == "application/pdf" %}
|
||||||
|
pdfs
|
||||||
|
{% elif document.mime_type == "image/png" %}
|
||||||
|
pngs
|
||||||
|
{% else %}
|
||||||
|
others
|
||||||
|
{% endif %}
|
||||||
|
/{{ title }}
|
||||||
|
```
|
||||||
|
|
||||||
|
For a PDF document, it would result in `pdfs/Title.pdf`, but for a PNG document, the path would be `pngs/Title.pdf`.
|
||||||
|
|
||||||
|
To use custom fields:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
{% if "Invoice" in custom_fields %}
|
||||||
|
invoices/{{ custom_fields.Invoice.value }}
|
||||||
|
{% else %}
|
||||||
|
not-invoices/{{ title }}
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the document has a custom field named "Invoice" with a value of 123, it would be filed into the `invoices/123.pdf`, but a document without the custom field
|
||||||
|
would be filed to `not-invoices/Title.pdf`
|
||||||
|
|
||||||
|
If the custom field is named "Invoice Number", you would access the value of it via the `get_cf_value` filter due to quirks of the Django Template Language:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
"invoices/{{ custom_fields|get_cf_value('Invoice Number') }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also use a custom `datetime` filter to format dates:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
invoices/
|
||||||
|
{{ custom_fields|get_cf_value("Date Field","2024-01-01")|datetime('%Y') }}/
|
||||||
|
{{ custom_fields|get_cf_value("Date Field","2024-01-01")|datetime('%m') }}/
|
||||||
|
{{ custom_fields|get_cf_value("Date Field","2024-01-01")|datetime('%d') }}/
|
||||||
|
Invoice_{{ custom_fields|get_cf_value("Select Field") }}_{{ custom_fields|get_cf_value("Date Field","2024-01-01")|replace("-", "") }}.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create a path like `invoices/2022/01/01/Invoice_OptionTwo_20220101.pdf` if the custom field "Date Field" is set to January 1, 2022 and "Select Field" is set to `OptionTwo`.
|
||||||
|
|
||||||
|
## Automatic recovery of invalid PDFs {#pdf-recovery}
|
||||||
|
|
||||||
|
Paperless will attempt to "clean" certain invalid PDFs with `qpdf` before processing if, for example, the mime_type
|
||||||
|
detection is incorrect. This can happen if the PDF is not properly formatted or contains errors.
|
||||||
|
|
||||||
## Celery Monitoring {#celery-monitoring}
|
## Celery Monitoring {#celery-monitoring}
|
||||||
|
|
||||||
The monitoring tool
|
The monitoring tool
|
||||||
@@ -649,8 +746,9 @@ external authentication solution using one of the following methods:
|
|||||||
|
|
||||||
This is a simple option that uses remote user authentication made available by certain SSO
|
This is a simple option that uses remote user authentication made available by certain SSO
|
||||||
applications. See the relevant configuration options for more information:
|
applications. See the relevant configuration options for more information:
|
||||||
[PAPERLESS_ENABLE_HTTP_REMOTE_USER](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER) and
|
[PAPERLESS_ENABLE_HTTP_REMOTE_USER](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER),
|
||||||
[PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME](configuration.md#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME)
|
[PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME](configuration.md#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME)
|
||||||
|
and [PAPERLESS_LOGOUT_REDIRECT_URL](configuration.md#PAPERLESS_LOGOUT_REDIRECT_URL)
|
||||||
|
|
||||||
### OpenID Connect and social authentication
|
### OpenID Connect and social authentication
|
||||||
|
|
||||||
@@ -686,4 +784,59 @@ More details about configuration option for various providers can be found in th
|
|||||||
|
|
||||||
### Disabling Regular Login
|
### Disabling Regular Login
|
||||||
|
|
||||||
Once external auth is set up, 'regular' login can be disabled with the [PAPERLESS_DISABLE_REGULAR_LOGIN](configuration.md#PAPERLESS_DISABLE_REGULAR_LOGIN) setting.
|
Once external auth is set up, 'regular' login can be disabled with the [PAPERLESS_DISABLE_REGULAR_LOGIN](configuration.md#PAPERLESS_DISABLE_REGULAR_LOGIN) setting and / or users can be automatically
|
||||||
|
redirected with the [PAPERLESS_REDIRECT_LOGIN_TO_SSO](configuration.md#PAPERLESS_REDIRECT_LOGIN_TO_SSO) setting.
|
||||||
|
|
||||||
|
## Decryption of encrypted emails before consumption {#gpg-decryptor}
|
||||||
|
|
||||||
|
Paperless-ngx can be configured to decrypt gpg encrypted emails before consumption.
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
You need a recent version of `gpg-agent >= 2.1.1` installed on your host.
|
||||||
|
Your host needs to be setup for decrypting your emails via `gpg-agent`, see this [tutorial](https://www.digitalocean.com/community/tutorials/how-to-use-gpg-to-encrypt-and-sign-messages#encrypt-and-decrypt-messages-with-gpg) for instance.
|
||||||
|
Test your setup and make sure that you can encrypt and decrypt files using your key
|
||||||
|
|
||||||
|
```
|
||||||
|
gpg --encrypt --armor -r person@email.com name_of_file
|
||||||
|
gpg --decrypt name_of_file.asc
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
First, enable the [PAPERLESS_ENABLE_GPG_DECRYPTOR environment variable](configuration.md#PAPERLESS_ENABLE_GPG_DECRYPTOR).
|
||||||
|
|
||||||
|
Then determine your local `gpg-agent.extra` socket by invoking
|
||||||
|
|
||||||
|
```
|
||||||
|
gpgconf --list-dir agent-extra-socket
|
||||||
|
```
|
||||||
|
|
||||||
|
on your host. A possible output is `~/.gnupg/S.gpg-agent.extra`.
|
||||||
|
Also find the location of your public keyring.
|
||||||
|
|
||||||
|
If using docker, you'll need to add the following volume mounts to your `docker-compose.yml` file:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
webserver:
|
||||||
|
volumes:
|
||||||
|
- /home/user/.gnupg/pubring.gpg:/usr/src/paperless/.gnupg/pubring.gpg
|
||||||
|
- <path to gpg-agent.extra socket>:/usr/src/paperless/.gnupg/S.gpg-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
For a 'bare-metal' installation no further configuration is necessary. If you
|
||||||
|
want to use a separate `GNUPG_HOME`, you can do so by configuring the [PAPERLESS_EMAIL_GNUPG_HOME environment variable](configuration.md#PAPERLESS_EMAIL_GNUPG_HOME).
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
- Make sure, that `gpg-agent` is running on your host machine
|
||||||
|
- Make sure, that encryption and decryption works from inside the container using the `gpg` commands from above.
|
||||||
|
- Check that all files in `/usr/src/paperless/.gnupg` have correct permissions
|
||||||
|
|
||||||
|
```shell
|
||||||
|
paperless@9da1865df327:~/.gnupg$ ls -al
|
||||||
|
drwx------ 1 paperless paperless 4096 Aug 18 17:52 .
|
||||||
|
drwxr-xr-x 1 paperless paperless 4096 Aug 18 17:52 ..
|
||||||
|
srw------- 1 paperless paperless 0 Aug 18 17:22 S.gpg-agent
|
||||||
|
-rw------- 1 paperless paperless 147940 Jul 24 10:23 pubring.gpg
|
||||||
|
```
|
||||||
|
117
docs/api.md
117
docs/api.md
@@ -11,7 +11,7 @@ The API provides the following main endpoints:
|
|||||||
- `/api/correspondents/`: Full CRUD support.
|
- `/api/correspondents/`: Full CRUD support.
|
||||||
- `/api/custom_fields/`: Full CRUD support.
|
- `/api/custom_fields/`: Full CRUD support.
|
||||||
- `/api/documents/`: Full CRUD support, except POSTing new documents.
|
- `/api/documents/`: Full CRUD support, except POSTing new documents.
|
||||||
See below.
|
See [below](#file-uploads).
|
||||||
- `/api/document_types/`: Full CRUD support.
|
- `/api/document_types/`: Full CRUD support.
|
||||||
- `/api/groups/`: Full CRUD support.
|
- `/api/groups/`: Full CRUD support.
|
||||||
- `/api/logs/`: Read-Only.
|
- `/api/logs/`: Read-Only.
|
||||||
@@ -24,6 +24,7 @@ The API provides the following main endpoints:
|
|||||||
- `/api/tasks/`: Read-only.
|
- `/api/tasks/`: Read-only.
|
||||||
- `/api/users/`: Full CRUD support.
|
- `/api/users/`: Full CRUD support.
|
||||||
- `/api/workflows/`: Full CRUD support.
|
- `/api/workflows/`: Full CRUD support.
|
||||||
|
- `/api/search/` GET, see [below](#global-search).
|
||||||
|
|
||||||
All of these endpoints except for the logging endpoint allow you to
|
All of these endpoints except for the logging endpoint allow you to
|
||||||
fetch (and edit and delete where appropriate) individual objects by
|
fetch (and edit and delete where appropriate) individual objects by
|
||||||
@@ -53,6 +54,7 @@ fields:
|
|||||||
- `archived_file_name`: Verbose filename of the archived document.
|
- `archived_file_name`: Verbose filename of the archived document.
|
||||||
Read-only. Null if no archived document is available.
|
Read-only. Null if no archived document is available.
|
||||||
- `notes`: Array of notes associated with the document.
|
- `notes`: Array of notes associated with the document.
|
||||||
|
- `page_count`: Number of pages.
|
||||||
- `set_permissions`: Allows setting document permissions. Optional,
|
- `set_permissions`: Allows setting document permissions. Optional,
|
||||||
write-only. See [below](#permissions).
|
write-only. See [below](#permissions).
|
||||||
- `custom_fields`: Array of custom fields & values, specified as
|
- `custom_fields`: Array of custom fields & values, specified as
|
||||||
@@ -140,6 +142,7 @@ document. Paperless only reports PDF metadata at this point.
|
|||||||
|
|
||||||
- `/api/documents/<id>/notes/`: Retrieve notes for a document.
|
- `/api/documents/<id>/notes/`: Retrieve notes for a document.
|
||||||
- `/api/documents/<id>/share_links/`: Retrieve share links for a document.
|
- `/api/documents/<id>/share_links/`: Retrieve share links for a document.
|
||||||
|
- `/api/documents/<id>/history/`: Retrieve history of changes for a document.
|
||||||
|
|
||||||
## Authorization
|
## Authorization
|
||||||
|
|
||||||
@@ -187,6 +190,38 @@ The REST api provides four different forms of authentication.
|
|||||||
[configuration](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API)),
|
[configuration](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API)),
|
||||||
you can authenticate against the API using Remote User auth.
|
you can authenticate against the API using Remote User auth.
|
||||||
|
|
||||||
|
## Global search
|
||||||
|
|
||||||
|
A global search endpoint is available at `/api/search/` and requires a search term
|
||||||
|
of > 2 characters e.g. `?query=foo`. This endpoint returns a maximum of 3 results
|
||||||
|
across nearly all objects, e.g. documents, tags, saved views, mail rules, etc.
|
||||||
|
Results are only included if the requesting user has the appropriate permissions.
|
||||||
|
|
||||||
|
Results are returned in the following format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
total: number
|
||||||
|
documents: []
|
||||||
|
saved_views: []
|
||||||
|
correspondents: []
|
||||||
|
document_types: []
|
||||||
|
storage_paths: []
|
||||||
|
tags: []
|
||||||
|
users: []
|
||||||
|
groups: []
|
||||||
|
mail_accounts: []
|
||||||
|
mail_rules: []
|
||||||
|
custom_fields: []
|
||||||
|
workflows: []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Global search first searches objects by name (or title for documents) matching the query.
|
||||||
|
If the optional `db_only` parameter is set, only document titles will be searched. Otherwise,
|
||||||
|
if the amount of documents returned by a simple title string search is < 3, results from the
|
||||||
|
search index will also be included.
|
||||||
|
|
||||||
## Searching for documents
|
## Searching for documents
|
||||||
|
|
||||||
Full text searching is available on the `/api/documents/` endpoint. Two
|
Full text searching is available on the `/api/documents/` endpoint. Two
|
||||||
@@ -201,12 +236,6 @@ results:
|
|||||||
Pagination works exactly the same as it does for normal requests on this
|
Pagination works exactly the same as it does for normal requests on this
|
||||||
endpoint.
|
endpoint.
|
||||||
|
|
||||||
Certain limitations apply to full text queries:
|
|
||||||
|
|
||||||
- Results are always sorted by search score. The results matching the
|
|
||||||
query best will show up first.
|
|
||||||
- Only a small subset of filtering parameters are supported.
|
|
||||||
|
|
||||||
Furthermore, each returned document has an additional `__search_hit__`
|
Furthermore, each returned document has an additional `__search_hit__`
|
||||||
attribute with various information about the search results:
|
attribute with various information about the search results:
|
||||||
|
|
||||||
@@ -246,6 +275,51 @@ attribute with various information about the search results:
|
|||||||
- `rank` is the index of the search results. The first result will
|
- `rank` is the index of the search results. The first result will
|
||||||
have rank 0.
|
have rank 0.
|
||||||
|
|
||||||
|
### Filtering by custom fields
|
||||||
|
|
||||||
|
You can filter documents by their custom field values by specifying the
|
||||||
|
`custom_field_query` query parameter. Here are some recipes for common
|
||||||
|
use cases:
|
||||||
|
|
||||||
|
1. Documents with a custom field "due" (date) between Aug 1, 2024 and
|
||||||
|
Sept 1, 2024 (inclusive):
|
||||||
|
|
||||||
|
`?custom_field_query=["due", "range", ["2024-08-01", "2024-09-01"]]`
|
||||||
|
|
||||||
|
2. Documents with a custom field "customer" (text) that equals "bob"
|
||||||
|
(case sensitive):
|
||||||
|
|
||||||
|
`?custom_field_query=["customer", "exact", "bob"]`
|
||||||
|
|
||||||
|
3. Documents with a custom field "answered" (boolean) set to `true`:
|
||||||
|
|
||||||
|
`?custom_field_query=["answered", "exact", true]`
|
||||||
|
|
||||||
|
4. Documents with a custom field "favorite animal" (select) set to either
|
||||||
|
"cat" or "dog":
|
||||||
|
|
||||||
|
`?custom_field_query=["favorite animal", "in", ["cat", "dog"]]`
|
||||||
|
|
||||||
|
5. Documents with a custom field "address" (text) that is empty:
|
||||||
|
|
||||||
|
`?custom_field_query=["OR", ["address", "isnull", true], ["address", "exact", ""]]`
|
||||||
|
|
||||||
|
6. Documents that don't have a field called "foo":
|
||||||
|
|
||||||
|
`?custom_field_query=["foo", "exists", false]`
|
||||||
|
|
||||||
|
7. Documents that have document links "references" to both document 3 and 7:
|
||||||
|
|
||||||
|
`?custom_field_query=["references", "contains", [3, 7]]`
|
||||||
|
|
||||||
|
All field types support basic operations including `exact`, `in`, `isnull`,
|
||||||
|
and `exists`. String, URL, and monetary fields support case-insensitive
|
||||||
|
substring matching operations including `icontains`, `istartswith`, and
|
||||||
|
`iendswith`. Integer, float, and date fields support arithmetic comparisons
|
||||||
|
including `gt` (>), `gte` (>=), `lt` (<), `lte` (<=), and `range`.
|
||||||
|
Lastly, document link fields support a `contains` operator that behaves
|
||||||
|
like a "is superset of" check.
|
||||||
|
|
||||||
### `/api/search/autocomplete/`
|
### `/api/search/autocomplete/`
|
||||||
|
|
||||||
Get auto completions for a partial search term.
|
Get auto completions for a partial search term.
|
||||||
@@ -288,6 +362,8 @@ The endpoint supports the following optional form fields:
|
|||||||
- `tags`: Similar to correspondent. Specify this multiple times to
|
- `tags`: Similar to correspondent. Specify this multiple times to
|
||||||
have multiple tags added to the document.
|
have multiple tags added to the document.
|
||||||
- `archive_serial_number`: An optional archive serial number to set.
|
- `archive_serial_number`: An optional archive serial number to set.
|
||||||
|
- `custom_fields`: An array of custom field ids to assign (with an empty
|
||||||
|
value) to the document.
|
||||||
|
|
||||||
The endpoint will immediately return HTTP 200 if the document consumption
|
The endpoint will immediately return HTTP 200 if the document consumption
|
||||||
process was started successfully, with the UUID of the consumption task
|
process was started successfully, with the UUID of the consumption task
|
||||||
@@ -340,7 +416,7 @@ The API supports various bulk-editing operations which are executed asynchronous
|
|||||||
|
|
||||||
### Documents
|
### Documents
|
||||||
|
|
||||||
For bulk operations on documents, use the endpoint `/api/bulk_edit/` which accepts
|
For bulk operations on documents, use the endpoint `/api/documents/bulk_edit/` which accepts
|
||||||
a json payload of the format:
|
a json payload of the format:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -367,15 +443,36 @@ The following methods are supported:
|
|||||||
- Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and / or `{ "remove_tags": [LIST_OF_TAG_IDS] }`
|
- Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and / or `{ "remove_tags": [LIST_OF_TAG_IDS] }`
|
||||||
- `delete`
|
- `delete`
|
||||||
- No `parameters` required
|
- No `parameters` required
|
||||||
- `redo_ocr`
|
- `reprocess`
|
||||||
- No `parameters` required
|
- No `parameters` required
|
||||||
- `set_permissions`
|
- `set_permissions`
|
||||||
- Requires `parameters`:
|
- Requires `parameters`:
|
||||||
- `"permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
||||||
- `"owner": OWNER_ID or null`
|
- `"owner": OWNER_ID or null`
|
||||||
- `"merge": true or false` (defaults to false)
|
- `"merge": true or false` (defaults to false)
|
||||||
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
|
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
|
||||||
removing them) or be merged with existing permissions.
|
removing them) or be merged with existing permissions.
|
||||||
|
- `merge`
|
||||||
|
- No additional `parameters` required.
|
||||||
|
- The ordering of the merged document is determined by the list of IDs.
|
||||||
|
- Optional `parameters`:
|
||||||
|
- `"metadata_document_id": DOC_ID` apply metadata (tags, correspondent, etc.) from this document to the merged document.
|
||||||
|
- `"delete_originals": true` to delete the original documents. This requires the calling user being the owner of
|
||||||
|
all documents that are merged.
|
||||||
|
- `split`
|
||||||
|
- Requires `parameters`:
|
||||||
|
- `"pages": [..]` The list should be a list of pages and/or a ranges, separated by commas e.g. `"[1,2-3,4,5-7]"`
|
||||||
|
- Optional `parameters`:
|
||||||
|
- `"delete_originals": true` to delete the original document after consumption. This requires the calling user being the owner of
|
||||||
|
the document.
|
||||||
|
- The split operation only accepts a single document.
|
||||||
|
- `rotate`
|
||||||
|
- Requires `parameters`:
|
||||||
|
- `"degrees": DEGREES`. Must be an integer i.e. 90, 180, 270
|
||||||
|
- `delete_pages`
|
||||||
|
- Requires `parameters`:
|
||||||
|
- `"pages": [..]` The list should be a list of integers e.g. `"[2,3,4]"`
|
||||||
|
- The delete_pages operation only accepts a single document.
|
||||||
|
|
||||||
### Objects
|
### Objects
|
||||||
|
|
||||||
|
@@ -10,7 +10,7 @@
|
|||||||
--md-hue: 222;
|
--md-hue: 222;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 400px) {
|
@media (min-width: 768px) {
|
||||||
.grid-left {
|
.grid-left {
|
||||||
width: 33%;
|
width: 33%;
|
||||||
float: left;
|
float: left;
|
||||||
|
1668
docs/changelog.md
1668
docs/changelog.md
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@ matcher.
|
|||||||
`redis://<username>:<password>@<host>:<port>/<DBIndex>`
|
`redis://<username>:<password>@<host>:<port>/<DBIndex>`
|
||||||
|
|
||||||
[More information on securing your Redis
|
[More information on securing your Redis
|
||||||
Instance](https://redis.io/docs/getting-started/#securing-redis).
|
Instance](https://redis.io/docs/latest/operate/oss_and_stack/management/security).
|
||||||
|
|
||||||
Defaults to `redis://localhost:6379`.
|
Defaults to `redis://localhost:6379`.
|
||||||
|
|
||||||
@@ -177,13 +177,13 @@ configure their endpoints, and enable the feature.
|
|||||||
|
|
||||||
#### [`PAPERLESS_TIKA_ENDPOINT=<url>`](#PAPERLESS_TIKA_ENDPOINT) {#PAPERLESS_TIKA_ENDPOINT}
|
#### [`PAPERLESS_TIKA_ENDPOINT=<url>`](#PAPERLESS_TIKA_ENDPOINT) {#PAPERLESS_TIKA_ENDPOINT}
|
||||||
|
|
||||||
: Set the endpoint URL were Paperless can reach your Tika server.
|
: Set the endpoint URL where Paperless can reach your Tika server.
|
||||||
|
|
||||||
Defaults to "<http://localhost:9998>".
|
Defaults to "<http://localhost:9998>".
|
||||||
|
|
||||||
#### [`PAPERLESS_TIKA_GOTENBERG_ENDPOINT=<url>`](#PAPERLESS_TIKA_GOTENBERG_ENDPOINT) {#PAPERLESS_TIKA_GOTENBERG_ENDPOINT}
|
#### [`PAPERLESS_TIKA_GOTENBERG_ENDPOINT=<url>`](#PAPERLESS_TIKA_GOTENBERG_ENDPOINT) {#PAPERLESS_TIKA_GOTENBERG_ENDPOINT}
|
||||||
|
|
||||||
: Set the endpoint URL were Paperless can reach your Gotenberg server.
|
: Set the endpoint URL where Paperless can reach your Gotenberg server.
|
||||||
|
|
||||||
Defaults to "<http://localhost:3000>".
|
Defaults to "<http://localhost:3000>".
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ and watch out for indentation if editing the YAML file.
|
|||||||
|
|
||||||
#### [`PAPERLESS_CONSUMPTION_DIR=<path>`](#PAPERLESS_CONSUMPTION_DIR) {#PAPERLESS_CONSUMPTION_DIR}
|
#### [`PAPERLESS_CONSUMPTION_DIR=<path>`](#PAPERLESS_CONSUMPTION_DIR) {#PAPERLESS_CONSUMPTION_DIR}
|
||||||
|
|
||||||
: This where your documents should go to be consumed. Make sure that
|
: This is where your documents should go to be consumed. Make sure that
|
||||||
it exists and that the user running the paperless service can
|
it exists and that the user running the paperless service can
|
||||||
read/write its contents before you start Paperless.
|
read/write its contents before you start Paperless.
|
||||||
|
|
||||||
@@ -219,10 +219,10 @@ database, classification model, etc).
|
|||||||
|
|
||||||
Defaults to "../data/", relative to the "src" directory.
|
Defaults to "../data/", relative to the "src" directory.
|
||||||
|
|
||||||
#### [`PAPERLESS_TRASH_DIR=<path>`](#PAPERLESS_TRASH_DIR) {#PAPERLESS_TRASH_DIR}
|
#### [`PAPERLESS_EMPTY_TRASH_DIR=<path>`](#PAPERLESS_EMPTY_TRASH_DIR) {#PAPERLESS_EMPTY_TRASH_DIR}
|
||||||
|
|
||||||
: Instead of removing deleted documents, they are moved to this
|
: When documents are deleted (e.g. after emptying the trash) the original files will be moved here
|
||||||
directory.
|
instead of being removed from the filesystem. Only the original version is kept.
|
||||||
|
|
||||||
This must be writeable by the user running paperless. When running
|
This must be writeable by the user running paperless. When running
|
||||||
inside docker, ensure that this path is within a permanent volume
|
inside docker, ensure that this path is within a permanent volume
|
||||||
@@ -230,7 +230,9 @@ directory.
|
|||||||
|
|
||||||
Note that the directory must exist prior to using this setting.
|
Note that the directory must exist prior to using this setting.
|
||||||
|
|
||||||
Defaults to empty (i.e. really delete documents).
|
Defaults to empty (i.e. really delete files).
|
||||||
|
|
||||||
|
This setting was previously named PAPERLESS_TRASH_DIR.
|
||||||
|
|
||||||
#### [`PAPERLESS_MEDIA_ROOT=<path>`](#PAPERLESS_MEDIA_ROOT) {#PAPERLESS_MEDIA_ROOT}
|
#### [`PAPERLESS_MEDIA_ROOT=<path>`](#PAPERLESS_MEDIA_ROOT) {#PAPERLESS_MEDIA_ROOT}
|
||||||
|
|
||||||
@@ -288,6 +290,12 @@ this folder is no longer needed and can be removed manually.
|
|||||||
|
|
||||||
Defaults to `/usr/share/nltk_data`
|
Defaults to `/usr/share/nltk_data`
|
||||||
|
|
||||||
|
#### [`PAPERLESS_MODEL_FILE=<path>`](#PAPERLESS_MODEL_FILE) {#PAPERLESS_MODEL_FILE}
|
||||||
|
|
||||||
|
: This is where paperless will store the classification model.
|
||||||
|
|
||||||
|
Defaults to `PAPERLESS_DATA_DIR/classification_model.pickle`.
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
#### [`PAPERLESS_LOGROTATE_MAX_SIZE=<num>`](#PAPERLESS_LOGROTATE_MAX_SIZE) {#PAPERLESS_LOGROTATE_MAX_SIZE}
|
#### [`PAPERLESS_LOGROTATE_MAX_SIZE=<num>`](#PAPERLESS_LOGROTATE_MAX_SIZE) {#PAPERLESS_LOGROTATE_MAX_SIZE}
|
||||||
@@ -491,8 +499,9 @@ followed by the normalized actual header name.
|
|||||||
#### [`PAPERLESS_LOGOUT_REDIRECT_URL=<str>`](#PAPERLESS_LOGOUT_REDIRECT_URL) {#PAPERLESS_LOGOUT_REDIRECT_URL}
|
#### [`PAPERLESS_LOGOUT_REDIRECT_URL=<str>`](#PAPERLESS_LOGOUT_REDIRECT_URL) {#PAPERLESS_LOGOUT_REDIRECT_URL}
|
||||||
|
|
||||||
: URL to redirect the user to after a logout. This can be used
|
: URL to redirect the user to after a logout. This can be used
|
||||||
together with PAPERLESS_ENABLE_HTTP_REMOTE_USER to
|
together with PAPERLESS_ENABLE_HTTP_REMOTE_USER and SSO to
|
||||||
redirect the user back to the SSO application's logout page.
|
redirect the user back to the SSO application's logout page to
|
||||||
|
complete the logout process.
|
||||||
|
|
||||||
Defaults to None, which disables this feature.
|
Defaults to None, which disables this feature.
|
||||||
|
|
||||||
@@ -585,10 +594,32 @@ system. See the corresponding
|
|||||||
|
|
||||||
#### [`PAPERLESS_DISABLE_REGULAR_LOGIN=<bool>`](#PAPERLESS_DISABLE_REGULAR_LOGIN) {#PAPERLESS_DISABLE_REGULAR_LOGIN}
|
#### [`PAPERLESS_DISABLE_REGULAR_LOGIN=<bool>`](#PAPERLESS_DISABLE_REGULAR_LOGIN) {#PAPERLESS_DISABLE_REGULAR_LOGIN}
|
||||||
|
|
||||||
: Disables the regular frontend username / password login, i.e. once you have setup SSO. Note that this setting does not disable the Django admin login. To prevent logins directly to Django, consider blocking `/admin/` in your [web server or reverse proxy configuration](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx).
|
: Disables the regular frontend username / password login, i.e. once you have setup SSO. Note that this setting does not disable the Django admin login nor logging in with local credentials via the API. To prevent access to the Django admin, consider blocking `/admin/` in your [web server or reverse proxy configuration](https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx).
|
||||||
|
|
||||||
|
You can optionally also automatically redirect users to the SSO login with [PAPERLESS_REDIRECT_LOGIN_TO_SSO](#PAPERLESS_REDIRECT_LOGIN_TO_SSO)
|
||||||
|
|
||||||
Defaults to False
|
Defaults to False
|
||||||
|
|
||||||
|
#### [`PAPERLESS_REDIRECT_LOGIN_TO_SSO=<bool>`](#PAPERLESS_REDIRECT_LOGIN_TO_SSO) {#PAPERLESS_REDIRECT_LOGIN_TO_SSO}
|
||||||
|
|
||||||
|
: When this setting is enabled users will automatically be redirected (using javascript) to the first SSO provider login. You may still want to disable the frontend login form for clarity.
|
||||||
|
|
||||||
|
Defaults to False
|
||||||
|
|
||||||
|
#### [`PAPERLESS_ACCOUNT_SESSION_REMEMBER=<bool>`](#PAPERLESS_ACCOUNT_SESSION_REMEMBER) {#PAPERLESS_ACCOUNT_SESSION_REMEMBER}
|
||||||
|
|
||||||
|
: If false, sessions will expire at browser close, if true will use `PAPERLESS_SESSION_COOKIE_AGE` for expiration. See the corresponding
|
||||||
|
[django-allauth documentation](https://docs.allauth.org/en/latest/account/configuration.html)
|
||||||
|
|
||||||
|
Defaults to True
|
||||||
|
|
||||||
|
#### [`PAPERLESS_SESSION_COOKIE_AGE=<int>`](#PAPERLESS_SESSION_COOKIE_AGE) {#PAPERLESS_SESSION_COOKIE_AGE}
|
||||||
|
|
||||||
|
: Login session cookie expiration. Applies if `PAPERLESS_ACCOUNT_SESSION_REMEMBER` is enabled. See the corresponding
|
||||||
|
[django documentation](https://docs.djangoproject.com/en/5.1/ref/settings/#std-setting-SESSION_COOKIE_AGE)
|
||||||
|
|
||||||
|
Defaults to 1209600 (2 weeks)
|
||||||
|
|
||||||
## OCR settings {#ocr}
|
## OCR settings {#ocr}
|
||||||
|
|
||||||
Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/)
|
Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/)
|
||||||
@@ -610,6 +641,8 @@ parsing documents.
|
|||||||
Keep in mind that Tesseract uses much more CPU time with multiple
|
Keep in mind that Tesseract uses much more CPU time with multiple
|
||||||
languages enabled.
|
languages enabled.
|
||||||
|
|
||||||
|
If you are including languages that are not installed by default, you will need to also set [`PAPERLESS_OCR_LANGUAGES`](configuration.md#PAPERLESS_OCR_LANGUAGES) for docker deployments or install the tesseract language packages manually for bare metal installations.
|
||||||
|
|
||||||
Defaults to "eng".
|
Defaults to "eng".
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
@@ -1080,7 +1113,7 @@ document text will be checked as normal.
|
|||||||
|
|
||||||
: Paperless searches an entire document for dates. The first date
|
: Paperless searches an entire document for dates. The first date
|
||||||
found will be used as the initial value for the created date. When
|
found will be used as the initial value for the created date. When
|
||||||
this variable is greater than 0 (or left to it's default value),
|
this variable is greater than 0 (or left to its default value),
|
||||||
paperless will also suggest other dates found in the document, up to
|
paperless will also suggest other dates found in the document, up to
|
||||||
a maximum of this setting. Note that duplicates will be removed,
|
a maximum of this setting. Note that duplicates will be removed,
|
||||||
which can result in fewer dates displayed in the frontend than this
|
which can result in fewer dates displayed in the frontend than this
|
||||||
@@ -1105,11 +1138,11 @@ This font can be changed here.
|
|||||||
|
|
||||||
#### [`PAPERLESS_IGNORE_DATES=<string>`](#PAPERLESS_IGNORE_DATES) {#PAPERLESS_IGNORE_DATES}
|
#### [`PAPERLESS_IGNORE_DATES=<string>`](#PAPERLESS_IGNORE_DATES) {#PAPERLESS_IGNORE_DATES}
|
||||||
|
|
||||||
: Paperless parses a documents creation date from filename and file
|
: Paperless parses a document's creation date from filename and file
|
||||||
content. You may specify a comma separated list of dates that should
|
content. You may specify a comma separated list of dates that should
|
||||||
be ignored during this process. This is useful for special dates
|
be ignored during this process. This is useful for special dates
|
||||||
(like date of birth) that appear in documents regularly but are very
|
(like date of birth) that appear in documents regularly but are very
|
||||||
unlikely to be the documents creation date.
|
unlikely to be the document's creation date.
|
||||||
|
|
||||||
The date is parsed using the order specified in PAPERLESS_DATE_ORDER
|
The date is parsed using the order specified in PAPERLESS_DATE_ORDER
|
||||||
|
|
||||||
@@ -1125,6 +1158,12 @@ within your documents.
|
|||||||
second, and year last order. Characters D, M, or Y can be shuffled
|
second, and year last order. Characters D, M, or Y can be shuffled
|
||||||
to meet the required order.
|
to meet the required order.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_ENABLE_GPG_DECRYPTOR=<bool>`](#PAPERLESS_ENABLE_GPG_DECRYPTOR) {#PAPERLESS_ENABLE_GPG_DECRYPTOR}
|
||||||
|
|
||||||
|
: Enable or disable the GPG decryptor for encrypted emails. See [GPG Decryptor](advanced_usage.md#gpg-decryptor) for more information.
|
||||||
|
|
||||||
|
Defaults to false.
|
||||||
|
|
||||||
### Polling {#polling}
|
### Polling {#polling}
|
||||||
|
|
||||||
#### [`PAPERLESS_CONSUMER_POLLING=<num>`](#PAPERLESS_CONSUMER_POLLING) {#PAPERLESS_CONSUMER_POLLING}
|
#### [`PAPERLESS_CONSUMER_POLLING=<num>`](#PAPERLESS_CONSUMER_POLLING) {#PAPERLESS_CONSUMER_POLLING}
|
||||||
@@ -1168,6 +1207,48 @@ consumers working on the same file. Configure this to prevent that.
|
|||||||
|
|
||||||
Defaults to 0.5 seconds.
|
Defaults to 0.5 seconds.
|
||||||
|
|
||||||
|
## Incoming Mail {#incoming_mail}
|
||||||
|
|
||||||
|
### Email OAuth {#email_oauth}
|
||||||
|
|
||||||
|
#### [`PAPERLESS_OAUTH_CALLBACK_BASE_URL=<str>`](#PAPERLESS_OAUTH_CALLBACK_BASE_URL) {#PAPERLESS_OAUTH_CALLBACK_BASE_URL}
|
||||||
|
|
||||||
|
: The base URL for the OAuth callback. This is used to construct the full URL for the OAuth callback. This should be the URL that the Paperless instance is accessible at. If not set, defaults to the `PAPERLESS_URL` setting. At least one of these settings must be set to enable OAuth Email setup.
|
||||||
|
|
||||||
|
Defaults to none (thus will use [PAPERLESS_URL](#PAPERLESS_URL)).
|
||||||
|
|
||||||
|
#### [`PAPERLESS_GMAIL_OAUTH_CLIENT_ID=<str>`](#PAPERLESS_GMAIL_OAUTH_CLIENT_ID) {#PAPERLESS_GMAIL_OAUTH_CLIENT_ID}
|
||||||
|
|
||||||
|
: The OAuth client ID for Gmail. This is required for Gmail OAuth Email setup. See [OAuth Email Setup](usage.md#oauth-email-setup) for more information.
|
||||||
|
|
||||||
|
Defaults to none.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_GMAIL_OAUTH_CLIENT_SECRET=<str>`](#PAPERLESS_GMAIL_OAUTH_CLIENT_SECRET) {#PAPERLESS_GMAIL_OAUTH_CLIENT_SECRET}
|
||||||
|
|
||||||
|
: The OAuth client secret for Gmail. This is required for Gmail OAuth Email setup. See [OAuth Email Setup](usage.md#oauth-email-setup) for more information.
|
||||||
|
|
||||||
|
Defaults to none.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_OUTLOOK_OAUTH_CLIENT_ID=<str>`](#PAPERLESS_OUTLOOK_OAUTH_CLIENT_ID) {#PAPERLESS_OUTLOOK_OAUTH_CLIENT_ID}
|
||||||
|
|
||||||
|
: The OAuth client ID for Outlook. This is required for Outlook OAuth Email setup. See [OAuth Email Setup](usage.md#oauth-email-setup) for more information.
|
||||||
|
|
||||||
|
Defaults to none.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_OUTLOOK_OAUTH_CLIENT_SECRET=<str>`](#PAPERLESS_OUTLOOK_OAUTH_CLIENT_SECRET) {#PAPERLESS_OUTLOOK_OAUTH_CLIENT_SECRET}
|
||||||
|
|
||||||
|
: The OAuth client secret for Outlook. This is required for Outlook OAuth Email setup. See [OAuth Email Setup](usage.md#oauth-email-setup) for more information.
|
||||||
|
|
||||||
|
Defaults to none.
|
||||||
|
|
||||||
|
### Encrypted Emails {#encrypted_emails}
|
||||||
|
|
||||||
|
#### [`PAPERLESS_EMAIL_GNUPG_HOME=<str>`](#PAPERLESS_EMAIL_GNUPG_HOME) {#PAPERLESS_EMAIL_GNUPG_HOME}
|
||||||
|
|
||||||
|
: Optional, sets the `GNUPG_HOME` path to use with GPG decryptor for encrypted emails. See [GPG Decryptor](advanced_usage.md#gpg-decryptor) for more information. If not set, defaults to the default `GNUPG_HOME` path.
|
||||||
|
|
||||||
|
Defaults to <not set>.
|
||||||
|
|
||||||
## Barcodes {#barcodes}
|
## Barcodes {#barcodes}
|
||||||
|
|
||||||
#### [`PAPERLESS_CONSUMER_ENABLE_BARCODES=<bool>`](#PAPERLESS_CONSUMER_ENABLE_BARCODES) {#PAPERLESS_CONSUMER_ENABLE_BARCODES}
|
#### [`PAPERLESS_CONSUMER_ENABLE_BARCODES=<bool>`](#PAPERLESS_CONSUMER_ENABLE_BARCODES) {#PAPERLESS_CONSUMER_ENABLE_BARCODES}
|
||||||
@@ -1206,6 +1287,12 @@ change this.
|
|||||||
|
|
||||||
Defaults to "PATCHT"
|
Defaults to "PATCHT"
|
||||||
|
|
||||||
|
#### [`PAPERLESS_CONSUMER_BARCODE_RETAIN_SPLIT_PAGES=<bool>`](#PAPERLESS_CONSUMER_BARCODE_RETAIN_SPLIT_PAGES) {#PAPERLESS_CONSUMER_BARCODE_RETAIN_SPLIT_PAGES}
|
||||||
|
|
||||||
|
: If set to true, all pages that are split by a barcode (such as PATCHT) will be kept.
|
||||||
|
|
||||||
|
Defaults to false.
|
||||||
|
|
||||||
#### [`PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE=<bool>`](#PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE) {#PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE}
|
#### [`PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE=<bool>`](#PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE) {#PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE}
|
||||||
|
|
||||||
: Enables the detection of barcodes in the scanned document and
|
: Enables the detection of barcodes in the scanned document and
|
||||||
@@ -1236,7 +1323,7 @@ barcode.
|
|||||||
|
|
||||||
: Defines the upscale factor used in barcode detection.
|
: Defines the upscale factor used in barcode detection.
|
||||||
Improves the detection of small barcodes, i.e. with a value of 1.5 by
|
Improves the detection of small barcodes, i.e. with a value of 1.5 by
|
||||||
upscaling the document beforce the detection process. Upscaling will
|
upscaling the document before the detection process. Upscaling will
|
||||||
only take place if value is bigger than 1.0. Otherwise upscaling will
|
only take place if value is bigger than 1.0. Otherwise upscaling will
|
||||||
not be performed to save resources. Try using in combination with
|
not be performed to save resources. Try using in combination with
|
||||||
PAPERLESS_CONSUMER_BARCODE_DPI set to a value higher than default.
|
PAPERLESS_CONSUMER_BARCODE_DPI set to a value higher than default.
|
||||||
@@ -1253,6 +1340,15 @@ combination with PAPERLESS_CONSUMER_BARCODE_UPSCALE bigger than 1.0.
|
|||||||
|
|
||||||
Defaults to "300"
|
Defaults to "300"
|
||||||
|
|
||||||
|
#### [`PAPERLESS_CONSUMER_BARCODE_MAX_PAGES=<int>`](#PAPERLESS_CONSUMER_BARCODE_MAX_PAGES) {#PAPERLESS_CONSUMER_BARCODE_MAX_PAGES}
|
||||||
|
|
||||||
|
: Because barcode detection is a computationally-intensive operation, this setting
|
||||||
|
limits the detection of barcodes to a number of first pages. If your scanner has
|
||||||
|
a limit for the number of pages that can be scanned it would be sensible to set this
|
||||||
|
as the limit here.
|
||||||
|
|
||||||
|
Defaults to "0", allowing all pages to be checked for barcodes.
|
||||||
|
|
||||||
#### [`PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE=<bool>`](#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE) {#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE}
|
#### [`PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE=<bool>`](#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE) {#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE}
|
||||||
|
|
||||||
: Enables the detection of barcodes in the scanned document and
|
: Enables the detection of barcodes in the scanned document and
|
||||||
@@ -1270,7 +1366,7 @@ assigns or creates tags if a properly formatted barcode is detected.
|
|||||||
|
|
||||||
: Defines a dictionary of filter regex and substitute expressions.
|
: Defines a dictionary of filter regex and substitute expressions.
|
||||||
|
|
||||||
Syntax: {"<regex>": "<substitute>" [,...]]}
|
Syntax: `{"<regex>": "<substitute>" [,...]]}`
|
||||||
|
|
||||||
A barcode is considered for tagging if the barcode text matches
|
A barcode is considered for tagging if the barcode text matches
|
||||||
at least one of the provided <regex> pattern.
|
at least one of the provided <regex> pattern.
|
||||||
@@ -1282,20 +1378,20 @@ assigns or creates tags if a properly formatted barcode is detected.
|
|||||||
|
|
||||||
Defaults to:
|
Defaults to:
|
||||||
|
|
||||||
{"TAG:(.*)": "\\g<1>"} which defines
|
`{"TAG:(.*)": "\\g<1>"}` which defines
|
||||||
- a regex TAG:(.*) which includes barcodes beginning with TAG:
|
- a regex TAG:(.*) which includes barcodes beginning with TAG:
|
||||||
followed by any text that gets stored into match group #1 and
|
followed by any text that gets stored into match group #1 and
|
||||||
- a substitute \\g<1> that replaces the original barcode text
|
- a substitute `\\g<1>` that replaces the original barcode text
|
||||||
by the content in match group #1.
|
by the content in match group #1.
|
||||||
Consequently, the tag is the barcode text without its TAG: prefix.
|
Consequently, the tag is the barcode text without its TAG: prefix.
|
||||||
|
|
||||||
More examples:
|
More examples:
|
||||||
|
|
||||||
{"ASN12.*": "JOHN", "ASN13.*": "SMITH"} for example maps
|
`{"ASN12.*": "JOHN", "ASN13.*": "SMITH"}` for example maps
|
||||||
- ASN12nnnn barcodes to the tag JOHN and
|
- ASN12nnnn barcodes to the tag JOHN and
|
||||||
- ASN13nnnn barcodes to the tag SMITH.
|
- ASN13nnnn barcodes to the tag SMITH.
|
||||||
|
|
||||||
{"T-J": "JOHN", "T-S": "SMITH", "T-D": "DOE"} directly maps
|
`{"T-J": "JOHN", "T-S": "SMITH", "T-D": "DOE"}` directly maps
|
||||||
- T-J barcodes to the tag JOHN,
|
- T-J barcodes to the tag JOHN,
|
||||||
- T-S barcodes to the tag SMITH and
|
- T-S barcodes to the tag SMITH and
|
||||||
- T-D barcodes to the tag DOE.
|
- T-D barcodes to the tag DOE.
|
||||||
@@ -1306,11 +1402,9 @@ assigns or creates tags if a properly formatted barcode is detected.
|
|||||||
|
|
||||||
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}
|
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}
|
||||||
|
|
||||||
: Enables an audit trail for documents, document types, correspondents, and tags. Log entries can be viewed in the Django backend only.
|
: Enables the audit trail for documents, document types, correspondents, and tags.
|
||||||
|
|
||||||
!!! warning
|
Defaults to true.
|
||||||
|
|
||||||
Once enabled cannot be disabled
|
|
||||||
|
|
||||||
## Collate Double-Sided Documents {#collate}
|
## Collate Double-Sided Documents {#collate}
|
||||||
|
|
||||||
@@ -1350,6 +1444,20 @@ processing. This only has an effect if
|
|||||||
|
|
||||||
Defaults to false.
|
Defaults to false.
|
||||||
|
|
||||||
|
## Trash
|
||||||
|
|
||||||
|
#### [`PAPERLESS_EMPTY_TRASH_DELAY=<num>`](#PAPERLESS_EMPTY_TRASH_DELAY) {#PAPERLESS_EMPTY_TRASH_DELAY}
|
||||||
|
|
||||||
|
: Sets how long in days documents remain in the 'trash' before they are permanently deleted.
|
||||||
|
|
||||||
|
Defaults to 30 days, minimum of 1 day.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_EMPTY_TRASH_TASK_CRON=<cron expression>`](#PAPERLESS_EMPTY_TRASH_TASK_CRON) {#PAPERLESS_EMPTY_TRASH_TASK_CRON}
|
||||||
|
|
||||||
|
: Configures the schedule to empty the trash of expired deleted documents.
|
||||||
|
|
||||||
|
Defaults to `0 1 * * *`, once per day.
|
||||||
|
|
||||||
## Binaries
|
## Binaries
|
||||||
|
|
||||||
There are a few external software packages that Paperless expects to
|
There are a few external software packages that Paperless expects to
|
||||||
@@ -1449,7 +1557,7 @@ specified as "chi-tra".
|
|||||||
PAPERLESS_OCR_LANGUAGES=tur ces chi-tra
|
PAPERLESS_OCR_LANGUAGES=tur ces chi-tra
|
||||||
```
|
```
|
||||||
|
|
||||||
Make sure it's a space separated list when using several values.
|
Make sure it's a space-separated list when using several values.
|
||||||
|
|
||||||
To actually use these languages, also set the default OCR language
|
To actually use these languages, also set the default OCR language
|
||||||
of paperless:
|
of paperless:
|
||||||
@@ -1480,7 +1588,7 @@ started by the container.
|
|||||||
|
|
||||||
## Frontend Settings
|
## Frontend Settings
|
||||||
|
|
||||||
#### [`PAPERLESS_APP_TITLE=<bool>`](#PAPERLESS_APP_TITLE) {#PAPERLESS_APP_TITLE}
|
#### [`PAPERLESS_APP_TITLE=<str>`](#PAPERLESS_APP_TITLE) {#PAPERLESS_APP_TITLE}
|
||||||
|
|
||||||
: If set, overrides the default name "Paperless-ngx"
|
: If set, overrides the default name "Paperless-ngx"
|
||||||
|
|
||||||
|
@@ -47,7 +47,7 @@ early on.
|
|||||||
Once installed, hooks will run when you commit. If the formatting isn't
|
Once installed, hooks will run when you commit. If the formatting isn't
|
||||||
quite right or a linter catches something, the commit will be rejected.
|
quite right or a linter catches something, the commit will be rejected.
|
||||||
You'll need to look at the output and fix the issue. Some hooks, such
|
You'll need to look at the output and fix the issue. Some hooks, such
|
||||||
as the Python formatting tool `black`, will format failing
|
as the Python linting and formatting tool `ruff`, will format failing
|
||||||
files, so all you need to do is `git add` those files again
|
files, so all you need to do is `git add` those files again
|
||||||
and retry your commit.
|
and retry your commit.
|
||||||
|
|
||||||
@@ -81,10 +81,6 @@ first-time setup.
|
|||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
Using a virtual environment is highly recommended. You can spawn one via `pipenv shell`.
|
Using a virtual environment is highly recommended. You can spawn one via `pipenv shell`.
|
||||||
Make sure you're using Python 3.10.x or lower. Otherwise you might
|
|
||||||
get issues with building dependencies. You can use
|
|
||||||
[pyenv](https://github.com/pyenv/pyenv) to install a specific
|
|
||||||
Python version.
|
|
||||||
|
|
||||||
5. Install pre-commit hooks:
|
5. Install pre-commit hooks:
|
||||||
|
|
||||||
@@ -364,10 +360,10 @@ If you want to build the documentation locally, this is how you do it:
|
|||||||
The docker image is primarily built by the GitHub actions workflow, but
|
The docker image is primarily built by the GitHub actions workflow, but
|
||||||
it can be faster when developing to build and tag an image locally.
|
it can be faster when developing to build and tag an image locally.
|
||||||
|
|
||||||
Building the image works as with any image:
|
Make sure you have the `docker-buildx` package installed. Building the image works as with any image:
|
||||||
|
|
||||||
```
|
```
|
||||||
docker build --file Dockerfile --tag paperless:local --progress simple .
|
docker build --file Dockerfile --tag paperless:local .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Extending Paperless-ngx
|
## Extending Paperless-ngx
|
||||||
|
@@ -132,3 +132,11 @@ Multiple options for ASGI servers exist:
|
|||||||
- `daphne` as a standalone server, which is the reference
|
- `daphne` as a standalone server, which is the reference
|
||||||
implementation for ASGI.
|
implementation for ASGI.
|
||||||
- `uvicorn` as a standalone server
|
- `uvicorn` as a standalone server
|
||||||
|
|
||||||
|
## _What about the Redis licensing change and using one of the open source forks_?
|
||||||
|
|
||||||
|
Currently (October 2024), forks of Redis such as Valkey or Redirect are not officially supported by our upstream
|
||||||
|
libraries, so using one of these to replace Redis is not officially supported.
|
||||||
|
|
||||||
|
However, they do claim to be compatible with the Redis protocol and will likely work, but we will
|
||||||
|
not be updating from using Redis as the broker officially just yet.
|
||||||
|
157
docs/setup.md
157
docs/setup.md
@@ -6,6 +6,7 @@ You can go multiple routes to setup and run Paperless:
|
|||||||
- [Pull the image from Docker Hub](#docker_hub)
|
- [Pull the image from Docker Hub](#docker_hub)
|
||||||
- [Build the Docker image yourself](#docker_build)
|
- [Build the Docker image yourself](#docker_build)
|
||||||
- [Install Paperless directly on your system manually (bare metal)](#bare_metal)
|
- [Install Paperless directly on your system manually (bare metal)](#bare_metal)
|
||||||
|
- A user-maintained list of commercial hosting providers can be found [in the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects)
|
||||||
|
|
||||||
The Docker routes are quick & easy. These are the recommended routes.
|
The Docker routes are quick & easy. These are the recommended routes.
|
||||||
This configures all the stuff from the above automatically so that it
|
This configures all the stuff from the above automatically so that it
|
||||||
@@ -249,9 +250,14 @@ a minimal installation of Debian/Buster, which is the current stable
|
|||||||
release at the time of writing. Windows is not and will never be
|
release at the time of writing. Windows is not and will never be
|
||||||
supported.
|
supported.
|
||||||
|
|
||||||
|
Paperless requires Python 3. At this time, 3.10 - 3.12 are tested versions.
|
||||||
|
Newer versions may work, but some dependencies may not fully support newer versions.
|
||||||
|
Support for older Python versions may be dropped as they reach end of life or as newer versions
|
||||||
|
are released, dependency support is confirmed, etc.
|
||||||
|
|
||||||
1. Install dependencies. Paperless requires the following packages.
|
1. Install dependencies. Paperless requires the following packages.
|
||||||
|
|
||||||
- `python3` - 3.9 - 3.11 are supported
|
- `python3`
|
||||||
- `python3-pip`
|
- `python3-pip`
|
||||||
- `python3-dev`
|
- `python3-dev`
|
||||||
- `default-libmysqlclient-dev` for MariaDB
|
- `default-libmysqlclient-dev` for MariaDB
|
||||||
@@ -263,14 +269,13 @@ supported.
|
|||||||
- `libpq-dev` for PostgreSQL
|
- `libpq-dev` for PostgreSQL
|
||||||
- `libmagic-dev` for mime type detection
|
- `libmagic-dev` for mime type detection
|
||||||
- `mariadb-client` for MariaDB compile time
|
- `mariadb-client` for MariaDB compile time
|
||||||
- `mime-support` for mime type detection
|
|
||||||
- `libzbar0` for barcode detection
|
- `libzbar0` for barcode detection
|
||||||
- `poppler-utils` for barcode detection
|
- `poppler-utils` for barcode detection
|
||||||
|
|
||||||
Use this list for your preferred package management:
|
Use this list for your preferred package management:
|
||||||
|
|
||||||
```
|
```
|
||||||
python3 python3-pip python3-dev imagemagick fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev mime-support libzbar0 poppler-utils
|
python3 python3-pip python3-dev imagemagick fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev libzbar0 poppler-utils
|
||||||
```
|
```
|
||||||
|
|
||||||
These dependencies are required for OCRmyPDF, which is used for text
|
These dependencies are required for OCRmyPDF, which is used for text
|
||||||
@@ -298,9 +303,19 @@ supported.
|
|||||||
|
|
||||||
- `libatlas-base-dev`
|
- `libatlas-base-dev`
|
||||||
- `libxslt1-dev`
|
- `libxslt1-dev`
|
||||||
|
- `mime-support`
|
||||||
|
|
||||||
You will also need `build-essential`, `python3-setuptools` and
|
You will also need these for installing some of the python dependencies:
|
||||||
`python3-wheel` for installing some of the python dependencies.
|
|
||||||
|
- `build-essential`
|
||||||
|
- `python3-setuptools`
|
||||||
|
- `python3-wheel`
|
||||||
|
|
||||||
|
Use this list for your preferred package management:
|
||||||
|
|
||||||
|
```
|
||||||
|
build-essential python3-setuptools python3-wheel
|
||||||
|
```
|
||||||
|
|
||||||
2. Install `redis` >= 6.0 and configure it to start automatically.
|
2. Install `redis` >= 6.0 and configure it to start automatically.
|
||||||
|
|
||||||
@@ -400,8 +415,7 @@ supported.
|
|||||||
sudo chown paperless:paperless /opt/paperless/consume
|
sudo chown paperless:paperless /opt/paperless/consume
|
||||||
```
|
```
|
||||||
|
|
||||||
8. Install python requirements from the `requirements.txt` file. It is
|
8. Install python requirements from the `requirements.txt` file.
|
||||||
up to you if you wish to use a virtual environment or not. First you should update your pip, so it gets the actual packages.
|
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
sudo -Hu paperless pip3 install -r requirements.txt
|
sudo -Hu paperless pip3 install -r requirements.txt
|
||||||
@@ -410,6 +424,12 @@ supported.
|
|||||||
This will install all python dependencies in the home directory of
|
This will install all python dependencies in the home directory of
|
||||||
the new paperless user.
|
the new paperless user.
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
|
||||||
|
It is up to you if you wish to use a virtual environment or not for the Python
|
||||||
|
dependencies. This is an alternative to the above and may require adjusting
|
||||||
|
the example scripts to utilize the virtual environment paths
|
||||||
|
|
||||||
9. Go to `/opt/paperless/src`, and execute the following commands:
|
9. Go to `/opt/paperless/src`, and execute the following commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -520,8 +540,7 @@ supported.
|
|||||||
15. Optional: If using the NLTK machine learning processing (see
|
15. Optional: If using the NLTK machine learning processing (see
|
||||||
[`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) for details),
|
[`PAPERLESS_ENABLE_NLTK`](configuration.md#PAPERLESS_ENABLE_NLTK) for details),
|
||||||
download the NLTK data for the Snowball
|
download the NLTK data for the Snowball
|
||||||
Stemmer, Stopwords and Punkt tokenizer to your
|
Stemmer, Stopwords and Punkt tokenizer to `/usr/share/nltk_data`. Refer to the [NLTK
|
||||||
`PAPERLESS_DATA_DIR/nltk`. Refer to the [NLTK
|
|
||||||
instructions](https://www.nltk.org/data.html) for details on how to
|
instructions](https://www.nltk.org/data.html) for details on how to
|
||||||
download the data.
|
download the data.
|
||||||
|
|
||||||
@@ -666,24 +685,37 @@ commands as well.
|
|||||||
1. Stop and remove the paperless container
|
1. Stop and remove the paperless container
|
||||||
2. If using an external database, stop the container
|
2. If using an external database, stop the container
|
||||||
3. Update Redis configuration
|
3. Update Redis configuration
|
||||||
a) If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
|
|
||||||
|
1. If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
|
||||||
and continue to step 4.
|
and continue to step 4.
|
||||||
b) Otherwise, in the `docker-compose.yml` add a new service for
|
|
||||||
|
1. Otherwise, in the `docker-compose.yml` add a new service for
|
||||||
Redis, following [the example compose
|
Redis, following [the example compose
|
||||||
files](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose)
|
files](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose)
|
||||||
c) Set the environment variable [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) so it points to
|
|
||||||
|
1. Set the environment variable [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) so it points to
|
||||||
the new Redis container
|
the new Redis container
|
||||||
|
|
||||||
4. Update user mapping
|
4. Update user mapping
|
||||||
a) If set, change the environment variable `PUID` to `USERMAP_UID`
|
|
||||||
b) If set, change the environment variable `PGID` to `USERMAP_GID`
|
1. If set, change the environment variable `PUID` to `USERMAP_UID`
|
||||||
|
|
||||||
|
1. If set, change the environment variable `PGID` to `USERMAP_GID`
|
||||||
|
|
||||||
5. Update configuration paths
|
5. Update configuration paths
|
||||||
a) Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`
|
|
||||||
|
1. Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`
|
||||||
|
|
||||||
6. Update media paths
|
6. Update media paths
|
||||||
a) Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to
|
|
||||||
|
1. Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to
|
||||||
`/data/media`
|
`/data/media`
|
||||||
|
|
||||||
7. Update timezone
|
7. Update timezone
|
||||||
a) Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same
|
|
||||||
|
1. Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same
|
||||||
value as `TZ`
|
value as `TZ`
|
||||||
|
|
||||||
8. Modify the `image:` to point to
|
8. Modify the `image:` to point to
|
||||||
`ghcr.io/paperless-ngx/paperless-ngx:latest` or a specific version
|
`ghcr.io/paperless-ngx/paperless-ngx:latest` or a specific version
|
||||||
if preferred.
|
if preferred.
|
||||||
@@ -691,95 +723,8 @@ commands as well.
|
|||||||
|
|
||||||
## Moving data from SQLite to PostgreSQL or MySQL/MariaDB {#sqlite_to_psql}
|
## Moving data from SQLite to PostgreSQL or MySQL/MariaDB {#sqlite_to_psql}
|
||||||
|
|
||||||
Moving your data from SQLite to PostgreSQL or MySQL/MariaDB is done via
|
The best way to migrate between database types is to perform an [export](administration.md#exporter) and then
|
||||||
executing a series of django management commands as below. The commands
|
[import](administration.md#importer) into a clean installation of Paperless-ngx.
|
||||||
below use PostgreSQL, but are applicable to MySQL/MariaDB with the
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
|
|
||||||
Make sure that your SQLite database is migrated to the latest version.
|
|
||||||
Starting paperless will make sure that this is the case. If your try to
|
|
||||||
load data from an old database schema in SQLite into a newer database
|
|
||||||
schema in PostgreSQL, you will run into trouble.
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
|
|
||||||
On some database fields, PostgreSQL enforces predefined limits on
|
|
||||||
maximum length, whereas SQLite does not. The fields in question are the
|
|
||||||
title of documents (128 characters), names of document types, tags and
|
|
||||||
correspondents (128 characters), and filenames (1024 characters). If you
|
|
||||||
have data in these fields that surpasses these limits, migration to
|
|
||||||
PostgreSQL is not possible and will fail with an error.
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
|
|
||||||
MySQL is case insensitive by default, treating values like "Name" and
|
|
||||||
"NAME" as identical. See [MySQL caveats](advanced_usage.md#mysql-caveats) for details.
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
|
|
||||||
MySQL also enforces limits on maximum lengths, but does so differently than
|
|
||||||
PostgreSQL. It may not be possible to migrate to MySQL due to this.
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
|
|
||||||
Using mariadb version 10.4+ is recommended. Using the `utf8mb3` character set on
|
|
||||||
an older system may fix issues that can arise while setting up Paperless-ngx but
|
|
||||||
`utf8mb3` can cause issues with consumption (where `utf8mb4` does not).
|
|
||||||
|
|
||||||
1. Stop paperless, if it is running.
|
|
||||||
|
|
||||||
2. Tell paperless to use PostgreSQL:
|
|
||||||
|
|
||||||
a) With docker, copy the provided `docker-compose.postgres.yml`
|
|
||||||
file to `docker-compose.yml`. Remember to adjust the consumption
|
|
||||||
directory, if necessary.
|
|
||||||
b) Without docker, configure the database in your `paperless.conf`
|
|
||||||
file. See [configuration](configuration.md) for
|
|
||||||
details.
|
|
||||||
|
|
||||||
3. Open a shell and initialize the database:
|
|
||||||
|
|
||||||
a) With docker, run the following command to open a shell within
|
|
||||||
the paperless container:
|
|
||||||
|
|
||||||
``` shell-session
|
|
||||||
$ cd /path/to/paperless
|
|
||||||
$ docker compose run --rm webserver /bin/bash
|
|
||||||
```
|
|
||||||
|
|
||||||
This will launch the container and initialize the PostgreSQL
|
|
||||||
database.
|
|
||||||
|
|
||||||
b) Without docker, remember to activate any virtual environment,
|
|
||||||
switch to the `src` directory and create the database schema:
|
|
||||||
|
|
||||||
``` shell-session
|
|
||||||
$ cd /path/to/paperless/src
|
|
||||||
$ python3 manage.py migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
This will not copy any data yet.
|
|
||||||
|
|
||||||
4. Dump your data from SQLite:
|
|
||||||
|
|
||||||
```shell-session
|
|
||||||
$ python3 manage.py dumpdata --database=sqlite --exclude=contenttypes --exclude=auth.Permission > data.json
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Load your data into PostgreSQL:
|
|
||||||
|
|
||||||
```shell-session
|
|
||||||
$ python3 manage.py loaddata data.json
|
|
||||||
```
|
|
||||||
|
|
||||||
6. If operating inside Docker, you may exit the shell now.
|
|
||||||
|
|
||||||
```shell-session
|
|
||||||
$ exit
|
|
||||||
```
|
|
||||||
|
|
||||||
7. Start paperless.
|
|
||||||
|
|
||||||
## Moving back to Paperless
|
## Moving back to Paperless
|
||||||
|
|
||||||
|
@@ -353,6 +353,20 @@ ways from the original. As the logs indicate, if you encounter this error you ca
|
|||||||
`PAPERLESS_OCR_USER_ARGS: '{"continue_on_soft_render_error": true}'` to try to 'force'
|
`PAPERLESS_OCR_USER_ARGS: '{"continue_on_soft_render_error": true}'` to try to 'force'
|
||||||
processing documents with this issue.
|
processing documents with this issue.
|
||||||
|
|
||||||
|
## Logs show "possible incompatible database column" when deleting documents {#convert-uuid-field}
|
||||||
|
|
||||||
|
You may see errors when deleting documents like:
|
||||||
|
|
||||||
|
```
|
||||||
|
Data too long for column 'transaction_id' at row 1
|
||||||
|
```
|
||||||
|
|
||||||
|
This error can occur in installations which have upgraded from a version of Paperless-ngx that used Django 4 (Paperless-ngx versions prior to v2.13.0) with a MariaDB/MySQL database. Due to the backawards-incompatible change in Django 5, the column "documents_document.transaction_id" will need to be re-created, which can be done with a one-time run of the following management command:
|
||||||
|
|
||||||
|
```shell-session
|
||||||
|
$ python3 manage.py convert_mariadb_uuid
|
||||||
|
```
|
||||||
|
|
||||||
## Platform-Specific Deployment Troubleshooting
|
## Platform-Specific Deployment Troubleshooting
|
||||||
|
|
||||||
A user-maintained wiki page is available to help troubleshoot issues that may arise when trying to deploy Paperless-ngx on specific platforms, for example SELinux. Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Platform%E2%80%90Specific-Troubleshooting).
|
A user-maintained wiki page is available to help troubleshoot issues that may arise when trying to deploy Paperless-ngx on specific platforms, for example SELinux. Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Platform%E2%80%90Specific-Troubleshooting).
|
||||||
|
151
docs/usage.md
151
docs/usage.md
@@ -109,10 +109,10 @@ process.
|
|||||||
|
|
||||||
### Mobile upload {#usage-mobile_upload}
|
### Mobile upload {#usage-mobile_upload}
|
||||||
|
|
||||||
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Affiliated-Projects) for a user-maintained list of affiliated projects and
|
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects) for a user-maintained list of related projects and
|
||||||
software (e.g. for mobile devices) that is compatible with Paperless-ngx.
|
software (e.g. for mobile devices) that is compatible with Paperless-ngx.
|
||||||
|
|
||||||
### IMAP (Email) {#usage-email}
|
### Email {#usage-email}
|
||||||
|
|
||||||
You can tell paperless-ngx to consume documents from your email
|
You can tell paperless-ngx to consume documents from your email
|
||||||
accounts. This is a very flexible and powerful feature, if you regularly
|
accounts. This is a very flexible and powerful feature, if you regularly
|
||||||
@@ -136,7 +136,8 @@ These rules perform the following:
|
|||||||
|
|
||||||
Paperless will check all emails only once and completely ignore messages
|
Paperless will check all emails only once and completely ignore messages
|
||||||
that do not match your filters. It will also only perform the rule action
|
that do not match your filters. It will also only perform the rule action
|
||||||
on e-mails that it has consumed documents from.
|
on e-mails that it has consumed documents from. The filename attachment
|
||||||
|
patterns can include wildcards and multiple patterns separated by a comma.
|
||||||
|
|
||||||
The actions all ensure that the same mail is not consumed twice by
|
The actions all ensure that the same mail is not consumed twice by
|
||||||
different means. These are as follows:
|
different means. These are as follows:
|
||||||
@@ -199,6 +200,14 @@ different means. These are as follows:
|
|||||||
Paperless is set up to check your mails every 10 minutes. This can be
|
Paperless is set up to check your mails every 10 minutes. This can be
|
||||||
configured via [`PAPERLESS_EMAIL_TASK_CRON`](configuration.md#PAPERLESS_EMAIL_TASK_CRON)
|
configured via [`PAPERLESS_EMAIL_TASK_CRON`](configuration.md#PAPERLESS_EMAIL_TASK_CRON)
|
||||||
|
|
||||||
|
#### OAuth Email Setup
|
||||||
|
|
||||||
|
Paperless-ngx supports OAuth2 authentication for Gmail and Outlook email accounts. To set up an email account with OAuth2, you will need to create a 'developer' app with the respective provider and obtain the client ID and client secret and set the appropriate [configuration variables](configuration.md#email_oauth). You will also need to set either [`PAPERLESS_OAUTH_CALLBACK_BASE_URL`](configuration.md#PAPERLESS_OAUTH_CALLBACK_BASE_URL) or [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) to the correct value for the OAuth2 flow to work correctly.
|
||||||
|
|
||||||
|
Specific instructions for setting up the required 'developer' app with Google or Microsoft are beyond the scope of this documentation, but you can find user-maintained instructions in [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Email-OAuth-App-Setup) or by searching the web.
|
||||||
|
|
||||||
|
Once setup, navigating to the email settings page in Paperless-ngx will allow you to add an email account for Gmail or Outlook using OAuth2. After authenticating, you will be presented with the newly-created account where you will need to enter and save your email address. After this, the account will work as any other email account in Paperless-ngx and refreshing tokens will be handled automatically.
|
||||||
|
|
||||||
### REST API
|
### REST API
|
||||||
|
|
||||||
You can also submit a document using the REST API, see [POSTing documents](api.md#file-uploads)
|
You can also submit a document using the REST API, see [POSTing documents](api.md#file-uploads)
|
||||||
@@ -206,12 +215,12 @@ for details.
|
|||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
As of version 1.14.0 Paperless-ngx added core support for user / group permissions. Permissions is
|
Permissions in Paperless-ngx are based around ['global' permissions](#global-permissions) as well as
|
||||||
based around 'global' permissions as well as 'object-level' permissions. Global permissions designate
|
['object-level' permissions](#object-permissions). Global permissions determine which parts of the
|
||||||
which parts of the application a user can access (e.g. Documents, Tags, Settings) and object-level
|
application a user can access (e.g. Documents, Tags, Settings) and object-level determine which
|
||||||
determine which objects are visible or editable. All objects have an 'owner' and 'view' and 'edit'
|
objects are visible or editable. All objects have an 'owner' and 'view' and 'edit' permissions which
|
||||||
permissions which can be granted to other users or groups. The paperless-ngx permissions system uses
|
can be granted to other users or groups. The paperless-ngx permissions system uses the built-in user
|
||||||
the built-in user model of the backend framework, Django.
|
model of the backend framework, Django.
|
||||||
|
|
||||||
!!! tip
|
!!! tip
|
||||||
|
|
||||||
@@ -219,37 +228,71 @@ the built-in user model of the backend framework, Django.
|
|||||||
for a Tag will _not_ affect the permissions of documents that have the Tag.
|
for a Tag will _not_ affect the permissions of documents that have the Tag.
|
||||||
|
|
||||||
Permissions can be set using the new "Permissions" tab when editing documents, or bulk-applied
|
Permissions can be set using the new "Permissions" tab when editing documents, or bulk-applied
|
||||||
in the UI by selecting documents and choosing the "Permissions" button. Owner can also optionally
|
in the UI by selecting documents and choosing the "Permissions" button.
|
||||||
be set for documents uploaded via the API. Documents consumed via the consumption dir currently
|
|
||||||
do not have an owner set.
|
|
||||||
|
|
||||||
!!! note
|
|
||||||
|
|
||||||
After migration to version 1.14.0 all existing documents, tags etc. will have no explicit owner
|
|
||||||
set which means they will be visible / editable by all users. Once an object has an owner set,
|
|
||||||
only the owner can explicitly grant / revoke permissions.
|
|
||||||
|
|
||||||
!!! note
|
|
||||||
|
|
||||||
When first migrating to permissions it is recommended to use a 'superuser' account (which
|
|
||||||
would usually have been setup during installation) to ensure you have full permissions.
|
|
||||||
|
|
||||||
Note that superusers have access to all objects.
|
|
||||||
|
|
||||||
### Default permissions
|
### Default permissions
|
||||||
|
|
||||||
Default permissions for documents can be set using workflows.
|
[Workflows](#workflows) provide advanced ways to control permissions.
|
||||||
|
|
||||||
For objects created via the web UI (tags, doc types, etc.) the default is to set the current user
|
For objects created via the web UI (tags, doc types, etc.) the default is to set the current user
|
||||||
as owner and no extra permissions, but you explicitly set these under Settings > Permissions.
|
as owner and no extra permissions, but you can explicitly set these under Settings > Permissions.
|
||||||
|
|
||||||
|
Documents consumed via the consumption directory do not have an owner or additional permissions set by default, but again, can be controlled with [Workflows](#workflows).
|
||||||
|
|
||||||
### Users and Groups
|
### Users and Groups
|
||||||
|
|
||||||
Paperless-ngx versions after 1.14.0 allow creating and editing users and groups via the 'frontend' UI.
|
Paperless-ngx supports editing users and groups via the 'frontend' UI, which can be found under
|
||||||
These can be found under Settings > Users & Groups, assuming the user has access. If a user is designated
|
Settings > Users & Groups, assuming the user has access. If a user is designated
|
||||||
as a member of a group those permissions will be inherited and this is reflected in the UI. Explicit
|
as a member of a group those permissions will be inherited and this is reflected in the UI. Explicit
|
||||||
permissions can be granted to limit access to certain parts of the UI (and corresponding API endpoints).
|
permissions can be granted to limit access to certain parts of the UI (and corresponding API endpoints).
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
|
||||||
|
By default, new users are not granted any permissions, except those inherited from any group(s) of which they are a member.
|
||||||
|
|
||||||
|
#### Superusers
|
||||||
|
|
||||||
|
Superusers can access all parts of the front and backend application as well as any and all objects.
|
||||||
|
|
||||||
|
#### Admin Status
|
||||||
|
|
||||||
|
Admin status (Django 'staff status') grants access to viewing the paperless logs and the system status dialog
|
||||||
|
as well as accessing the Django backend.
|
||||||
|
|
||||||
|
#### Detailed Explanation of Global Permissions {#global-permissions}
|
||||||
|
|
||||||
|
Global permissions define what areas of the app and API endpoints users can access. For example, they
|
||||||
|
determine if a user can create, edit, delete or view _any_ documents, but individual documents themselves
|
||||||
|
still have "object-level" permissions.
|
||||||
|
|
||||||
|
| Type | Details |
|
||||||
|
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. |
|
||||||
|
| Correspondent | Add, edit, delete or view Correspondents. |
|
||||||
|
| CustomField | Add, edit, delete or view Custom Fields. |
|
||||||
|
| Document | Add, edit, delete or view Documents. |
|
||||||
|
| DocumentType | Add, edit, delete or view Document Types. |
|
||||||
|
| Group | Add, edit, delete or view Groups. |
|
||||||
|
| MailAccount | Add, edit, delete or view Mail Accounts. |
|
||||||
|
| MailRule | Add, edit, delete or view Mail Rules. |
|
||||||
|
| Note | Add, edit, delete or view Notes. |
|
||||||
|
| PaperlessTask | View or dismiss (_Change_) File Tasks. |
|
||||||
|
| SavedView | Add, edit, delete or view Saved Views. |
|
||||||
|
| ShareLink | Add, delete or view Share Links. |
|
||||||
|
| StoragePath | Add, edit, delete or view Storage Paths. |
|
||||||
|
| Tag | Add, edit, delete or view Tags. |
|
||||||
|
| UISettings | Add, edit, delete or view the UI settings that are used by the web app.<br/>:warning: **Users that will access the web UI must be granted at least _View_ permissions.** |
|
||||||
|
| User | Add, edit, delete or view Users. |
|
||||||
|
| Workflow | Add, edit, delete or view Workflows.<br/>Note that Workflows are global, in other words all users who can access workflows have access to the same set of them. |
|
||||||
|
|
||||||
|
#### Detailed Explanation of Object Permissions {#object-permissions}
|
||||||
|
|
||||||
|
| Type | Details |
|
||||||
|
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Owner | By default objects are only visible and editable by their owner.<br/>Only the object owner can grant permissions to other users or groups.<br/>Additionally, only document owners can create share links and add / remove custom fields.<br/>For backwards compatibility objects can have no owner which makes them visible to any user. |
|
||||||
|
| View | Confers the ability to view (not edit) a document, tag, etc.<br/>Users without 'view' (or higher) permissions will be shown _'Private'_ in place of the object name for example when viewing a document with a tag for which the user doesn't have permissions. |
|
||||||
|
| Edit | Confers the ability to edit (and view) a document, tag, etc. |
|
||||||
|
|
||||||
### Password reset
|
### Password reset
|
||||||
|
|
||||||
In order to enable the password reset feature you will need to setup an SMTP backend, see
|
In order to enable the password reset feature you will need to setup an SMTP backend, see
|
||||||
@@ -391,13 +434,12 @@ to optionally attach data to documents which does not fit in the existing set of
|
|||||||
Paperless-ngx provides.
|
Paperless-ngx provides.
|
||||||
|
|
||||||
1. First, create a custom field (under "Manage"), with a given name and data type. This could be something like "Invoice Number" or "Date Paid", with a data type of "Number", "Date", "String", etc.
|
1. First, create a custom field (under "Manage"), with a given name and data type. This could be something like "Invoice Number" or "Date Paid", with a data type of "Number", "Date", "String", etc.
|
||||||
2. Once created, a field can be used with documents and data stored. To do so, use the "Custom Fields" menu on the document detail page, choose your existing field and click "Add". Once the field is visible in the form you can enter the appropriate
|
2. Once created, a field can be used with documents and data stored. To do so, use the "Custom Fields" menu on the document detail page, choose your existing field from the dropdown. Once the field is visible in the form you can enter the appropriate data which will be validated according to the custom field "data type".
|
||||||
data which will be validated according to the custom field "data type".
|
|
||||||
3. Fields can be removed by hovering over the field name revealing a "Remove" button.
|
3. Fields can be removed by hovering over the field name revealing a "Remove" button.
|
||||||
|
|
||||||
!!! important
|
!!! important
|
||||||
|
|
||||||
Added / removed fields, as well as any data is not saved to the document until you
|
Added / removed fields, as well as any data, is not saved to the document until you
|
||||||
actually hit the "Save" button, similar to other changes on the document details page.
|
actually hit the "Save" button, similar to other changes on the document details page.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
@@ -416,6 +458,7 @@ The following custom field types are supported:
|
|||||||
- `Number`: float number e.g. 12.3456
|
- `Number`: float number e.g. 12.3456
|
||||||
- `Monetary`: [ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes) and a number with exactly two decimals, e.g. USD12.30
|
- `Monetary`: [ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes) and a number with exactly two decimals, e.g. USD12.30
|
||||||
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
|
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
|
||||||
|
- `Select`: a pre-defined list of strings from which the user can choose
|
||||||
|
|
||||||
## Share Links
|
## Share Links
|
||||||
|
|
||||||
@@ -430,6 +473,34 @@ Paperless-ngx added the ability to create shareable links to files in version 2.
|
|||||||
|
|
||||||
If your paperless-ngx instance is behind a reverse-proxy you may want to create an exception to bypass any authentication layers that are part of your setup in order to make links truly publicly-accessible. Of course, do so with caution.
|
If your paperless-ngx instance is behind a reverse-proxy you may want to create an exception to bypass any authentication layers that are part of your setup in order to make links truly publicly-accessible. Of course, do so with caution.
|
||||||
|
|
||||||
|
## PDF Actions
|
||||||
|
|
||||||
|
Paperless-ngx supports four basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files):
|
||||||
|
|
||||||
|
- Merging documents: available when selecting multiple documents for 'bulk editing'.
|
||||||
|
- Rotating documents: available when selecting multiple documents for 'bulk editing' and from an individual document's details page.
|
||||||
|
- Splitting documents: available from an individual document's details page.
|
||||||
|
- Deleting pages: available from an individual document's details page.
|
||||||
|
|
||||||
|
!!! important
|
||||||
|
|
||||||
|
Note that rotation and deleting pages alter the Paperless-ngx _original_ file, which would, for example, invalidate a digital signature.
|
||||||
|
|
||||||
|
## Document History
|
||||||
|
|
||||||
|
As of version 2.7, Paperless-ngx automatically records all changes to a document and records this in an audit log. The feature requires [`PAPERLESS_AUDIT_LOG_ENABLED`](configuration.md#PAPERLESS_AUDIT_LOG_ENABLED) be enabled, which it is by default as of version 2.7.
|
||||||
|
Changes to documents are visible under the "History" tab. Note that certain changes such as those made by workflows, record the 'actor'
|
||||||
|
as "System".
|
||||||
|
|
||||||
|
## Document Trash
|
||||||
|
|
||||||
|
When you first delete a document it is moved to the 'trash' until either it is explicitly deleted or it is automatically removed after a set amount of time has passed.
|
||||||
|
You can set how long documents remain in the trash before being automatically deleted with [`PAPERLESS_EMPTY_TRASH_DELAY`](configuration.md#PAPERLESS_EMPTY_TRASH_DELAY), which defaults
|
||||||
|
to 30 days. Until the file is actually deleted (e.g. the trash is emptied), all files and database content remains intact and can be restored at any point up until that time.
|
||||||
|
|
||||||
|
Additionally you may configure a directory where deleted files are moved to when they the trash is emptied with [`PAPERLESS_EMPTY_TRASH_DIR`](configuration.md#PAPERLESS_EMPTY_TRASH_DIR).
|
||||||
|
Note that the empty trash directory only stores the original file, the archive file and all database information is permanently removed once a document is fully deleted.
|
||||||
|
|
||||||
## Best practices {#basic-searching}
|
## Best practices {#basic-searching}
|
||||||
|
|
||||||
Paperless offers a couple tools that help you organize your document
|
Paperless offers a couple tools that help you organize your document
|
||||||
@@ -502,6 +573,16 @@ collection.
|
|||||||
|
|
||||||
## Searching {#basic-usage_searching}
|
## Searching {#basic-usage_searching}
|
||||||
|
|
||||||
|
### Global search
|
||||||
|
|
||||||
|
The top search bar in the web UI performs a "global" search of the various
|
||||||
|
objects Paperless-ngx uses, including documents, tags, workflows, etc. Only
|
||||||
|
objects for which the user has appropriate permissions are returned. For
|
||||||
|
documents, if there are < 3 results, "advanced" search results (which use
|
||||||
|
the document index) will also be included. This can be disabled under settings.
|
||||||
|
|
||||||
|
### Document searches
|
||||||
|
|
||||||
Paperless offers an extensive searching mechanism that is designed to
|
Paperless offers an extensive searching mechanism that is designed to
|
||||||
allow you to quickly find a document you're looking for (for example,
|
allow you to quickly find a document you're looking for (for example,
|
||||||
that thing that just broke and you bought a couple months ago, that
|
that thing that just broke and you bought a couple months ago, that
|
||||||
@@ -557,6 +638,12 @@ language](https://whoosh.readthedocs.io/en/latest/querylang.html). For
|
|||||||
details on what date parsing utilities are available, see [Date
|
details on what date parsing utilities are available, see [Date
|
||||||
parsing](https://whoosh.readthedocs.io/en/latest/dates.html#parsing-date-queries).
|
parsing](https://whoosh.readthedocs.io/en/latest/dates.html#parsing-date-queries).
|
||||||
|
|
||||||
|
## Keyboard shortcuts / hotkeys
|
||||||
|
|
||||||
|
A list of available hotkeys can be shown on any page using <kbd>Shift</kbd> +
|
||||||
|
<kbd>?</kbd>. The help dialog shows only the keys that are currently available
|
||||||
|
based on which area of Paperless-ngx you are using.
|
||||||
|
|
||||||
## The recommended workflow {#usage-recommended-workflow}
|
## The recommended workflow {#usage-recommended-workflow}
|
||||||
|
|
||||||
Once you have familiarized yourself with paperless and are ready to use
|
Once you have familiarized yourself with paperless and are ready to use
|
||||||
|
@@ -37,11 +37,11 @@ def worker_int(worker):
|
|||||||
id2name = {th.ident: th.name for th in threading.enumerate()}
|
id2name = {th.ident: th.name for th in threading.enumerate()}
|
||||||
code = []
|
code = []
|
||||||
for threadId, stack in sys._current_frames().items():
|
for threadId, stack in sys._current_frames().items():
|
||||||
code.append("\n# Thread: %s(%d)" % (id2name.get(threadId, ""), threadId))
|
code.append(f"\n# Thread: {id2name.get(threadId, '')}({threadId})")
|
||||||
for filename, lineno, name, line in traceback.extract_stack(stack):
|
for filename, lineno, name, line in traceback.extract_stack(stack):
|
||||||
code.append('File: "%s", line %d, in %s' % (filename, lineno, name))
|
code.append(f'File: "{filename}", line {lineno}, in {name}')
|
||||||
if line:
|
if line:
|
||||||
code.append(" %s" % (line.strip()))
|
code.append(f" {line.strip()}")
|
||||||
worker.log.debug("\n".join(code))
|
worker.log.debug("\n".join(code))
|
||||||
|
|
||||||
|
|
||||||
|
@@ -71,7 +71,17 @@ if ! docker stats --no-stream &> /dev/null ; then
|
|||||||
sleep 3
|
sleep 3
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Added handling for timezone for busybox based linux, not having timedatectl available (i.e. QNAP QTS)
|
||||||
|
# if neither timedatectl nor /etc/TZ is succeeding, defaulting to GMT.
|
||||||
|
if command -v timedatectl &> /dev/null ; then
|
||||||
default_time_zone=$(timedatectl show -p Timezone --value)
|
default_time_zone=$(timedatectl show -p Timezone --value)
|
||||||
|
elif [ -f /etc/TZ ] && [ -f /etc/tzlist ] ; then
|
||||||
|
TZ=$(cat /etc/TZ)
|
||||||
|
default_time_zone=$(grep -B 1 -m 1 "$TZ" /etc/tzlist | head -1 | cut -f 2 -d =)
|
||||||
|
else
|
||||||
|
echo "WARN: unable to detect timezone, defaulting to Etc/UTC"
|
||||||
|
default_time_zone="Etc/UTC"
|
||||||
|
fi
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -335,7 +345,7 @@ read -r -a OCR_LANGUAGES_ARRAY <<< "${_split_langs}"
|
|||||||
fi
|
fi
|
||||||
echo "PAPERLESS_TIME_ZONE=$TIME_ZONE"
|
echo "PAPERLESS_TIME_ZONE=$TIME_ZONE"
|
||||||
echo "PAPERLESS_OCR_LANGUAGE=$OCR_LANGUAGE"
|
echo "PAPERLESS_OCR_LANGUAGE=$OCR_LANGUAGE"
|
||||||
echo "PAPERLESS_SECRET_KEY=$SECRET_KEY"
|
echo "PAPERLESS_SECRET_KEY='$SECRET_KEY'"
|
||||||
if [[ ! ${DEFAULT_LANGUAGES[*]} =~ ${OCR_LANGUAGES_ARRAY[*]} ]] ; then
|
if [[ ! ${DEFAULT_LANGUAGES[*]} =~ ${OCR_LANGUAGES_ARRAY[*]} ]] ; then
|
||||||
echo "PAPERLESS_OCR_LANGUAGES=${OCR_LANGUAGES_ARRAY[*]}"
|
echo "PAPERLESS_OCR_LANGUAGES=${OCR_LANGUAGES_ARRAY[*]}"
|
||||||
fi
|
fi
|
||||||
|
11
mkdocs.yml
11
mkdocs.yml
@@ -6,6 +6,12 @@ theme:
|
|||||||
text: Roboto
|
text: Roboto
|
||||||
code: Roboto Mono
|
code: Roboto Mono
|
||||||
palette:
|
palette:
|
||||||
|
# Palette toggle for automatic mode
|
||||||
|
- media: "(prefers-color-scheme)"
|
||||||
|
toggle:
|
||||||
|
icon: material/brightness-auto
|
||||||
|
name: Switch to light mode
|
||||||
|
|
||||||
# Palette toggle for light mode
|
# Palette toggle for light mode
|
||||||
- media: "(prefers-color-scheme: light)"
|
- media: "(prefers-color-scheme: light)"
|
||||||
scheme: default
|
scheme: default
|
||||||
@@ -18,7 +24,7 @@ theme:
|
|||||||
scheme: slate
|
scheme: slate
|
||||||
toggle:
|
toggle:
|
||||||
icon: material/brightness-4
|
icon: material/brightness-4
|
||||||
name: Switch to light mode
|
name: Switch to system preference
|
||||||
features:
|
features:
|
||||||
- navigation.tabs
|
- navigation.tabs
|
||||||
- navigation.top
|
- navigation.top
|
||||||
@@ -49,6 +55,9 @@ markdown_extensions:
|
|||||||
- name: mermaid
|
- name: mermaid
|
||||||
class: mermaid
|
class: mermaid
|
||||||
format: !!python/name:pymdownx.superfences.fence_code_format
|
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||||
|
- pymdownx.emoji:
|
||||||
|
emoji_index: !!python/name:material.extensions.emoji.twemoji
|
||||||
|
emoji_generator: !!python/name:material.extensions.emoji.to_svg
|
||||||
strict: true
|
strict: true
|
||||||
nav:
|
nav:
|
||||||
- index.md
|
- index.md
|
||||||
|
41
paperless-ngx.code-workspace
Normal file
41
paperless-ngx.code-workspace
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./src",
|
||||||
|
"name": "Backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./src-ui",
|
||||||
|
"name": "Frontend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.github",
|
||||||
|
"name": "CI/CD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./docs",
|
||||||
|
"name": "Documentation"
|
||||||
|
}
|
||||||
|
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"files.exclude": {
|
||||||
|
"**/__pycache__": true,
|
||||||
|
"**/.mypy_cache": true,
|
||||||
|
"**/.ruff_cache": true,
|
||||||
|
"**/.pytest_cache": true,
|
||||||
|
"**/.idea": true,
|
||||||
|
"**/.venv": true,
|
||||||
|
"**/.coverage": true,
|
||||||
|
"**/coverage.json": true
|
||||||
|
},
|
||||||
|
"python.defaultInterpreterPath": ".venv/bin/python3",
|
||||||
|
},
|
||||||
|
"extensions": {
|
||||||
|
"recommendations": ["ms-python.python", "charliermarsh.ruff", "editorconfig.editorconfig"],
|
||||||
|
"unwantedRecommendations": ["ms-python.black-formatter"]
|
||||||
|
}
|
||||||
|
}
|
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
#PAPERLESS_CONSUMPTION_DIR=../consume
|
#PAPERLESS_CONSUMPTION_DIR=../consume
|
||||||
#PAPERLESS_DATA_DIR=../data
|
#PAPERLESS_DATA_DIR=../data
|
||||||
#PAPERLESS_TRASH_DIR=
|
#PAPERLESS_EMPTY_TRASH_DIR=
|
||||||
#PAPERLESS_MEDIA_ROOT=../media
|
#PAPERLESS_MEDIA_ROOT=../media
|
||||||
#PAPERLESS_STATICDIR=../static
|
#PAPERLESS_STATICDIR=../static
|
||||||
#PAPERLESS_FILENAME_FORMAT=
|
#PAPERLESS_FILENAME_FORMAT=
|
||||||
|
@@ -14,6 +14,7 @@ following additional information about it:
|
|||||||
* Thumbnail Path: ${DOCUMENT_THUMBNAIL_PATH}
|
* Thumbnail Path: ${DOCUMENT_THUMBNAIL_PATH}
|
||||||
* Download URL: ${DOCUMENT_DOWNLOAD_URL}
|
* Download URL: ${DOCUMENT_DOWNLOAD_URL}
|
||||||
* Thumbnail URL: ${DOCUMENT_THUMBNAIL_URL}
|
* Thumbnail URL: ${DOCUMENT_THUMBNAIL_URL}
|
||||||
|
* Owner Name: ${DOCUMENT_OWNER}
|
||||||
* Correspondent: ${DOCUMENT_CORRESPONDENT}
|
* Correspondent: ${DOCUMENT_CORRESPONDENT}
|
||||||
* Tags: ${DOCUMENT_TAGS}
|
* Tags: ${DOCUMENT_TAGS}
|
||||||
|
|
||||||
|
@@ -3,4 +3,4 @@
|
|||||||
docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -v paperless_pgdata:/var/lib/postgresql/data -d postgres:15
|
docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -v paperless_pgdata:/var/lib/postgresql/data -d postgres:15
|
||||||
docker run -d -p 6379:6379 redis:latest
|
docker run -d -p 6379:6379 redis:latest
|
||||||
docker run -p 3000:3000 -d gotenberg/gotenberg:7.8 gotenberg --chromium-disable-javascript=true --chromium-allow-list="file:///tmp/.*"
|
docker run -p 3000:3000 -d gotenberg/gotenberg:7.8 gotenberg --chromium-disable-javascript=true --chromium-allow-list="file:///tmp/.*"
|
||||||
docker run -p 9998:9998 -d ghcr.io/paperless-ngx/tika:latest
|
docker run -p 9998:9998 -d docker.io/apache/tika:latest
|
||||||
|
@@ -33,6 +33,7 @@
|
|||||||
"it-IT": "src/locale/messages.it_IT.xlf",
|
"it-IT": "src/locale/messages.it_IT.xlf",
|
||||||
"ja-JP": "src/locale/messages.ja_JP.xlf",
|
"ja-JP": "src/locale/messages.ja_JP.xlf",
|
||||||
"lb-LU": "src/locale/messages.lb_LU.xlf",
|
"lb-LU": "src/locale/messages.lb_LU.xlf",
|
||||||
|
"ko-KR": "src/locale/messages.ko_KR.xlf",
|
||||||
"nl-NL": "src/locale/messages.nl_NL.xlf",
|
"nl-NL": "src/locale/messages.nl_NL.xlf",
|
||||||
"no-NO": "src/locale/messages.no_NO.xlf",
|
"no-NO": "src/locale/messages.no_NO.xlf",
|
||||||
"pl-PL": "src/locale/messages.pl_PL.xlf",
|
"pl-PL": "src/locale/messages.pl_PL.xlf",
|
||||||
@@ -51,8 +52,11 @@
|
|||||||
},
|
},
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular-devkit/build-angular:browser",
|
"builder": "@angular-builders/custom-webpack:browser",
|
||||||
"options": {
|
"options": {
|
||||||
|
"customWebpackConfig": {
|
||||||
|
"path": "./extra-webpack.config.ts"
|
||||||
|
},
|
||||||
"outputPath": "dist/paperless-ui",
|
"outputPath": "dist/paperless-ui",
|
||||||
"outputHashing": "none",
|
"outputHashing": "none",
|
||||||
"index": "src/index.html",
|
"index": "src/index.html",
|
||||||
@@ -66,8 +70,8 @@
|
|||||||
"src/assets",
|
"src/assets",
|
||||||
"src/manifest.webmanifest",
|
"src/manifest.webmanifest",
|
||||||
{
|
{
|
||||||
"glob": "{pdf.worker.min.js,pdf.min.js}",
|
"glob": "{pdf.worker.min.mjs,pdf.min.mjs}",
|
||||||
"input": "node_modules/pdfjs-dist/build/",
|
"input": "node_modules/pdfjs-dist/legacy/build/",
|
||||||
"output": "/assets/js/"
|
"output": "/assets/js/"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -76,9 +80,7 @@
|
|||||||
],
|
],
|
||||||
"scripts": [],
|
"scripts": [],
|
||||||
"allowedCommonJsDependencies": [
|
"allowedCommonJsDependencies": [
|
||||||
"pdfjs-dist",
|
"ng2-pdf-viewer",
|
||||||
"pdfjs-dist/web/pdf_viewer",
|
|
||||||
"filesize",
|
|
||||||
"file-saver"
|
"file-saver"
|
||||||
],
|
],
|
||||||
"vendorChunk": true,
|
"vendorChunk": true,
|
||||||
@@ -126,7 +128,7 @@
|
|||||||
"defaultConfiguration": ""
|
"defaultConfiguration": ""
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-devkit/build-angular:dev-server",
|
"builder": "@angular-builders/custom-webpack:dev-server",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "paperless-ui:build:en-US"
|
"buildTarget": "paperless-ui:build:en-US"
|
||||||
},
|
},
|
||||||
@@ -137,7 +139,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extract-i18n": {
|
"extract-i18n": {
|
||||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
"builder": "@angular-builders/custom-webpack:extract-i18n",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "paperless-ui:build"
|
"buildTarget": "paperless-ui:build"
|
||||||
}
|
}
|
||||||
|
@@ -9,7 +9,7 @@ test('dashboard inbox link', async ({ page }) => {
|
|||||||
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await page.getByRole('link', { name: 'Documents in inbox' }).click()
|
await page.getByRole('link', { name: 'Documents in inbox' }).click()
|
||||||
await expect(page).toHaveURL(/tags__id__all=9/)
|
await expect(page).toHaveURL(/tags__id__in=9/)
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@@ -124,7 +124,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"size": -1,
|
"size": -1,
|
||||||
"mimeType": "application/json",
|
"mimeType": "application/json",
|
||||||
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true}]}"
|
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]}]}"
|
||||||
},
|
},
|
||||||
"headersSize": -1,
|
"headersSize": -1,
|
||||||
"bodySize": -1,
|
"bodySize": -1,
|
||||||
@@ -236,7 +236,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"size": -1,
|
"size": -1,
|
||||||
"mimeType": "application/json",
|
"mimeType": "application/json",
|
||||||
"text": "{\"documents_total\":61,\"documents_inbox\":8,\"inbox_tag\":9,\"document_file_type_counts\":[{\"mime_type\":\"application/pdf\",\"mime_type_count\":57},{\"mime_type\":\"text/plain\",\"mime_type_count\":3},{\"mime_type\":\"text/csv\",\"mime_type_count\":1}],\"character_count\":2407053}"
|
"text": "{\"documents_total\":61,\"documents_inbox\":8,\"inbox_tags\":[9],\"document_file_type_counts\":[{\"mime_type\":\"application/pdf\",\"mime_type_count\":57},{\"mime_type\":\"text/plain\",\"mime_type_count\":3},{\"mime_type\":\"text/csv\",\"mime_type_count\":1}],\"character_count\":2407053}"
|
||||||
},
|
},
|
||||||
"headersSize": -1,
|
"headersSize": -1,
|
||||||
"bodySize": -1,
|
"bodySize": -1,
|
||||||
@@ -250,7 +250,7 @@
|
|||||||
"time": 0.609,
|
"time": 0.609,
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "http://localhost:8000/api/documents/?page=1&page_size=10&ordering=-created&truncate_content=true&tags__id__all=9",
|
"url": "http://localhost:8000/api/documents/?page=1&page_size=10&ordering=-created&truncate_content=true&tags__id__in=9",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
"headers": [
|
"headers": [
|
||||||
@@ -284,7 +284,7 @@
|
|||||||
"value": "true"
|
"value": "true"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "tags__id__all",
|
"name": "tags__id__in",
|
||||||
"value": "9"
|
"value": "9"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -468,7 +468,7 @@
|
|||||||
"time": 0.951,
|
"time": 0.951,
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9",
|
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=9",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
"headers": [
|
"headers": [
|
||||||
@@ -502,7 +502,7 @@
|
|||||||
"value": "true"
|
"value": "true"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "tags__id__all",
|
"name": "tags__id__in",
|
||||||
"value": "9"
|
"value": "9"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
@@ -124,7 +124,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"size": -1,
|
"size": -1,
|
||||||
"mimeType": "application/json",
|
"mimeType": "application/json",
|
||||||
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true}]}"
|
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]}]}"
|
||||||
},
|
},
|
||||||
"headersSize": -1,
|
"headersSize": -1,
|
||||||
"bodySize": -1,
|
"bodySize": -1,
|
||||||
@@ -236,7 +236,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"size": -1,
|
"size": -1,
|
||||||
"mimeType": "application/json",
|
"mimeType": "application/json",
|
||||||
"text": "{\"documents_total\":61,\"documents_inbox\":8,\"inbox_tag\":9,\"document_file_type_counts\":[{\"mime_type\":\"application/pdf\",\"mime_type_count\":57},{\"mime_type\":\"text/plain\",\"mime_type_count\":3},{\"mime_type\":\"text/csv\",\"mime_type_count\":1}],\"character_count\":2407053}"
|
"text": "{\"documents_total\":61,\"documents_inbox\":8,\"inbox_tags\":[9],\"document_file_type_counts\":[{\"mime_type\":\"application/pdf\",\"mime_type_count\":57},{\"mime_type\":\"text/plain\",\"mime_type_count\":3},{\"mime_type\":\"text/csv\",\"mime_type_count\":1}],\"character_count\":2407053}"
|
||||||
},
|
},
|
||||||
"headersSize": -1,
|
"headersSize": -1,
|
||||||
"bodySize": -1,
|
"bodySize": -1,
|
||||||
@@ -250,7 +250,7 @@
|
|||||||
"time": 0.622,
|
"time": 0.622,
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"url": "http://localhost:8000/api/documents/?page=1&page_size=10&ordering=-created&truncate_content=true&tags__id__all=9",
|
"url": "http://localhost:8000/api/documents/?page=1&page_size=10&ordering=-created&truncate_content=true&tags__id__in=9",
|
||||||
"httpVersion": "HTTP/1.1",
|
"httpVersion": "HTTP/1.1",
|
||||||
"cookies": [],
|
"cookies": [],
|
||||||
"headers": [
|
"headers": [
|
||||||
@@ -284,7 +284,7 @@
|
|||||||
"value": "true"
|
"value": "true"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "tags__id__all",
|
"name": "tags__id__in",
|
||||||
"value": "9"
|
"value": "9"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
@@ -124,7 +124,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"size": -1,
|
"size": -1,
|
||||||
"mimeType": "application/json",
|
"mimeType": "application/json",
|
||||||
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true}]}"
|
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]}]}"
|
||||||
},
|
},
|
||||||
"headersSize": -1,
|
"headersSize": -1,
|
||||||
"bodySize": -1,
|
"bodySize": -1,
|
||||||
@@ -236,7 +236,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"size": -1,
|
"size": -1,
|
||||||
"mimeType": "application/json",
|
"mimeType": "application/json",
|
||||||
"text": "{\"documents_total\":61,\"documents_inbox\":8,\"inbox_tag\":9,\"document_file_type_counts\":[{\"mime_type\":\"application/pdf\",\"mime_type_count\":57},{\"mime_type\":\"text/plain\",\"mime_type_count\":3},{\"mime_type\":\"text/csv\",\"mime_type_count\":1}],\"character_count\":2407053}"
|
"text": "{\"documents_total\":61,\"documents_inbox\":8,\"inbox_tags\":[9],\"document_file_type_counts\":[{\"mime_type\":\"application/pdf\",\"mime_type_count\":57},{\"mime_type\":\"text/plain\",\"mime_type_count\":3},{\"mime_type\":\"text/csv\",\"mime_type_count\":1}],\"character_count\":2407053}"
|
||||||
},
|
},
|
||||||
"headersSize": -1,
|
"headersSize": -1,
|
||||||
"bodySize": -1,
|
"bodySize": -1,
|
||||||
|
@@ -124,7 +124,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"size": -1,
|
"size": -1,
|
||||||
"mimeType": "application/json",
|
"mimeType": "application/json",
|
||||||
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true}]}"
|
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]}]}"
|
||||||
},
|
},
|
||||||
"headersSize": -1,
|
"headersSize": -1,
|
||||||
"bodySize": -1,
|
"bodySize": -1,
|
||||||
@@ -236,7 +236,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"size": -1,
|
"size": -1,
|
||||||
"mimeType": "application/json",
|
"mimeType": "application/json",
|
||||||
"text": "{\"documents_total\":61,\"documents_inbox\":8,\"inbox_tag\":9,\"document_file_type_counts\":[{\"mime_type\":\"application/pdf\",\"mime_type_count\":57},{\"mime_type\":\"text/plain\",\"mime_type_count\":3},{\"mime_type\":\"text/csv\",\"mime_type_count\":1}],\"character_count\":2407053}"
|
"text": "{\"documents_total\":61,\"documents_inbox\":8,\"inbox_tags\":[9],\"document_file_type_counts\":[{\"mime_type\":\"application/pdf\",\"mime_type_count\":57},{\"mime_type\":\"text/plain\",\"mime_type_count\":3},{\"mime_type\":\"text/csv\",\"mime_type_count\":1}],\"character_count\":2407053}"
|
||||||
},
|
},
|
||||||
"headersSize": -1,
|
"headersSize": -1,
|
||||||
"bodySize": -1,
|
"bodySize": -1,
|
||||||
|
@@ -71,7 +71,7 @@ test('should show a mobile preview', async ({ page }) => {
|
|||||||
await page.setViewportSize({ width: 400, height: 1000 })
|
await page.setViewportSize({ width: 400, height: 1000 })
|
||||||
await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible()
|
await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible()
|
||||||
await page.getByRole('tab', { name: 'Preview' }).click()
|
await page.getByRole('tab', { name: 'Preview' }).click()
|
||||||
await page.waitForSelector('pngx-pdf-viewer')
|
await page.waitForSelector('pdf-viewer')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should show a list of notes', async ({ page }) => {
|
test('should show a list of notes', async ({ page }) => {
|
||||||
|
@@ -45,8 +45,8 @@ test('basic filtering', async ({ page }) => {
|
|||||||
test('text filtering', async ({ page }) => {
|
test('text filtering', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
||||||
await page.goto('/documents')
|
await page.goto('/documents')
|
||||||
await page.getByRole('textbox').click()
|
await page.getByRole('main').getByRole('combobox').click()
|
||||||
await page.getByRole('textbox').fill('test')
|
await page.getByRole('main').getByRole('combobox').fill('test')
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/32 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/32 documents/)
|
||||||
await expect(page).toHaveURL(/title_content=test/)
|
await expect(page).toHaveURL(/title_content=test/)
|
||||||
await page.getByRole('button', { name: 'Title & content' }).click()
|
await page.getByRole('button', { name: 'Title & content' }).click()
|
||||||
@@ -59,12 +59,12 @@ test('text filtering', async ({ page }) => {
|
|||||||
await expect(page.locator('pngx-document-list')).toHaveText(/26 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/26 documents/)
|
||||||
await page.getByRole('button', { name: 'Advanced search' }).click()
|
await page.getByRole('button', { name: 'Advanced search' }).click()
|
||||||
await page.getByRole('button', { name: 'ASN' }).click()
|
await page.getByRole('button', { name: 'ASN' }).click()
|
||||||
await page.getByRole('textbox').fill('1123')
|
await page.getByRole('main').getByRole('combobox').nth(1).fill('1123')
|
||||||
await expect(page).toHaveURL(/archive_serial_number=1123/)
|
await expect(page).toHaveURL(/archive_serial_number=1123/)
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
|
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
|
||||||
await page.locator('select').selectOption('greater')
|
await page.locator('select').selectOption('greater')
|
||||||
await page.getByRole('textbox').click()
|
await page.getByRole('main').getByRole('combobox').nth(1).click()
|
||||||
await page.getByRole('textbox').fill('1123')
|
await page.getByRole('main').getByRole('combobox').nth(1).fill('1123')
|
||||||
await expect(page).toHaveURL(/archive_serial_number__gt=1123/)
|
await expect(page).toHaveURL(/archive_serial_number__gt=1123/)
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/5 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/5 documents/)
|
||||||
await page.locator('select').selectOption('less')
|
await page.locator('select').selectOption('less')
|
||||||
@@ -81,15 +81,11 @@ test('text filtering', async ({ page }) => {
|
|||||||
test('date filtering', async ({ page }) => {
|
test('date filtering', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
|
||||||
await page.goto('/documents')
|
await page.goto('/documents')
|
||||||
await page.getByRole('button', { name: 'Created' }).click()
|
await page.getByRole('button', { name: 'Dates' }).click()
|
||||||
await page.getByRole('menuitem', { name: 'Last 3 months' }).click()
|
await page.getByRole('menuitem', { name: 'Last 3 months' }).first().click()
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
|
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
|
||||||
await page.getByRole('button', { name: 'Created Clear selected' }).click()
|
await page.getByRole('menuitem', { name: 'Last 3 months' }).first().click()
|
||||||
await page.getByRole('button', { name: 'Created' }).click()
|
await page.getByLabel('Datesselected').getByRole('button').first().click()
|
||||||
await page
|
|
||||||
.getByRole('menuitem', { name: 'After mm/dd/yyyy' })
|
|
||||||
.getByRole('button')
|
|
||||||
.click()
|
|
||||||
await page.getByRole('combobox', { name: 'Select month' }).selectOption('12')
|
await page.getByRole('combobox', { name: 'Select month' }).selectOption('12')
|
||||||
await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')
|
await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')
|
||||||
await page.getByText('11', { exact: true }).click()
|
await page.getByText('11', { exact: true }).click()
|
||||||
@@ -138,11 +134,11 @@ test('sorting', async ({ page }) => {
|
|||||||
test('change views', async ({ page }) => {
|
test('change views', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR5, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR5, { notFound: 'fallback' })
|
||||||
await page.goto('/documents')
|
await page.goto('/documents')
|
||||||
await page.locator('pngx-page-header label').first().click()
|
await page.locator('.btn-group label').first().click()
|
||||||
await expect(page.locator('pngx-document-list table')).toBeVisible()
|
await expect(page.locator('pngx-document-list table')).toBeVisible()
|
||||||
await page.locator('pngx-page-header label').nth(1).click()
|
await page.locator('.btn-group label').nth(1).click()
|
||||||
await expect(page.locator('pngx-document-card-small').first()).toBeAttached()
|
await expect(page.locator('pngx-document-card-small').first()).toBeAttached()
|
||||||
await page.locator('pngx-page-header label').nth(2).click()
|
await page.locator('.btn-group label').nth(2).click()
|
||||||
await expect(page.locator('pngx-document-card-large').first()).toBeAttached()
|
await expect(page.locator('pngx-document-card-large').first()).toBeAttached()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
24
src-ui/extra-webpack.config.ts
Normal file
24
src-ui/extra-webpack.config.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as webpack from 'webpack'
|
||||||
|
import {
|
||||||
|
CustomWebpackBrowserSchema,
|
||||||
|
TargetOptions,
|
||||||
|
} from '@angular-builders/custom-webpack'
|
||||||
|
const { codecovWebpackPlugin } = require('@codecov/webpack-plugin')
|
||||||
|
|
||||||
|
export default (
|
||||||
|
config: webpack.Configuration,
|
||||||
|
options: CustomWebpackBrowserSchema,
|
||||||
|
targetOptions: TargetOptions
|
||||||
|
) => {
|
||||||
|
if (config.plugins) {
|
||||||
|
config.plugins.push(
|
||||||
|
codecovWebpackPlugin({
|
||||||
|
enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
|
||||||
|
bundleName: 'paperless-ngx',
|
||||||
|
uploadToken: process.env.CODECOV_TOKEN,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
@@ -7,7 +7,6 @@ module.exports = {
|
|||||||
'abstract-name-filter-service',
|
'abstract-name-filter-service',
|
||||||
'abstract-paperless-service',
|
'abstract-paperless-service',
|
||||||
],
|
],
|
||||||
coveragePathIgnorePatterns: ['/src/app/components/common/pdf-viewer/*'],
|
|
||||||
transformIgnorePatterns: [`<rootDir>/node_modules/(?!.*\\.mjs$|lodash-es)`],
|
transformIgnorePatterns: [`<rootDir>/node_modules/(?!.*\\.mjs$|lodash-es)`],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^src/(.*)': '<rootDir>/src/$1',
|
'^src/(.*)': '<rootDir>/src/$1',
|
||||||
|
4544
src-ui/messages.xlf
4544
src-ui/messages.xlf
File diff suppressed because it is too large
Load Diff
10468
src-ui/package-lock.json
generated
10468
src-ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,58 +11,60 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^17.2.1",
|
"@angular/cdk": "^18.2.11",
|
||||||
"@angular/common": "~17.2.3",
|
"@angular/common": "~18.2.10",
|
||||||
"@angular/compiler": "~17.2.3",
|
"@angular/compiler": "~18.2.10",
|
||||||
"@angular/core": "~17.2.3",
|
"@angular/core": "~18.2.10",
|
||||||
"@angular/forms": "~17.2.3",
|
"@angular/forms": "~18.2.10",
|
||||||
"@angular/localize": "~17.2.3",
|
"@angular/localize": "~18.2.10",
|
||||||
"@angular/platform-browser": "~17.2.3",
|
"@angular/platform-browser": "~18.2.10",
|
||||||
"@angular/platform-browser-dynamic": "~17.2.3",
|
"@angular/platform-browser-dynamic": "~18.2.10",
|
||||||
"@angular/router": "~17.2.3",
|
"@angular/router": "~18.2.10",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^16.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^17.0.1",
|
||||||
"@ng-select/ng-select": "^12.0.7",
|
"@ng-select/ng-select": "^13.9.1",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
|
"ng2-pdf-viewer": "^10.3.4",
|
||||||
"ngx-bootstrap-icons": "^1.9.3",
|
"ngx-bootstrap-icons": "^1.9.3",
|
||||||
"ngx-color": "^9.0.0",
|
"ngx-color": "^9.0.0",
|
||||||
"ngx-cookie-service": "^17.1.0",
|
"ngx-cookie-service": "^18.0.0",
|
||||||
"ngx-file-drop": "^16.0.0",
|
"ngx-file-drop": "^16.0.0",
|
||||||
"ngx-filesize": "^3.0.3",
|
"ngx-ui-tour-ng-bootstrap": "^15.0.0",
|
||||||
"ngx-ui-tour-ng-bootstrap": "^14.0.2",
|
|
||||||
"pdfjs-dist": "^3.11.174",
|
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.8.1",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^11.0.2",
|
||||||
"zone.js": "^0.14.4"
|
"zone.js": "^0.14.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/jest": "17.0.2",
|
"@angular-builders/custom-webpack": "^18.0.0",
|
||||||
"@angular-devkit/build-angular": "~17.2.2",
|
"@angular-builders/jest": "^18.0.0",
|
||||||
"@angular-eslint/builder": "17.2.1",
|
"@angular-devkit/build-angular": "^18.2.2",
|
||||||
"@angular-eslint/eslint-plugin": "17.2.1",
|
"@angular-devkit/core": "^18.2.11",
|
||||||
"@angular-eslint/eslint-plugin-template": "17.2.1",
|
"@angular-devkit/schematics": "^18.2.11",
|
||||||
"@angular-eslint/schematics": "17.2.1",
|
"@angular-eslint/builder": "18.4.0",
|
||||||
"@angular-eslint/template-parser": "17.2.1",
|
"@angular-eslint/eslint-plugin": "18.4.0",
|
||||||
"@angular/cli": "~17.2.2",
|
"@angular-eslint/eslint-plugin-template": "18.4.0",
|
||||||
"@angular/compiler-cli": "~17.2.2",
|
"@angular-eslint/schematics": "18.4.0",
|
||||||
"@playwright/test": "^1.42.0",
|
"@angular-eslint/template-parser": "18.4.0",
|
||||||
"@types/jest": "^29.5.12",
|
"@angular/cli": "~18.2.11",
|
||||||
"@types/node": "^20.11.24",
|
"@angular/compiler-cli": "~18.2.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.1.0",
|
"@codecov/webpack-plugin": "^1.2.1",
|
||||||
"@typescript-eslint/parser": "^7.1.0",
|
"@playwright/test": "^1.48.2",
|
||||||
"concurrently": "^8.2.2",
|
"@types/jest": "^29.5.14",
|
||||||
"eslint": "^8.57.0",
|
"@types/node": "^22.8.6",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.12.2",
|
||||||
|
"@typescript-eslint/parser": "^8.12.2",
|
||||||
|
"@typescript-eslint/utils": "^8.0.0",
|
||||||
|
"eslint": "^9.14.0",
|
||||||
"jest": "29.7.0",
|
"jest": "29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"jest-preset-angular": "^14.0.0",
|
"jest-preset-angular": "^14.2.4",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"ts-node": "~10.9.1",
|
"ts-node": "~10.9.1",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.5.4"
|
||||||
"wait-on": "^7.2.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -24,6 +24,7 @@ import localeFr from '@angular/common/locales/fr'
|
|||||||
import localeHu from '@angular/common/locales/hu'
|
import localeHu from '@angular/common/locales/hu'
|
||||||
import localeIt from '@angular/common/locales/it'
|
import localeIt from '@angular/common/locales/it'
|
||||||
import localeJa from '@angular/common/locales/ja'
|
import localeJa from '@angular/common/locales/ja'
|
||||||
|
import localeKo from '@angular/common/locales/ko'
|
||||||
import localeLb from '@angular/common/locales/lb'
|
import localeLb from '@angular/common/locales/lb'
|
||||||
import localeNl from '@angular/common/locales/nl'
|
import localeNl from '@angular/common/locales/nl'
|
||||||
import localeNo from '@angular/common/locales/no'
|
import localeNo from '@angular/common/locales/no'
|
||||||
@@ -55,6 +56,7 @@ registerLocaleData(localeFr)
|
|||||||
registerLocaleData(localeHu)
|
registerLocaleData(localeHu)
|
||||||
registerLocaleData(localeIt)
|
registerLocaleData(localeIt)
|
||||||
registerLocaleData(localeJa)
|
registerLocaleData(localeJa)
|
||||||
|
registerLocaleData(localeKo)
|
||||||
registerLocaleData(localeLb)
|
registerLocaleData(localeLb)
|
||||||
registerLocaleData(localeNl)
|
registerLocaleData(localeNl)
|
||||||
registerLocaleData(localeNo)
|
registerLocaleData(localeNo)
|
||||||
@@ -76,12 +78,16 @@ const mock = () => {
|
|||||||
let storage: { [key: string]: string } = {}
|
let storage: { [key: string]: string } = {}
|
||||||
return {
|
return {
|
||||||
getItem: (key: string) => (key in storage ? storage[key] : null),
|
getItem: (key: string) => (key in storage ? storage[key] : null),
|
||||||
setItem: (key: string, value: string) => (storage[key] = value || ''),
|
setItem: (key: string, value: string) => {
|
||||||
|
if (value.length > 1000000) throw new Error('localStorage overflow')
|
||||||
|
storage[key] = value || ''
|
||||||
|
},
|
||||||
removeItem: (key: string) => delete storage[key],
|
removeItem: (key: string) => delete storage[key],
|
||||||
clear: () => (storage = {}),
|
clear: () => (storage = {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'open', { value: jest.fn() })
|
||||||
Object.defineProperty(window, 'localStorage', { value: mock() })
|
Object.defineProperty(window, 'localStorage', { value: mock() })
|
||||||
Object.defineProperty(window, 'sessionStorage', { value: mock() })
|
Object.defineProperty(window, 'sessionStorage', { value: mock() })
|
||||||
Object.defineProperty(window, 'getComputedStyle', {
|
Object.defineProperty(window, 'getComputedStyle', {
|
||||||
|
@@ -26,6 +26,7 @@ import { MailComponent } from './components/manage/mail/mail.component'
|
|||||||
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
|
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
|
||||||
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
||||||
import { ConfigComponent } from './components/admin/config/config.component'
|
import { ConfigComponent } from './components/admin/config/config.component'
|
||||||
|
import { TrashComponent } from './components/admin/trash/trash.component'
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||||
@@ -140,10 +141,18 @@ export const routes: Routes = [
|
|||||||
path: 'logs',
|
path: 'logs',
|
||||||
component: LogsComponent,
|
component: LogsComponent,
|
||||||
canActivate: [PermissionsGuard],
|
canActivate: [PermissionsGuard],
|
||||||
|
data: {
|
||||||
|
requireAdmin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'trash',
|
||||||
|
component: TrashComponent,
|
||||||
|
canActivate: [PermissionsGuard],
|
||||||
data: {
|
data: {
|
||||||
requiredPermission: {
|
requiredPermission: {
|
||||||
action: PermissionAction.View,
|
action: PermissionAction.Delete,
|
||||||
type: PermissionType.Admin,
|
type: PermissionType.Document,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@@ -1,12 +1,11 @@
|
|||||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import {
|
import {
|
||||||
ComponentFixture,
|
ComponentFixture,
|
||||||
TestBed,
|
TestBed,
|
||||||
fakeAsync,
|
fakeAsync,
|
||||||
tick,
|
tick,
|
||||||
} from '@angular/core/testing'
|
} from '@angular/core/testing'
|
||||||
import { Router } from '@angular/router'
|
import { Router, RouterModule } from '@angular/router'
|
||||||
import { RouterTestingModule } from '@angular/router/testing'
|
|
||||||
import { TourService, TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourService, TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { Subject } from 'rxjs'
|
import { Subject } from 'rxjs'
|
||||||
import { routes } from './app-routing.module'
|
import { routes } from './app-routing.module'
|
||||||
@@ -21,6 +20,11 @@ import { ToastService, Toast } from './services/toast.service'
|
|||||||
import { SettingsService } from './services/settings.service'
|
import { SettingsService } from './services/settings.service'
|
||||||
import { FileDropComponent } from './components/file-drop/file-drop.component'
|
import { FileDropComponent } from './components/file-drop/file-drop.component'
|
||||||
import { NgxFileDropModule } from 'ngx-file-drop'
|
import { NgxFileDropModule } from 'ngx-file-drop'
|
||||||
|
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { HotKeyService } from './services/hot-key.service'
|
||||||
|
import { PermissionsGuard } from './guards/permissions.guard'
|
||||||
|
import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard'
|
||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
|
||||||
describe('AppComponent', () => {
|
describe('AppComponent', () => {
|
||||||
let component: AppComponent
|
let component: AppComponent
|
||||||
@@ -31,16 +35,22 @@ describe('AppComponent', () => {
|
|||||||
let toastService: ToastService
|
let toastService: ToastService
|
||||||
let router: Router
|
let router: Router
|
||||||
let settingsService: SettingsService
|
let settingsService: SettingsService
|
||||||
|
let hotKeyService: HotKeyService
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
declarations: [AppComponent, ToastsComponent, FileDropComponent],
|
declarations: [AppComponent, ToastsComponent, FileDropComponent],
|
||||||
providers: [],
|
|
||||||
imports: [
|
imports: [
|
||||||
HttpClientTestingModule,
|
|
||||||
TourNgBootstrapModule,
|
TourNgBootstrapModule,
|
||||||
RouterTestingModule.withRoutes(routes),
|
RouterModule.forRoot(routes),
|
||||||
NgxFileDropModule,
|
NgxFileDropModule,
|
||||||
|
NgbModalModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
PermissionsGuard,
|
||||||
|
DirtySavedViewGuard,
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
@@ -50,6 +60,7 @@ describe('AppComponent', () => {
|
|||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
router = TestBed.inject(Router)
|
router = TestBed.inject(Router)
|
||||||
|
hotKeyService = TestBed.inject(HotKeyService)
|
||||||
fixture = TestBed.createComponent(AppComponent)
|
fixture = TestBed.createComponent(AppComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
})
|
})
|
||||||
@@ -139,4 +150,20 @@ describe('AppComponent', () => {
|
|||||||
fileStatusSubject.next(new FileStatus())
|
fileStatusSubject.next(new FileStatus())
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should support hotkeys', () => {
|
||||||
|
const addShortcutSpy = jest.spyOn(hotKeyService, 'addShortcut')
|
||||||
|
const routerSpy = jest.spyOn(router, 'navigate')
|
||||||
|
// prevent actual navigation
|
||||||
|
routerSpy.mockReturnValue(new Promise(() => {}))
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(addShortcutSpy).toHaveBeenCalled()
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'h' }))
|
||||||
|
expect(routerSpy).toHaveBeenCalledWith(['/dashboard'])
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'd' }))
|
||||||
|
expect(routerSpy).toHaveBeenCalledWith(['/documents'])
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 's' }))
|
||||||
|
expect(routerSpy).toHaveBeenCalledWith(['/settings'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@@ -12,6 +12,7 @@ import {
|
|||||||
PermissionsService,
|
PermissionsService,
|
||||||
PermissionType,
|
PermissionType,
|
||||||
} from './services/permissions.service'
|
} from './services/permissions.service'
|
||||||
|
import { HotKeyService } from './services/hot-key.service'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-root',
|
selector: 'pngx-root',
|
||||||
@@ -31,8 +32,11 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
private tasksService: TasksService,
|
private tasksService: TasksService,
|
||||||
public tourService: TourService,
|
public tourService: TourService,
|
||||||
private renderer: Renderer2,
|
private renderer: Renderer2,
|
||||||
private permissionsService: PermissionsService
|
private permissionsService: PermissionsService,
|
||||||
|
private hotKeyService: HotKeyService
|
||||||
) {
|
) {
|
||||||
|
let anyWindow = window as any
|
||||||
|
anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.mjs'
|
||||||
this.settings.updateAppearanceSettings()
|
this.settings.updateAppearanceSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +127,36 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.hotKeyService
|
||||||
|
.addShortcut({ keys: 'h', description: $localize`Dashboard` })
|
||||||
|
.subscribe(() => {
|
||||||
|
this.router.navigate(['/dashboard'])
|
||||||
|
})
|
||||||
|
if (
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.Document
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.hotKeyService
|
||||||
|
.addShortcut({ keys: 'd', description: $localize`Documents` })
|
||||||
|
.subscribe(() => {
|
||||||
|
this.router.navigate(['/documents'])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.Change,
|
||||||
|
PermissionType.UISettings
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.hotKeyService
|
||||||
|
.addShortcut({ keys: 's', description: $localize`Settings` })
|
||||||
|
.subscribe(() => {
|
||||||
|
this.router.navigate(['/settings'])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const prevBtnTitle = $localize`Prev`
|
const prevBtnTitle = $localize`Prev`
|
||||||
const nextBtnTitle = $localize`Next`
|
const nextBtnTitle = $localize`Next`
|
||||||
const endBtnTitle = $localize`End`
|
const endBtnTitle = $localize`End`
|
||||||
|
@@ -7,7 +7,11 @@ import {
|
|||||||
NgbDateParserFormatter,
|
NgbDateParserFormatter,
|
||||||
NgbModule,
|
NgbModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'
|
import {
|
||||||
|
HTTP_INTERCEPTORS,
|
||||||
|
provideHttpClient,
|
||||||
|
withInterceptorsFromDi,
|
||||||
|
} from '@angular/common/http'
|
||||||
import { DocumentListComponent } from './components/document-list/document-list.component'
|
import { DocumentListComponent } from './components/document-list/document-list.component'
|
||||||
import { DocumentDetailComponent } from './components/document-detail/document-detail.component'
|
import { DocumentDetailComponent } from './components/document-detail/document-detail.component'
|
||||||
import { DashboardComponent } from './components/dashboard/dashboard.component'
|
import { DashboardComponent } from './components/dashboard/dashboard.component'
|
||||||
@@ -31,12 +35,13 @@ import { ToastsComponent } from './components/common/toasts/toasts.component'
|
|||||||
import { FilterEditorComponent } from './components/document-list/filter-editor/filter-editor.component'
|
import { FilterEditorComponent } from './components/document-list/filter-editor/filter-editor.component'
|
||||||
import { FilterableDropdownComponent } from './components/common/filterable-dropdown/filterable-dropdown.component'
|
import { FilterableDropdownComponent } from './components/common/filterable-dropdown/filterable-dropdown.component'
|
||||||
import { ToggleableDropdownButtonComponent } from './components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
|
import { ToggleableDropdownButtonComponent } from './components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
|
||||||
import { DateDropdownComponent } from './components/common/date-dropdown/date-dropdown.component'
|
import { DatesDropdownComponent } from './components/common/dates-dropdown/dates-dropdown.component'
|
||||||
import { DocumentCardLargeComponent } from './components/document-list/document-card-large/document-card-large.component'
|
import { DocumentCardLargeComponent } from './components/document-list/document-card-large/document-card-large.component'
|
||||||
import { DocumentCardSmallComponent } from './components/document-list/document-card-small/document-card-small.component'
|
import { DocumentCardSmallComponent } from './components/document-list/document-card-small/document-card-small.component'
|
||||||
import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component'
|
import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component'
|
||||||
import { NgxFileDropModule } from 'ngx-file-drop'
|
import { NgxFileDropModule } from 'ngx-file-drop'
|
||||||
import { TextComponent } from './components/common/input/text/text.component'
|
import { TextComponent } from './components/common/input/text/text.component'
|
||||||
|
import { TextAreaComponent } from './components/common/input/textarea/textarea.component'
|
||||||
import { SelectComponent } from './components/common/input/select/select.component'
|
import { SelectComponent } from './components/common/input/select/select.component'
|
||||||
import { CheckComponent } from './components/common/input/check/check.component'
|
import { CheckComponent } from './components/common/input/check/check.component'
|
||||||
import { UrlComponent } from './components/common/input/url/url.component'
|
import { UrlComponent } from './components/common/input/url/url.component'
|
||||||
@@ -104,8 +109,9 @@ import { FileDropComponent } from './components/file-drop/file-drop.component'
|
|||||||
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
||||||
import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
|
import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||||
|
import { CustomFieldsQueryDropdownComponent } from './components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
|
||||||
import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
import { PdfViewerComponent } from './components/common/pdf-viewer/pdf-viewer.component'
|
import { PdfViewerModule } from 'ng2-pdf-viewer'
|
||||||
import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component'
|
import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component'
|
||||||
import { PreviewPopupComponent } from './components/common/preview-popup/preview-popup.component'
|
import { PreviewPopupComponent } from './components/common/preview-popup/preview-popup.component'
|
||||||
import { SwitchComponent } from './components/common/input/switch/switch.component'
|
import { SwitchComponent } from './components/common/input/switch/switch.component'
|
||||||
@@ -115,10 +121,20 @@ import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|||||||
import { ConfirmButtonComponent } from './components/common/confirm-button/confirm-button.component'
|
import { ConfirmButtonComponent } from './components/common/confirm-button/confirm-button.component'
|
||||||
import { MonetaryComponent } from './components/common/input/monetary/monetary.component'
|
import { MonetaryComponent } from './components/common/input/monetary/monetary.component'
|
||||||
import { SystemStatusDialogComponent } from './components/common/system-status-dialog/system-status-dialog.component'
|
import { SystemStatusDialogComponent } from './components/common/system-status-dialog/system-status-dialog.component'
|
||||||
import { NgxFilesizeModule } from 'ngx-filesize'
|
import { RotateConfirmDialogComponent } from './components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||||
|
import { MergeConfirmDialogComponent } from './components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||||
|
import { SplitConfirmDialogComponent } from './components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
|
||||||
|
import { DocumentHistoryComponent } from './components/document-history/document-history.component'
|
||||||
|
import { DragDropSelectComponent } from './components/common/input/drag-drop-select/drag-drop-select.component'
|
||||||
|
import { CustomFieldDisplayComponent } from './components/common/custom-field-display/custom-field-display.component'
|
||||||
|
import { GlobalSearchComponent } from './components/app-frame/global-search/global-search.component'
|
||||||
|
import { HotkeyDialogComponent } from './components/common/hotkey-dialog/hotkey-dialog.component'
|
||||||
|
import { DeletePagesConfirmDialogComponent } from './components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component'
|
||||||
|
import { TrashComponent } from './components/admin/trash/trash.component'
|
||||||
import {
|
import {
|
||||||
airplane,
|
airplane,
|
||||||
archive,
|
archive,
|
||||||
|
arrowClockwise,
|
||||||
arrowCounterclockwise,
|
arrowCounterclockwise,
|
||||||
arrowDown,
|
arrowDown,
|
||||||
arrowLeft,
|
arrowLeft,
|
||||||
@@ -127,12 +143,17 @@ import {
|
|||||||
arrowRightShort,
|
arrowRightShort,
|
||||||
arrowUpRight,
|
arrowUpRight,
|
||||||
asterisk,
|
asterisk,
|
||||||
|
braces,
|
||||||
|
bodyText,
|
||||||
|
boxArrowInRight,
|
||||||
boxArrowUp,
|
boxArrowUp,
|
||||||
boxArrowUpRight,
|
boxArrowUpRight,
|
||||||
boxes,
|
boxes,
|
||||||
calendar,
|
calendar,
|
||||||
calendarEvent,
|
calendarEvent,
|
||||||
|
calendarEventFill,
|
||||||
cardChecklist,
|
cardChecklist,
|
||||||
|
cardHeading,
|
||||||
caretDown,
|
caretDown,
|
||||||
caretUp,
|
caretUp,
|
||||||
chatLeftText,
|
chatLeftText,
|
||||||
@@ -148,11 +169,14 @@ import {
|
|||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
clipboardFill,
|
clipboardFill,
|
||||||
dash,
|
dash,
|
||||||
|
dashCircle,
|
||||||
diagram3,
|
diagram3,
|
||||||
dice5,
|
dice5,
|
||||||
doorOpen,
|
doorOpen,
|
||||||
download,
|
download,
|
||||||
envelope,
|
envelope,
|
||||||
|
envelopeAt,
|
||||||
|
envelopeAtFill,
|
||||||
exclamationCircleFill,
|
exclamationCircleFill,
|
||||||
exclamationTriangle,
|
exclamationTriangle,
|
||||||
exclamationTriangleFill,
|
exclamationTriangleFill,
|
||||||
@@ -161,6 +185,7 @@ import {
|
|||||||
fileEarmarkCheck,
|
fileEarmarkCheck,
|
||||||
fileEarmarkFill,
|
fileEarmarkFill,
|
||||||
fileEarmarkLock,
|
fileEarmarkLock,
|
||||||
|
fileEarmarkMinus,
|
||||||
files,
|
files,
|
||||||
fileText,
|
fileText,
|
||||||
filter,
|
filter,
|
||||||
@@ -168,15 +193,19 @@ import {
|
|||||||
folderFill,
|
folderFill,
|
||||||
funnel,
|
funnel,
|
||||||
gear,
|
gear,
|
||||||
|
google,
|
||||||
grid,
|
grid,
|
||||||
gripVertical,
|
gripVertical,
|
||||||
hash,
|
hash,
|
||||||
hddStack,
|
hddStack,
|
||||||
house,
|
house,
|
||||||
infoCircle,
|
infoCircle,
|
||||||
|
journals,
|
||||||
link,
|
link,
|
||||||
listTask,
|
listTask,
|
||||||
listUl,
|
listUl,
|
||||||
|
microsoft,
|
||||||
|
nodePlus,
|
||||||
pencil,
|
pencil,
|
||||||
people,
|
people,
|
||||||
peopleFill,
|
peopleFill,
|
||||||
@@ -185,15 +214,18 @@ import {
|
|||||||
personFill,
|
personFill,
|
||||||
personFillLock,
|
personFillLock,
|
||||||
personLock,
|
personLock,
|
||||||
|
personSquare,
|
||||||
plus,
|
plus,
|
||||||
plusCircle,
|
plusCircle,
|
||||||
questionCircle,
|
questionCircle,
|
||||||
|
scissors,
|
||||||
search,
|
search,
|
||||||
slashCircle,
|
slashCircle,
|
||||||
sliders2Vertical,
|
sliders2Vertical,
|
||||||
sortAlphaDown,
|
sortAlphaDown,
|
||||||
sortAlphaUpAlt,
|
sortAlphaUpAlt,
|
||||||
tagFill,
|
tagFill,
|
||||||
|
tag,
|
||||||
tags,
|
tags,
|
||||||
textIndentLeft,
|
textIndentLeft,
|
||||||
textLeft,
|
textLeft,
|
||||||
@@ -203,12 +235,14 @@ import {
|
|||||||
uiRadios,
|
uiRadios,
|
||||||
upcScan,
|
upcScan,
|
||||||
x,
|
x,
|
||||||
|
xCircle,
|
||||||
xLg,
|
xLg,
|
||||||
} from 'ngx-bootstrap-icons'
|
} from 'ngx-bootstrap-icons'
|
||||||
|
|
||||||
const icons = {
|
const icons = {
|
||||||
airplane,
|
airplane,
|
||||||
archive,
|
archive,
|
||||||
|
arrowClockwise,
|
||||||
arrowCounterclockwise,
|
arrowCounterclockwise,
|
||||||
arrowDown,
|
arrowDown,
|
||||||
arrowLeft,
|
arrowLeft,
|
||||||
@@ -217,12 +251,17 @@ const icons = {
|
|||||||
arrowRightShort,
|
arrowRightShort,
|
||||||
arrowUpRight,
|
arrowUpRight,
|
||||||
asterisk,
|
asterisk,
|
||||||
|
braces,
|
||||||
|
bodyText,
|
||||||
|
boxArrowInRight,
|
||||||
boxArrowUp,
|
boxArrowUp,
|
||||||
boxArrowUpRight,
|
boxArrowUpRight,
|
||||||
boxes,
|
boxes,
|
||||||
calendar,
|
calendar,
|
||||||
calendarEvent,
|
calendarEvent,
|
||||||
|
calendarEventFill,
|
||||||
cardChecklist,
|
cardChecklist,
|
||||||
|
cardHeading,
|
||||||
caretDown,
|
caretDown,
|
||||||
caretUp,
|
caretUp,
|
||||||
chatLeftText,
|
chatLeftText,
|
||||||
@@ -238,11 +277,14 @@ const icons = {
|
|||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
clipboardFill,
|
clipboardFill,
|
||||||
dash,
|
dash,
|
||||||
|
dashCircle,
|
||||||
diagram3,
|
diagram3,
|
||||||
dice5,
|
dice5,
|
||||||
doorOpen,
|
doorOpen,
|
||||||
download,
|
download,
|
||||||
envelope,
|
envelope,
|
||||||
|
envelopeAt,
|
||||||
|
envelopeAtFill,
|
||||||
exclamationCircleFill,
|
exclamationCircleFill,
|
||||||
exclamationTriangle,
|
exclamationTriangle,
|
||||||
exclamationTriangleFill,
|
exclamationTriangleFill,
|
||||||
@@ -251,6 +293,7 @@ const icons = {
|
|||||||
fileEarmarkCheck,
|
fileEarmarkCheck,
|
||||||
fileEarmarkFill,
|
fileEarmarkFill,
|
||||||
fileEarmarkLock,
|
fileEarmarkLock,
|
||||||
|
fileEarmarkMinus,
|
||||||
files,
|
files,
|
||||||
fileText,
|
fileText,
|
||||||
filter,
|
filter,
|
||||||
@@ -258,15 +301,19 @@ const icons = {
|
|||||||
folderFill,
|
folderFill,
|
||||||
funnel,
|
funnel,
|
||||||
gear,
|
gear,
|
||||||
|
google,
|
||||||
grid,
|
grid,
|
||||||
gripVertical,
|
gripVertical,
|
||||||
hash,
|
hash,
|
||||||
hddStack,
|
hddStack,
|
||||||
house,
|
house,
|
||||||
infoCircle,
|
infoCircle,
|
||||||
|
journals,
|
||||||
link,
|
link,
|
||||||
listTask,
|
listTask,
|
||||||
listUl,
|
listUl,
|
||||||
|
microsoft,
|
||||||
|
nodePlus,
|
||||||
pencil,
|
pencil,
|
||||||
people,
|
people,
|
||||||
peopleFill,
|
peopleFill,
|
||||||
@@ -275,15 +322,18 @@ const icons = {
|
|||||||
personFill,
|
personFill,
|
||||||
personFillLock,
|
personFillLock,
|
||||||
personLock,
|
personLock,
|
||||||
|
personSquare,
|
||||||
plus,
|
plus,
|
||||||
plusCircle,
|
plusCircle,
|
||||||
questionCircle,
|
questionCircle,
|
||||||
|
scissors,
|
||||||
search,
|
search,
|
||||||
slashCircle,
|
slashCircle,
|
||||||
sliders2Vertical,
|
sliders2Vertical,
|
||||||
sortAlphaDown,
|
sortAlphaDown,
|
||||||
sortAlphaUpAlt,
|
sortAlphaUpAlt,
|
||||||
tagFill,
|
tagFill,
|
||||||
|
tag,
|
||||||
tags,
|
tags,
|
||||||
textIndentLeft,
|
textIndentLeft,
|
||||||
textLeft,
|
textLeft,
|
||||||
@@ -293,6 +343,7 @@ const icons = {
|
|||||||
uiRadios,
|
uiRadios,
|
||||||
upcScan,
|
upcScan,
|
||||||
x,
|
x,
|
||||||
|
xCircle,
|
||||||
xLg,
|
xLg,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +363,7 @@ import localeFr from '@angular/common/locales/fr'
|
|||||||
import localeHu from '@angular/common/locales/hu'
|
import localeHu from '@angular/common/locales/hu'
|
||||||
import localeIt from '@angular/common/locales/it'
|
import localeIt from '@angular/common/locales/it'
|
||||||
import localeJa from '@angular/common/locales/ja'
|
import localeJa from '@angular/common/locales/ja'
|
||||||
|
import localeKo from '@angular/common/locales/ko'
|
||||||
import localeLb from '@angular/common/locales/lb'
|
import localeLb from '@angular/common/locales/lb'
|
||||||
import localeNl from '@angular/common/locales/nl'
|
import localeNl from '@angular/common/locales/nl'
|
||||||
import localeNo from '@angular/common/locales/no'
|
import localeNo from '@angular/common/locales/no'
|
||||||
@@ -343,6 +395,7 @@ registerLocaleData(localeFr)
|
|||||||
registerLocaleData(localeHu)
|
registerLocaleData(localeHu)
|
||||||
registerLocaleData(localeIt)
|
registerLocaleData(localeIt)
|
||||||
registerLocaleData(localeJa)
|
registerLocaleData(localeJa)
|
||||||
|
registerLocaleData(localeKo)
|
||||||
registerLocaleData(localeLb)
|
registerLocaleData(localeLb)
|
||||||
registerLocaleData(localeNl)
|
registerLocaleData(localeNl)
|
||||||
registerLocaleData(localeNo)
|
registerLocaleData(localeNo)
|
||||||
@@ -391,11 +444,12 @@ function initializeApp(settings: SettingsService) {
|
|||||||
FilterEditorComponent,
|
FilterEditorComponent,
|
||||||
FilterableDropdownComponent,
|
FilterableDropdownComponent,
|
||||||
ToggleableDropdownButtonComponent,
|
ToggleableDropdownButtonComponent,
|
||||||
DateDropdownComponent,
|
DatesDropdownComponent,
|
||||||
DocumentCardLargeComponent,
|
DocumentCardLargeComponent,
|
||||||
DocumentCardSmallComponent,
|
DocumentCardSmallComponent,
|
||||||
BulkEditorComponent,
|
BulkEditorComponent,
|
||||||
TextComponent,
|
TextComponent,
|
||||||
|
TextAreaComponent,
|
||||||
SelectComponent,
|
SelectComponent,
|
||||||
CheckComponent,
|
CheckComponent,
|
||||||
UrlComponent,
|
UrlComponent,
|
||||||
@@ -448,8 +502,8 @@ function initializeApp(settings: SettingsService) {
|
|||||||
CustomFieldsComponent,
|
CustomFieldsComponent,
|
||||||
CustomFieldEditDialogComponent,
|
CustomFieldEditDialogComponent,
|
||||||
CustomFieldsDropdownComponent,
|
CustomFieldsDropdownComponent,
|
||||||
|
CustomFieldsQueryDropdownComponent,
|
||||||
ProfileEditDialogComponent,
|
ProfileEditDialogComponent,
|
||||||
PdfViewerComponent,
|
|
||||||
DocumentLinkComponent,
|
DocumentLinkComponent,
|
||||||
PreviewPopupComponent,
|
PreviewPopupComponent,
|
||||||
SwitchComponent,
|
SwitchComponent,
|
||||||
@@ -458,21 +512,31 @@ function initializeApp(settings: SettingsService) {
|
|||||||
ConfirmButtonComponent,
|
ConfirmButtonComponent,
|
||||||
MonetaryComponent,
|
MonetaryComponent,
|
||||||
SystemStatusDialogComponent,
|
SystemStatusDialogComponent,
|
||||||
|
RotateConfirmDialogComponent,
|
||||||
|
MergeConfirmDialogComponent,
|
||||||
|
SplitConfirmDialogComponent,
|
||||||
|
DocumentHistoryComponent,
|
||||||
|
DragDropSelectComponent,
|
||||||
|
CustomFieldDisplayComponent,
|
||||||
|
GlobalSearchComponent,
|
||||||
|
HotkeyDialogComponent,
|
||||||
|
DeletePagesConfirmDialogComponent,
|
||||||
|
TrashComponent,
|
||||||
],
|
],
|
||||||
|
bootstrap: [AppComponent],
|
||||||
imports: [
|
imports: [
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
AppRoutingModule,
|
AppRoutingModule,
|
||||||
NgbModule,
|
NgbModule,
|
||||||
HttpClientModule,
|
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
|
PdfViewerModule,
|
||||||
NgxFileDropModule,
|
NgxFileDropModule,
|
||||||
NgSelectModule,
|
NgSelectModule,
|
||||||
ColorSliderModule,
|
ColorSliderModule,
|
||||||
TourNgBootstrapModule,
|
TourNgBootstrapModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
NgxBootstrapIconsModule.pick(icons),
|
NgxBootstrapIconsModule.pick(icons),
|
||||||
NgxFilesizeModule,
|
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
@@ -501,7 +565,7 @@ function initializeApp(settings: SettingsService) {
|
|||||||
DirtyDocGuard,
|
DirtyDocGuard,
|
||||||
DirtySavedViewGuard,
|
DirtySavedViewGuard,
|
||||||
UsernamePipe,
|
UsernamePipe,
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
],
|
],
|
||||||
bootstrap: [AppComponent],
|
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
@@ -11,7 +11,7 @@
|
|||||||
<ul ngbNav #nav="ngbNav" class="nav-tabs">
|
<ul ngbNav #nav="ngbNav" class="nav-tabs">
|
||||||
@for (category of optionCategories; track category) {
|
@for (category of optionCategories; track category) {
|
||||||
<li [ngbNavItem]="category">
|
<li [ngbNavItem]="category">
|
||||||
<a ngbNavLink i18n>{{category}}</a>
|
<a ngbNavLink>{{category}}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
||||||
|
@@ -5,7 +5,7 @@ import { ConfigService } from 'src/app/services/config.service'
|
|||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { OutputTypeConfig } from 'src/app/data/paperless-config'
|
import { OutputTypeConfig } from 'src/app/data/paperless-config'
|
||||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import { BrowserModule } from '@angular/platform-browser'
|
import { BrowserModule } from '@angular/platform-browser'
|
||||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
@@ -18,6 +18,7 @@ import { SelectComponent } from '../../common/input/select/select.component'
|
|||||||
import { FileComponent } from '../../common/input/file/file.component'
|
import { FileComponent } from '../../common/input/file/file.component'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
|
||||||
describe('ConfigComponent', () => {
|
describe('ConfigComponent', () => {
|
||||||
let component: ConfigComponent
|
let component: ConfigComponent
|
||||||
@@ -38,7 +39,6 @@ describe('ConfigComponent', () => {
|
|||||||
PageHeaderComponent,
|
PageHeaderComponent,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
HttpClientTestingModule,
|
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
NgbModule,
|
NgbModule,
|
||||||
NgSelectModule,
|
NgSelectModule,
|
||||||
@@ -46,6 +46,10 @@ describe('ConfigComponent', () => {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
],
|
],
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
configService = TestBed.inject(ConfigService)
|
configService = TestBed.inject(ConfigService)
|
||||||
|
@@ -8,10 +8,11 @@ import { LogService } from 'src/app/services/rest/log.service'
|
|||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { LogsComponent } from './logs.component'
|
import { LogsComponent } from './logs.component'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import { NgbModule, NgbNavLink } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModule, NgbNavLink } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { BrowserModule, By } from '@angular/platform-browser'
|
import { BrowserModule, By } from '@angular/platform-browser'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
|
||||||
const paperless_logs = [
|
const paperless_logs = [
|
||||||
'[2023-05-29 03:05:01,224] [DEBUG] [paperless.tasks] Training data unchanged.',
|
'[2023-05-29 03:05:01,224] [DEBUG] [paperless.tasks] Training data unchanged.',
|
||||||
@@ -37,13 +38,15 @@ describe('LogsComponent', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
declarations: [LogsComponent, PageHeaderComponent],
|
declarations: [LogsComponent, PageHeaderComponent],
|
||||||
providers: [],
|
|
||||||
imports: [
|
imports: [
|
||||||
HttpClientTestingModule,
|
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
NgbModule,
|
NgbModule,
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
],
|
],
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
logService = TestBed.inject(LogService)
|
logService = TestBed.inject(LogService)
|
||||||
|
@@ -7,9 +7,9 @@
|
|||||||
<button class="btn btn-sm btn-outline-primary" (click)="tourService.start()">
|
<button class="btn btn-sm btn-outline-primary" (click)="tourService.start()">
|
||||||
<i-bs class="me-1" name="airplane"></i-bs> <ng-container i18n>Start tour</ng-container>
|
<i-bs class="me-1" name="airplane"></i-bs> <ng-container i18n>Start tour</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
@if (permissionsService.isAdmin()) {
|
||||||
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
|
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
|
||||||
[disabled]="!systemStatus"
|
[disabled]="!systemStatus">
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }">
|
|
||||||
@if (!systemStatus) {
|
@if (!systemStatus) {
|
||||||
<div class="spinner-border spinner-border-sm me-1 h-75" role="status"></div>
|
<div class="spinner-border spinner-border-sm me-1 h-75" role="status"></div>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -26,10 +26,11 @@
|
|||||||
}
|
}
|
||||||
<ng-container i18n>System Status</ng-container>
|
<ng-container i18n>System Status</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<a *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }" class="btn btn-sm btn-primary" href="admin/" target="_blank">
|
<a class="btn btn-sm btn-primary" href="admin/" target="_blank">
|
||||||
<ng-container i18n>Open Django Admin</ng-container>
|
<ng-container i18n>Open Django Admin</ng-container>
|
||||||
<i-bs name="arrow-up-right"></i-bs>
|
<i-bs name="arrow-up-right"></i-bs>
|
||||||
</a>
|
</a>
|
||||||
|
}
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
|
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
|
||||||
@@ -191,11 +192,35 @@
|
|||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="offset-md-3 col">
|
<div class="offset-md-3 col">
|
||||||
<pngx-input-check i18n-title title="Show confirmation dialogs" formControlName="bulkEditConfirmationDialogs" i18n-hint hint="Deleting documents will always ask for confirmation."></pngx-input-check>
|
<pngx-input-check i18n-title title="Show confirmation dialogs" formControlName="bulkEditConfirmationDialogs"></pngx-input-check>
|
||||||
<pngx-input-check i18n-title title="Apply on close" formControlName="bulkEditApplyOnClose"></pngx-input-check>
|
<pngx-input-check i18n-title title="Apply on close" formControlName="bulkEditApplyOnClose"></pngx-input-check>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h4 class="mt-4" i18n>Global search</h4>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="offset-md-3 col">
|
||||||
|
<pngx-input-check i18n-title title="Do not include advanced search results" formControlName="searchDbOnly"></pngx-input-check>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="offset-md-3 col">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-2 col-form-label pt-0">
|
||||||
|
<span i18n>Full search links to</span>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<select class="form-select" formControlName="searchLink">
|
||||||
|
<option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
|
||||||
|
<option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h4 class="mt-4" i18n>Notes</h4>
|
<h4 class="mt-4" i18n>Notes</h4>
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
@@ -307,7 +332,7 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li [ngbNavItem]="SettingsNavIDs.SavedViews">
|
<li [ngbNavItem]="SettingsNavIDs.SavedViews" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.SavedView }">
|
||||||
<a ngbNavLink i18n>Saved views</a>
|
<a ngbNavLink i18n>Saved views</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
|
|
||||||
@@ -319,17 +344,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4 i18n>Views</h4>
|
<h4 i18n>Views</h4>
|
||||||
<div formGroupName="savedViews">
|
<ul class="list-group" formGroupName="savedViews">
|
||||||
|
|
||||||
@for (view of savedViews; track view) {
|
@for (view of savedViews; track view) {
|
||||||
<div [formGroupName]="view.id" class="row">
|
<li class="list-group-item py-3">
|
||||||
<div class="mb-3 col">
|
<div [formGroupName]="view.id">
|
||||||
<label class="form-label" for="name_{{view.id}}" i18n>Name</label>
|
<div class="row">
|
||||||
<input type="text" class="form-control" formControlName="name" id="name_{{view.id}}">
|
<div class="col">
|
||||||
|
<pngx-input-text title="Name" formControlName="name"></pngx-input-text>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2 col">
|
<div class="col">
|
||||||
<label class="form-label" for="show_on_dashboard_{{view.id}}" i18n> <span class="visually-hidden">Appears on</span></label>
|
<div class="form-check form-switch mt-3">
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input type="checkbox" class="form-check-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard">
|
<input type="checkbox" class="form-check-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard">
|
||||||
<label class="form-check-label" for="show_on_dashboard_{{view.id}}" i18n>Show on dashboard</label>
|
<label class="form-check-label" for="show_on_dashboard_{{view.id}}" i18n>Show on dashboard</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -338,9 +363,8 @@
|
|||||||
<label class="form-check-label" for="show_in_sidebar_{{view.id}}" i18n>Show in sidebar</label>
|
<label class="form-check-label" for="show_in_sidebar_{{view.id}}" i18n>Show in sidebar</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2 col-auto">
|
<div class="col-auto">
|
||||||
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
|
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
|
||||||
|
|
||||||
<pngx-confirm-button
|
<pngx-confirm-button
|
||||||
label="Delete"
|
label="Delete"
|
||||||
i18n-label
|
i18n-label
|
||||||
@@ -351,20 +375,40 @@
|
|||||||
</pngx-confirm-button>
|
</pngx-confirm-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<pngx-input-number i18n-title title="Documents page size" [showAdd]="false" formControlName="page_size"></pngx-input-number>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label" for="display_mode_{{view.id}}" i18n>Display as</label>
|
||||||
|
<select class="form-select" formControlName="display_mode">
|
||||||
|
<option [ngValue]="DisplayMode.TABLE" i18n>Table</option>
|
||||||
|
<option [ngValue]="DisplayMode.SMALL_CARDS" i18n>Small Cards</option>
|
||||||
|
<option [ngValue]="DisplayMode.LARGE_CARDS" i18n>Large Cards</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
@if (displayFields) {
|
||||||
|
<pngx-input-drag-drop-select i18n-title title="Show" i18n-emptyText emptyText="Default" [items]="displayFields" formControlName="display_fields"></pngx-input-drag-drop-select>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (savedViews && savedViews.length === 0) {
|
@if (savedViews && savedViews.length === 0) {
|
||||||
|
<li class="list-group-item">
|
||||||
<div i18n>No saved views defined.</div>
|
<div i18n>No saved views defined.</div>
|
||||||
|
</li>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!savedViews) {
|
@if (!savedViews) {
|
||||||
<div>
|
<li class="list-group-item">
|
||||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||||
<div class="visually-hidden" i18n>Loading...</div>
|
<div class="visually-hidden" i18n>Loading...</div>
|
||||||
</div>
|
</li>
|
||||||
}
|
}
|
||||||
|
|
||||||
</div>
|
</ul>
|
||||||
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
@@ -373,4 +417,5 @@
|
|||||||
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
|
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
|
<button type="submit" class="btn btn-primary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
|
||||||
|
<button type="button" (click)="reset()" class="btn btn-secondary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button>
|
||||||
</form>
|
</form>
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { ViewportScroller, DatePipe } from '@angular/common'
|
import { ViewportScroller, DatePipe } from '@angular/common'
|
||||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
@@ -48,6 +48,9 @@ import {
|
|||||||
InstallType,
|
InstallType,
|
||||||
SystemStatusItemStatus,
|
SystemStatusItemStatus,
|
||||||
} from 'src/app/data/system-status'
|
} from 'src/app/data/system-status'
|
||||||
|
import { DragDropSelectComponent } from '../../common/input/drag-drop-select/drag-drop-select.component'
|
||||||
|
import { DragDropModule } from '@angular/cdk/drag-drop'
|
||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
|
||||||
const savedViews = [
|
const savedViews = [
|
||||||
{ id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true },
|
{ id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true },
|
||||||
@@ -96,11 +99,10 @@ describe('SettingsComponent', () => {
|
|||||||
PermissionsGroupComponent,
|
PermissionsGroupComponent,
|
||||||
IfOwnerDirective,
|
IfOwnerDirective,
|
||||||
ConfirmButtonComponent,
|
ConfirmButtonComponent,
|
||||||
|
DragDropSelectComponent,
|
||||||
],
|
],
|
||||||
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
|
|
||||||
imports: [
|
imports: [
|
||||||
NgbModule,
|
NgbModule,
|
||||||
HttpClientTestingModule,
|
|
||||||
RouterTestingModule.withRoutes(routes),
|
RouterTestingModule.withRoutes(routes),
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
@@ -108,6 +110,14 @@ describe('SettingsComponent', () => {
|
|||||||
NgSelectModule,
|
NgSelectModule,
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
NgbModalModule,
|
NgbModalModule,
|
||||||
|
DragDropModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
CustomDatePipe,
|
||||||
|
DatePipe,
|
||||||
|
PermissionsGuard,
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
@@ -305,7 +315,7 @@ describe('SettingsComponent', () => {
|
|||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
expect(storeSpy).toHaveBeenCalled()
|
expect(storeSpy).toHaveBeenCalled()
|
||||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||||
expect(setSpy).toHaveBeenCalledTimes(25)
|
expect(setSpy).toHaveBeenCalledTimes(27)
|
||||||
|
|
||||||
// succeed
|
// succeed
|
||||||
storeSpy.mockReturnValueOnce(of(true))
|
storeSpy.mockReturnValueOnce(of(true))
|
||||||
@@ -418,6 +428,7 @@ describe('SettingsComponent', () => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
|
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
|
||||||
|
jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true)
|
||||||
completeSetup()
|
completeSetup()
|
||||||
expect(component['systemStatus']).toEqual(status) // private
|
expect(component['systemStatus']).toEqual(status) // private
|
||||||
expect(component.systemStatusHasErrors).toBeTruthy()
|
expect(component.systemStatusHasErrors).toBeTruthy()
|
||||||
@@ -436,4 +447,11 @@ describe('SettingsComponent', () => {
|
|||||||
size: 'xl',
|
size: 'xl',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should support reset', () => {
|
||||||
|
completeSetup()
|
||||||
|
component.settingsForm.get('themeColor').setValue('#ff0000')
|
||||||
|
component.reset()
|
||||||
|
expect(component.settingsForm.get('themeColor').value).toEqual('')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@@ -27,7 +27,7 @@ import {
|
|||||||
} from 'rxjs'
|
} from 'rxjs'
|
||||||
import { Group } from 'src/app/data/group'
|
import { Group } from 'src/app/data/group'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
SystemStatusItemStatus,
|
SystemStatusItemStatus,
|
||||||
SystemStatus,
|
SystemStatus,
|
||||||
} from 'src/app/data/system-status'
|
} from 'src/app/data/system-status'
|
||||||
|
import { DisplayMode } from 'src/app/data/document'
|
||||||
|
|
||||||
enum SettingsNavIDs {
|
enum SettingsNavIDs {
|
||||||
General = 1,
|
General = 1,
|
||||||
@@ -73,8 +74,8 @@ export class SettingsComponent
|
|||||||
extends ComponentWithPermissions
|
extends ComponentWithPermissions
|
||||||
implements OnInit, AfterViewInit, OnDestroy, DirtyComponent
|
implements OnInit, AfterViewInit, OnDestroy, DirtyComponent
|
||||||
{
|
{
|
||||||
SettingsNavIDs = SettingsNavIDs
|
|
||||||
activeNavID: number
|
activeNavID: number
|
||||||
|
DisplayMode = DisplayMode
|
||||||
|
|
||||||
savedViewGroup = new FormGroup({})
|
savedViewGroup = new FormGroup({})
|
||||||
|
|
||||||
@@ -99,6 +100,8 @@ export class SettingsComponent
|
|||||||
defaultPermsEditUsers: new FormControl(null),
|
defaultPermsEditUsers: new FormControl(null),
|
||||||
defaultPermsEditGroups: new FormControl(null),
|
defaultPermsEditGroups: new FormControl(null),
|
||||||
documentEditingRemoveInboxTags: new FormControl(null),
|
documentEditingRemoveInboxTags: new FormControl(null),
|
||||||
|
searchDbOnly: new FormControl(null),
|
||||||
|
searchLink: new FormControl(null),
|
||||||
|
|
||||||
notificationsConsumerNewDocument: new FormControl(null),
|
notificationsConsumerNewDocument: new FormControl(null),
|
||||||
notificationsConsumerSuccess: new FormControl(null),
|
notificationsConsumerSuccess: new FormControl(null),
|
||||||
@@ -110,6 +113,10 @@ export class SettingsComponent
|
|||||||
})
|
})
|
||||||
|
|
||||||
savedViews: SavedView[]
|
savedViews: SavedView[]
|
||||||
|
SettingsNavIDs = SettingsNavIDs
|
||||||
|
get displayFields() {
|
||||||
|
return this.settings.allDisplayFields
|
||||||
|
}
|
||||||
|
|
||||||
store: BehaviorSubject<any>
|
store: BehaviorSubject<any>
|
||||||
storeSub: Subscription
|
storeSub: Subscription
|
||||||
@@ -121,7 +128,9 @@ export class SettingsComponent
|
|||||||
users: User[]
|
users: User[]
|
||||||
groups: Group[]
|
groups: Group[]
|
||||||
|
|
||||||
private systemStatus: SystemStatus
|
public systemStatus: SystemStatus
|
||||||
|
|
||||||
|
public readonly GlobalSearchType = GlobalSearchType
|
||||||
|
|
||||||
get systemStatusHasErrors(): boolean {
|
get systemStatusHasErrors(): boolean {
|
||||||
return (
|
return (
|
||||||
@@ -299,6 +308,8 @@ export class SettingsComponent
|
|||||||
documentEditingRemoveInboxTags: this.settings.get(
|
documentEditingRemoveInboxTags: this.settings.get(
|
||||||
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS
|
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS
|
||||||
),
|
),
|
||||||
|
searchDbOnly: this.settings.get(SETTINGS_KEYS.SEARCH_DB_ONLY),
|
||||||
|
searchLink: this.settings.get(SETTINGS_KEYS.SEARCH_FULL_TYPE),
|
||||||
savedViews: {},
|
savedViews: {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,6 +351,9 @@ export class SettingsComponent
|
|||||||
name: view.name,
|
name: view.name,
|
||||||
show_on_dashboard: view.show_on_dashboard,
|
show_on_dashboard: view.show_on_dashboard,
|
||||||
show_in_sidebar: view.show_in_sidebar,
|
show_in_sidebar: view.show_in_sidebar,
|
||||||
|
page_size: view.page_size,
|
||||||
|
display_mode: view.display_mode,
|
||||||
|
display_fields: view.display_fields,
|
||||||
}
|
}
|
||||||
this.savedViewGroup.addControl(
|
this.savedViewGroup.addControl(
|
||||||
view.id.toString(),
|
view.id.toString(),
|
||||||
@@ -348,6 +362,9 @@ export class SettingsComponent
|
|||||||
name: new FormControl(null),
|
name: new FormControl(null),
|
||||||
show_on_dashboard: new FormControl(null),
|
show_on_dashboard: new FormControl(null),
|
||||||
show_in_sidebar: new FormControl(null),
|
show_in_sidebar: new FormControl(null),
|
||||||
|
page_size: new FormControl(null),
|
||||||
|
display_mode: new FormControl(null),
|
||||||
|
display_fields: new FormControl([]),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -385,12 +402,7 @@ export class SettingsComponent
|
|||||||
this.settingsForm.patchValue(currentFormValue)
|
this.settingsForm.patchValue(currentFormValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (this.permissionsService.isAdmin()) {
|
||||||
this.permissionsService.currentUserCan(
|
|
||||||
PermissionAction.View,
|
|
||||||
PermissionType.Admin
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
this.systemStatusService.get().subscribe((status) => {
|
this.systemStatusService.get().subscribe((status) => {
|
||||||
this.systemStatus = status
|
this.systemStatus = status
|
||||||
})
|
})
|
||||||
@@ -527,6 +539,14 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS,
|
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS,
|
||||||
this.settingsForm.value.documentEditingRemoveInboxTags
|
this.settingsForm.value.documentEditingRemoveInboxTags
|
||||||
)
|
)
|
||||||
|
this.settings.set(
|
||||||
|
SETTINGS_KEYS.SEARCH_DB_ONLY,
|
||||||
|
this.settingsForm.value.searchDbOnly
|
||||||
|
)
|
||||||
|
this.settings.set(
|
||||||
|
SETTINGS_KEYS.SEARCH_FULL_TYPE,
|
||||||
|
this.settingsForm.value.searchLink
|
||||||
|
)
|
||||||
this.settings.setLanguage(this.settingsForm.value.displayLanguage)
|
this.settings.setLanguage(this.settingsForm.value.displayLanguage)
|
||||||
this.settings
|
this.settings
|
||||||
.storeSettings()
|
.storeSettings()
|
||||||
@@ -535,8 +555,8 @@ export class SettingsComponent
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.store.next(this.settingsForm.value)
|
this.store.next(this.settingsForm.value)
|
||||||
this.documentListViewService.updatePageSize()
|
|
||||||
this.settings.updateAppearanceSettings()
|
this.settings.updateAppearanceSettings()
|
||||||
|
this.settings.initializeDisplayFields()
|
||||||
let savedToast: Toast = {
|
let savedToast: Toast = {
|
||||||
content: $localize`Settings were saved successfully.`,
|
content: $localize`Settings were saved successfully.`,
|
||||||
delay: 5000,
|
delay: 5000,
|
||||||
@@ -597,6 +617,10 @@ export class SettingsComponent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.settingsForm.patchValue(this.store.getValue())
|
||||||
|
}
|
||||||
|
|
||||||
clearThemeColor() {
|
clearThemeColor() {
|
||||||
this.settingsForm.get('themeColor').patchValue('')
|
this.settingsForm.get('themeColor').patchValue('')
|
||||||
}
|
}
|
||||||
|
@@ -29,7 +29,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="col">
|
<th scope="col">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input type="checkbox" class="form-check-input" id="all-tasks" [disabled]="currentTasks.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
|
<input type="checkbox" class="form-check-input" id="all-tasks" [disabled]="currentTasks.length === 0" [(ngModel)]="togggleAll" (click)="toggleAll($event); $event.stopPropagation();">
|
||||||
<label class="form-check-label" for="all-tasks"></label>
|
<label class="form-check-label" for="all-tasks"></label>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { DatePipe } from '@angular/common'
|
import { DatePipe } from '@angular/common'
|
||||||
import {
|
import {
|
||||||
HttpTestingController,
|
HttpTestingController,
|
||||||
HttpClientTestingModule,
|
provideHttpClientTesting,
|
||||||
} from '@angular/common/http/testing'
|
} from '@angular/common/http/testing'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
@@ -29,6 +29,8 @@ import { PageHeaderComponent } from '../../common/page-header/page-header.compon
|
|||||||
import { TasksComponent } from './tasks.component'
|
import { TasksComponent } from './tasks.component'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
|
||||||
const tasks: PaperlessTask[] = [
|
const tasks: PaperlessTask[] = [
|
||||||
{
|
{
|
||||||
@@ -124,6 +126,12 @@ describe('TasksComponent', () => {
|
|||||||
CustomDatePipe,
|
CustomDatePipe,
|
||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
],
|
],
|
||||||
|
imports: [
|
||||||
|
NgbModule,
|
||||||
|
RouterTestingModule.withRoutes(routes),
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
FormsModule,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: PermissionsService,
|
provide: PermissionsService,
|
||||||
@@ -134,12 +142,8 @@ describe('TasksComponent', () => {
|
|||||||
CustomDatePipe,
|
CustomDatePipe,
|
||||||
DatePipe,
|
DatePipe,
|
||||||
PermissionsGuard,
|
PermissionsGuard,
|
||||||
],
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
imports: [
|
provideHttpClientTesting(),
|
||||||
NgbModule,
|
|
||||||
HttpClientTestingModule,
|
|
||||||
RouterTestingModule.withRoutes(routes),
|
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
|
@@ -18,6 +18,7 @@ export class TasksComponent
|
|||||||
{
|
{
|
||||||
public activeTab: string
|
public activeTab: string
|
||||||
public selectedTasks: Set<number> = new Set()
|
public selectedTasks: Set<number> = new Set()
|
||||||
|
public togggleAll: boolean = false
|
||||||
public expandedTask: number
|
public expandedTask: number
|
||||||
|
|
||||||
public pageSize: number = 25
|
public pageSize: number = 25
|
||||||
@@ -69,11 +70,11 @@ export class TasksComponent
|
|||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled = false
|
||||||
modal.close()
|
modal.close()
|
||||||
this.tasksService.dismissTasks(tasks)
|
this.tasksService.dismissTasks(tasks)
|
||||||
this.selectedTasks.clear()
|
this.clearSelection()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.tasksService.dismissTasks(tasks)
|
this.tasksService.dismissTasks(tasks)
|
||||||
this.selectedTasks.clear()
|
this.clearSelection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +121,7 @@ export class TasksComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
|
this.togggleAll = false
|
||||||
this.selectedTasks.clear()
|
this.selectedTasks.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
98
src-ui/src/app/components/admin/trash/trash.component.html
Normal file
98
src-ui/src/app/components/admin/trash/trash.component.html
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<pngx-page-header
|
||||||
|
title="Trash"
|
||||||
|
i18n-title
|
||||||
|
info="Manage trashed documents that are pending deletion."
|
||||||
|
i18n-info
|
||||||
|
infoLink="usage/#document-trash">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedDocuments.size === 0">
|
||||||
|
<i-bs name="x"></i-bs> <ng-container i18n>Clear selection</ng-container>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="restoreAll(selectedDocuments)" [disabled]="selectedDocuments.size === 0">
|
||||||
|
<i-bs name="arrow-counterclockwise"></i-bs> <ng-container i18n>Restore selected</ng-container>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash(selectedDocuments)" [disabled]="selectedDocuments.size === 0">
|
||||||
|
<i-bs name="trash"></i-bs> <ng-container i18n>Delete selected</ng-container>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash()" [disabled]="documentsInTrash.length === 0">
|
||||||
|
<i-bs name="trash"></i-bs> <ng-container i18n>Empty trash</ng-container>
|
||||||
|
</button>
|
||||||
|
</pngx-page-header>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="totalDocuments" [(page)]="page" [maxSize]="5" (pageChange)="reload()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border table-responsive mb-3">
|
||||||
|
<table class="table table-striped align-middle shadow-sm mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">
|
||||||
|
<div class="form-check m-0 ms-2 me-n2">
|
||||||
|
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="allToggled" [disabled]="documentsInTrash.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
|
||||||
|
<label class="form-check-label" for="all-objects"></label>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="fw-normal" i18n>Name</th>
|
||||||
|
<th scope="col" class="fw-normal d-none d-sm-table-cell" i18n>Remaining</th>
|
||||||
|
<th scope="col" class="fw-normal" i18n>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (isLoading) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="5">
|
||||||
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
|
<ng-container i18n>Loading...</ng-container>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@for (document of documentsInTrash; track document.id) {
|
||||||
|
<tr (click)="toggleSelected(document); $event.stopPropagation();">
|
||||||
|
<td>
|
||||||
|
<div class="form-check m-0 ms-2 me-n2">
|
||||||
|
<input type="checkbox" class="form-check-input" id="{{document.id}}" [checked]="selectedDocuments.has(document.id)" (click)="toggleSelected(document); $event.stopPropagation();">
|
||||||
|
<label class="form-check-label" for="{{document.id}}"></label>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td scope="row">{{ document.title }}</td>
|
||||||
|
<td scope="row" i18n>{{ getDaysRemaining(document) }} days</td>
|
||||||
|
<td scope="row">
|
||||||
|
<div class="btn-group d-block d-sm-none">
|
||||||
|
<div ngbDropdown container="body" class="d-inline-block">
|
||||||
|
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||||
|
<i-bs name="three-dots-vertical"></i-bs>
|
||||||
|
</button>
|
||||||
|
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
||||||
|
<button (click)="restore(document)" ngbDropdownItem i18n>Restore</button>
|
||||||
|
<button (click)="delete(document)" ngbDropdownItem i18n>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group d-none d-sm-block">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" (click)="restore(document); $event.stopPropagation();">
|
||||||
|
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs> <ng-container i18n>Restore</ng-container>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" (click)="delete(document); $event.stopPropagation();">
|
||||||
|
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!isLoading) {
|
||||||
|
<div class="d-flex mb-2">
|
||||||
|
<div>
|
||||||
|
<ng-container i18n>{totalDocuments, plural, =1 {One document in trash} other {{{totalDocuments || 0}} total documents in trash}}</ng-container>
|
||||||
|
@if (selectedDocuments.size > 0) {
|
||||||
|
({{selectedDocuments.size}} selected)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (documentsInTrash.length > 20) {
|
||||||
|
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="totalDocuments" [(page)]="page" [maxSize]="5" (pageChange)="reload()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
200
src-ui/src/app/components/admin/trash/trash.component.spec.ts
Normal file
200
src-ui/src/app/components/admin/trash/trash.component.spec.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
|
||||||
|
import { TrashComponent } from './trash.component'
|
||||||
|
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||||
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
|
import {
|
||||||
|
NgbModal,
|
||||||
|
NgbPaginationModule,
|
||||||
|
NgbPopoverModule,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
|
import { TrashService } from 'src/app/services/trash.service'
|
||||||
|
import { of, throwError } from 'rxjs'
|
||||||
|
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||||
|
import { By } from '@angular/platform-browser'
|
||||||
|
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
|
||||||
|
const documentsInTrash = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'test1',
|
||||||
|
created: new Date('2023-03-01T10:26:03.093116Z'),
|
||||||
|
deleted_at: new Date('2023-03-01T10:26:03.093116Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'test2',
|
||||||
|
created: new Date('2023-03-01T10:26:03.093116Z'),
|
||||||
|
deleted_at: new Date('2023-03-01T10:26:03.093116Z'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('TrashComponent', () => {
|
||||||
|
let component: TrashComponent
|
||||||
|
let fixture: ComponentFixture<TrashComponent>
|
||||||
|
let trashService: TrashService
|
||||||
|
let modalService: NgbModal
|
||||||
|
let toastService: ToastService
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [
|
||||||
|
TrashComponent,
|
||||||
|
PageHeaderComponent,
|
||||||
|
ConfirmDialogComponent,
|
||||||
|
SafeHtmlPipe,
|
||||||
|
],
|
||||||
|
imports: [
|
||||||
|
HttpClientTestingModule,
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
NgbPopoverModule,
|
||||||
|
NgbPaginationModule,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(TrashComponent)
|
||||||
|
trashService = TestBed.inject(TrashService)
|
||||||
|
modalService = TestBed.inject(NgbModal)
|
||||||
|
toastService = TestBed.inject(ToastService)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call correct service method on reload', () => {
|
||||||
|
const trashSpy = jest.spyOn(trashService, 'getTrash')
|
||||||
|
trashSpy.mockReturnValue(
|
||||||
|
of({
|
||||||
|
count: 2,
|
||||||
|
all: documentsInTrash.map((d) => d.id),
|
||||||
|
results: documentsInTrash,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
component.reload()
|
||||||
|
expect(trashSpy).toHaveBeenCalled()
|
||||||
|
expect(component.documentsInTrash).toEqual(documentsInTrash)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support delete document, show error if needed', () => {
|
||||||
|
const trashSpy = jest.spyOn(trashService, 'emptyTrash')
|
||||||
|
let modal
|
||||||
|
modalService.activeInstances.subscribe((instances) => {
|
||||||
|
modal = instances[0]
|
||||||
|
})
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
|
||||||
|
// fail first
|
||||||
|
trashSpy.mockReturnValue(throwError(() => 'Error'))
|
||||||
|
component.delete(documentsInTrash[0])
|
||||||
|
modal.componentInstance.confirmClicked.next()
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
trashSpy.mockReturnValue(of('OK'))
|
||||||
|
component.delete(documentsInTrash[0])
|
||||||
|
expect(modal).toBeDefined()
|
||||||
|
modal.componentInstance.confirmClicked.next()
|
||||||
|
expect(trashSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support empty trash, show error if needed', () => {
|
||||||
|
const trashSpy = jest.spyOn(trashService, 'emptyTrash')
|
||||||
|
let modal
|
||||||
|
modalService.activeInstances.subscribe((instances) => {
|
||||||
|
modal = instances[instances.length - 1]
|
||||||
|
})
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
|
||||||
|
// fail first
|
||||||
|
trashSpy.mockReturnValue(throwError(() => 'Error'))
|
||||||
|
component.emptyTrash()
|
||||||
|
modal.componentInstance.confirmClicked.next()
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
trashSpy.mockReturnValue(of('OK'))
|
||||||
|
component.emptyTrash()
|
||||||
|
expect(modal).toBeDefined()
|
||||||
|
modal.componentInstance.confirmClicked.next()
|
||||||
|
expect(trashSpy).toHaveBeenCalled()
|
||||||
|
modal.close()
|
||||||
|
component.emptyTrash(new Set([1, 2]))
|
||||||
|
modal.componentInstance.confirmClicked.next()
|
||||||
|
expect(trashSpy).toHaveBeenCalledWith([1, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support restore document, show error if needed', () => {
|
||||||
|
const restoreSpy = jest.spyOn(trashService, 'restoreDocuments')
|
||||||
|
const reloadSpy = jest.spyOn(component, 'reload')
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
|
||||||
|
// fail first
|
||||||
|
restoreSpy.mockReturnValue(throwError(() => 'Error'))
|
||||||
|
component.restore(documentsInTrash[0])
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
|
expect(reloadSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
restoreSpy.mockReturnValue(of('OK'))
|
||||||
|
component.restore(documentsInTrash[0])
|
||||||
|
expect(restoreSpy).toHaveBeenCalledWith([documentsInTrash[0].id])
|
||||||
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support restore all documents, show error if needed', () => {
|
||||||
|
const restoreSpy = jest.spyOn(trashService, 'restoreDocuments')
|
||||||
|
const reloadSpy = jest.spyOn(component, 'reload')
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
|
||||||
|
// fail first
|
||||||
|
restoreSpy.mockReturnValue(throwError(() => 'Error'))
|
||||||
|
component.restoreAll()
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
|
expect(reloadSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
restoreSpy.mockReturnValue(of('OK'))
|
||||||
|
component.restoreAll()
|
||||||
|
expect(restoreSpy).toHaveBeenCalled()
|
||||||
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
|
component.restoreAll(new Set([1, 2]))
|
||||||
|
expect(restoreSpy).toHaveBeenCalledWith([1, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support toggle all items in view', () => {
|
||||||
|
component.documentsInTrash = documentsInTrash
|
||||||
|
expect(component.selectedDocuments.size).toEqual(0)
|
||||||
|
const toggleAllSpy = jest.spyOn(component, 'toggleAll')
|
||||||
|
const checkButton = fixture.debugElement.queryAll(
|
||||||
|
By.css('input.form-check-input')
|
||||||
|
)[0]
|
||||||
|
checkButton.nativeElement.dispatchEvent(new Event('click'))
|
||||||
|
checkButton.nativeElement.checked = true
|
||||||
|
checkButton.nativeElement.dispatchEvent(new Event('click'))
|
||||||
|
expect(toggleAllSpy).toHaveBeenCalled()
|
||||||
|
expect(component.selectedDocuments.size).toEqual(documentsInTrash.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support toggle item', () => {
|
||||||
|
component.selectedDocuments = new Set([1])
|
||||||
|
component.toggleSelected(documentsInTrash[0])
|
||||||
|
expect(component.selectedDocuments.size).toEqual(0)
|
||||||
|
component.toggleSelected(documentsInTrash[0])
|
||||||
|
expect(component.selectedDocuments.size).toEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support clear selection', () => {
|
||||||
|
component.selectedDocuments = new Set([1])
|
||||||
|
component.clearSelection()
|
||||||
|
expect(component.selectedDocuments.size).toEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should correctly display days remaining', () => {
|
||||||
|
expect(component.getDaysRemaining(documentsInTrash[0])).toBeLessThan(0)
|
||||||
|
const tenDaysAgo = new Date()
|
||||||
|
tenDaysAgo.setDate(tenDaysAgo.getDate() - 10)
|
||||||
|
expect(
|
||||||
|
component.getDaysRemaining({ deleted_at: tenDaysAgo })
|
||||||
|
).toBeGreaterThan(0) // 10 days ago but depends on month
|
||||||
|
})
|
||||||
|
})
|
165
src-ui/src/app/components/admin/trash/trash.component.ts
Normal file
165
src-ui/src/app/components/admin/trash/trash.component.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { Component, OnDestroy } from '@angular/core'
|
||||||
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { Document } from 'src/app/data/document'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { TrashService } from 'src/app/services/trash.service'
|
||||||
|
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||||
|
import { Subject, takeUntil } from 'rxjs'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-trash',
|
||||||
|
templateUrl: './trash.component.html',
|
||||||
|
styleUrl: './trash.component.scss',
|
||||||
|
})
|
||||||
|
export class TrashComponent implements OnDestroy {
|
||||||
|
public documentsInTrash: Document[] = []
|
||||||
|
public selectedDocuments: Set<number> = new Set()
|
||||||
|
public allToggled: boolean = false
|
||||||
|
public page: number = 1
|
||||||
|
public totalDocuments: number
|
||||||
|
public isLoading: boolean = false
|
||||||
|
unsubscribeNotifier: Subject<void> = new Subject()
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private trashService: TrashService,
|
||||||
|
private toastService: ToastService,
|
||||||
|
private modalService: NgbModal,
|
||||||
|
private settingsService: SettingsService
|
||||||
|
) {
|
||||||
|
this.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.unsubscribeNotifier.next()
|
||||||
|
this.unsubscribeNotifier.complete()
|
||||||
|
}
|
||||||
|
|
||||||
|
reload() {
|
||||||
|
this.isLoading = true
|
||||||
|
this.trashService.getTrash(this.page).subscribe((r) => {
|
||||||
|
this.documentsInTrash = r.results
|
||||||
|
this.totalDocuments = r.count
|
||||||
|
this.isLoading = false
|
||||||
|
this.selectedDocuments.clear()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(document: Document) {
|
||||||
|
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
modal.componentInstance.title = $localize`Confirm delete`
|
||||||
|
modal.componentInstance.messageBold = $localize`This operation will permanently delete this document.`
|
||||||
|
modal.componentInstance.message = $localize`This operation cannot be undone.`
|
||||||
|
modal.componentInstance.btnClass = 'btn-danger'
|
||||||
|
modal.componentInstance.btnCaption = $localize`Delete`
|
||||||
|
modal.componentInstance.confirmClicked
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => {
|
||||||
|
modal.componentInstance.buttonsEnabled = false
|
||||||
|
this.trashService.emptyTrash([document.id]).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toastService.showInfo($localize`Document deleted`)
|
||||||
|
modal.close()
|
||||||
|
this.reload()
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.toastService.showError($localize`Error deleting document`, err)
|
||||||
|
modal.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyTrash(documents?: Set<number>) {
|
||||||
|
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
modal.componentInstance.title = $localize`Confirm delete`
|
||||||
|
modal.componentInstance.messageBold = documents
|
||||||
|
? $localize`This operation will permanently delete the selected documents.`
|
||||||
|
: $localize`This operation will permanently delete all documents in the trash.`
|
||||||
|
modal.componentInstance.message = $localize`This operation cannot be undone.`
|
||||||
|
modal.componentInstance.btnClass = 'btn-danger'
|
||||||
|
modal.componentInstance.btnCaption = $localize`Delete`
|
||||||
|
modal.componentInstance.confirmClicked
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.trashService
|
||||||
|
.emptyTrash(documents ? Array.from(documents) : null)
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toastService.showInfo($localize`Document(s) deleted`)
|
||||||
|
this.allToggled = false
|
||||||
|
modal.close()
|
||||||
|
this.reload()
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error deleting document(s)`,
|
||||||
|
err
|
||||||
|
)
|
||||||
|
modal.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
restore(document: Document) {
|
||||||
|
this.trashService.restoreDocuments([document.id]).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toastService.showInfo($localize`Document restored`)
|
||||||
|
this.reload()
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.toastService.showError($localize`Error restoring document`, err)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreAll(documents: Set<number> = null) {
|
||||||
|
this.trashService
|
||||||
|
.restoreDocuments(documents ? Array.from(documents) : null)
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toastService.showInfo($localize`Document(s) restored`)
|
||||||
|
this.allToggled = false
|
||||||
|
this.reload()
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error restoring document(s)`,
|
||||||
|
err
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAll(event: PointerEvent) {
|
||||||
|
if ((event.target as HTMLInputElement).checked) {
|
||||||
|
this.selectedDocuments = new Set(this.documentsInTrash.map((t) => t.id))
|
||||||
|
} else {
|
||||||
|
this.clearSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSelected(object: Document) {
|
||||||
|
this.selectedDocuments.has(object.id)
|
||||||
|
? this.selectedDocuments.delete(object.id)
|
||||||
|
: this.selectedDocuments.add(object.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelection() {
|
||||||
|
this.allToggled = false
|
||||||
|
this.selectedDocuments.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
getDaysRemaining(document: Document): number {
|
||||||
|
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
|
||||||
|
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
|
||||||
|
const days = Math.ceil(diff / (1000 * 3600 * 24))
|
||||||
|
return delay - days
|
||||||
|
}
|
||||||
|
}
|
@@ -26,7 +26,7 @@
|
|||||||
@for (user of users; track user) {
|
@for (user of users; track user) {
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
|
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
|
||||||
<div class="col d-flex align-items-center">{{user.first_name}} {{user.last_name}}</div>
|
<div class="col d-flex align-items-center">{{user.first_name}} {{user.last_name}}</div>
|
||||||
<div class="col d-flex align-items-center">{{user.groups?.map(getGroupName, this).join(', ')}}</div>
|
<div class="col d-flex align-items-center">{{user.groups?.map(getGroupName, this).join(', ')}}</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
@@ -52,7 +52,6 @@
|
|||||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Add Group</ng-container>
|
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Add Group</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</h4>
|
</h4>
|
||||||
@if (groups.length > 0) {
|
|
||||||
<ul class="list-group">
|
<ul class="list-group">
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -65,7 +64,7 @@
|
|||||||
@for (group of groups; track group) {
|
@for (group of groups; track group) {
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
|
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
|
||||||
<div class="col"></div>
|
<div class="col"></div>
|
||||||
<div class="col"></div>
|
<div class="col"></div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
@@ -86,7 +85,6 @@
|
|||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@if (!users || !groups) {
|
@if (!users || !groups) {
|
||||||
<div>
|
<div>
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { DatePipe } from '@angular/common'
|
import { DatePipe } from '@angular/common'
|
||||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import {
|
import {
|
||||||
ComponentFixture,
|
ComponentFixture,
|
||||||
TestBed,
|
TestBed,
|
||||||
@@ -44,6 +44,7 @@ import { UsersAndGroupsComponent } from './users-groups.component'
|
|||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { Group } from 'src/app/data/group'
|
import { Group } from 'src/app/data/group'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
|
||||||
const users = [
|
const users = [
|
||||||
{ id: 1, username: 'user1', is_superuser: false },
|
{ id: 1, username: 'user1', is_superuser: false },
|
||||||
@@ -84,10 +85,8 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
PermissionsGroupComponent,
|
PermissionsGroupComponent,
|
||||||
IfOwnerDirective,
|
IfOwnerDirective,
|
||||||
],
|
],
|
||||||
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
|
|
||||||
imports: [
|
imports: [
|
||||||
NgbModule,
|
NgbModule,
|
||||||
HttpClientTestingModule,
|
|
||||||
RouterTestingModule.withRoutes(routes),
|
RouterTestingModule.withRoutes(routes),
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
@@ -95,6 +94,13 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
NgSelectModule,
|
NgSelectModule,
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
],
|
],
|
||||||
|
providers: [
|
||||||
|
CustomDatePipe,
|
||||||
|
DatePipe,
|
||||||
|
PermissionsGuard,
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
fixture = TestBed.createComponent(UsersAndGroupsComponent)
|
fixture = TestBed.createComponent(UsersAndGroupsComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
|
@@ -5,7 +5,7 @@
|
|||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
|
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
|
||||||
[ngClass]="{ 'slim': slimSidebarEnabled, 'col-auto col-md-3 col-lg-2' : !slimSidebarEnabled, 'py-3' : !customAppTitle?.length || slimSidebarEnabled, 'py-2': customAppTitle?.length }"
|
[ngClass]="{ 'slim': slimSidebarEnabled, 'col-auto col-md-3 col-lg-2 col-xxxl-1' : !slimSidebarEnabled, 'py-3' : !customAppTitle?.length || slimSidebarEnabled, 'py-2': customAppTitle?.length }"
|
||||||
routerLink="/dashboard"
|
routerLink="/dashboard"
|
||||||
tourAnchor="tour.intro">
|
tourAnchor="tour.intro">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.43 238.91" width="1em" height="1.5em" fill="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.43 238.91" width="1em" height="1.5em" fill="currentColor">
|
||||||
@@ -24,19 +24,10 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="search-form-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
|
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1">
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
<div class="col-12 col-md-7">
|
||||||
<form (ngSubmit)="search()" class="form-inline flex-grow-1">
|
<pngx-global-search></pngx-global-search>
|
||||||
<i-bs width="1em" height="1em" name="search"></i-bs>
|
</div>
|
||||||
<input class="form-control form-control-sm" type="text" placeholder="Search documents" aria-label="Search"
|
|
||||||
[formControl]="searchField" [ngbTypeahead]="searchAutoComplete" (keyup)="searchFieldKeyup($event)"
|
|
||||||
(selectItem)="itemSelected($event)" i18n-placeholder>
|
|
||||||
@if (!searchFieldEmpty) {
|
|
||||||
<button type="button" class="btn btn-link btn-sm ps-0 pe-1 position-absolute top-0 end-0" (click)="resetSearchField()">
|
|
||||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<ul ngbNav class="order-sm-3">
|
<ul ngbNav class="order-sm-3">
|
||||||
<li ngbDropdown class="nav-item dropdown">
|
<li ngbDropdown class="nav-item dropdown">
|
||||||
@@ -52,7 +43,7 @@
|
|||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
</div>
|
</div>
|
||||||
<button ngbDropdownItem class="nav-link" (click)="editProfile()">
|
<button ngbDropdownItem class="nav-link" (click)="editProfile()">
|
||||||
<i-bs class="me-2" name="person"></i-bs> <ng-container i18n>My Profile</ng-container>
|
<i-bs class="me-2" name="person"></i-bs><ng-container i18n>My Profile</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()"
|
<a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }">
|
*pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }">
|
||||||
@@ -85,14 +76,14 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="sidebar-sticky pt-3 d-flex flex-column justify-space-around">
|
<div class="sidebar-sticky pt-3 d-flex flex-column justify-space-around">
|
||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item">
|
<li class="nav-item app-link">
|
||||||
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-1" name="house"></i-bs><span> <ng-container i18n>Dashboard</ng-container></span>
|
<i-bs class="me-1" name="house"></i-bs><span> <ng-container i18n>Dashboard</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||||
<a class="nav-link" routerLink="documents" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="documents" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
@@ -100,18 +91,20 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
|
||||||
@if (savedViewService.loading || savedViewService.sidebarViews?.length > 0) {
|
<div class="nav-group mt-3 mb-1" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||||
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted">
|
|
||||||
<span i18n>Saved views</span>
|
|
||||||
@if (savedViewService.loading) {
|
@if (savedViewService.loading) {
|
||||||
|
<h6 class="sidebar-heading px-3 text-muted">
|
||||||
|
<span i18n>Saved views</span>
|
||||||
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
||||||
}
|
|
||||||
</h6>
|
</h6>
|
||||||
}
|
} @else if (savedViewService.sidebarViews?.length > 0) {
|
||||||
|
<h6 class="sidebar-heading px-3 text-muted">
|
||||||
|
<span i18n>Saved views</span>
|
||||||
|
</h6>
|
||||||
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||||
@for (view of savedViewService.sidebarViews; track view) {
|
@for (view of savedViewService.sidebarViews; track view.id) {
|
||||||
<li class="nav-item w-100" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
|
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
|
||||||
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
||||||
(cdkDragEnded)="onDragEnd($event)">
|
(cdkDragEnded)="onDragEnd($event)">
|
||||||
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}"
|
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}"
|
||||||
@@ -128,18 +121,19 @@
|
|||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</ng-container>
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
<div class="nav-group mt-3 mb-1" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||||
@if (openDocuments.length > 0) {
|
@if (openDocuments.length > 0) {
|
||||||
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted">
|
<h6 class="sidebar-heading px-3 text-muted">
|
||||||
<span i18n>Open documents</span>
|
<span i18n>Open documents</span>
|
||||||
</h6>
|
</h6>
|
||||||
}
|
}
|
||||||
<ul class="nav flex-column mb-2">
|
<ul class="nav flex-column mb-2">
|
||||||
@for (d of openDocuments; track d) {
|
@for (d of openDocuments; track d) {
|
||||||
<li class="nav-item w-100">
|
<li class="nav-item w-100 app-link">
|
||||||
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}"
|
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}"
|
||||||
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle"
|
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle"
|
||||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||||
popoverClass="popover-slim">
|
popoverClass="popover-slim">
|
||||||
@@ -151,8 +145,8 @@
|
|||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@if (openDocuments.length >= 1) {
|
@if (openDocuments.length >= 1) {
|
||||||
<li class="nav-item w-100">
|
<li class="nav-item w-100 app-link">
|
||||||
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()"
|
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()"
|
||||||
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-1" name="x"></i-bs><span> <ng-container i18n>Close all</ng-container></span>
|
<i-bs class="me-1" name="x"></i-bs><span> <ng-container i18n>Close all</ng-container></span>
|
||||||
@@ -160,13 +154,14 @@
|
|||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</ng-container>
|
</div>
|
||||||
|
|
||||||
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted">
|
<div class="nav-group mt-3 mb-1">
|
||||||
|
<h6 class="sidebar-heading px-3 text-muted">
|
||||||
<span i18n>Manage</span>
|
<span i18n>Manage</span>
|
||||||
</h6>
|
</h6>
|
||||||
<ul class="nav flex-column mb-2">
|
<ul class="nav flex-column mb-2">
|
||||||
<li class="nav-item"
|
<li class="nav-item app-link"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||||
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Correspondents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Correspondents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
@@ -174,7 +169,7 @@
|
|||||||
<i-bs class="me-1" name="person"></i-bs><span> <ng-container i18n>Correspondents</ng-container></span>
|
<i-bs class="me-1" name="person"></i-bs><span> <ng-container i18n>Correspondents</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"
|
||||||
tourAnchor="tour.tags">
|
tourAnchor="tour.tags">
|
||||||
<a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Tags"
|
<a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Tags"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
@@ -182,7 +177,7 @@
|
|||||||
<i-bs class="me-1" name="tags"></i-bs><span> <ng-container i18n>Tags</ng-container></span>
|
<i-bs class="me-1" name="tags"></i-bs><span> <ng-container i18n>Tags</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item"
|
<li class="nav-item app-link"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||||
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
@@ -190,21 +185,21 @@
|
|||||||
<i-bs class="me-1" name="hash"></i-bs><span> <ng-container i18n>Document Types</ng-container></span>
|
<i-bs class="me-1" name="hash"></i-bs><span> <ng-container i18n>Document Types</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
||||||
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-1" name="folder"></i-bs><span> <ng-container i18n>Storage Paths</ng-container></span>
|
<i-bs class="me-1" name="folder"></i-bs><span> <ng-container i18n>Storage Paths</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
||||||
<a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-1" name="ui-radios"></i-bs><span> <ng-container i18n>Custom Fields</ng-container></span>
|
<i-bs class="me-1" name="ui-radios"></i-bs><span> <ng-container i18n>Custom Fields</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item"
|
<li class="nav-item app-link"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||||
tourAnchor="tour.workflows">
|
tourAnchor="tour.workflows">
|
||||||
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||||
@@ -213,7 +208,7 @@
|
|||||||
<i-bs class="me-1" name="boxes"></i-bs><span> <ng-container i18n>Workflows</ng-container></span>
|
<i-bs class="me-1" name="boxes"></i-bs><span> <ng-container i18n>Workflows</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||||
tourAnchor="tour.mail">
|
tourAnchor="tour.mail">
|
||||||
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
@@ -221,13 +216,22 @@
|
|||||||
<i-bs class="me-1" name="envelope"></i-bs><span> <ng-container i18n>Mail</ng-container></span>
|
<i-bs class="me-1" name="envelope"></i-bs><span> <ng-container i18n>Mail</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
||||||
|
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
||||||
|
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="trash"></i-bs><span> <ng-container i18n>Trash</ng-container></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h6 class="sidebar-heading px-3 mt-auto pt-4 mb-1 text-muted">
|
<div class="nav-group mt-auto mb-1">
|
||||||
|
<h6 class="sidebar-heading px-3 pt-4 text-muted">
|
||||||
<span i18n>Administration</span>
|
<span i18n>Administration</span>
|
||||||
</h6>
|
</h6>
|
||||||
<ul class="nav flex-column mb-2">
|
<ul class="nav flex-column mb-2">
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
|
||||||
tourAnchor="tour.settings">
|
tourAnchor="tour.settings">
|
||||||
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
@@ -235,21 +239,21 @@
|
|||||||
<i-bs class="me-1" name="gear"></i-bs><span> <ng-container i18n>Settings</ng-container></span>
|
<i-bs class="me-1" name="gear"></i-bs><span> <ng-container i18n>Settings</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
|
||||||
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-1" name="sliders2-vertical"></i-bs><span> <ng-container i18n>Configuration</ng-container></span>
|
<i-bs class="me-1" name="sliders2-vertical"></i-bs><span> <ng-container i18n>Configuration</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
||||||
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-1" name="people"></i-bs><span> <ng-container i18n>Users & Groups</ng-container></span>
|
<i-bs class="me-1" name="people"></i-bs><span> <ng-container i18n>Users & Groups</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item"
|
<li class="nav-item app-link"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
|
||||||
tourAnchor="tour.file-tasks">
|
tourAnchor="tour.file-tasks">
|
||||||
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
|
||||||
@@ -263,13 +267,15 @@
|
|||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }">
|
@if (permissionsService.isAdmin()) {
|
||||||
|
<li class="nav-item app-link">
|
||||||
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-1" name="text-left"></i-bs><span> <ng-container i18n>Logs</ng-container></span>
|
<i-bs class="me-1" name="text-left"></i-bs><span> <ng-container i18n>Logs</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
}
|
||||||
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
||||||
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
|
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
|
||||||
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||||
@@ -316,7 +322,7 @@
|
|||||||
container="body">
|
container="body">
|
||||||
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||||
@if (appRemoteVersion?.update_available) {
|
@if (appRemoteVersion?.update_available) {
|
||||||
<ng-container i18n>Update available</ng-container>
|
<ng-container i18n>Update available</ng-container>
|
||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
@@ -333,6 +339,7 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main role="main" class="ms-sm-auto px-md-4"
|
<main role="main" class="ms-sm-auto px-md-4"
|
||||||
|
@@ -12,23 +12,30 @@
|
|||||||
z-index: 995; /* Behind the navbar */
|
z-index: 995; /* Behind the navbar */
|
||||||
padding: 50px 0 0; /* Height of navbar */
|
padding: 50px 0 0; /* Height of navbar */
|
||||||
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
|
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
|
||||||
|
overflow-y: auto;
|
||||||
|
--pngx-sidebar-width: 100%;
|
||||||
|
max-width: var(--pngx-sidebar-width);
|
||||||
|
|
||||||
.sidebar-heading .spinner-border {
|
.sidebar-heading .spinner-border {
|
||||||
width: 0.8em;
|
width: 0.8em;
|
||||||
height: 0.8em;
|
height: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-group:not(:has(.app-link)) .sidebar-heading {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
// These come from the col-* classes for non-slim sidebar, needed for animation
|
// These come from the col-* classes for non-slim sidebar, needed for animation
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
max-width: 25%;
|
--pngx-sidebar-width: 25%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 992px) {
|
@media (min-width: 992px) {
|
||||||
max-width: 16.66666667%;
|
--pngx-sidebar-width: 16.66666667%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 2400px) {
|
@media (min-width: 2400px) {
|
||||||
max-width: 8.33333333%;
|
--pngx-sidebar-width: 8.33333333%;
|
||||||
}
|
}
|
||||||
|
|
||||||
transition: all .2s ease;
|
transition: all .2s ease;
|
||||||
@@ -105,12 +112,17 @@ main {
|
|||||||
|
|
||||||
.sidebar-slim-toggler {
|
.sidebar-slim-toggler {
|
||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
position: fixed;
|
||||||
right: -12px;
|
left: calc(var(--pngx-sidebar-width) - 12px);
|
||||||
top: 60px;
|
top: 60px;
|
||||||
z-index: 996;
|
z-index: 996;
|
||||||
--bs-btn-padding-x: 0.35rem;
|
--bs-btn-padding-x: 0.35rem;
|
||||||
--bs-btn-padding-y: 0.125rem;
|
--bs-btn-padding-y: 0.125rem;
|
||||||
|
transition: all .2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.slim .sidebar-slim-toggler {
|
||||||
|
--pngx-sidebar-width: 50px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,59 +265,6 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar .search-form-container {
|
|
||||||
max-width: 550px;
|
|
||||||
|
|
||||||
form {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
> i-bs {
|
|
||||||
position: absolute;
|
|
||||||
left: 0.6rem;
|
|
||||||
top: .35rem;
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
|
|
||||||
@media screen and (min-width: 768px) {
|
|
||||||
// adjust for smaller font size on non-mobile
|
|
||||||
top: 0.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
&:focus-within {
|
|
||||||
form > i-bs {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control::placeholder {
|
|
||||||
color: rgba(255, 255, 255, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
color: rgba(255, 255, 255, 0.3);
|
|
||||||
background-color: rgba(0, 0, 0, 0.15);
|
|
||||||
padding-left: 1.8rem;
|
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
|
||||||
transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
|
|
||||||
max-width: 600px;
|
|
||||||
min-width: 300px; // 1/2 max
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
background-color: rgba(0, 0, 0, 0.3);
|
|
||||||
color: var(--bs-light);
|
|
||||||
flex-grow: 1;
|
|
||||||
padding-left: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-check {
|
.version-check {
|
||||||
animation: pulse 2s ease-in-out 0s 1;
|
animation: pulse 2s ease-in-out 0s 1;
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
HttpClientTestingModule,
|
|
||||||
HttpTestingController,
|
HttpTestingController,
|
||||||
|
provideHttpClientTesting,
|
||||||
} from '@angular/common/http/testing'
|
} from '@angular/common/http/testing'
|
||||||
import { AppFrameComponent } from './app-frame.component'
|
import { AppFrameComponent } from './app-frame.component'
|
||||||
import {
|
import {
|
||||||
@@ -30,14 +30,14 @@ import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
|||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
import { SearchService } from 'src/app/services/rest/search.service'
|
import { SearchService } from 'src/app/services/rest/search.service'
|
||||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
|
||||||
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
|
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop'
|
import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { GlobalSearchComponent } from './global-search/global-search.component'
|
||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
|
||||||
const saved_views = [
|
const saved_views = [
|
||||||
{
|
{
|
||||||
@@ -89,17 +89,18 @@ describe('AppFrameComponent', () => {
|
|||||||
let toastService: ToastService
|
let toastService: ToastService
|
||||||
let messagesService: DjangoMessagesService
|
let messagesService: DjangoMessagesService
|
||||||
let openDocumentsService: OpenDocumentsService
|
let openDocumentsService: OpenDocumentsService
|
||||||
let searchService: SearchService
|
|
||||||
let documentListViewService: DocumentListViewService
|
|
||||||
let router: Router
|
let router: Router
|
||||||
let savedViewSpy
|
let savedViewSpy
|
||||||
let modalService: NgbModal
|
let modalService: NgbModal
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
declarations: [AppFrameComponent, IfPermissionsDirective],
|
declarations: [
|
||||||
|
AppFrameComponent,
|
||||||
|
IfPermissionsDirective,
|
||||||
|
GlobalSearchComponent,
|
||||||
|
],
|
||||||
imports: [
|
imports: [
|
||||||
HttpClientTestingModule,
|
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
RouterTestingModule.withRoutes(routes),
|
RouterTestingModule.withRoutes(routes),
|
||||||
NgbModule,
|
NgbModule,
|
||||||
@@ -114,7 +115,7 @@ describe('AppFrameComponent', () => {
|
|||||||
{
|
{
|
||||||
provide: SavedViewService,
|
provide: SavedViewService,
|
||||||
useValue: {
|
useValue: {
|
||||||
initialize: () => {},
|
reload: () => {},
|
||||||
listAll: () =>
|
listAll: () =>
|
||||||
of({
|
of({
|
||||||
all: [saved_views.map((v) => v.id)],
|
all: [saved_views.map((v) => v.id)],
|
||||||
@@ -149,6 +150,8 @@ describe('AppFrameComponent', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
PermissionsGuard,
|
PermissionsGuard,
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
@@ -159,8 +162,6 @@ describe('AppFrameComponent', () => {
|
|||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
messagesService = TestBed.inject(DjangoMessagesService)
|
messagesService = TestBed.inject(DjangoMessagesService)
|
||||||
openDocumentsService = TestBed.inject(OpenDocumentsService)
|
openDocumentsService = TestBed.inject(OpenDocumentsService)
|
||||||
searchService = TestBed.inject(SearchService)
|
|
||||||
documentListViewService = TestBed.inject(DocumentListViewService)
|
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
router = TestBed.inject(Router)
|
router = TestBed.inject(Router)
|
||||||
|
|
||||||
@@ -169,7 +170,7 @@ describe('AppFrameComponent', () => {
|
|||||||
.mockReturnValue('Hello World')
|
.mockReturnValue('Hello World')
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
|
||||||
savedViewSpy = jest.spyOn(savedViewService, 'initialize')
|
savedViewSpy = jest.spyOn(savedViewService, 'reload')
|
||||||
|
|
||||||
fixture = TestBed.createComponent(AppFrameComponent)
|
fixture = TestBed.createComponent(AppFrameComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
@@ -296,62 +297,6 @@ describe('AppFrameComponent', () => {
|
|||||||
expect(component.canDeactivate()).toBeFalsy()
|
expect(component.canDeactivate()).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call autocomplete endpoint on input', fakeAsync(() => {
|
|
||||||
const autocompleteSpy = jest.spyOn(searchService, 'autocomplete')
|
|
||||||
component.searchAutoComplete(of('hello')).subscribe()
|
|
||||||
tick(250)
|
|
||||||
expect(autocompleteSpy).toHaveBeenCalled()
|
|
||||||
|
|
||||||
component.searchAutoComplete(of('hello world 1')).subscribe()
|
|
||||||
tick(250)
|
|
||||||
expect(autocompleteSpy).toHaveBeenCalled()
|
|
||||||
}))
|
|
||||||
|
|
||||||
it('should handle autocomplete backend failure gracefully', fakeAsync(() => {
|
|
||||||
const serviceAutocompleteSpy = jest.spyOn(searchService, 'autocomplete')
|
|
||||||
serviceAutocompleteSpy.mockReturnValue(
|
|
||||||
throwError(() => new Error('autcomplete failed'))
|
|
||||||
)
|
|
||||||
// serviceAutocompleteSpy.mockReturnValue(of([' world']))
|
|
||||||
let result
|
|
||||||
component.searchAutoComplete(of('hello')).subscribe((res) => {
|
|
||||||
result = res
|
|
||||||
})
|
|
||||||
tick(250)
|
|
||||||
expect(serviceAutocompleteSpy).toHaveBeenCalled()
|
|
||||||
expect(result).toEqual([])
|
|
||||||
}))
|
|
||||||
|
|
||||||
it('should support reset search field', () => {
|
|
||||||
const resetSpy = jest.spyOn(component, 'resetSearchField')
|
|
||||||
const input = (fixture.nativeElement as HTMLDivElement).querySelector(
|
|
||||||
'input'
|
|
||||||
) as HTMLInputElement
|
|
||||||
input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }))
|
|
||||||
expect(resetSpy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support choosing a search item', () => {
|
|
||||||
expect(component.searchField.value).toEqual('')
|
|
||||||
component.itemSelected({ item: 'hello', preventDefault: () => true })
|
|
||||||
expect(component.searchField.value).toEqual('hello ')
|
|
||||||
component.itemSelected({ item: 'world', preventDefault: () => true })
|
|
||||||
expect(component.searchField.value).toEqual('hello world ')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should navigate via quickFilter on search', () => {
|
|
||||||
const str = 'hello world '
|
|
||||||
component.searchField.patchValue(str)
|
|
||||||
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
|
||||||
component.search()
|
|
||||||
expect(qfSpy).toHaveBeenCalledWith([
|
|
||||||
{
|
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
|
||||||
value: str.trim(),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should disable global dropzone on start drag + drop, re-enable after', () => {
|
it('should disable global dropzone on start drag + drop, re-enable after', () => {
|
||||||
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
|
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
|
||||||
component.onDragStart(null)
|
component.onDragStart(null)
|
||||||
|
@@ -1,15 +1,7 @@
|
|||||||
import { Component, HostListener, OnInit } from '@angular/core'
|
import { Component, HostListener, OnInit } from '@angular/core'
|
||||||
import { FormControl } from '@angular/forms'
|
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
import { from, Observable } from 'rxjs'
|
import { Observable } from 'rxjs'
|
||||||
import {
|
import { first } from 'rxjs/operators'
|
||||||
debounceTime,
|
|
||||||
distinctUntilChanged,
|
|
||||||
map,
|
|
||||||
switchMap,
|
|
||||||
first,
|
|
||||||
catchError,
|
|
||||||
} from 'rxjs/operators'
|
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||||
import {
|
import {
|
||||||
@@ -17,11 +9,8 @@ import {
|
|||||||
DjangoMessagesService,
|
DjangoMessagesService,
|
||||||
} from 'src/app/services/django-messages.service'
|
} from 'src/app/services/django-messages.service'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { SearchService } from 'src/app/services/rest/search.service'
|
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
|
||||||
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
|
|
||||||
import {
|
import {
|
||||||
RemoteVersionService,
|
RemoteVersionService,
|
||||||
AppRemoteVersion,
|
AppRemoteVersion,
|
||||||
@@ -63,16 +52,12 @@ export class AppFrameComponent
|
|||||||
|
|
||||||
slimSidebarAnimating: boolean = false
|
slimSidebarAnimating: boolean = false
|
||||||
|
|
||||||
searchField = new FormControl('')
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public router: Router,
|
public router: Router,
|
||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
private openDocumentsService: OpenDocumentsService,
|
private openDocumentsService: OpenDocumentsService,
|
||||||
private searchService: SearchService,
|
|
||||||
public savedViewService: SavedViewService,
|
public savedViewService: SavedViewService,
|
||||||
private remoteVersionService: RemoteVersionService,
|
private remoteVersionService: RemoteVersionService,
|
||||||
private list: DocumentListViewService,
|
|
||||||
public settingsService: SettingsService,
|
public settingsService: SettingsService,
|
||||||
public tasksService: TasksService,
|
public tasksService: TasksService,
|
||||||
private readonly toastService: ToastService,
|
private readonly toastService: ToastService,
|
||||||
@@ -88,7 +73,7 @@ export class AppFrameComponent
|
|||||||
PermissionType.SavedView
|
PermissionType.SavedView
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
this.savedViewService.initialize()
|
this.savedViewService.reload()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,65 +149,6 @@ export class AppFrameComponent
|
|||||||
return !this.openDocumentsService.hasDirty()
|
return !this.openDocumentsService.hasDirty()
|
||||||
}
|
}
|
||||||
|
|
||||||
get searchFieldEmpty(): boolean {
|
|
||||||
return this.searchField.value.trim().length == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
resetSearchField() {
|
|
||||||
this.searchField.reset('')
|
|
||||||
}
|
|
||||||
|
|
||||||
searchFieldKeyup(event: KeyboardEvent) {
|
|
||||||
if (event.key == 'Escape') {
|
|
||||||
this.resetSearchField()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
searchAutoComplete = (text$: Observable<string>) =>
|
|
||||||
text$.pipe(
|
|
||||||
debounceTime(200),
|
|
||||||
distinctUntilChanged(),
|
|
||||||
map((term) => {
|
|
||||||
if (term.lastIndexOf(' ') != -1) {
|
|
||||||
return term.substring(term.lastIndexOf(' ') + 1)
|
|
||||||
} else {
|
|
||||||
return term
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
switchMap((term) =>
|
|
||||||
term.length < 2
|
|
||||||
? from([[]])
|
|
||||||
: this.searchService.autocomplete(term).pipe(
|
|
||||||
catchError(() => {
|
|
||||||
return from([[]])
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
itemSelected(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
let currentSearch: string = this.searchField.value
|
|
||||||
let lastSpaceIndex = currentSearch.lastIndexOf(' ')
|
|
||||||
if (lastSpaceIndex != -1) {
|
|
||||||
currentSearch = currentSearch.substring(0, lastSpaceIndex + 1)
|
|
||||||
currentSearch += event.item + ' '
|
|
||||||
} else {
|
|
||||||
currentSearch = event.item + ' '
|
|
||||||
}
|
|
||||||
this.searchField.patchValue(currentSearch)
|
|
||||||
}
|
|
||||||
|
|
||||||
search() {
|
|
||||||
this.closeMenu()
|
|
||||||
this.list.quickFilter([
|
|
||||||
{
|
|
||||||
rule_type: FILTER_FULLTEXT_QUERY,
|
|
||||||
value: (this.searchField.value as string).trim(),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
closeDocument(d: Document) {
|
closeDocument(d: Document) {
|
||||||
this.openDocumentsService
|
this.openDocumentsService
|
||||||
.closeDocument(d)
|
.closeDocument(d)
|
||||||
|
@@ -0,0 +1,174 @@
|
|||||||
|
|
||||||
|
<div ngbDropdown #resultsDropdown="ngbDropdown" (openChange)="onDropdownOpenChange">
|
||||||
|
<form class="form-inline position-relative">
|
||||||
|
<i-bs width="1em" height="1em" name="search"></i-bs>
|
||||||
|
<div class="input-group">
|
||||||
|
<div class="form-control form-control-sm">
|
||||||
|
<input class="bg-transparent border-0 w-100 h-100" #searchInput type="text" name="query"
|
||||||
|
placeholder="Search" aria-label="Search" i18n-placeholder
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
[(ngModel)]="query"
|
||||||
|
(ngModelChange)="this.queryDebounce.next($event)"
|
||||||
|
(keydown)="searchInputKeyDown($event)"
|
||||||
|
ngbDropdownAnchor>
|
||||||
|
<div class="position-absolute top-50 end-0 translate-middle">
|
||||||
|
@if (loading) {
|
||||||
|
<div class="spinner-border spinner-border-sm text-muted mt-1"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (query) {
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="runFullSearch()">
|
||||||
|
@if (useAdvancedForFullSearch) {
|
||||||
|
<ng-container i18n>Advanced search</ng-container>
|
||||||
|
} @else {
|
||||||
|
<ng-container i18n>Search</ng-container>
|
||||||
|
}
|
||||||
|
<i-bs width="1em" height="1em" name="arrow-right-short"></i-bs>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ng-template #resultItemTemplate let-item="item" let-nameProp="nameProp" let-type="type" let-icon="icon" let-date="date">
|
||||||
|
<div #resultItem ngbDropdownItem class="py-2 d-flex align-items-center focus-ring border-0 cursor-pointer" tabindex="-1"
|
||||||
|
(click)="primaryAction(type, item, $event)"
|
||||||
|
(mouseenter)="onItemHover($event)">
|
||||||
|
<i-bs width="1.2em" height="1.2em" name="{{icon}}" class="me-2 text-muted"></i-bs>
|
||||||
|
<div class="text-truncate">
|
||||||
|
{{item[nameProp]}}
|
||||||
|
@if (date) {
|
||||||
|
<small class="small text-muted">{{date | customDate}}</small>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="btn-group ms-auto">
|
||||||
|
<button #primaryButton type="button" class="btn btn-sm btn-outline-primary d-flex"
|
||||||
|
(click)="primaryAction(type, item, $event); $event.stopImmediatePropagation()"
|
||||||
|
(keydown)="onButtonKeyDown($event)"
|
||||||
|
[disabled]="disablePrimaryButton(type, item)"
|
||||||
|
(mouseenter)="onButtonHover($event)">
|
||||||
|
@if (type === DataType.Document) {
|
||||||
|
<i-bs width="1em" height="1em" name="box-arrow-in-right"></i-bs>
|
||||||
|
<span> <ng-container i18n>Open</ng-container></span>
|
||||||
|
} @else if (type === DataType.SavedView) {
|
||||||
|
<i-bs width="1em" height="1em" name="eye"></i-bs>
|
||||||
|
<span> <ng-container i18n>Open</ng-container></span>
|
||||||
|
} @else if (type === DataType.Workflow || type === DataType.CustomField || type === DataType.Group || type === DataType.User || type === DataType.MailAccount || type === DataType.MailRule) {
|
||||||
|
<i-bs width="1em" height="1em" name="pencil"></i-bs>
|
||||||
|
<span> <ng-container i18n>Open</ng-container></span>
|
||||||
|
} @else {
|
||||||
|
<i-bs width="1em" height="1em" name="filter"></i-bs>
|
||||||
|
<span> <ng-container i18n>Filter documents</ng-container></span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
@if (type !== DataType.SavedView && type !== DataType.Workflow && type !== DataType.CustomField && type !== DataType.Group && type !== DataType.User && type !== DataType.MailAccount && type !== DataType.MailRule) {
|
||||||
|
<button #secondaryButton type="button" class="btn btn-sm btn-outline-primary d-flex"
|
||||||
|
(click)="secondaryAction(type, item, $event); $event.stopImmediatePropagation()"
|
||||||
|
(keydown)="onButtonKeyDown($event)"
|
||||||
|
[disabled]="disableSecondaryButton(type, item)"
|
||||||
|
(mouseenter)="onButtonHover($event)">
|
||||||
|
@if (type === DataType.Document) {
|
||||||
|
<i-bs width="1em" height="1em" name="download"></i-bs>
|
||||||
|
<span> <ng-container i18n>Download</ng-container></span>
|
||||||
|
} @else {
|
||||||
|
<i-bs width="1em" height="1em" name="box-arrow-in-right"></i-bs>
|
||||||
|
<span> <ng-container i18n>Open</ng-container></span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<div ngbDropdownMenu class="w-100 mh-75 overflow-y-scroll shadow-lg">
|
||||||
|
<div (keydown)="dropdownKeyDown($event)">
|
||||||
|
@if (searchResults?.total === 0) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.noResults">No results</h6>
|
||||||
|
} @else {
|
||||||
|
@if (searchResults?.documents.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.documents">Documents</h6>
|
||||||
|
@for (document of searchResults.documents; track document.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: document, nameProp: 'title', type: DataType.Document, icon: 'file-text', date: document.added}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (searchResults?.saved_views.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.saved_views">Saved Views</h6>
|
||||||
|
@for (saved_view of searchResults.saved_views; track saved_view.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: saved_view, nameProp: 'name', type: DataType.SavedView, icon: 'funnel'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults?.tags.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.tags">Tags</h6>
|
||||||
|
@for (tag of searchResults.tags; track tag.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: tag, nameProp: 'name', type: DataType.Tag, icon: 'tag'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults?.correspondents.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.correspondents">Correspondents</h6>
|
||||||
|
@for (correspondent of searchResults.correspondents; track correspondent.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: correspondent, nameProp: 'name', type: DataType.Correspondent, icon: 'person'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults?.document_types.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.documentTypes">Document types</h6>
|
||||||
|
@for (documentType of searchResults.document_types; track documentType.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: documentType, nameProp: 'name', type: DataType.DocumentType, icon: 'file-earmark'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults?.storage_paths.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.storagePaths">Storage paths</h6>
|
||||||
|
@for (storagePath of searchResults.storage_paths; track storagePath.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: storagePath, nameProp: 'name', type: DataType.StoragePath, icon: 'folder'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults?.users.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.users">Users</h6>
|
||||||
|
@for (user of searchResults.users; track user.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: user, nameProp: 'username', type: DataType.User, icon: 'person-square'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults?.groups.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.groups">Groups</h6>
|
||||||
|
@for (group of searchResults.groups; track group.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: group, nameProp: 'name', type: DataType.Group, icon: 'people'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults?.custom_fields.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.customFields">Custom fields</h6>
|
||||||
|
@for (customField of searchResults.custom_fields; track customField.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: customField, nameProp: 'name', type: DataType.CustomField, icon: 'ui-radios'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults?.mail_accounts.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.mailAccounts">Mail accounts</h6>
|
||||||
|
@for (mailAccount of searchResults.mail_accounts; track mailAccount.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailAccount, nameProp: 'name', type: DataType.MailAccount, icon: 'envelope-at'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults?.mail_rules.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.mailRules">Mail rules</h6>
|
||||||
|
@for (mailRule of searchResults.mail_rules; track mailRule.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailRule, nameProp: 'name', type: DataType.MailRule, icon: 'envelope'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (searchResults?.workflows.length) {
|
||||||
|
<h6 class="dropdown-header" i18n="@@searchResults.workflows">Workflows</h6>
|
||||||
|
@for (workflow of searchResults.workflows; track workflow.id) {
|
||||||
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: workflow, nameProp: 'name', type: DataType.Workflow, icon: 'boxes'}"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@@ -0,0 +1,97 @@
|
|||||||
|
form {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
> i-bs[name="search"] {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.6rem;
|
||||||
|
top: .35rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
|
||||||
|
@media screen and (min-width: 768px) {
|
||||||
|
// adjust for smaller font size on non-mobile
|
||||||
|
top: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
i-bs[name="search"],
|
||||||
|
.badge {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group .btn {
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
color: var(--pngx-primary-text-contrast);
|
||||||
|
padding-top: .15rem;
|
||||||
|
padding-bottom: .15rem;
|
||||||
|
min-height: calc(1.3em + 0.5rem + calc(var(--bs-border-width) * 2)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
background-color: rgba(0, 0, 0, 0.15);
|
||||||
|
padding-left: 1.8rem;
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
|
||||||
|
> input {
|
||||||
|
outline: none;
|
||||||
|
color: var(--pngx-primary-text-contrast);
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
color: var(--bs-light);
|
||||||
|
flex-grow: 1;
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
--pngx-focus-alpha: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mh-75 {
|
||||||
|
max-height: 75vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
&:has(button:focus) {
|
||||||
|
background-color: var(--pngx-bg-darker);
|
||||||
|
}
|
||||||
|
|
||||||
|
& button {
|
||||||
|
transition: all 0.3s ease, color 0.15s ease;
|
||||||
|
max-width: 2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
& button span {
|
||||||
|
opacity: 0;
|
||||||
|
transition: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover button,
|
||||||
|
&:has(button:focus) button {
|
||||||
|
max-width: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover button span,
|
||||||
|
&:has(button:focus) span {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,550 @@
|
|||||||
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
TestBed,
|
||||||
|
fakeAsync,
|
||||||
|
tick,
|
||||||
|
} from '@angular/core/testing'
|
||||||
|
import { GlobalSearchComponent } from './global-search.component'
|
||||||
|
import { of } from 'rxjs'
|
||||||
|
import { SearchService } from 'src/app/services/rest/search.service'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
|
import {
|
||||||
|
NgbDropdownModule,
|
||||||
|
NgbModal,
|
||||||
|
NgbModalModule,
|
||||||
|
NgbModalRef,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
|
import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component'
|
||||||
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||||
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
|
import {
|
||||||
|
FILTER_FULLTEXT_QUERY,
|
||||||
|
FILTER_HAS_CORRESPONDENT_ANY,
|
||||||
|
FILTER_HAS_DOCUMENT_TYPE_ANY,
|
||||||
|
FILTER_HAS_STORAGE_PATH_ANY,
|
||||||
|
FILTER_HAS_TAGS_ALL,
|
||||||
|
FILTER_TITLE_CONTENT,
|
||||||
|
} from 'src/app/data/filter-rule-type'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component'
|
||||||
|
import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component'
|
||||||
|
import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
|
||||||
|
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
|
import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
|
||||||
|
import { ElementRef } from '@angular/core'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { DataType } from 'src/app/data/datatype'
|
||||||
|
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
|
||||||
|
const searchResults = {
|
||||||
|
total: 11,
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Test',
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
document_type: { id: 1, name: 'Test' },
|
||||||
|
storage_path: { id: 1, path: 'Test' },
|
||||||
|
tags: [],
|
||||||
|
correspondents: [],
|
||||||
|
custom_fields: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
saved_views: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestSavedView',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
correspondents: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestCorrespondent',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
document_types: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestDocumentType',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
storage_paths: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestStoragePath',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestTag',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
username: 'TestUser',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestGroup',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mail_accounts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestMailAccount',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mail_rules: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestMailRule',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
custom_fields: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestCustomField',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
workflows: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'TestWorkflow',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GlobalSearchComponent', () => {
|
||||||
|
let component: GlobalSearchComponent
|
||||||
|
let fixture: ComponentFixture<GlobalSearchComponent>
|
||||||
|
let searchService: SearchService
|
||||||
|
let router: Router
|
||||||
|
let modalService: NgbModal
|
||||||
|
let documentService: DocumentService
|
||||||
|
let documentListViewService: DocumentListViewService
|
||||||
|
let toastService: ToastService
|
||||||
|
let settingsService: SettingsService
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [GlobalSearchComponent],
|
||||||
|
imports: [
|
||||||
|
NgbModalModule,
|
||||||
|
NgbDropdownModule,
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
searchService = TestBed.inject(SearchService)
|
||||||
|
router = TestBed.inject(Router)
|
||||||
|
modalService = TestBed.inject(NgbModal)
|
||||||
|
documentService = TestBed.inject(DocumentService)
|
||||||
|
documentListViewService = TestBed.inject(DocumentListViewService)
|
||||||
|
toastService = TestBed.inject(ToastService)
|
||||||
|
settingsService = TestBed.inject(SettingsService)
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(GlobalSearchComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle keyboard nav', () => {
|
||||||
|
const focusSpy = jest.spyOn(component.searchInput.nativeElement, 'focus')
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/' }))
|
||||||
|
expect(focusSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.searchResults = searchResults as any
|
||||||
|
component.resultsDropdown.open()
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
component['currentItemIndex'] = 0
|
||||||
|
component['setCurrentItem']()
|
||||||
|
const firstItemFocusSpy = jest.spyOn(
|
||||||
|
component.primaryButtons.get(1).nativeElement,
|
||||||
|
'focus'
|
||||||
|
)
|
||||||
|
component.dropdownKeyDown(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowDown' })
|
||||||
|
)
|
||||||
|
expect(component['currentItemIndex']).toBe(1)
|
||||||
|
expect(firstItemFocusSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
const secondaryItemFocusSpy = jest.spyOn(
|
||||||
|
component.secondaryButtons.get(1).nativeElement,
|
||||||
|
'focus'
|
||||||
|
)
|
||||||
|
component.dropdownKeyDown(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowRight' })
|
||||||
|
)
|
||||||
|
expect(secondaryItemFocusSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.dropdownKeyDown(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowLeft' })
|
||||||
|
)
|
||||||
|
expect(firstItemFocusSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
const zeroItemSpy = jest.spyOn(
|
||||||
|
component.primaryButtons.get(0).nativeElement,
|
||||||
|
'focus'
|
||||||
|
)
|
||||||
|
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
|
||||||
|
expect(component['currentItemIndex']).toBe(0)
|
||||||
|
expect(zeroItemSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
const inputFocusSpy = jest.spyOn(
|
||||||
|
component.searchInput.nativeElement,
|
||||||
|
'focus'
|
||||||
|
)
|
||||||
|
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
|
||||||
|
expect(component['currentItemIndex']).toBe(-1)
|
||||||
|
expect(inputFocusSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.dropdownKeyDown(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowDown' })
|
||||||
|
)
|
||||||
|
component['currentItemIndex'] = searchResults.total - 1
|
||||||
|
component['setCurrentItem']()
|
||||||
|
component.dropdownKeyDown(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowDown' })
|
||||||
|
)
|
||||||
|
expect(component['currentItemIndex']).toBe(-1)
|
||||||
|
|
||||||
|
// Search input
|
||||||
|
|
||||||
|
component.searchInputKeyDown(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowUp' })
|
||||||
|
)
|
||||||
|
expect(component['currentItemIndex']).toBe(searchResults.total - 1)
|
||||||
|
|
||||||
|
component.searchInputKeyDown(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowDown' })
|
||||||
|
)
|
||||||
|
expect(component['currentItemIndex']).toBe(0)
|
||||||
|
|
||||||
|
component.searchResults = { total: 1 } as any
|
||||||
|
const primaryActionSpy = jest.spyOn(component, 'primaryAction')
|
||||||
|
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||||
|
expect(primaryActionSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.query = 'test'
|
||||||
|
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
|
||||||
|
component.searchInputKeyDown(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Escape' })
|
||||||
|
)
|
||||||
|
expect(resetSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.query = ''
|
||||||
|
const blurSpy = jest.spyOn(component.searchInput.nativeElement, 'blur')
|
||||||
|
component.searchInputKeyDown(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Escape' })
|
||||||
|
)
|
||||||
|
expect(blurSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.searchResults = { total: 1 } as any
|
||||||
|
component.resultsDropdown.open()
|
||||||
|
|
||||||
|
component.searchInputKeyDown(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowDown' })
|
||||||
|
)
|
||||||
|
expect(component['currentItemIndex']).toBe(0)
|
||||||
|
const closeSpy = jest.spyOn(component.resultsDropdown, 'close')
|
||||||
|
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||||
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.searchResults = searchResults as any
|
||||||
|
component.resultsDropdown.open()
|
||||||
|
component.query = 'test'
|
||||||
|
const advancedSearchSpy = jest.spyOn(component, 'runFullSearch')
|
||||||
|
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||||
|
expect(advancedSearchSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should search on query debounce', fakeAsync(() => {
|
||||||
|
const query = 'test'
|
||||||
|
const searchSpy = jest.spyOn(searchService, 'globalSearch')
|
||||||
|
searchSpy.mockReturnValue(of({} as any))
|
||||||
|
const dropdownOpenSpy = jest.spyOn(component.resultsDropdown, 'open')
|
||||||
|
component.queryDebounce.next(query)
|
||||||
|
tick(401)
|
||||||
|
expect(searchSpy).toHaveBeenCalledWith(query)
|
||||||
|
expect(dropdownOpenSpy).toHaveBeenCalled()
|
||||||
|
}))
|
||||||
|
|
||||||
|
it('should support primary action', () => {
|
||||||
|
const object = { id: 1 }
|
||||||
|
const routerSpy = jest.spyOn(router, 'navigate')
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
|
||||||
|
let modal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
|
|
||||||
|
component.primaryAction(DataType.Document, object)
|
||||||
|
expect(routerSpy).toHaveBeenCalledWith(['/documents', object.id], {})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.SavedView, object)
|
||||||
|
expect(routerSpy).toHaveBeenCalledWith(['/view', object.id], {})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.Correspondent, object)
|
||||||
|
expect(routerSpy).toHaveBeenCalledWith(['/documents'], {
|
||||||
|
queryParams: Object.assign(
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
reverse: 1,
|
||||||
|
sort: 'created',
|
||||||
|
},
|
||||||
|
queryParamsFromFilterRules([
|
||||||
|
{
|
||||||
|
rule_type: FILTER_HAS_CORRESPONDENT_ANY,
|
||||||
|
value: object.id.toString(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.DocumentType, object)
|
||||||
|
expect(routerSpy).toHaveBeenCalledWith(['/documents'], {
|
||||||
|
queryParams: Object.assign(
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
reverse: 1,
|
||||||
|
sort: 'created',
|
||||||
|
},
|
||||||
|
queryParamsFromFilterRules([
|
||||||
|
{
|
||||||
|
rule_type: FILTER_HAS_DOCUMENT_TYPE_ANY,
|
||||||
|
value: object.id.toString(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.StoragePath, object)
|
||||||
|
expect(routerSpy).toHaveBeenCalledWith(['/documents'], {
|
||||||
|
queryParams: Object.assign(
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
reverse: 1,
|
||||||
|
sort: 'created',
|
||||||
|
},
|
||||||
|
queryParamsFromFilterRules([
|
||||||
|
{
|
||||||
|
rule_type: FILTER_HAS_STORAGE_PATH_ANY,
|
||||||
|
value: object.id.toString(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.Tag, object)
|
||||||
|
expect(routerSpy).toHaveBeenCalledWith(['/documents'], {
|
||||||
|
queryParams: Object.assign(
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
reverse: 1,
|
||||||
|
sort: 'created',
|
||||||
|
},
|
||||||
|
queryParamsFromFilterRules([
|
||||||
|
{ rule_type: FILTER_HAS_TAGS_ALL, value: object.id.toString() },
|
||||||
|
])
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.User, object)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(UserEditDialogComponent, {
|
||||||
|
size: 'lg',
|
||||||
|
})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.Group, object)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(GroupEditDialogComponent, {
|
||||||
|
size: 'lg',
|
||||||
|
})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.MailAccount, object)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(MailAccountEditDialogComponent, {
|
||||||
|
size: 'xl',
|
||||||
|
})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.MailRule, object)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(MailRuleEditDialogComponent, {
|
||||||
|
size: 'xl',
|
||||||
|
})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.CustomField, object)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(CustomFieldEditDialogComponent, {
|
||||||
|
size: 'md',
|
||||||
|
})
|
||||||
|
|
||||||
|
component.primaryAction(DataType.Workflow, object)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(WorkflowEditDialogComponent, {
|
||||||
|
size: 'xl',
|
||||||
|
})
|
||||||
|
|
||||||
|
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
|
||||||
|
// fail first
|
||||||
|
editDialog.failed.emit({ error: 'error creating item' })
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
// succeed
|
||||||
|
editDialog.succeeded.emit(true)
|
||||||
|
expect(toastInfoSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support secondary action', () => {
|
||||||
|
const doc = searchResults.documents[0]
|
||||||
|
const openSpy = jest.spyOn(window, 'open')
|
||||||
|
component.secondaryAction('document', doc)
|
||||||
|
expect(openSpy).toHaveBeenCalledWith(documentService.getDownloadUrl(doc.id))
|
||||||
|
|
||||||
|
const correspondent = searchResults.correspondents[0]
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
|
||||||
|
let modal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
|
|
||||||
|
component.secondaryAction(DataType.Correspondent, correspondent)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
|
||||||
|
size: 'md',
|
||||||
|
})
|
||||||
|
|
||||||
|
component.secondaryAction(
|
||||||
|
DataType.DocumentType,
|
||||||
|
searchResults.document_types[0]
|
||||||
|
)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
|
||||||
|
size: 'md',
|
||||||
|
})
|
||||||
|
|
||||||
|
component.secondaryAction(
|
||||||
|
DataType.StoragePath,
|
||||||
|
searchResults.storage_paths[0]
|
||||||
|
)
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
|
||||||
|
size: 'md',
|
||||||
|
})
|
||||||
|
|
||||||
|
component.secondaryAction(DataType.Tag, searchResults.tags[0])
|
||||||
|
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
|
||||||
|
size: 'md',
|
||||||
|
})
|
||||||
|
|
||||||
|
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
|
||||||
|
// fail first
|
||||||
|
editDialog.failed.emit({ error: 'error creating item' })
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
// succeed
|
||||||
|
editDialog.succeeded.emit(true)
|
||||||
|
expect(toastInfoSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support reset', () => {
|
||||||
|
const debounce = jest.spyOn(component.queryDebounce, 'next')
|
||||||
|
const closeSpy = jest.spyOn(component.resultsDropdown, 'close')
|
||||||
|
component['reset'](true)
|
||||||
|
expect(debounce).toHaveBeenCalledWith(null)
|
||||||
|
expect(component.searchResults).toBeNull()
|
||||||
|
expect(component['currentItemIndex']).toBe(-1)
|
||||||
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support focus current item', () => {
|
||||||
|
component.searchResults = searchResults as any
|
||||||
|
fixture.detectChanges()
|
||||||
|
const focusSpy = jest.spyOn(
|
||||||
|
component.primaryButtons.get(0).nativeElement,
|
||||||
|
'focus'
|
||||||
|
)
|
||||||
|
component['currentItemIndex'] = 0
|
||||||
|
component['setCurrentItem']()
|
||||||
|
expect(focusSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reset on dropdown close', () => {
|
||||||
|
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
|
||||||
|
component.onDropdownOpenChange(false)
|
||||||
|
expect(resetSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should focus button on dropdown item hover', () => {
|
||||||
|
component.searchResults = searchResults as any
|
||||||
|
fixture.detectChanges()
|
||||||
|
const item: ElementRef = component.resultItems.first
|
||||||
|
const focusSpy = jest.spyOn(
|
||||||
|
component.primaryButtons.first.nativeElement,
|
||||||
|
'focus'
|
||||||
|
)
|
||||||
|
component.onItemHover({ currentTarget: item.nativeElement } as any)
|
||||||
|
expect(component['currentItemIndex']).toBe(0)
|
||||||
|
expect(focusSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should focus on button hover', () => {
|
||||||
|
const event = { currentTarget: { focus: jest.fn() } }
|
||||||
|
const focusSpy = jest.spyOn(event.currentTarget, 'focus')
|
||||||
|
component.onButtonHover(event as any)
|
||||||
|
expect(focusSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support open in new window', () => {
|
||||||
|
const openSpy = jest.spyOn(window, 'open')
|
||||||
|
const event = new Event('click')
|
||||||
|
event['ctrlKey'] = true
|
||||||
|
component.primaryAction(DataType.Document, { id: 2 }, event as any)
|
||||||
|
expect(openSpy).toHaveBeenCalledWith('/documents/2', '_blank')
|
||||||
|
|
||||||
|
component.searchResults = searchResults as any
|
||||||
|
component.resultsDropdown.open()
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
const button = component.primaryButtons.get(0).nativeElement
|
||||||
|
const keyboardEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Enter',
|
||||||
|
ctrlKey: true,
|
||||||
|
})
|
||||||
|
const dispatchSpy = jest.spyOn(button, 'dispatchEvent')
|
||||||
|
button.dispatchEvent(keyboardEvent)
|
||||||
|
expect(dispatchSpy).toHaveBeenCalledTimes(2) // once for keydown, second for click
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support title content search and advanced search', () => {
|
||||||
|
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||||
|
component.query = 'test'
|
||||||
|
component.runFullSearch()
|
||||||
|
expect(qfSpy).toHaveBeenCalledWith([
|
||||||
|
{ rule_type: FILTER_TITLE_CONTENT, value: 'test' },
|
||||||
|
])
|
||||||
|
|
||||||
|
settingsService.set(
|
||||||
|
SETTINGS_KEYS.SEARCH_FULL_TYPE,
|
||||||
|
GlobalSearchType.ADVANCED
|
||||||
|
)
|
||||||
|
component.query = 'test'
|
||||||
|
component.runFullSearch()
|
||||||
|
expect(qfSpy).toHaveBeenCalledWith([
|
||||||
|
{ rule_type: FILTER_FULLTEXT_QUERY, value: 'test' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
@@ -0,0 +1,416 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
ViewChild,
|
||||||
|
ElementRef,
|
||||||
|
ViewChildren,
|
||||||
|
QueryList,
|
||||||
|
OnInit,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
|
import { NgbDropdown, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { Subject, debounceTime, distinctUntilChanged, filter, map } from 'rxjs'
|
||||||
|
import {
|
||||||
|
FILTER_FULLTEXT_QUERY,
|
||||||
|
FILTER_HAS_CORRESPONDENT_ANY,
|
||||||
|
FILTER_HAS_DOCUMENT_TYPE_ANY,
|
||||||
|
FILTER_HAS_STORAGE_PATH_ANY,
|
||||||
|
FILTER_HAS_TAGS_ALL,
|
||||||
|
FILTER_TITLE_CONTENT,
|
||||||
|
} from 'src/app/data/filter-rule-type'
|
||||||
|
import { DataType } from 'src/app/data/datatype'
|
||||||
|
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||||
|
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||||
|
import {
|
||||||
|
PermissionsService,
|
||||||
|
PermissionAction,
|
||||||
|
} from 'src/app/services/permissions.service'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import {
|
||||||
|
GlobalSearchResult,
|
||||||
|
SearchService,
|
||||||
|
} from 'src/app/services/rest/search.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
|
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
|
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||||
|
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||||
|
import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
|
||||||
|
import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component'
|
||||||
|
import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component'
|
||||||
|
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
||||||
|
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||||
|
import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component'
|
||||||
|
import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
|
||||||
|
import { HotKeyService } from 'src/app/services/hot-key.service'
|
||||||
|
import { paramsFromViewState } from 'src/app/utils/query-params'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-global-search',
|
||||||
|
templateUrl: './global-search.component.html',
|
||||||
|
styleUrl: './global-search.component.scss',
|
||||||
|
})
|
||||||
|
export class GlobalSearchComponent implements OnInit {
|
||||||
|
public DataType = DataType
|
||||||
|
public query: string
|
||||||
|
public queryDebounce: Subject<string>
|
||||||
|
public searchResults: GlobalSearchResult
|
||||||
|
private currentItemIndex: number = -1
|
||||||
|
private domIndex: number = -1
|
||||||
|
public loading: boolean = false
|
||||||
|
|
||||||
|
@ViewChild('searchInput') searchInput: ElementRef
|
||||||
|
@ViewChild('resultsDropdown') resultsDropdown: NgbDropdown
|
||||||
|
@ViewChildren('resultItem') resultItems: QueryList<ElementRef>
|
||||||
|
@ViewChildren('primaryButton') primaryButtons: QueryList<ElementRef>
|
||||||
|
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
|
||||||
|
|
||||||
|
get useAdvancedForFullSearch(): boolean {
|
||||||
|
return (
|
||||||
|
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
|
||||||
|
GlobalSearchType.ADVANCED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public searchService: SearchService,
|
||||||
|
private router: Router,
|
||||||
|
private modalService: NgbModal,
|
||||||
|
private documentService: DocumentService,
|
||||||
|
private documentListViewService: DocumentListViewService,
|
||||||
|
private permissionsService: PermissionsService,
|
||||||
|
private toastService: ToastService,
|
||||||
|
private hotkeyService: HotKeyService,
|
||||||
|
private settingsService: SettingsService
|
||||||
|
) {
|
||||||
|
this.queryDebounce = new Subject<string>()
|
||||||
|
|
||||||
|
this.queryDebounce
|
||||||
|
.pipe(
|
||||||
|
debounceTime(400),
|
||||||
|
map((query) => query?.trim()),
|
||||||
|
filter((query) => !query?.length || query?.length > 2),
|
||||||
|
distinctUntilChanged()
|
||||||
|
)
|
||||||
|
.subscribe((text) => {
|
||||||
|
this.query = text
|
||||||
|
if (text) this.search(text)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnInit() {
|
||||||
|
this.hotkeyService
|
||||||
|
.addShortcut({ keys: '/', description: $localize`Global search` })
|
||||||
|
.subscribe(() => {
|
||||||
|
this.searchInput.nativeElement.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private search(query: string) {
|
||||||
|
this.loading = true
|
||||||
|
this.searchService.globalSearch(query).subscribe((results) => {
|
||||||
|
this.searchResults = results
|
||||||
|
this.loading = false
|
||||||
|
this.resultsDropdown.open()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public primaryAction(
|
||||||
|
type: string,
|
||||||
|
object: ObjectWithId,
|
||||||
|
event: PointerEvent = null
|
||||||
|
) {
|
||||||
|
const newWindow = event?.metaKey || event?.ctrlKey
|
||||||
|
this.reset(true)
|
||||||
|
let filterRuleType: number
|
||||||
|
let editDialogComponent: any
|
||||||
|
let size: string = 'md'
|
||||||
|
switch (type) {
|
||||||
|
case DataType.Document:
|
||||||
|
this.navigateOrOpenInNewWindow(['/documents', object.id], newWindow)
|
||||||
|
return
|
||||||
|
case DataType.SavedView:
|
||||||
|
this.navigateOrOpenInNewWindow(['/view', object.id], newWindow)
|
||||||
|
return
|
||||||
|
case DataType.Correspondent:
|
||||||
|
filterRuleType = FILTER_HAS_CORRESPONDENT_ANY
|
||||||
|
break
|
||||||
|
case DataType.DocumentType:
|
||||||
|
filterRuleType = FILTER_HAS_DOCUMENT_TYPE_ANY
|
||||||
|
break
|
||||||
|
case DataType.StoragePath:
|
||||||
|
filterRuleType = FILTER_HAS_STORAGE_PATH_ANY
|
||||||
|
break
|
||||||
|
case DataType.Tag:
|
||||||
|
filterRuleType = FILTER_HAS_TAGS_ALL
|
||||||
|
break
|
||||||
|
case DataType.User:
|
||||||
|
editDialogComponent = UserEditDialogComponent
|
||||||
|
size = 'lg'
|
||||||
|
break
|
||||||
|
case DataType.Group:
|
||||||
|
editDialogComponent = GroupEditDialogComponent
|
||||||
|
size = 'lg'
|
||||||
|
break
|
||||||
|
case DataType.MailAccount:
|
||||||
|
editDialogComponent = MailAccountEditDialogComponent
|
||||||
|
size = 'xl'
|
||||||
|
break
|
||||||
|
case DataType.MailRule:
|
||||||
|
editDialogComponent = MailRuleEditDialogComponent
|
||||||
|
size = 'xl'
|
||||||
|
break
|
||||||
|
case DataType.CustomField:
|
||||||
|
editDialogComponent = CustomFieldEditDialogComponent
|
||||||
|
break
|
||||||
|
case DataType.Workflow:
|
||||||
|
editDialogComponent = WorkflowEditDialogComponent
|
||||||
|
size = 'xl'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterRuleType) {
|
||||||
|
let params = paramsFromViewState({
|
||||||
|
filterRules: [
|
||||||
|
{ rule_type: filterRuleType, value: object.id.toString() },
|
||||||
|
],
|
||||||
|
currentPage: 1,
|
||||||
|
sortField: this.documentListViewService.sortField ?? 'created',
|
||||||
|
sortReverse: this.documentListViewService.sortReverse,
|
||||||
|
})
|
||||||
|
this.navigateOrOpenInNewWindow(['/documents'], newWindow, {
|
||||||
|
queryParams: params,
|
||||||
|
})
|
||||||
|
} else if (editDialogComponent) {
|
||||||
|
const modalRef: NgbModalRef = this.modalService.open(
|
||||||
|
editDialogComponent,
|
||||||
|
{ size }
|
||||||
|
)
|
||||||
|
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
|
||||||
|
modalRef.componentInstance.object = object
|
||||||
|
modalRef.componentInstance.succeeded.subscribe(() => {
|
||||||
|
this.toastService.showInfo($localize`Successfully updated object.`)
|
||||||
|
})
|
||||||
|
modalRef.componentInstance.failed.subscribe((e) => {
|
||||||
|
this.toastService.showError($localize`Error occurred saving object.`, e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public secondaryAction(type: string, object: ObjectWithId) {
|
||||||
|
this.reset(true)
|
||||||
|
let editDialogComponent: any
|
||||||
|
let size: string = 'md'
|
||||||
|
switch (type) {
|
||||||
|
case DataType.Document:
|
||||||
|
window.open(this.documentService.getDownloadUrl(object.id))
|
||||||
|
break
|
||||||
|
case DataType.Correspondent:
|
||||||
|
editDialogComponent = CorrespondentEditDialogComponent
|
||||||
|
break
|
||||||
|
case DataType.DocumentType:
|
||||||
|
editDialogComponent = DocumentTypeEditDialogComponent
|
||||||
|
break
|
||||||
|
case DataType.StoragePath:
|
||||||
|
editDialogComponent = StoragePathEditDialogComponent
|
||||||
|
break
|
||||||
|
case DataType.Tag:
|
||||||
|
editDialogComponent = TagEditDialogComponent
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editDialogComponent) {
|
||||||
|
const modalRef: NgbModalRef = this.modalService.open(
|
||||||
|
editDialogComponent,
|
||||||
|
{ size }
|
||||||
|
)
|
||||||
|
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
|
||||||
|
modalRef.componentInstance.object = object
|
||||||
|
modalRef.componentInstance.succeeded.subscribe(() => {
|
||||||
|
this.toastService.showInfo($localize`Successfully updated object.`)
|
||||||
|
})
|
||||||
|
modalRef.componentInstance.failed.subscribe((e) => {
|
||||||
|
this.toastService.showError($localize`Error occurred saving object.`, e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset(close: boolean = false) {
|
||||||
|
this.queryDebounce.next(null)
|
||||||
|
this.query = null
|
||||||
|
this.searchResults = null
|
||||||
|
this.currentItemIndex = -1
|
||||||
|
if (close) {
|
||||||
|
this.resultsDropdown.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCurrentItem() {
|
||||||
|
// QueryLists do not always reflect the current DOM order, so we need to find the actual element
|
||||||
|
// Yes, using some vanilla JS
|
||||||
|
const result: HTMLElement = this.resultItems.first.nativeElement.parentNode
|
||||||
|
.querySelectorAll('.dropdown-item')
|
||||||
|
.item(this.currentItemIndex)
|
||||||
|
this.domIndex = this.resultItems
|
||||||
|
.toArray()
|
||||||
|
.indexOf(this.resultItems.find((item) => item.nativeElement === result))
|
||||||
|
const item: ElementRef = this.primaryButtons.get(this.domIndex)
|
||||||
|
item.nativeElement.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
public onItemHover(event: MouseEvent) {
|
||||||
|
const item: ElementRef = this.resultItems
|
||||||
|
.toArray()
|
||||||
|
.find((item) => item.nativeElement === event.currentTarget)
|
||||||
|
this.currentItemIndex = this.resultItems.toArray().indexOf(item)
|
||||||
|
this.setCurrentItem()
|
||||||
|
}
|
||||||
|
|
||||||
|
public onButtonHover(event: MouseEvent) {
|
||||||
|
;(event.currentTarget as HTMLElement).focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
public searchInputKeyDown(event: KeyboardEvent) {
|
||||||
|
if (
|
||||||
|
event.key === 'ArrowDown' &&
|
||||||
|
this.searchResults?.total &&
|
||||||
|
this.resultsDropdown.isOpen()
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
this.currentItemIndex = 0
|
||||||
|
this.setCurrentItem()
|
||||||
|
} else if (
|
||||||
|
event.key === 'ArrowUp' &&
|
||||||
|
this.searchResults?.total &&
|
||||||
|
this.resultsDropdown.isOpen()
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
this.currentItemIndex = this.searchResults.total - 1
|
||||||
|
this.setCurrentItem()
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
if (this.searchResults?.total === 1 && this.resultsDropdown.isOpen()) {
|
||||||
|
this.primaryButtons.first.nativeElement.click()
|
||||||
|
this.searchInput.nativeElement.blur()
|
||||||
|
} else if (this.query?.length) {
|
||||||
|
this.runFullSearch()
|
||||||
|
this.reset(true)
|
||||||
|
}
|
||||||
|
} else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) {
|
||||||
|
if (this.query?.length) {
|
||||||
|
this.reset(true)
|
||||||
|
} else {
|
||||||
|
this.searchInput.nativeElement.blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public dropdownKeyDown(event: KeyboardEvent) {
|
||||||
|
if (
|
||||||
|
this.searchResults?.total &&
|
||||||
|
this.resultsDropdown.isOpen() &&
|
||||||
|
document.activeElement !== this.searchInput.nativeElement
|
||||||
|
) {
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
if (this.currentItemIndex < this.searchResults.total - 1) {
|
||||||
|
this.currentItemIndex++
|
||||||
|
this.setCurrentItem()
|
||||||
|
} else {
|
||||||
|
this.searchInput.nativeElement.focus()
|
||||||
|
this.currentItemIndex = -1
|
||||||
|
}
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
if (this.currentItemIndex > 0) {
|
||||||
|
this.currentItemIndex--
|
||||||
|
this.setCurrentItem()
|
||||||
|
} else {
|
||||||
|
this.searchInput.nativeElement.focus()
|
||||||
|
this.currentItemIndex = -1
|
||||||
|
}
|
||||||
|
} else if (event.key === 'ArrowRight') {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
this.secondaryButtons.get(this.domIndex)?.nativeElement.focus()
|
||||||
|
} else if (event.key === 'ArrowLeft') {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
this.primaryButtons.get(this.domIndex).nativeElement.focus()
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
this.reset(true)
|
||||||
|
this.searchInput.nativeElement.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onButtonKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||||
|
event.target.dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDropdownOpenChange(open: boolean) {
|
||||||
|
if (!open) {
|
||||||
|
this.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public disablePrimaryButton(type: DataType, object: ObjectWithId): boolean {
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
DataType.Workflow,
|
||||||
|
DataType.CustomField,
|
||||||
|
DataType.Group,
|
||||||
|
DataType.User,
|
||||||
|
].includes(type)
|
||||||
|
) {
|
||||||
|
return !this.permissionsService.currentUserHasObjectPermissions(
|
||||||
|
PermissionAction.Change,
|
||||||
|
object
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
public disableSecondaryButton(type: DataType, object: ObjectWithId): boolean {
|
||||||
|
if (DataType.Document === type) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return !this.permissionsService.currentUserHasObjectPermissions(
|
||||||
|
PermissionAction.Change,
|
||||||
|
object
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public runFullSearch() {
|
||||||
|
const ruleType = this.useAdvancedForFullSearch
|
||||||
|
? FILTER_FULLTEXT_QUERY
|
||||||
|
: FILTER_TITLE_CONTENT
|
||||||
|
this.documentListViewService.quickFilter([
|
||||||
|
{ rule_type: ruleType, value: this.query },
|
||||||
|
])
|
||||||
|
this.reset(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private navigateOrOpenInNewWindow(
|
||||||
|
commands: any,
|
||||||
|
newWindow: boolean = false,
|
||||||
|
extras: Object = {}
|
||||||
|
) {
|
||||||
|
if (newWindow) {
|
||||||
|
const url = this.router.serializeUrl(
|
||||||
|
this.router.createUrlTree(commands, extras)
|
||||||
|
)
|
||||||
|
window.open(url, '_blank')
|
||||||
|
} else {
|
||||||
|
this.router.navigate(commands, extras)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -86,14 +86,4 @@ describe('ConfirmDialogComponent', () => {
|
|||||||
expect(closeModalSpy).toHaveBeenCalled()
|
expect(closeModalSpy).toHaveBeenCalled()
|
||||||
expect(confirmSubjectResult).toBeFalsy()
|
expect(confirmSubjectResult).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support delay confirm', fakeAsync(() => {
|
|
||||||
component.confirmButtonEnabled = false
|
|
||||||
component.delayConfirm(1)
|
|
||||||
expect(component.confirmButtonEnabled).toBeFalsy()
|
|
||||||
tick(1500)
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(component.confirmButtonEnabled).toBeTruthy()
|
|
||||||
discardPeriodicTasks()
|
|
||||||
}))
|
|
||||||
})
|
})
|
||||||
|
@@ -54,26 +54,6 @@ export class ConfirmDialogComponent {
|
|||||||
confirmSubject: Subject<boolean>
|
confirmSubject: Subject<boolean>
|
||||||
alternativeSubject: Subject<boolean>
|
alternativeSubject: Subject<boolean>
|
||||||
|
|
||||||
delayConfirm(seconds: number) {
|
|
||||||
const refreshInterval = 0.15 // s
|
|
||||||
|
|
||||||
this.secondsTotal = seconds
|
|
||||||
this.seconds = seconds
|
|
||||||
|
|
||||||
interval(refreshInterval * 1000)
|
|
||||||
.pipe(
|
|
||||||
take(this.secondsTotal / refreshInterval + 2) // need 2 more for animation to complete after 0
|
|
||||||
)
|
|
||||||
.subscribe((count) => {
|
|
||||||
this.seconds = Math.max(
|
|
||||||
0,
|
|
||||||
this.secondsTotal - refreshInterval * (count + 1)
|
|
||||||
)
|
|
||||||
this.confirmButtonEnabled =
|
|
||||||
this.secondsTotal - refreshInterval * count < 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel() {
|
cancel() {
|
||||||
this.confirmSubject?.next(false)
|
this.confirmSubject?.next(false)
|
||||||
this.confirmSubject?.complete()
|
this.confirmSubject?.complete()
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user