Compare commits
592 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
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 | ||
![]() |
ccd6ad9936 | ||
![]() |
979fcb0570 | ||
![]() |
58afec98f1 | ||
![]() |
91434a5c6f | ||
![]() |
37c4545444 | ||
![]() |
7b7b257725 | ||
![]() |
1eff4b306f | ||
![]() |
700af8caa2 | ||
![]() |
90b43e154a | ||
![]() |
a3892302b0 | ||
![]() |
d2ee319684 | ||
![]() |
5a6923a9aa | ||
![]() |
0a634883a7 | ||
![]() |
428f9cd761 | ||
![]() |
d828c1a2ff | ||
![]() |
25b49db7c0 | ||
![]() |
55a40708a6 | ||
![]() |
dae5bca883 | ||
![]() |
fc74da9b82 | ||
![]() |
2b006907d5 | ||
![]() |
1ba1afdce5 | ||
![]() |
a98317c52a | ||
![]() |
ffddd0f323 | ||
![]() |
4cb2f0acef | ||
![]() |
98663e902f | ||
![]() |
0f5e935214 | ||
![]() |
b9636a3def | ||
![]() |
35574f3b86 | ||
![]() |
00a8f0cd6e | ||
![]() |
6779042242 | ||
![]() |
6379e7b54f | ||
![]() |
2fa742c94b | ||
![]() |
aa0da2f516 | ||
![]() |
f07441a408 | ||
![]() |
f6084acfc8 | ||
![]() |
23ceb2a5ec | ||
![]() |
a698791059 | ||
![]() |
41c1f38ab2 | ||
![]() |
83c85dc10e | ||
![]() |
a020d807d4 | ||
![]() |
464ee51de8 | ||
![]() |
c57c1d5389 | ||
![]() |
af16bb3934 | ||
![]() |
fba416e8e1 | ||
![]() |
3d8de50b5a | ||
![]() |
86263a52ea | ||
![]() |
754627681c | ||
![]() |
bf11dc8d1b | ||
![]() |
84721b001f | ||
![]() |
f48a20c75f | ||
![]() |
16f4552e0e | ||
![]() |
86811d0733 | ||
![]() |
d2f9b5d5e5 | ||
![]() |
f5e1675107 | ||
![]() |
ae016cae4b | ||
![]() |
c7f6d03508 | ||
![]() |
955f2d0db9 | ||
![]() |
ba32684df6 | ||
![]() |
2f7adf40ac | ||
![]() |
a52031161b | ||
![]() |
3e2c541b7b | ||
![]() |
967fc98090 | ||
![]() |
22e95f45bd | ||
![]() |
38c777ec0f | ||
![]() |
ccbc97399a | ||
![]() |
f43013a746 | ||
![]() |
1335ab5f1b | ||
![]() |
90b4691f16 | ||
![]() |
f053ee3191 | ||
![]() |
86748c1e96 | ||
![]() |
22ded7d4c3 | ||
![]() |
ff5063849a | ||
![]() |
ec49284274 | ||
![]() |
6bd5c34b54 | ||
![]() |
db0a2eb1a3 | ||
![]() |
4948438378 | ||
![]() |
76064178f5 | ||
![]() |
c772bd94b0 | ||
![]() |
7f8f7fbb15 | ||
![]() |
388d821f45 | ||
![]() |
9c15623a89 | ||
![]() |
966eb00de0 | ||
![]() |
1c699278a3 | ||
![]() |
4c6c976f63 | ||
![]() |
ebc9ce17b5 | ||
![]() |
8039ce3c2b | ||
![]() |
385d48f644 | ||
![]() |
f682fe25fc | ||
![]() |
7924bf8611 | ||
![]() |
d75b909d28 | ||
![]() |
47dfe85a7c | ||
![]() |
4d0e8a338f | ||
![]() |
cfc64d37bb | ||
![]() |
2db66280cc | ||
![]() |
9f045f4494 | ||
![]() |
4fdb28c8d6 | ||
![]() |
f1049cf889 | ||
![]() |
8d664fad56 | ||
![]() |
f6ddcfa839 | ||
![]() |
ce59f2ad5e | ||
![]() |
0de00a4ac1 | ||
![]() |
bec72dffeb | ||
![]() |
463e95367c | ||
![]() |
1739de2694 | ||
![]() |
fd8db27a88 | ||
![]() |
9ddf14bebe | ||
![]() |
1cf8ea3aba | ||
![]() |
134993fce6 | ||
![]() |
ff1955e014 | ||
![]() |
d83bbdc50b | ||
![]() |
907b6d1294 | ||
![]() |
4e7bb1c8da | ||
![]() |
09ab694d05 | ||
![]() |
03ced65d5f | ||
![]() |
01d919cf31 | ||
![]() |
8d5f331e63 | ||
![]() |
ed556ead6f | ||
![]() |
e10a904f33 | ||
![]() |
f2a05b61da | ||
![]() |
21f96f0679 | ||
![]() |
2ffabd54e5 | ||
![]() |
1197437750 | ||
![]() |
d1339374d0 | ||
![]() |
5ba4b9d6b2 | ||
![]() |
b386ea9426 | ||
![]() |
197174f400 | ||
![]() |
97dceba783 | ||
![]() |
2ed4400827 | ||
![]() |
58cbcbd6ef | ||
![]() |
4aeb2e1a74 | ||
![]() |
45c5f81b34 | ||
![]() |
13201dbfff | ||
![]() |
0b1523f4e5 | ||
![]() |
cd3b1a221e | ||
![]() |
4855f4b8b1 | ||
![]() |
6587470033 | ||
![]() |
6487dab132 | ||
![]() |
b643a68fa3 | ||
![]() |
b60e16fe33 | ||
![]() |
c508be6ecd | ||
![]() |
3b2d4fe876 | ||
![]() |
b47f301831 | ||
![]() |
a79b9de1a2 | ||
![]() |
e98da2e72c | ||
![]() |
718171a125 | ||
![]() |
aaa130e20d | ||
![]() |
4606caeaa8 | ||
![]() |
4813a7bc70 | ||
![]() |
fb82aa0ee1 | ||
![]() |
c7e0c32226 | ||
![]() |
607adf44f3 | ||
![]() |
625780899d | ||
![]() |
25542c56b9 | ||
![]() |
45e2b7f814 | ||
![]() |
6b34f592df | ||
![]() |
6cf732e6ec | ||
![]() |
dfd959839f | ||
![]() |
d165e89ac3 | ||
![]() |
421a87c94b | ||
![]() |
b55529b913 | ||
![]() |
c62d892969 | ||
![]() |
9e6aa55230 | ||
![]() |
6090305b77 | ||
![]() |
d1b516a089 | ||
![]() |
89aff63e52 | ||
![]() |
11e9c4d8cc | ||
![]() |
9d84e95771 | ||
![]() |
2aced1c305 | ||
![]() |
454098630b | ||
![]() |
8f3ab2791b | ||
![]() |
61209b1057 | ||
![]() |
38a817e887 | ||
![]() |
5e3d1b26e7 | ||
![]() |
4996b7e5f7 | ||
![]() |
2c8fddb554 | ||
![]() |
ae05011062 | ||
![]() |
50a6b7e154 | ||
![]() |
d0ce4113e0 | ||
![]() |
d55900b877 | ||
![]() |
2a73ab4693 | ||
![]() |
2aea220c6d | ||
![]() |
b0c305e852 | ||
![]() |
73a77d2a45 | ||
![]() |
3a011e7c04 | ||
![]() |
d48b75d862 | ||
![]() |
f6e26d5953 | ||
![]() |
7863780883 | ||
![]() |
c2ac9a26a2 | ||
![]() |
58e8f796d1 | ||
![]() |
ba0f4718e5 | ||
![]() |
6fb4daf03e | ||
![]() |
f6c34494a7 | ||
![]() |
1e4d284b30 | ||
![]() |
1f3406fd77 | ||
![]() |
398faf36fc | ||
![]() |
ce841d4196 | ||
![]() |
c77f8acf41 | ||
![]() |
212674f9df | ||
![]() |
283ced56d1 | ||
![]() |
530e57151d | ||
![]() |
1141c3f361 | ||
![]() |
49416d3372 | ||
![]() |
88ae60a4a0 | ||
![]() |
6651c80fb9 | ||
![]() |
6d6650d5f6 | ||
![]() |
6df252c99b | ||
![]() |
5881f05dbc | ||
![]() |
00eba3b223 | ||
![]() |
f7ab8d23a7 | ||
![]() |
85b596d20d | ||
![]() |
4d43f6b63d | ||
![]() |
ea1eb551a7 | ||
![]() |
5842944d1e | ||
![]() |
5781a0d51f | ||
![]() |
e5f48739a0 | ||
![]() |
d378c861f6 | ||
![]() |
9466bfdb00 | ||
![]() |
f02e8e0dc3 | ||
![]() |
c1ed87a44f | ||
![]() |
16169ca331 | ||
![]() |
26900e0766 | ||
![]() |
aa798604b3 | ||
![]() |
bb98fc5f65 | ||
![]() |
dc1918ad10 | ||
![]() |
ea632d0417 | ||
![]() |
648dc709fd | ||
![]() |
1a84f6a20e | ||
![]() |
96af953e6f | ||
![]() |
6db9e292ba | ||
![]() |
2e2362e2df | ||
![]() |
51dd95be3d | ||
![]() |
e16645b146 | ||
![]() |
0068f091bb | ||
![]() |
ad6efd2898 | ||
![]() |
86e380bb1c | ||
![]() |
58aacd4814 | ||
![]() |
ad07791bac | ||
![]() |
783090c2cd | ||
![]() |
41a3c7c89b | ||
![]() |
16cc7415c1 | ||
![]() |
98c5cf89ef | ||
![]() |
53e04e66cf | ||
![]() |
2a6e79acc8 | ||
![]() |
2da5e46386 | ||
![]() |
4dbf8d7969 | ||
![]() |
4a52fc27d4 | ||
![]() |
05cd34c8af | ||
![]() |
8a622181fc | ||
![]() |
13f38bf3a1 | ||
![]() |
16acc2d6ad | ||
![]() |
c2c9a953d3 | ||
![]() |
530f4a8b28 | ||
![]() |
8eb1dc4f62 | ||
![]() |
a2b87fe012 | ||
![]() |
3dcb973adb | ||
![]() |
8e8810cbaa | ||
![]() |
b0aeec4c43 | ||
![]() |
1ac298f6ff | ||
![]() |
6d5f4e92cc | ||
![]() |
416ad13aaf | ||
![]() |
a7e1299194 | ||
![]() |
a12e1fae72 | ||
![]() |
f525ac0af6 | ||
![]() |
58bf9c552b | ||
![]() |
22d257cd1f | ||
![]() |
f1bf1ddc54 | ||
![]() |
6015cc0e4a | ||
![]() |
f9926d77d5 | ||
![]() |
4f85dcecfc | ||
![]() |
30c31a3d4c | ||
![]() |
c64667d396 | ||
![]() |
9f6613fe05 | ||
![]() |
ea47af7034 | ||
![]() |
d46abeff01 | ||
![]() |
2b39697ffb | ||
![]() |
4b00a72ff5 | ||
![]() |
e590b2482e | ||
![]() |
eb7dd80410 | ||
![]() |
86338465fb | ||
![]() |
a41dbdd12c | ||
![]() |
1e10a438cd | ||
![]() |
ab34ea724d | ||
![]() |
fd8bfe1a80 | ||
![]() |
9043f45350 | ||
![]() |
5921e6d13e | ||
![]() |
ee2bfe2350 | ||
![]() |
0957a7ca8e | ||
![]() |
f4e75c7fb7 | ||
![]() |
fae0e3b405 | ||
![]() |
ef335517ce | ||
![]() |
e2be166e67 | ||
![]() |
37e34d92de | ||
![]() |
bd35030c59 | ||
![]() |
a82e3771ae | ||
![]() |
3115106dc1 | ||
![]() |
d623af9c41 | ||
![]() |
355a434a07 | ||
![]() |
8da2535a65 | ||
![]() |
5963dfe41b | ||
![]() |
c6dcaa0472 | ||
![]() |
21063a5c22 | ||
![]() |
ba2f51bed1 | ||
![]() |
bbf64b7e93 | ||
![]() |
3b6ce16f1c | ||
![]() |
46e6be319f | ||
![]() |
e6d6f21d33 | ||
![]() |
77b9b79a9e | ||
![]() |
f0016ad70c | ||
![]() |
054468ffc2 | ||
![]() |
607c1282e3 | ||
![]() |
3f4f4444f7 | ||
![]() |
54372b5618 | ||
![]() |
670a3f6c7f | ||
![]() |
35a4d3fb54 | ||
![]() |
fb81612ed1 | ||
![]() |
c5d622279c | ||
![]() |
b93f655039 | ||
![]() |
428ffb4729 | ||
![]() |
061f33fb05 | ||
![]() |
da058b915b | ||
![]() |
cf869b1356 | ||
![]() |
05e294fc81 | ||
![]() |
bd904d9e6b | ||
![]() |
a7ac719711 | ||
![]() |
370f6ddb3b | ||
![]() |
3861e84f89 | ||
![]() |
76001105b8 | ||
![]() |
d47cc9460b | ||
![]() |
fbeb03c377 | ||
![]() |
2b13fa4712 | ||
![]() |
98255f8e14 | ||
![]() |
eaeeb6447f | ||
![]() |
e6a9868e86 | ||
![]() |
f03592d17d | ||
![]() |
e5db44bc2b | ||
![]() |
ffad42615f | ||
![]() |
5576a073a5 | ||
![]() |
151d337f6c | ||
![]() |
74e89b0ee3 | ||
![]() |
945fb675e9 | ||
![]() |
7570945cf0 | ||
![]() |
d94d80a8c8 | ||
![]() |
8a0a49dd57 | ||
![]() |
66b2d90c50 | ||
![]() |
5723bd8dd8 | ||
![]() |
9b08ce1761 | ||
![]() |
a6248bec2d | ||
![]() |
b1f6f52486 | ||
![]() |
638d9970fd | ||
![]() |
5e8de4c1da | ||
![]() |
088bad9030 | ||
![]() |
cfa908243b | ||
![]() |
cabeb00632 | ||
![]() |
2426c01978 | ||
![]() |
80f2bee6e8 | ||
![]() |
7ec51758eb | ||
![]() |
9e93ae952a | ||
![]() |
829836ddf6 | ||
![]() |
1197a048bc | ||
![]() |
7289c4ea56 | ||
![]() |
55dadf0b00 | ||
![]() |
341815cc03 | ||
![]() |
d22b27afe7 | ||
![]() |
6ee2d023d5 | ||
![]() |
5010cc6f15 | ||
![]() |
383cced158 | ||
![]() |
a3d6967192 | ||
![]() |
c5881f75c9 | ||
![]() |
c4b7429e99 | ||
![]() |
b1eced3612 | ||
![]() |
9d5b07537d | ||
![]() |
122e4141b0 | ||
![]() |
be2de4f15d | ||
![]() |
92a920021d | ||
![]() |
72000cac36 | ||
![]() |
4510902677 | ||
![]() |
c2b9d2fa7b | ||
![]() |
cd38c39908 |
3
.codespellrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[codespell]
|
||||||
|
write-changes = True
|
||||||
|
ignore-words-list = criterias,afterall,valeu,ureue,equest,ure
|
13
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -20,7 +20,7 @@ body:
|
|||||||
- [The troubleshooting documentation](https://docs.paperless-ngx.com/troubleshooting/).
|
- [The troubleshooting documentation](https://docs.paperless-ngx.com/troubleshooting/).
|
||||||
- [The installation instructions](https://docs.paperless-ngx.com/setup/#installation).
|
- [The installation instructions](https://docs.paperless-ngx.com/setup/#installation).
|
||||||
- [Existing issues and discussions](https://github.com/paperless-ngx/paperless-ngx/search?q=&type=issues).
|
- [Existing issues and discussions](https://github.com/paperless-ngx/paperless-ngx/search?q=&type=issues).
|
||||||
- Disable any customer container initialization scripts, if using
|
- Disable any custom container initialization scripts, if using
|
||||||
|
|
||||||
If you encounter issues while installing or configuring Paperless-ngx, please post in the ["Support" section of the discussions](https://github.com/paperless-ngx/paperless-ngx/discussions/new?category=support).
|
If you encounter issues while installing or configuring Paperless-ngx, please post in the ["Support" section of the discussions](https://github.com/paperless-ngx/paperless-ngx/discussions/new?category=support).
|
||||||
- type: textarea
|
- type: textarea
|
||||||
@@ -102,3 +102,14 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Other
|
label: Other
|
||||||
description: Any other relevant details.
|
description: Any other relevant details.
|
||||||
|
- type: checkboxes
|
||||||
|
id: required-checks
|
||||||
|
attributes:
|
||||||
|
label: Please confirm the following
|
||||||
|
options:
|
||||||
|
- label: I believe this issue is a bug that affects all users of Paperless-ngx, not something specific to my installation.
|
||||||
|
required: true
|
||||||
|
- label: I have already searched for relevant existing issues and discussions before opening this report.
|
||||||
|
required: true
|
||||||
|
- label: I have updated the title field above with a concise description.
|
||||||
|
required: true
|
||||||
|
15
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -8,7 +8,11 @@ Note: All PRs with code changes should be targeted to the `dev` branch, pure doc
|
|||||||
Please include a summary of the change and which issue is fixed (if any) and any relevant motivation / context. List any dependencies that are required for this change. If appropriate, please include an explanation of how your proposed change can be tested. Screenshots and / or videos can also be helpful if appropriate.
|
Please include a summary of the change and which issue is fixed (if any) and any relevant motivation / context. List any dependencies that are required for this change. If appropriate, please include an explanation of how your proposed change can be tested. Screenshots and / or videos can also be helpful if appropriate.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
Fixes # (issue)
|
<!--
|
||||||
|
⚠️ Important: Pull requests that implement a new feature or enhancement *should almost always target an existing feature request* with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. If that is not currently the case, please open a feature request instead of this PR to gather feedback from both users and the project maintainers.
|
||||||
|
-->
|
||||||
|
|
||||||
|
Closes #(issue or discussion)
|
||||||
|
|
||||||
## Type of change
|
## Type of change
|
||||||
|
|
||||||
@@ -17,10 +21,11 @@ What type of change does your PR introduce to Paperless-ngx?
|
|||||||
NOTE: Please check only one box!
|
NOTE: Please check only one box!
|
||||||
-->
|
-->
|
||||||
|
|
||||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
- [ ] Bug fix: non-breaking change which fixes an issue.
|
||||||
- [ ] New feature (non-breaking change which adds functionality)
|
- [ ] New feature / Enhancement: non-breaking change which adds functionality. _Please read the important note above._
|
||||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
- [ ] Breaking change: fix or feature that would cause existing functionality to not work as expected.
|
||||||
- [ ] Other (please explain):
|
- [ ] Documentation only.
|
||||||
|
- [ ] Other. Please explain:
|
||||||
|
|
||||||
## Checklist:
|
## Checklist:
|
||||||
|
|
||||||
|
2
.github/dependabot.yml
vendored
@@ -47,6 +47,8 @@ updates:
|
|||||||
# Add reviewers
|
# Add reviewers
|
||||||
reviewers:
|
reviewers:
|
||||||
- "paperless-ngx/backend"
|
- "paperless-ngx/backend"
|
||||||
|
ignore:
|
||||||
|
- dependency-name: "uvicorn"
|
||||||
groups:
|
groups:
|
||||||
development:
|
development:
|
||||||
patterns:
|
patterns:
|
||||||
|
68
.github/workflows/ci.yml
vendored
@@ -16,7 +16,7 @@ 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.10.24"
|
DEFAULT_PIP_ENV_VERSION: "2023.12.1"
|
||||||
# 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.10"
|
||||||
|
|
||||||
@@ -37,12 +37,12 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
-
|
-
|
||||||
name: Install python
|
name: Install python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
-
|
-
|
||||||
name: Check files
|
name: Check files
|
||||||
uses: pre-commit/action@v3.0.0
|
uses: pre-commit/action@v3.0.1
|
||||||
|
|
||||||
documentation:
|
documentation:
|
||||||
name: "Build & Deploy Documentation"
|
name: "Build & Deploy Documentation"
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Set up Python
|
name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
cache: "pipenv"
|
cache: "pipenv"
|
||||||
@@ -87,7 +87,7 @@ jobs:
|
|||||||
pipenv --python ${{ steps.setup-python.outputs.python-version }} run mkdocs gh-deploy --force --no-history
|
pipenv --python ${{ steps.setup-python.outputs.python-version }} run mkdocs gh-deploy --force --no-history
|
||||||
-
|
-
|
||||||
name: Upload artifact
|
name: Upload artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: documentation
|
name: documentation
|
||||||
path: site/
|
path: site/
|
||||||
@@ -114,7 +114,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Set up Python
|
name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "${{ matrix.python-version }}"
|
python-version: "${{ matrix.python-version }}"
|
||||||
cache: "pipenv"
|
cache: "pipenv"
|
||||||
@@ -155,7 +155,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Upload coverage
|
name: Upload coverage
|
||||||
if: ${{ matrix.python-version == env.DEFAULT_PYTHON_VERSION }}
|
if: ${{ matrix.python-version == env.DEFAULT_PYTHON_VERSION }}
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: backend-coverage-report
|
name: backend-coverage-report
|
||||||
path: src/coverage.xml
|
path: src/coverage.xml
|
||||||
@@ -169,7 +169,7 @@ jobs:
|
|||||||
docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml down
|
docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml down
|
||||||
|
|
||||||
install-frontend-depedendencies:
|
install-frontend-depedendencies:
|
||||||
name: "Install Frontend Dependendencies"
|
name: "Install Frontend Dependencies"
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs:
|
needs:
|
||||||
- pre-commit
|
- pre-commit
|
||||||
@@ -182,9 +182,9 @@ jobs:
|
|||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: 'src-ui/package-lock.json'
|
cache-dependency-path: 'src-ui/package-lock.json'
|
||||||
- name: Cache frontend depdendencies
|
- name: Cache frontend dependencies
|
||||||
id: cache-frontend-deps
|
id: cache-frontend-deps
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.npm
|
~/.npm
|
||||||
@@ -219,9 +219,9 @@ jobs:
|
|||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: 'src-ui/package-lock.json'
|
cache-dependency-path: 'src-ui/package-lock.json'
|
||||||
- name: Cache frontend depdendencies
|
- name: Cache frontend dependencies
|
||||||
id: cache-frontend-deps
|
id: cache-frontend-deps
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.npm
|
~/.npm
|
||||||
@@ -238,7 +238,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Upload Jest coverage
|
name: Upload Jest coverage
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: jest-coverage-report-${{ matrix.shard-index }}
|
name: jest-coverage-report-${{ matrix.shard-index }}
|
||||||
path: |
|
path: |
|
||||||
@@ -253,9 +253,9 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Upload Playwright test results
|
name: Upload Playwright test results
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report-${{ matrix.shard-index }}
|
||||||
path: src-ui/playwright-report
|
path: src-ui/playwright-report
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
@@ -269,13 +269,21 @@ jobs:
|
|||||||
-
|
-
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
-
|
-
|
||||||
name: Download frontend coverage
|
name: Download frontend jest coverage
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: src-ui/coverage/
|
path: src-ui/coverage/
|
||||||
|
pattern: jest-coverage-report-*
|
||||||
|
-
|
||||||
|
name: Download frontend playwright coverage
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: src-ui/coverage/
|
||||||
|
pattern: playwright-report-*
|
||||||
|
merge-multiple: true
|
||||||
-
|
-
|
||||||
name: Upload frontend coverage to Codecov
|
name: Upload frontend coverage to Codecov
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v4
|
||||||
with:
|
with:
|
||||||
# not required for public repos, but intermittently fails otherwise
|
# not required for public repos, but intermittently fails otherwise
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
@@ -285,13 +293,13 @@ jobs:
|
|||||||
files: '!coverage.xml'
|
files: '!coverage.xml'
|
||||||
-
|
-
|
||||||
name: Download backend coverage
|
name: Download backend coverage
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: backend-coverage-report
|
name: backend-coverage-report
|
||||||
path: src/
|
path: src/
|
||||||
-
|
-
|
||||||
name: Upload coverage to Codecov
|
name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v4
|
||||||
with:
|
with:
|
||||||
# not required for public repos, but intermittently fails otherwise
|
# not required for public repos, but intermittently fails otherwise
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
@@ -302,7 +310,7 @@ jobs:
|
|||||||
build-docker-image:
|
build-docker-image:
|
||||||
name: Build Docker image for ${{ github.ref_name }}
|
name: Build Docker image for ${{ github.ref_name }}
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v'))
|
if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || startsWith(github.ref, 'refs/heads/fix-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v'))
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-build-docker-image-${{ github.ref_name }}
|
group: ${{ github.workflow }}-build-docker-image-${{ github.ref_name }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
@@ -416,7 +424,7 @@ jobs:
|
|||||||
docker cp frontend-extract:/usr/src/paperless/src/documents/static/frontend src/documents/static/frontend/
|
docker cp frontend-extract:/usr/src/paperless/src/documents/static/frontend src/documents/static/frontend/
|
||||||
-
|
-
|
||||||
name: Upload frontend artifact
|
name: Upload frontend artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: frontend-compiled
|
name: frontend-compiled
|
||||||
path: src/documents/static/frontend/
|
path: src/documents/static/frontend/
|
||||||
@@ -435,7 +443,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Set up Python
|
name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
cache: "pipenv"
|
cache: "pipenv"
|
||||||
@@ -461,13 +469,13 @@ jobs:
|
|||||||
sudo apt-get install -qq --no-install-recommends gettext liblept5
|
sudo apt-get install -qq --no-install-recommends gettext liblept5
|
||||||
-
|
-
|
||||||
name: Download frontend artifact
|
name: Download frontend artifact
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: frontend-compiled
|
name: frontend-compiled
|
||||||
path: src/documents/static/frontend/
|
path: src/documents/static/frontend/
|
||||||
-
|
-
|
||||||
name: Download documentation artifact
|
name: Download documentation artifact
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: documentation
|
name: documentation
|
||||||
path: docs/_build/html/
|
path: docs/_build/html/
|
||||||
@@ -533,7 +541,7 @@ jobs:
|
|||||||
tar -cJf paperless-ngx.tar.xz paperless-ngx/
|
tar -cJf paperless-ngx.tar.xz paperless-ngx/
|
||||||
-
|
-
|
||||||
name: Upload release artifact
|
name: Upload release artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release
|
name: release
|
||||||
path: dist/paperless-ngx.tar.xz
|
path: dist/paperless-ngx.tar.xz
|
||||||
@@ -552,7 +560,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Download release artifact
|
name: Download release artifact
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release
|
name: release
|
||||||
path: ./
|
path: ./
|
||||||
@@ -569,7 +577,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Create Release and Changelog
|
name: Create Release and Changelog
|
||||||
id: create-release
|
id: create-release
|
||||||
uses: release-drafter/release-drafter@v5
|
uses: release-drafter/release-drafter@v6
|
||||||
with:
|
with:
|
||||||
name: Paperless-ngx ${{ steps.get_version.outputs.version }}
|
name: Paperless-ngx ${{ steps.get_version.outputs.version }}
|
||||||
tag: ${{ steps.get_version.outputs.version }}
|
tag: ${{ steps.get_version.outputs.version }}
|
||||||
@@ -603,7 +611,7 @@ jobs:
|
|||||||
ref: main
|
ref: main
|
||||||
-
|
-
|
||||||
name: Set up Python
|
name: Set up Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
cache: "pipenv"
|
cache: "pipenv"
|
||||||
@@ -637,7 +645,7 @@ jobs:
|
|||||||
script: |
|
script: |
|
||||||
const { repo, owner } = context.repo;
|
const { repo, owner } = context.repo;
|
||||||
const result = await github.rest.pulls.create({
|
const result = await github.rest.pulls.create({
|
||||||
title: '[Documentation] Add ${{ needs.publish-release.outputs.version }} changelog',
|
title: 'Documentation: Add ${{ needs.publish-release.outputs.version }} changelog',
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
head: '${{ needs.publish-release.outputs.version }}-changelog',
|
head: '${{ needs.publish-release.outputs.version }}-changelog',
|
||||||
|
6
.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.4.0
|
uses: stumpylog/image-cleaner-action/ephemeral@v0.6.0
|
||||||
with:
|
with:
|
||||||
token: "${{ env.TOKEN }}"
|
token: "${{ env.TOKEN }}"
|
||||||
owner: "${{ github.repository_owner }}"
|
owner: "${{ github.repository_owner }}"
|
||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
package_name: "${{ matrix.primary-name }}"
|
package_name: "${{ matrix.primary-name }}"
|
||||||
scheme: "branch"
|
scheme: "branch"
|
||||||
repo_name: "paperless-ngx"
|
repo_name: "paperless-ngx"
|
||||||
match_regex: "feature-"
|
match_regex: "(feature|fix)"
|
||||||
do_delete: "true"
|
do_delete: "true"
|
||||||
|
|
||||||
cleanup-untagged-images:
|
cleanup-untagged-images:
|
||||||
@@ -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.4.0
|
uses: stumpylog/image-cleaner-action/untagged@v0.6.0
|
||||||
with:
|
with:
|
||||||
token: "${{ env.TOKEN }}"
|
token: "${{ env.TOKEN }}"
|
||||||
owner: "${{ github.repository_owner }}"
|
owner: "${{ github.repository_owner }}"
|
||||||
|
4
.github/workflows/codeql-analysis.yml
vendored
@@ -42,7 +42,7 @@ jobs:
|
|||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v2
|
uses: github/codeql-action/init@v3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@@ -51,4 +51,4 @@ jobs:
|
|||||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v2
|
uses: github/codeql-action/analyze@v3
|
||||||
|
1
.github/workflows/crowdin.yml
vendored
@@ -7,6 +7,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
paths: [
|
paths: [
|
||||||
'src/locale/**',
|
'src/locale/**',
|
||||||
|
'src-ui/messages.xlf',
|
||||||
'src-ui/src/locale/**'
|
'src-ui/src/locale/**'
|
||||||
]
|
]
|
||||||
branches: [ dev ]
|
branches: [ dev ]
|
||||||
|
2
.github/workflows/project-actions.yml
vendored
@@ -22,6 +22,6 @@ jobs:
|
|||||||
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
|
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
|
||||||
steps:
|
steps:
|
||||||
- name: Label PR with release-drafter
|
- name: Label PR with release-drafter
|
||||||
uses: release-drafter/release-drafter@v5
|
uses: release-drafter/release-drafter@v6
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
180
.github/workflows/repo-maintenance.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
|||||||
name: 'Stale'
|
name: 'Stale'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v8
|
- uses: actions/stale@v9
|
||||||
with:
|
with:
|
||||||
days-before-stale: 7
|
days-before-stale: 7
|
||||||
days-before-close: 14
|
days-before-close: 14
|
||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
stale-issue-message: >
|
stale-issue-message: >
|
||||||
This issue has been automatically marked as stale because it has not had
|
This issue has been automatically marked as stale because it has not had
|
||||||
recent activity. It will be closed if no further activity occurs. Thank you
|
recent activity. It will be closed if no further activity occurs. Thank you
|
||||||
for your contributions.
|
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'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -43,14 +43,17 @@ jobs:
|
|||||||
This issue has been automatically locked since there
|
This issue has been automatically locked since there
|
||||||
has not been any recent activity after it was closed.
|
has not been any recent activity after it was closed.
|
||||||
Please open a new discussion or issue for related concerns.
|
Please open a new discussion or issue for related concerns.
|
||||||
|
See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.
|
||||||
pr-comment: >
|
pr-comment: >
|
||||||
This pull request has been automatically locked since there
|
This pull request has been automatically locked since there
|
||||||
has not been any recent activity after it was closed.
|
has not been any recent activity after it was closed.
|
||||||
Please open a new discussion or issue for related concerns.
|
Please open a new discussion or issue for related concerns.
|
||||||
|
See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.
|
||||||
discussion-comment: >
|
discussion-comment: >
|
||||||
This discussion has been automatically locked since there
|
This discussion has been automatically locked since there
|
||||||
has not been any recent activity after it was closed.
|
has not been any recent activity after it was closed.
|
||||||
Please open a new discussion for related concerns.
|
Please open a new discussion for related concerns.
|
||||||
|
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'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -81,7 +84,7 @@ jobs:
|
|||||||
console.log(`Found ${result.repository.discussions.nodes.length} open answered discussions`)
|
console.log(`Found ${result.repository.discussions.nodes.length} open answered discussions`)
|
||||||
|
|
||||||
for (const discussion of result.repository.discussions.nodes) {
|
for (const discussion of result.repository.discussions.nodes) {
|
||||||
console.log(`Closing dicussion #${discussion.number} (${discussion.id})`)
|
console.log(`Closing discussion #${discussion.number} (${discussion.id})`)
|
||||||
|
|
||||||
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}) {
|
||||||
@@ -90,7 +93,7 @@ jobs:
|
|||||||
}`;
|
}`;
|
||||||
const commentVariables = {
|
const commentVariables = {
|
||||||
discussion: discussion.id,
|
discussion: discussion.id,
|
||||||
body: 'This discussion has been automatically closed because it was marked as answered.',
|
body: 'This discussion has been automatically closed because it was marked as answered. Please see our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.',
|
||||||
}
|
}
|
||||||
await github.graphql(addCommentMutation, commentVariables)
|
await github.graphql(addCommentMutation, commentVariables)
|
||||||
|
|
||||||
@@ -107,3 +110,172 @@ jobs:
|
|||||||
|
|
||||||
await sleep(1000)
|
await sleep(1000)
|
||||||
}
|
}
|
||||||
|
close-outdated-discussions:
|
||||||
|
name: 'Close Outdated Discussions'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
const CUTOFF_DAYS = 180;
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - CUTOFF_DAYS);
|
||||||
|
|
||||||
|
const query = `query(
|
||||||
|
$owner:String!,
|
||||||
|
$name:String!,
|
||||||
|
$supportCategory:ID!,
|
||||||
|
$generalCategory:ID!,
|
||||||
|
) {
|
||||||
|
supportDiscussions: repository(owner:$owner, name:$name){
|
||||||
|
discussions(
|
||||||
|
categoryId:$supportCategory,
|
||||||
|
last:50,
|
||||||
|
answered:false,
|
||||||
|
states:[OPEN],
|
||||||
|
) {
|
||||||
|
nodes {
|
||||||
|
id,
|
||||||
|
number,
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
generalDiscussions: repository(owner:$owner, name:$name){
|
||||||
|
discussions(
|
||||||
|
categoryId:$generalCategory,
|
||||||
|
last:50,
|
||||||
|
states:[OPEN],
|
||||||
|
) {
|
||||||
|
nodes {
|
||||||
|
id,
|
||||||
|
number,
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
const variables = {
|
||||||
|
owner: context.repo.owner,
|
||||||
|
name: context.repo.repo,
|
||||||
|
supportCategory: "DIC_kwDOG1Zs184CBKWK",
|
||||||
|
generalCategory: "DIC_kwDOG1Zs184CBKWJ"
|
||||||
|
}
|
||||||
|
const result = await github.graphql(query, variables);
|
||||||
|
const combinedDiscussions = [
|
||||||
|
...result.supportDiscussions.discussions.nodes,
|
||||||
|
...result.generalDiscussions.discussions.nodes,
|
||||||
|
]
|
||||||
|
|
||||||
|
console.log(`Checking ${combinedDiscussions.length} open discussions`);
|
||||||
|
|
||||||
|
for (const discussion of combinedDiscussions) {
|
||||||
|
if (new Date(discussion.updatedAt) < cutoff) {
|
||||||
|
console.log(`Closing outdated discussion #${discussion.number} (${discussion.id}), last updated at ${discussion.updatedAt}`);
|
||||||
|
const addCommentMutation = `mutation($discussion:ID!, $body:String!) {
|
||||||
|
addDiscussionComment(input:{discussionId:$discussion, body:$body}) {
|
||||||
|
clientMutationId
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
const commentVariables = {
|
||||||
|
discussion: discussion.id,
|
||||||
|
body: 'This discussion has been automatically closed due to inactivity. Please see our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.',
|
||||||
|
}
|
||||||
|
await github.graphql(addCommentMutation, commentVariables);
|
||||||
|
|
||||||
|
const closeDiscussionMutation = `mutation($discussion:ID!, $reason:DiscussionCloseReason!) {
|
||||||
|
closeDiscussion(input:{discussionId:$discussion, reason:$reason}) {
|
||||||
|
clientMutationId
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
const closeVariables = {
|
||||||
|
discussion: discussion.id,
|
||||||
|
reason: "OUTDATED",
|
||||||
|
}
|
||||||
|
await github.graphql(closeDiscussionMutation, closeVariables);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close-unsupported-feature-requests:
|
||||||
|
name: 'Close Unsupported Feature Requests'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
const CUTOFF_1_DAYS = 180;
|
||||||
|
const CUTOFF_1_COUNT = 5;
|
||||||
|
const CUTOFF_2_DAYS = 365;
|
||||||
|
const CUTOFF_2_COUNT = 10;
|
||||||
|
|
||||||
|
const cutoff1Date = new Date();
|
||||||
|
cutoff1Date.setDate(cutoff1Date.getDate() - CUTOFF_1_DAYS);
|
||||||
|
const cutoff2Date = new Date();
|
||||||
|
cutoff2Date.setDate(cutoff2Date.getDate() - CUTOFF_2_DAYS);
|
||||||
|
|
||||||
|
const query = `query(
|
||||||
|
$owner:String!,
|
||||||
|
$name:String!,
|
||||||
|
$featureRequestsCategory:ID!,
|
||||||
|
) {
|
||||||
|
repository(owner:$owner, name:$name){
|
||||||
|
discussions(
|
||||||
|
categoryId:$featureRequestsCategory,
|
||||||
|
last:100,
|
||||||
|
states:[OPEN],
|
||||||
|
) {
|
||||||
|
nodes {
|
||||||
|
id,
|
||||||
|
number,
|
||||||
|
updatedAt,
|
||||||
|
upvoteCount,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
const variables = {
|
||||||
|
owner: context.repo.owner,
|
||||||
|
name: context.repo.repo,
|
||||||
|
featureRequestsCategory: "DIC_kwDOG1Zs184CBNr4"
|
||||||
|
}
|
||||||
|
const result = await github.graphql(query, variables);
|
||||||
|
|
||||||
|
for (const discussion of result.repository.discussions.nodes) {
|
||||||
|
const discussionDate = new Date(discussion.updatedAt);
|
||||||
|
if ((discussionDate < cutoff1Date && discussion.upvoteCount < CUTOFF_1_COUNT) ||
|
||||||
|
(discussionDate < cutoff2Date && discussion.upvoteCount < CUTOFF_2_COUNT)) {
|
||||||
|
console.log(`Closing discussion #${discussion.number} (${discussion.id}), last updated at ${discussion.updatedAt} with votes ${discussion.upvoteCount}`);
|
||||||
|
const addCommentMutation = `mutation($discussion:ID!, $body:String!) {
|
||||||
|
addDiscussionComment(input:{discussionId:$discussion, body:$body}) {
|
||||||
|
clientMutationId
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
const commentVariables = {
|
||||||
|
discussion: discussion.id,
|
||||||
|
body: 'This discussion has been automatically closed due to lack of community support. Please see our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.',
|
||||||
|
}
|
||||||
|
await github.graphql(addCommentMutation, commentVariables);
|
||||||
|
|
||||||
|
const closeDiscussionMutation = `mutation($discussion:ID!, $reason:DiscussionCloseReason!) {
|
||||||
|
closeDiscussion(input:{discussionId:$discussion, reason:$reason}) {
|
||||||
|
clientMutationId
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
const closeVariables = {
|
||||||
|
discussion: discussion.id,
|
||||||
|
reason: "OUTDATED",
|
||||||
|
}
|
||||||
|
await github.graphql(closeDiscussionMutation, closeVariables);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -5,12 +5,14 @@
|
|||||||
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
|
||||||
exclude: "tsconfig.*json"
|
exclude: "tsconfig.*json"
|
||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
|
args:
|
||||||
|
- "--unsafe"
|
||||||
- id: check-toml
|
- id: check-toml
|
||||||
- id: check-executables-have-shebangs
|
- id: check-executables-have-shebangs
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
@@ -26,6 +28,14 @@ repos:
|
|||||||
- svg
|
- svg
|
||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
- id: detect-private-key
|
- id: detect-private-key
|
||||||
|
- repo: https://github.com/codespell-project/codespell
|
||||||
|
rev: v2.2.6
|
||||||
|
hooks:
|
||||||
|
- id: codespell
|
||||||
|
exclude: "(^src-ui/src/locale/)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)"
|
||||||
|
exclude_types:
|
||||||
|
- pofile
|
||||||
|
- json
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
rev: 'v3.1.0'
|
rev: 'v3.1.0'
|
||||||
hooks:
|
hooks:
|
||||||
@@ -37,11 +47,11 @@ 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.1.5'
|
rev: 'v0.4.2'
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
- repo: https://github.com/psf/black-pre-commit-mirror
|
- repo: https://github.com/psf/black-pre-commit-mirror
|
||||||
rev: 23.11.0
|
rev: 24.4.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
# Dockerfile hooks
|
# Dockerfile hooks
|
||||||
@@ -57,6 +67,6 @@ repos:
|
|||||||
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
|
||||||
|
42
.ruff.toml
@@ -1,8 +1,3 @@
|
|||||||
# https://beta.ruff.rs/docs/settings/
|
|
||||||
# https://beta.ruff.rs/docs/rules/
|
|
||||||
extend-select = ["I", "W", "UP", "COM", "DJ", "EXE", "ISC", "ICN", "G201", "INP", "PIE", "RSE", "SIM", "TID", "PLC", "PLE", "RUF"]
|
|
||||||
# TODO PTH
|
|
||||||
ignore = ["DJ001", "SIM105", "RUF012"]
|
|
||||||
fix = true
|
fix = true
|
||||||
line-length = 88
|
line-length = 88
|
||||||
respect-gitignore = true
|
respect-gitignore = true
|
||||||
@@ -11,13 +6,42 @@ target-version = "py39"
|
|||||||
output-format = "grouped"
|
output-format = "grouped"
|
||||||
show-fixes = true
|
show-fixes = true
|
||||||
|
|
||||||
[per-file-ignores]
|
# https://docs.astral.sh/ruff/settings/
|
||||||
|
# https://docs.astral.sh/ruff/rules/
|
||||||
|
[lint]
|
||||||
|
extend-select = [
|
||||||
|
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
|
||||||
|
"I", # https://docs.astral.sh/ruff/rules/#isort-i
|
||||||
|
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
|
||||||
|
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
|
||||||
|
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
|
||||||
|
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
|
||||||
|
"ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc
|
||||||
|
"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn
|
||||||
|
"G201", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
|
||||||
|
"INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp
|
||||||
|
"PIE", # https://docs.astral.sh/ruff/rules/#flake8-pie-pie
|
||||||
|
"Q", # https://docs.astral.sh/ruff/rules/#flake8-quotes-q
|
||||||
|
"RSE", # https://docs.astral.sh/ruff/rules/#flake8-raise-rse
|
||||||
|
"T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20
|
||||||
|
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
|
||||||
|
"TID", # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid
|
||||||
|
"TCH", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tch
|
||||||
|
"PLC", # https://docs.astral.sh/ruff/rules/#pylint-pl
|
||||||
|
"PLE", # https://docs.astral.sh/ruff/rules/#pylint-pl
|
||||||
|
"RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf
|
||||||
|
"FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
|
||||||
|
]
|
||||||
|
# TODO PTH https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
|
||||||
|
ignore = ["DJ001", "SIM105", "RUF012"]
|
||||||
|
|
||||||
|
[lint.per-file-ignores]
|
||||||
".github/scripts/*.py" = ["E501", "INP001", "SIM117"]
|
".github/scripts/*.py" = ["E501", "INP001", "SIM117"]
|
||||||
"docker/wait-for-redis.py" = ["INP001"]
|
"docker/wait-for-redis.py" = ["INP001", "T201"]
|
||||||
"*/tests/*.py" = ["E501", "SIM117"]
|
"*/tests/*.py" = ["E501", "SIM117"]
|
||||||
"*/migrations/*.py" = ["E501", "SIM"]
|
"*/migrations/*.py" = ["E501", "SIM", "T201"]
|
||||||
"src/paperless_tesseract/tests/test_parser.py" = ["RUF001"]
|
"src/paperless_tesseract/tests/test_parser.py" = ["RUF001"]
|
||||||
"src/documents/models.py" = ["SIM115"]
|
"src/documents/models.py" = ["SIM115"]
|
||||||
|
|
||||||
[isort]
|
[lint.isort]
|
||||||
force-single-line = true
|
force-single-line = true
|
||||||
|
@@ -94,7 +94,7 @@ The following files need to be changed:
|
|||||||
|
|
||||||
- src-ui/angular.json (under the _projects/paperless-ui/i18n/locales_ JSON key)
|
- src-ui/angular.json (under the _projects/paperless-ui/i18n/locales_ JSON key)
|
||||||
- src/paperless/settings.py (in the _LANGUAGES_ array)
|
- src/paperless/settings.py (in the _LANGUAGES_ array)
|
||||||
- src-ui/src/app/services/settings.service.ts (inside the _getLanguageOptions_ method)
|
- src-ui/src/app/services/settings.service.ts (inside the _LANGUAGE_OPTIONS_ array)
|
||||||
- src-ui/src/app/app.module.ts (import locale from _angular/common/locales_ and call _registerLocaleData_)
|
- src-ui/src/app/app.module.ts (import locale from _angular/common/locales_ and call _registerLocaleData_)
|
||||||
|
|
||||||
Please add the language in the correct order, alphabetically by locale.
|
Please add the language in the correct order, alphabetically by locale.
|
||||||
@@ -137,3 +137,19 @@ All team members are notified when mentioned or assigned to a relevant issue or
|
|||||||
We are not overly strict with inviting people to the organization. If you have read the [team permissions](#permissions) and think having additional access would enhance your contributions, please reach out to an [admin](#structure) of the team.
|
We are not overly strict with inviting people to the organization. If you have read the [team permissions](#permissions) and think having additional access would enhance your contributions, please reach out to an [admin](#structure) of the team.
|
||||||
|
|
||||||
The admins occasionally invite contributors directly if we believe having them on a team will accelerate their work.
|
The admins occasionally invite contributors directly if we believe having them on a team will accelerate their work.
|
||||||
|
|
||||||
|
# Automatic Repository Maintenance
|
||||||
|
|
||||||
|
The Paperless-ngx team appreciates all effort and interest from the community in filing bug reports, creating feature requests, sharing ideas and helping other
|
||||||
|
community members. That said, in an effort to keep the repository organized and managebale the project uses automatic handling of certain areas:
|
||||||
|
|
||||||
|
- Issues that cannot be reproduced will be marked 'stale' after 7 days of inactivity and closed after 14 further 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 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Thank you all for your contributions.
|
||||||
|
25
Dockerfile
@@ -12,7 +12,7 @@ COPY ./src-ui /src/src-ui
|
|||||||
WORKDIR /src/src-ui
|
WORKDIR /src/src-ui
|
||||||
RUN set -eux \
|
RUN set -eux \
|
||||||
&& npm update npm -g \
|
&& npm update npm -g \
|
||||||
&& npm ci --omit=optional
|
&& npm ci
|
||||||
RUN set -eux \
|
RUN set -eux \
|
||||||
&& ./node_modules/.bin/ng build --configuration production
|
&& ./node_modules/.bin/ng build --configuration production
|
||||||
|
|
||||||
@@ -29,7 +29,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.10.24 \
|
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2023.12.1 \
|
||||||
&& echo "Generating requirement.txt" \
|
&& echo "Generating requirement.txt" \
|
||||||
&& pipenv requirements > requirements.txt
|
&& pipenv requirements > requirements.txt
|
||||||
|
|
||||||
@@ -52,8 +52,15 @@ 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.3
|
ARG QPDF_VERSION=11.9.0
|
||||||
ARG GS_VERSION=10.02.0
|
ARG GS_VERSION=10.02.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
|
# Begin installation and configuration
|
||||||
@@ -123,13 +130,13 @@ RUN set -eux \
|
|||||||
&& 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-2_${TARGETARCH}.deb \
|
||||||
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10_${GS_VERSION}.dfsg-2_${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-2_${TARGETARCH}.deb \
|
||||||
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/ghostscript_${GS_VERSION}.dfsg-2_${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-2_all.deb \
|
||||||
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10-common_${GS_VERSION}.dfsg-2_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-2_all.deb \
|
||||||
&& dpkg --install ./libgs10_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
|
&& dpkg --install ./libgs10_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
|
||||||
&& dpkg --install ./ghostscript_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
|
&& dpkg --install ./ghostscript_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
|
||||||
@@ -187,7 +194,7 @@ RUN set -eux \
|
|||||||
&& chmod 755 /usr/local/bin/paperless_cmd.sh \
|
&& chmod 755 /usr/local/bin/paperless_cmd.sh \
|
||||||
&& mv flower-conditional.sh /usr/local/bin/flower-conditional.sh \
|
&& mv flower-conditional.sh /usr/local/bin/flower-conditional.sh \
|
||||||
&& chmod 755 /usr/local/bin/flower-conditional.sh \
|
&& chmod 755 /usr/local/bin/flower-conditional.sh \
|
||||||
&& echo "Installing managment commands" \
|
&& echo "Installing management commands" \
|
||||||
&& chmod +x install_management_commands.sh \
|
&& chmod +x install_management_commands.sh \
|
||||||
&& ./install_management_commands.sh
|
&& ./install_management_commands.sh
|
||||||
|
|
||||||
@@ -268,3 +275,5 @@ ENTRYPOINT ["/sbin/docker-entrypoint.sh"]
|
|||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["/usr/local/bin/paperless_cmd.sh"]
|
CMD ["/usr/local/bin/paperless_cmd.sh"]
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --retries=5 CMD [ "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000" ]
|
||||||
|
23
Pipfile
@@ -4,24 +4,25 @@ verify_ssl = true
|
|||||||
name = "pypi"
|
name = "pypi"
|
||||||
|
|
||||||
[packages]
|
[packages]
|
||||||
dateparser = "~=1.1"
|
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.7"
|
django = "~=4.2.11"
|
||||||
|
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.3"
|
django-filter = "~=24.2"
|
||||||
django-guardian = "*"
|
django-guardian = "*"
|
||||||
django-multiselectfield = "*"
|
django-multiselectfield = "*"
|
||||||
djangorestframework = "~=3.14"
|
djangorestframework = "==3.14.0"
|
||||||
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 = "*"
|
||||||
@@ -33,7 +34,7 @@ inotifyrecursive = "~=0.3"
|
|||||||
langdetect = "*"
|
langdetect = "*"
|
||||||
mysqlclient = "*"
|
mysqlclient = "*"
|
||||||
nltk = "*"
|
nltk = "*"
|
||||||
ocrmypdf = "~=15.0"
|
ocrmypdf = "~=15.4"
|
||||||
pathvalidate = "*"
|
pathvalidate = "*"
|
||||||
pdf2image = "*"
|
pdf2image = "*"
|
||||||
psycopg2 = "*"
|
psycopg2 = "*"
|
||||||
@@ -45,16 +46,20 @@ python-magic = "*"
|
|||||||
pyzbar = "*"
|
pyzbar = "*"
|
||||||
rapidfuzz = "*"
|
rapidfuzz = "*"
|
||||||
redis = {extras = ["hiredis"], version = "*"}
|
redis = {extras = ["hiredis"], version = "*"}
|
||||||
scikit-learn = "~=1.3"
|
scikit-learn = "~=1.4"
|
||||||
setproctitle = "*"
|
setproctitle = "*"
|
||||||
tika-client = "*"
|
tika-client = "*"
|
||||||
tqdm = "*"
|
tqdm = "*"
|
||||||
uvicorn = {extras = ["standard"], version = "*"}
|
uvicorn = {extras = ["standard"], version = "==0.25.0"}
|
||||||
watchdog = "~=3.0"
|
watchdog = "~=4.0"
|
||||||
whitenoise = "~=6.6"
|
whitenoise = "~=6.6"
|
||||||
whoosh="~=2.7"
|
whoosh="~=2.7"
|
||||||
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
|
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
|
||||||
|
|
||||||
|
# Locked for issues
|
||||||
|
# See https://github.com/paperless-ngx/paperless-ngx/discussions/6610 & https://bugs.launchpad.net/lxml/+bug/2059910
|
||||||
|
lxml = "==5.1.1"
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
# Linting
|
# Linting
|
||||||
black = "*"
|
black = "*"
|
||||||
|
3467
Pipfile.lock
generated
20
README.md
@@ -21,7 +21,7 @@ Paperless-ngx is a document management system that transforms your physical docu
|
|||||||
|
|
||||||
Paperless-ngx is the official successor to the original [Paperless](https://github.com/the-paperless-project/paperless) & [Paperless-ng](https://github.com/jonaswinkler/paperless-ng) projects and is designed to distribute the responsibility of advancing and supporting the project among a team of people. [Consider joining us!](#community-support)
|
Paperless-ngx is the official successor to the original [Paperless](https://github.com/the-paperless-project/paperless) & [Paperless-ng](https://github.com/jonaswinkler/paperless-ng) projects and is designed to distribute the responsibility of advancing and supporting the project among a team of people. [Consider joining us!](#community-support)
|
||||||
|
|
||||||
A demo is available at [demo.paperless-ngx.com](https://demo.paperless-ngx.com) using login `demo` / `demo`. _Note: demo content is reset frequently and confidential information should not be uploaded._
|
Thanks to the generous folks at [DigitalOcean](https://m.do.co/c/8d70b916d462), a demo is available at [demo.paperless-ngx.com](https://demo.paperless-ngx.com) using login `demo` / `demo`. _Note: demo content is reset frequently and confidential information should not be uploaded._
|
||||||
|
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Getting started](#getting-started)
|
- [Getting started](#getting-started)
|
||||||
@@ -30,9 +30,19 @@ A demo is available at [demo.paperless-ngx.com](https://demo.paperless-ngx.com)
|
|||||||
- [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/>
|
||||||
|
<a href="https://m.do.co/c/8d70b916d462" style="padding-top: 4px; display: block;">
|
||||||
|
<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: light)" srcset="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>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
@@ -53,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.
|
||||||
|
|
||||||
@@ -83,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
|
||||||
|
|
||||||
|
9
SECURITY.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
The Paperless-ngx team and community take security bugs seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
|
||||||
|
|
||||||
|
To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/paperless-ngx/paperless-ngx/security/advisories/new) tab.
|
||||||
|
|
||||||
|
The team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
|
@@ -1,6 +1,6 @@
|
|||||||
# Docker Compose file for running paperless testing with actual gotenberg
|
# Docker Compose file for running paperless testing with actual gotenberg
|
||||||
# and Tika containers for a more end to end test of the Tika related functionality
|
# and Tika containers for a more end to end test of the Tika related functionality
|
||||||
# Can be used locally or by the CI to start the nessecary 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"
|
version: "3.7"
|
||||||
|
@@ -39,7 +39,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
|
||||||
@@ -60,11 +60,6 @@ services:
|
|||||||
- tika
|
- tika
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
volumes:
|
volumes:
|
||||||
- data:/usr/src/paperless/data
|
- data:/usr/src/paperless/data
|
||||||
- media:/usr/src/paperless/media
|
- media:/usr/src/paperless/media
|
||||||
|
@@ -35,7 +35,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
|
||||||
@@ -54,11 +54,6 @@ services:
|
|||||||
- broker
|
- broker
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
volumes:
|
volumes:
|
||||||
- data:/usr/src/paperless/data
|
- data:/usr/src/paperless/data
|
||||||
- media:/usr/src/paperless/media
|
- media:/usr/src/paperless/media
|
||||||
@@ -73,7 +68,6 @@ services:
|
|||||||
PAPERLESS_DBPASS: paperless # only needed if non-default password
|
PAPERLESS_DBPASS: paperless # only needed if non-default password
|
||||||
PAPERLESS_DBPORT: 3306
|
PAPERLESS_DBPORT: 3306
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
media:
|
media:
|
||||||
|
@@ -18,7 +18,7 @@
|
|||||||
# To install and update paperless with this file, do the following:
|
# To install and update paperless with this file, do the following:
|
||||||
#
|
#
|
||||||
# - Open portainer Stacks list and click 'Add stack'
|
# - Open portainer Stacks list and click 'Add stack'
|
||||||
# - Paste the contents of this file and assign a name, e.g. 'Paperless'
|
# - Paste the contents of this file and assign a name, e.g. 'paperless'
|
||||||
# - Click 'Deploy the stack' and wait for it to be deployed
|
# - Click 'Deploy the stack' and wait for it to be deployed
|
||||||
# - Open the list of containers, select paperless_webserver_1
|
# - Open the list of containers, select paperless_webserver_1
|
||||||
# - Click 'Console' and then 'Connect' to open the command line inside the container
|
# - Click 'Console' and then 'Connect' to open the command line inside the container
|
||||||
@@ -37,7 +37,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
|
||||||
@@ -54,11 +54,6 @@ services:
|
|||||||
- broker
|
- broker
|
||||||
ports:
|
ports:
|
||||||
- "8010:8000"
|
- "8010:8000"
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
volumes:
|
volumes:
|
||||||
- data:/usr/src/paperless/data
|
- data:/usr/src/paperless/data
|
||||||
- media:/usr/src/paperless/media
|
- media:/usr/src/paperless/media
|
||||||
|
@@ -39,7 +39,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
|
||||||
@@ -58,11 +58,6 @@ services:
|
|||||||
- tika
|
- tika
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
volumes:
|
volumes:
|
||||||
- data:/usr/src/paperless/data
|
- data:/usr/src/paperless/data
|
||||||
- media:/usr/src/paperless/media
|
- media:/usr/src/paperless/media
|
||||||
|
@@ -35,7 +35,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
|
||||||
@@ -52,11 +52,6 @@ services:
|
|||||||
- broker
|
- broker
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
volumes:
|
volumes:
|
||||||
- data:/usr/src/paperless/data
|
- data:/usr/src/paperless/data
|
||||||
- media:/usr/src/paperless/media
|
- media:/usr/src/paperless/media
|
||||||
@@ -67,7 +62,6 @@ services:
|
|||||||
PAPERLESS_REDIS: redis://broker:6379
|
PAPERLESS_REDIS: redis://broker:6379
|
||||||
PAPERLESS_DBHOST: db
|
PAPERLESS_DBHOST: db
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
media:
|
media:
|
||||||
|
@@ -47,11 +47,6 @@ services:
|
|||||||
- tika
|
- tika
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
volumes:
|
volumes:
|
||||||
- data:/usr/src/paperless/data
|
- data:/usr/src/paperless/data
|
||||||
- media:/usr/src/paperless/media
|
- media:/usr/src/paperless/media
|
||||||
|
@@ -38,11 +38,6 @@ services:
|
|||||||
- broker
|
- broker
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-fs", "-S", "--max-time", "2", "http://localhost:8000"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
volumes:
|
volumes:
|
||||||
- data:/usr/src/paperless/data
|
- data:/usr/src/paperless/data
|
||||||
- media:/usr/src/paperless/media
|
- media:/usr/src/paperless/media
|
||||||
@@ -52,7 +47,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PAPERLESS_REDIS: redis://broker:6379
|
PAPERLESS_REDIS: redis://broker:6379
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
media:
|
media:
|
||||||
|
@@ -86,17 +86,17 @@ initialize() {
|
|||||||
"${CONSUME_DIR}"; do
|
"${CONSUME_DIR}"; do
|
||||||
if [[ ! -d "${dir}" ]]; then
|
if [[ ! -d "${dir}" ]]; then
|
||||||
echo "Creating directory ${dir}"
|
echo "Creating directory ${dir}"
|
||||||
mkdir "${dir}"
|
mkdir --parents "${dir}"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
local -r tmp_dir="/tmp/paperless"
|
local -r tmp_dir="${PAPERLESS_SCRATCH_DIR:=/tmp/paperless}"
|
||||||
echo "Creating directory ${tmp_dir}"
|
echo "Creating directory scratch directory ${tmp_dir}"
|
||||||
mkdir -p "${tmp_dir}"
|
mkdir --parents "${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."
|
||||||
chown -R paperless:paperless ${tmp_dir}
|
chown -R paperless:paperless "${tmp_dir}"
|
||||||
for dir in \
|
for dir in \
|
||||||
"${export_dir}" \
|
"${export_dir}" \
|
||||||
"${DATA_DIR}" \
|
"${DATA_DIR}" \
|
||||||
|
@@ -80,7 +80,7 @@ django_checks() {
|
|||||||
|
|
||||||
search_index() {
|
search_index() {
|
||||||
|
|
||||||
local -r index_version=7
|
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
|
||||||
|
@@ -1,14 +1,15 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
SUPERVISORD_WORKING_DIR="${PAPERLESS_SUPERVISORD_WORKING_DIR:-$PWD}"
|
||||||
rootless_args=()
|
rootless_args=()
|
||||||
if [ "$(id -u)" == "$(id -u paperless)" ]; then
|
if [ "$(id -u)" == "$(id -u paperless)" ]; then
|
||||||
rootless_args=(
|
rootless_args=(
|
||||||
--user
|
--user
|
||||||
paperless
|
paperless
|
||||||
--logfile
|
--logfile
|
||||||
supervisord.log
|
"${SUPERVISORD_WORKING_DIR}/supervisord.log"
|
||||||
--pidfile
|
--pidfile
|
||||||
supervisord.pid
|
"${SUPERVISORD_WORKING_DIR}/supervisord.pid"
|
||||||
)
|
)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
@@ -67,15 +67,15 @@ you installed paperless-ngx in the first place. The releases are
|
|||||||
available at the [release
|
available at the [release
|
||||||
page](https://github.com/paperless-ngx/paperless-ngx/releases).
|
page](https://github.com/paperless-ngx/paperless-ngx/releases).
|
||||||
|
|
||||||
First of all, ensure that paperless is stopped.
|
First of all, make sure no active processes (like consumption) are running, then [make a backup](#backup).
|
||||||
|
|
||||||
|
After that, ensure that paperless is stopped:
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
$ cd /path/to/paperless
|
$ cd /path/to/paperless
|
||||||
$ docker compose down
|
$ docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
After that, [make a backup](#backup).
|
|
||||||
|
|
||||||
1. If you pull the image from the docker hub, all you need to do is:
|
1. If you pull the image from the docker hub, all you need to do is:
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
@@ -349,11 +349,17 @@ 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.
|
||||||
|
|
||||||
!!! 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.
|
||||||
|
|
||||||
### 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
|
||||||
@@ -607,3 +613,10 @@ document_fuzzy_match [--ratio] [--processes N]
|
|||||||
| ----------- | -------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
| ----------- | -------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| --ratio | No | 85.0 | a number between 0 and 100, setting how similar a document must be for it to be reported. Higher numbers mean more similarity. |
|
| --ratio | No | 85.0 | a number between 0 and 100, setting how similar a document must be for it to be reported. Higher numbers mean more similarity. |
|
||||||
| --processes | No | 1/4 of system cores | Number of processes to use for matching. Setting 1 disables multiple processes |
|
| --processes | No | 1/4 of system cores | Number of processes to use for matching. Setting 1 disables multiple processes |
|
||||||
|
| --delete | No | False | If provided, one document of a matched pair above the ratio will be deleted. |
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
If providing the `--delete` option, it is highly recommended to have a backup.
|
||||||
|
While every effort has been taken to ensure proper operation, there is always the
|
||||||
|
chance of deletion of a file you want to keep.
|
||||||
|
@@ -136,6 +136,11 @@ script can access the following relevant environment variables set:
|
|||||||
be triggered, leading to failures as two tasks work on the
|
be triggered, leading to failures as two tasks work on the
|
||||||
same document path
|
same document path
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
If your script modifies `DOCUMENT_WORKING_PATH` in a non-deterministic
|
||||||
|
way, this may allow duplicate documents to be stored
|
||||||
|
|
||||||
A simple but common example for this would be creating a simple script
|
A simple but common example for this would be creating a simple script
|
||||||
like this:
|
like this:
|
||||||
|
|
||||||
@@ -251,7 +256,8 @@ document. You will end up getting files like `0000123.pdf` in your media
|
|||||||
directory. This isn't necessarily a bad thing, because you normally
|
directory. This isn't necessarily a bad thing, because you normally
|
||||||
don't have to access these files manually. However, if you wish to name
|
don't have to access these files manually. However, if you wish to name
|
||||||
your files differently, you can do that by adjusting the
|
your files differently, you can do that by adjusting the
|
||||||
[`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) configuration option. Paperless adds the
|
[`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) configuration option
|
||||||
|
or using [storage paths (see below)](#storage-paths). Paperless adds the
|
||||||
correct file extension e.g. `.pdf`, `.jpg` automatically.
|
correct file extension e.g. `.pdf`, `.jpg` automatically.
|
||||||
|
|
||||||
This variable allows you to configure the filename (folders are allowed)
|
This variable allows you to configure the filename (folders are allowed)
|
||||||
@@ -284,6 +290,15 @@ will create a directory structure as follows:
|
|||||||
paperless will report your files as missing and won't be able to find
|
paperless will report your files as missing and won't be able to find
|
||||||
them.
|
them.
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
|
||||||
|
Paperless checks the filename of a document whenever it is saved. Changing (or deleting)
|
||||||
|
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
|
||||||
|
[`document renamer`](administration.md#renamer) to move any existing documents.
|
||||||
|
|
||||||
|
#### Placeholders
|
||||||
|
|
||||||
Paperless provides the following placeholders within filenames:
|
Paperless provides the following placeholders within filenames:
|
||||||
|
|
||||||
- `{asn}`: The archive serial number of the document, or "none".
|
- `{asn}`: The archive serial number of the document, or "none".
|
||||||
@@ -316,6 +331,12 @@ Paperless provides the following placeholders within filenames:
|
|||||||
- `{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
|
||||||
|
|
||||||
|
When using file name placeholders, in particular when using `{tag_list}`,
|
||||||
|
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.
|
||||||
|
|
||||||
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
|
||||||
@@ -326,34 +347,12 @@ paperless will automatically append `_01`, `_02`, etc to the filename.
|
|||||||
This happens if all the placeholders in a filename evaluate to the same
|
This happens if all the placeholders in a filename evaluate to the same
|
||||||
value.
|
value.
|
||||||
|
|
||||||
!!! tip
|
If there are any errors in the placeholders included in `PAPERLESS_FILENAME_FORMAT`,
|
||||||
|
paperless will fall back to using the default naming scheme instead.
|
||||||
You can affect how empty placeholders are treated by changing the
|
|
||||||
following setting to `true`.
|
|
||||||
|
|
||||||
```
|
|
||||||
PAPERLESS_FILENAME_FORMAT_REMOVE_NONE=True
|
|
||||||
```
|
|
||||||
|
|
||||||
Doing this results in all empty placeholders resolving to "" instead
|
|
||||||
of "none" as stated above. Spaces before empty placeholders are
|
|
||||||
removed as well, empty directories are omitted.
|
|
||||||
|
|
||||||
!!! tip
|
|
||||||
|
|
||||||
Paperless checks the filename of a document whenever it is saved.
|
|
||||||
Therefore, you need to update the filenames of your documents and move
|
|
||||||
them after altering this setting by invoking the
|
|
||||||
[`document renamer`](administration.md#renamer).
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
|
|
||||||
Make absolutely sure you get the spelling of the placeholders right, or
|
|
||||||
else paperless will use the default naming scheme instead.
|
|
||||||
|
|
||||||
!!! caution
|
!!! caution
|
||||||
|
|
||||||
As of now, you could totally tell paperless to store your files anywhere
|
As of now, you could potentially tell paperless to store your files anywhere
|
||||||
outside the media directory by setting
|
outside the media directory by setting
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -361,28 +360,25 @@ value.
|
|||||||
```
|
```
|
||||||
|
|
||||||
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 of paperless.
|
the predefined volumes, they will be lost after a restart.
|
||||||
|
|
||||||
!!! warning
|
##### Empty placeholders
|
||||||
|
|
||||||
When file naming handling, in particular when using `{tag_list}`,
|
You can affect how empty placeholders are treated by changing the
|
||||||
you may run into the limits of your operating system's maximum
|
[`PAPERLESS_FILENAME_FORMAT_REMOVE_NONE`](configuration.md#PAPERLESS_FILENAME_FORMAT_REMOVE_NONE) setting.
|
||||||
path lengths. Files will retain the previous path instead and
|
|
||||||
the issue logged.
|
|
||||||
|
|
||||||
## Storage paths
|
Enabling this results in all empty placeholders resolving to "" instead of "none" as stated above. Spaces
|
||||||
|
before empty placeholders are removed as well, empty directories are omitted.
|
||||||
|
|
||||||
One of the best things in Paperless is that you can not only access the
|
### Storage paths
|
||||||
documents via the web interface, but also via the file system.
|
|
||||||
|
|
||||||
When a single storage layout is not sufficient for your use case,
|
When a single storage layout is not sufficient for your use case, storage paths allow for more complex
|
||||||
storage paths come to the rescue. Storage paths allow you to configure
|
structure to set precisely where each document is stored in the file system.
|
||||||
more precisely where each document is stored in the file system.
|
|
||||||
|
|
||||||
- Each storage path is a [`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) and
|
- Each storage path is a [`PAPERLESS_FILENAME_FORMAT`](configuration.md#PAPERLESS_FILENAME_FORMAT) and
|
||||||
follows the rules described above
|
follows the rules described above
|
||||||
- Each document is assigned a storage path using the matching
|
- Each document is assigned a storage path using the matching algorithms described above, but can be
|
||||||
algorithms described above, but can be overwritten at any time
|
overwritten at any time
|
||||||
|
|
||||||
For example, you could define the following two storage paths:
|
For example, you could define the following two storage paths:
|
||||||
|
|
||||||
@@ -429,8 +425,10 @@ to view more detailed information about the health of the celery workers
|
|||||||
used for asynchronous tasks. This includes details on currently running,
|
used for asynchronous tasks. This includes details on currently running,
|
||||||
queued and completed tasks, timing and more. Flower can also be used
|
queued and completed tasks, timing and more. Flower can also be used
|
||||||
with Prometheus, as it exports metrics. For details on its capabilities,
|
with Prometheus, as it exports metrics. For details on its capabilities,
|
||||||
refer to the Flower documentation.
|
refer to the [Flower](https://flower.readthedocs.io/en/latest/index.html)
|
||||||
|
documentation.
|
||||||
|
|
||||||
|
Flower can be enabled with the setting [PAPERLESS_ENABLE_FLOWER](configuration.md#PAPERLESS_ENABLE_FLOWER).
|
||||||
To configure Flower further, create a `flowerconfig.py` and
|
To configure Flower further, create a `flowerconfig.py` and
|
||||||
place it into the `src/paperless` directory. For a Docker
|
place it into the `src/paperless` directory. For a Docker
|
||||||
installation, you can use volumes to accomplish this:
|
installation, you can use volumes to accomplish this:
|
||||||
@@ -439,6 +437,8 @@ installation, you can use volumes to accomplish this:
|
|||||||
services:
|
services:
|
||||||
# ...
|
# ...
|
||||||
webserver:
|
webserver:
|
||||||
|
environment:
|
||||||
|
- PAPERLESS_ENABLE_FLOWER
|
||||||
ports:
|
ports:
|
||||||
- 5555:5555 # (2)!
|
- 5555:5555 # (2)!
|
||||||
# ...
|
# ...
|
||||||
@@ -447,7 +447,7 @@ services:
|
|||||||
```
|
```
|
||||||
|
|
||||||
1. Note the `:ro` tag means the file will be mounted as read only.
|
1. Note the `:ro` tag means the file will be mounted as read only.
|
||||||
2. `flower` runs by default on port 5555, but this can be configured
|
2. By default, Flower runs on port 5555, but this can be configured.
|
||||||
|
|
||||||
## Custom Container Initialization
|
## Custom Container Initialization
|
||||||
|
|
||||||
@@ -508,6 +508,18 @@ existing tables) with:
|
|||||||
an older system may fix issues that can arise while setting up Paperless-ngx but
|
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).
|
`utf8mb3` can cause issues with consumption (where `utf8mb4` does not).
|
||||||
|
|
||||||
|
### Missing timezones
|
||||||
|
|
||||||
|
MySQL as well as MariaDB do not have any timezone information by default (though some
|
||||||
|
docker images such as the official MariaDB image take care of this for you) which will
|
||||||
|
cause unexpected behavior with date-based queries.
|
||||||
|
|
||||||
|
To fix this, execute one of the following commands:
|
||||||
|
|
||||||
|
MySQL: `mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root mysql -p`
|
||||||
|
|
||||||
|
MariaDB: `mariadb-tzinfo-to-sql /usr/share/zoneinfo | mariadb -u root mysql -p`
|
||||||
|
|
||||||
## Barcodes {#barcodes}
|
## Barcodes {#barcodes}
|
||||||
|
|
||||||
Paperless is able to utilize barcodes for automatically performing some tasks.
|
Paperless is able to utilize barcodes for automatically performing some tasks.
|
||||||
@@ -548,6 +560,14 @@ barcode is located. However, differing from the splitting, the page with the
|
|||||||
barcode _will_ be retained. This allows application of a barcode to any page, including
|
barcode _will_ be retained. This allows application of a barcode to any page, including
|
||||||
one which holds data to keep in the document.
|
one which holds data to keep in the document.
|
||||||
|
|
||||||
|
### Tag Assignment
|
||||||
|
|
||||||
|
When enabled, Paperless will parse barcodes and attempt to interpret and assign tags.
|
||||||
|
|
||||||
|
See the relevant settings [`PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE`](configuration.md#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE)
|
||||||
|
and [`PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING`](configuration.md#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING)
|
||||||
|
for more information.
|
||||||
|
|
||||||
## Automatic collation of double-sided documents {#collate}
|
## Automatic collation of double-sided documents {#collate}
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
@@ -608,7 +628,7 @@ scan a completely new "odd numbered pages" one. The old staging file will get di
|
|||||||
|
|
||||||
The collation feature can be used together with the [subdirs as tags](configuration.md#consume_config)
|
The collation feature can be used together with the [subdirs as tags](configuration.md#consume_config)
|
||||||
feature (but this is not a requirement). Just create a correctly named double-sided subdir
|
feature (but this is not a requirement). Just create a correctly named double-sided subdir
|
||||||
in the hierachy and upload your scans there. For example, both `double-sided/foo/bar` as
|
in the hierarchy and upload your scans there. For example, both `double-sided/foo/bar` as
|
||||||
well as `foo/bar/double-sided` will cause the collated document to be treated as if it
|
well as `foo/bar/double-sided` will cause the collated document to be treated as if it
|
||||||
were uploaded into `foo/bar` and receive both `foo` and `bar` tags, but not `double-sided`.
|
were uploaded into `foo/bar` and receive both `foo` and `bar` tags, but not `double-sided`.
|
||||||
|
|
||||||
@@ -619,3 +639,52 @@ single-sided split marker page, the split document(s) will have an empty page at
|
|||||||
whatever else was on the backside of the split marker page.) You can work around that by having
|
whatever else was on the backside of the split marker page.) You can work around that by having
|
||||||
a split marker page that has the split barcode on _both_ sides. This way, the extra page will
|
a split marker page that has the split barcode on _both_ sides. This way, the extra page will
|
||||||
get automatically removed.
|
get automatically removed.
|
||||||
|
|
||||||
|
## SSO and third party authentication with Paperless-ngx
|
||||||
|
|
||||||
|
Paperless-ngx has a built-in authentication system from Django but you can easily integrate an
|
||||||
|
external authentication solution using one of the following methods:
|
||||||
|
|
||||||
|
### Remote User authentication
|
||||||
|
|
||||||
|
This is a simple option that uses remote user authentication made available by certain SSO
|
||||||
|
applications. See the relevant configuration options for more information:
|
||||||
|
[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)
|
||||||
|
and [PAPERLESS_LOGOUT_REDIRECT_URL](configuration.md#PAPERLESS_LOGOUT_REDIRECT_URL)
|
||||||
|
|
||||||
|
### OpenID Connect and social authentication
|
||||||
|
|
||||||
|
Version 2.5.0 of Paperless-ngx added support for integrating other authentication systems via
|
||||||
|
the [django-allauth](https://github.com/pennersr/django-allauth) package. Once set up, users
|
||||||
|
can either log in or (optionally) sign up using any third party systems you integrate. See the
|
||||||
|
relevant [configuration settings](configuration.md#PAPERLESS_SOCIALACCOUNT_PROVIDERS) and
|
||||||
|
[django-allauth docs](https://docs.allauth.org/en/latest/socialaccount/configuration.html)
|
||||||
|
for more information.
|
||||||
|
|
||||||
|
To associate an existing Paperless-ngx account with a social account, first login with your
|
||||||
|
regular credentials and then choose "My Profile" from the user dropdown in the app and you
|
||||||
|
will see options to connect social account(s). If enabled, signup options will be available
|
||||||
|
on the login page.
|
||||||
|
|
||||||
|
As an example, to set up login via Github, the following environment variables would need to be
|
||||||
|
set:
|
||||||
|
|
||||||
|
```conf
|
||||||
|
PAPERLESS_APPS="allauth.socialaccount.providers.github"
|
||||||
|
PAPERLESS_SOCIALACCOUNT_PROVIDERS='{"github": {"APPS": [{"provider_id": "github","name": "Github","client_id": "<CLIENT_ID>","secret": "<CLIENT_SECRET>"}]}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, to use OpenID Connect ("OIDC"), via Keycloak in this example:
|
||||||
|
|
||||||
|
```conf
|
||||||
|
PAPERLESS_APPS="allauth.socialaccount.providers.openid_connect"
|
||||||
|
PAPERLESS_SOCIALACCOUNT_PROVIDERS='
|
||||||
|
{"openid_connect": {"APPS": [{"provider_id": "keycloak","name": "Keycloak","client_id": "paperless","secret": "<CLIENT_SECRET>","settings": { "server_url": "https://<KEYCLOAK_SERVER>/realms/<REALM>/.well-known/openid-configuration"}}]}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
More details about configuration option for various providers can be found in the [allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html#provider-specifics).
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
150
docs/api.md
@@ -8,20 +8,23 @@ most of the available filters and ordering fields.
|
|||||||
|
|
||||||
The API provides the following main endpoints:
|
The API provides the following main endpoints:
|
||||||
|
|
||||||
- `/api/documents/`: Full CRUD support, except POSTing new documents.
|
|
||||||
See below.
|
|
||||||
- `/api/correspondents/`: Full CRUD support.
|
- `/api/correspondents/`: Full CRUD support.
|
||||||
|
- `/api/custom_fields/`: Full CRUD support.
|
||||||
|
- `/api/documents/`: Full CRUD support, except POSTing new documents.
|
||||||
|
See [below](#posting-documents-file-uploads).
|
||||||
- `/api/document_types/`: Full CRUD support.
|
- `/api/document_types/`: Full CRUD support.
|
||||||
|
- `/api/groups/`: Full CRUD support.
|
||||||
- `/api/logs/`: Read-Only.
|
- `/api/logs/`: Read-Only.
|
||||||
- `/api/tags/`: Full CRUD support.
|
|
||||||
- `/api/tasks/`: Read-only.
|
|
||||||
- `/api/mail_accounts/`: Full CRUD support.
|
- `/api/mail_accounts/`: Full CRUD support.
|
||||||
- `/api/mail_rules/`: Full CRUD support.
|
- `/api/mail_rules/`: Full CRUD support.
|
||||||
- `/api/users/`: Full CRUD support.
|
|
||||||
- `/api/groups/`: Full CRUD support.
|
|
||||||
- `/api/share_links/`: Full CRUD support.
|
|
||||||
- `/api/custom_fields/`: Full CRUD support.
|
|
||||||
- `/api/profile/`: GET, PATCH
|
- `/api/profile/`: GET, PATCH
|
||||||
|
- `/api/share_links/`: Full CRUD support.
|
||||||
|
- `/api/storage_paths/`: Full CRUD support.
|
||||||
|
- `/api/tags/`: Full CRUD support.
|
||||||
|
- `/api/tasks/`: Read-only.
|
||||||
|
- `/api/users/`: 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
|
||||||
@@ -56,6 +59,10 @@ fields:
|
|||||||
- `custom_fields`: Array of custom fields & values, specified as
|
- `custom_fields`: Array of custom fields & values, specified as
|
||||||
`{ field: CUSTOM_FIELD_ID, value: VALUE }`
|
`{ field: CUSTOM_FIELD_ID, value: VALUE }`
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
|
||||||
|
Note that all endpoint URLs must end with a `/`slash.
|
||||||
|
|
||||||
## Downloading documents
|
## Downloading documents
|
||||||
|
|
||||||
In addition to that, the document endpoint offers these additional
|
In addition to that, the document endpoint offers these additional
|
||||||
@@ -134,10 +141,11 @@ 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
|
||||||
|
|
||||||
The REST api provides three different forms of authentication.
|
The REST api provides four different forms of authentication.
|
||||||
|
|
||||||
1. Basic authentication
|
1. Basic authentication
|
||||||
|
|
||||||
@@ -175,6 +183,44 @@ The REST api provides three different forms of authentication.
|
|||||||
|
|
||||||
Tokens can also be managed in the Django admin.
|
Tokens can also be managed in the Django admin.
|
||||||
|
|
||||||
|
4. Remote User authentication
|
||||||
|
|
||||||
|
If enabled (see
|
||||||
|
[configuration](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API)),
|
||||||
|
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
|
||||||
@@ -183,7 +229,7 @@ results:
|
|||||||
|
|
||||||
- `/api/documents/?query=your%20search%20query`: Search for a document
|
- `/api/documents/?query=your%20search%20query`: Search for a document
|
||||||
using a full text query. For details on the syntax, see [Basic Usage - Searching](usage.md#basic-usage_searching).
|
using a full text query. For details on the syntax, see [Basic Usage - Searching](usage.md#basic-usage_searching).
|
||||||
- `/api/documents/?more_like=1234`: Search for documents similar to
|
- `/api/documents/?more_like_id=1234`: Search for documents similar to
|
||||||
the document with id 1234.
|
the document with id 1234.
|
||||||
|
|
||||||
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
|
||||||
@@ -272,9 +318,12 @@ The endpoint supports the following optional form fields:
|
|||||||
- `correspondent`: Specify the ID of a correspondent that the consumer
|
- `correspondent`: Specify the ID of a correspondent that the consumer
|
||||||
should use for the document.
|
should use for the document.
|
||||||
- `document_type`: Similar to correspondent.
|
- `document_type`: Similar to correspondent.
|
||||||
|
- `storage_path`: Similar to correspondent.
|
||||||
- `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
|
||||||
@@ -321,6 +370,77 @@ granted). You can pass the parameter `full_perms=true` to API calls to view the
|
|||||||
full permissions of objects in a format that mirrors the `set_permissions`
|
full permissions of objects in a format that mirrors the `set_permissions`
|
||||||
parameter above.
|
parameter above.
|
||||||
|
|
||||||
|
## Bulk Editing
|
||||||
|
|
||||||
|
The API supports various bulk-editing operations which are executed asynchronously.
|
||||||
|
|
||||||
|
### Documents
|
||||||
|
|
||||||
|
For bulk operations on documents, use the endpoint `/api/documents/bulk_edit/` which accepts
|
||||||
|
a json payload of the format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"documents": [LIST_OF_DOCUMENT_IDS],
|
||||||
|
"method": METHOD, // see below
|
||||||
|
"parameters": args // see below
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The following methods are supported:
|
||||||
|
|
||||||
|
- `set_correspondent`
|
||||||
|
- Requires `parameters`: `{ "correspondent": CORRESPONDENT_ID }`
|
||||||
|
- `set_document_type`
|
||||||
|
- Requires `parameters`: `{ "document_type": DOCUMENT_TYPE_ID }`
|
||||||
|
- `set_storage_path`
|
||||||
|
- Requires `parameters`: `{ "storage_path": STORAGE_PATH_ID }`
|
||||||
|
- `add_tag`
|
||||||
|
- Requires `parameters`: `{ "tag": TAG_ID }`
|
||||||
|
- `remove_tag`
|
||||||
|
- Requires `parameters`: `{ "tag": TAG_ID }`
|
||||||
|
- `modify_tags`
|
||||||
|
- Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and / or `{ "remove_tags": [LIST_OF_TAG_IDS] }`
|
||||||
|
- `delete`
|
||||||
|
- No `parameters` required
|
||||||
|
- `redo_ocr`
|
||||||
|
- No `parameters` required
|
||||||
|
- `set_permissions`
|
||||||
|
- Requires `parameters`:
|
||||||
|
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
||||||
|
- `"owner": OWNER_ID or null`
|
||||||
|
- `"merge": true or false` (defaults to false)
|
||||||
|
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
|
||||||
|
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.
|
||||||
|
- `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]"`
|
||||||
|
- The split operation only accepts a single document.
|
||||||
|
- `rotate`
|
||||||
|
- Requires `parameters`:
|
||||||
|
- `"degrees": DEGREES`. Must be an integer i.e. 90, 180, 270
|
||||||
|
|
||||||
|
### Objects
|
||||||
|
|
||||||
|
Bulk editing for objects (tags, document types etc.) currently supports set permissions or delete
|
||||||
|
operations, using the endpoint: `/api/bulk_edit_objects/`, which requires a json payload of the format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"objects": [LIST_OF_OBJECT_IDS],
|
||||||
|
"object_type": "tags", "correspondents", "document_types" or "storage_paths",
|
||||||
|
"operation": "set_permissions" or "delete",
|
||||||
|
"owner": OWNER_ID, // optional
|
||||||
|
"permissions": { "view": { "users": [] ... }, "change": { ... } }, // (see 'set_permissions' format above)
|
||||||
|
"merge": true / false // defaults to false, see above
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## API Versioning
|
## API Versioning
|
||||||
|
|
||||||
The REST API is versioned since Paperless-ngx 1.3.0.
|
The REST API is versioned since Paperless-ngx 1.3.0.
|
||||||
@@ -377,3 +497,13 @@ Initial API version.
|
|||||||
color to use for a specific tag, which is either black or white
|
color to use for a specific tag, which is either black or white
|
||||||
depending on the brightness of `Tag.color`.
|
depending on the brightness of `Tag.color`.
|
||||||
- Removed field `Tag.colour`.
|
- Removed field `Tag.colour`.
|
||||||
|
|
||||||
|
#### Version 3
|
||||||
|
|
||||||
|
- Permissions endpoints have been added.
|
||||||
|
- The format of the `/api/ui_settings/` has changed.
|
||||||
|
|
||||||
|
#### Version 4
|
||||||
|
|
||||||
|
- Consumption templates were refactored to workflows and API endpoints
|
||||||
|
changed as such.
|
||||||
|
Before Width: | Height: | Size: 160 KiB |
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 2.3 MiB |
Before Width: | Height: | Size: 550 KiB After Width: | Height: | Size: 559 KiB |
BIN
docs/assets/screenshots/workflow.png
Normal file
After Width: | Height: | Size: 137 KiB |
1096
docs/changelog.md
@@ -3,6 +3,11 @@
|
|||||||
Paperless provides a wide range of customizations. Depending on how you
|
Paperless provides a wide range of customizations. Depending on how you
|
||||||
run paperless, these settings have to be defined in different places.
|
run paperless, these settings have to be defined in different places.
|
||||||
|
|
||||||
|
Certain configuration options may be set via the UI. This currently includes
|
||||||
|
common [OCR](#ocr) related settings and some frontend settings. If set, these will take
|
||||||
|
preference over the settings via environment variables. If not set, the environment setting
|
||||||
|
or applicable default will be utilized instead.
|
||||||
|
|
||||||
- If you run paperless on docker, `paperless.conf` is not used.
|
- If you run paperless on docker, `paperless.conf` is not used.
|
||||||
Rather, configure paperless by copying necessary options to
|
Rather, configure paperless by copying necessary options to
|
||||||
`docker-compose.env`.
|
`docker-compose.env`.
|
||||||
@@ -29,6 +34,8 @@ matcher.
|
|||||||
`redis://<username>:<password>@<host>:<port>`
|
`redis://<username>:<password>@<host>:<port>`
|
||||||
- With the requirepass option PAPERLESS_REDIS =
|
- With the requirepass option PAPERLESS_REDIS =
|
||||||
`redis://:<password>@<host>:<port>`
|
`redis://:<password>@<host>:<port>`
|
||||||
|
- To include the redis database index PAPERLESS_REDIS =
|
||||||
|
`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/getting-started/#securing-redis).
|
||||||
@@ -170,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>".
|
||||||
|
|
||||||
@@ -195,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.
|
||||||
|
|
||||||
@@ -257,7 +264,7 @@ directory. See [File name handling](advanced_usage.md#file-name-handling) for de
|
|||||||
: Tells paperless to replace placeholders in
|
: Tells paperless to replace placeholders in
|
||||||
`PAPERLESS_FILENAME_FORMAT` that would resolve to
|
`PAPERLESS_FILENAME_FORMAT` that would resolve to
|
||||||
'none' to be omitted from the resulting filename. This also holds
|
'none' to be omitted from the resulting filename. This also holds
|
||||||
true for directory names. See [File name handling](advanced_usage.md#file-name-handling) for
|
true for directory names. See [File name handling](advanced_usage.md#empty-placeholders) for
|
||||||
details.
|
details.
|
||||||
|
|
||||||
Defaults to `false` which disables this feature.
|
Defaults to `false` which disables this feature.
|
||||||
@@ -447,19 +454,32 @@ applications.
|
|||||||
|
|
||||||
This will allow authentication by simply adding a
|
This will allow authentication by simply adding a
|
||||||
`Remote-User: <username>` header to a request. Use with care! You
|
`Remote-User: <username>` header to a request. Use with care! You
|
||||||
especially *must: ensure that any such header is not passed from
|
especially *must* ensure that any such header is not passed from
|
||||||
your proxy server to paperless.
|
external requests to your reverse-proxy to paperless (that would
|
||||||
|
effectively bypass all authentication).
|
||||||
|
|
||||||
If you're exposing paperless to the internet directly, do not use
|
If you're exposing paperless to the internet directly (i.e.
|
||||||
this.
|
without a reverse proxy), do not use this.
|
||||||
|
|
||||||
Also see the warning [in the official documentation](https://docs.djangoproject.com/en/4.1/howto/auth-remote-user/#configuration).
|
Also see the warning [in the official documentation](https://docs.djangoproject.com/en/4.1/howto/auth-remote-user/#configuration).
|
||||||
|
|
||||||
Defaults to "false" which disables this feature.
|
Defaults to "false" which disables this feature.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_ENABLE_HTTP_REMOTE_USER_API=<bool>`](#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API) {#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API}
|
||||||
|
|
||||||
|
: Allows authentication via HTTP_REMOTE_USER directly against the API
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
See the warning above about securing your installation when using remote user header authentication. This setting is separate from
|
||||||
|
`PAPERLESS_ENABLE_HTTP_REMOTE_USER` to avoid introducing a security vulnerability to existing reverse proxy setups. As above,
|
||||||
|
ensure that your reverse proxy does not simply pass the `Remote-User` header from the internet to paperless.
|
||||||
|
|
||||||
|
Defaults to "false" which disables this feature.
|
||||||
|
|
||||||
#### [`PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME=<str>`](#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME) {#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME}
|
#### [`PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME=<str>`](#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME) {#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME}
|
||||||
|
|
||||||
: If "PAPERLESS_ENABLE_HTTP_REMOTE_USER" is enabled, this
|
: If "PAPERLESS_ENABLE_HTTP_REMOTE_USER" or `PAPERLESS_ENABLE_HTTP_REMOTE_USER_API` are enabled, this
|
||||||
property allows to customize the name of the HTTP header from which
|
property allows to customize the name of the HTTP header from which
|
||||||
the authenticated username is extracted. Values are in terms of
|
the authenticated username is extracted. Values are in terms of
|
||||||
[HttpRequest.META](https://docs.djangoproject.com/en/4.1/ref/request-response/#django.http.HttpRequest.META).
|
[HttpRequest.META](https://docs.djangoproject.com/en/4.1/ref/request-response/#django.http.HttpRequest.META).
|
||||||
@@ -471,8 +491,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.
|
||||||
|
|
||||||
@@ -516,6 +537,64 @@ This is for use with self-signed certificates against local IMAP servers.
|
|||||||
Settings this value has security implications for the security of your email.
|
Settings this value has security implications for the security of your email.
|
||||||
Understand what it does and be sure you need to before setting.
|
Understand what it does and be sure you need to before setting.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_SOCIALACCOUNT_PROVIDERS=<json>`](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) {#PAPERLESS_SOCIALACCOUNT_PROVIDERS}
|
||||||
|
|
||||||
|
: This variable is used to setup login and signup via social account providers which are compatible with django-allauth.
|
||||||
|
See the corresponding [django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html)
|
||||||
|
for a list of provider configurations. You will also need to include the relevant Django 'application' inside the
|
||||||
|
[PAPERLESS_APPS](#PAPERLESS_APPS) setting to activate that specific authentication provider (e.g. `allauth.socialaccount.providers.openid_connect` for the [OIDC Connect provider](https://docs.allauth.org/en/latest/socialaccount/providers/openid_connect.html)).
|
||||||
|
|
||||||
|
Defaults to None, which does not enable any third party authentication systems.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_SOCIAL_AUTO_SIGNUP=<bool>`](#PAPERLESS_SOCIAL_AUTO_SIGNUP) {#PAPERLESS_SOCIAL_AUTO_SIGNUP}
|
||||||
|
|
||||||
|
: Attempt to signup the user using retrieved email, username etc from the third party authentication
|
||||||
|
system. See the corresponding
|
||||||
|
[django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/configuration.html)
|
||||||
|
|
||||||
|
Defaults to False
|
||||||
|
|
||||||
|
#### [`PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS}
|
||||||
|
|
||||||
|
: Allow users to signup for a new Paperless-ngx account using any setup third party authentication systems.
|
||||||
|
|
||||||
|
Defaults to True
|
||||||
|
|
||||||
|
#### [`PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS}
|
||||||
|
|
||||||
|
: Allow users to signup for a new Paperless-ngx account.
|
||||||
|
|
||||||
|
Defaults to False
|
||||||
|
|
||||||
|
#### [`PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL=<string>`](#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL) {#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL}
|
||||||
|
|
||||||
|
: The protocol used when generating URLs, e.g. login callback URLs. See the corresponding
|
||||||
|
[django-allauth documentation](https://docs.allauth.org/en/latest/account/configuration.html)
|
||||||
|
|
||||||
|
Defaults to 'https'
|
||||||
|
|
||||||
|
#### [`PAPERLESS_ACCOUNT_EMAIL_VERIFICATION=<string>`](#PAPERLESS_ACCOUNT_EMAIL_VERIFICATION) {#PAPERLESS_ACCOUNT_EMAIL_VERIFICATION}
|
||||||
|
|
||||||
|
: Determines whether email addresses are verified during signup (as performed by Django allauth). See the relevant
|
||||||
|
[paperless settings](#PAPERLESS_EMAIL_HOST) and [the allauth docs](https://docs.allauth.org/en/latest/account/configuration.html)
|
||||||
|
|
||||||
|
Defaults to 'optional'
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
|
||||||
|
If you do not have a working email server set up you should set this to 'none'.
|
||||||
|
|
||||||
|
#### [`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).
|
||||||
|
|
||||||
|
Defaults to False
|
||||||
|
|
||||||
|
#### [`PAPERLESS_ACCOUNT_SESSION_REMEMBER=<bool>`](#PAPERLESS_ACCOUNT_SESSION_REMEMBER) {#PAPERLESS_ACCOUNT_SESSION_REMEMBER}
|
||||||
|
|
||||||
|
: See the corresponding
|
||||||
|
[django-allauth documentation](https://docs.allauth.org/en/latest/account/configuration.html)
|
||||||
|
|
||||||
## OCR settings {#ocr}
|
## OCR settings {#ocr}
|
||||||
|
|
||||||
Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/)
|
Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/)
|
||||||
@@ -660,11 +739,13 @@ completely.
|
|||||||
|
|
||||||
Specifying 1 here will only use the first page.
|
Specifying 1 here will only use the first page.
|
||||||
|
|
||||||
|
The value must be greater than or equal to 1 to be used.
|
||||||
|
|
||||||
When combined with `PAPERLESS_OCR_MODE=redo` or
|
When combined with `PAPERLESS_OCR_MODE=redo` or
|
||||||
`PAPERLESS_OCR_MODE=force`, paperless will not modify any text it
|
`PAPERLESS_OCR_MODE=force`, paperless will not modify any text it
|
||||||
finds on excluded pages and copy it verbatim.
|
finds on excluded pages and copy it verbatim.
|
||||||
|
|
||||||
Defaults to 0, which disables this feature and always uses all
|
Defaults to unset, which disables this feature and always uses all
|
||||||
pages.
|
pages.
|
||||||
|
|
||||||
#### [`PAPERLESS_OCR_IMAGE_DPI=<num>`](#PAPERLESS_OCR_IMAGE_DPI) {#PAPERLESS_OCR_IMAGE_DPI}
|
#### [`PAPERLESS_OCR_IMAGE_DPI=<num>`](#PAPERLESS_OCR_IMAGE_DPI) {#PAPERLESS_OCR_IMAGE_DPI}
|
||||||
@@ -678,7 +759,7 @@ fails, it uses this value as a fallback.
|
|||||||
|
|
||||||
Set this to the DPI your scanner produces images at.
|
Set this to the DPI your scanner produces images at.
|
||||||
|
|
||||||
Default is none, which will automatically calculate image DPI so
|
Defaults to unset, which will automatically calculate image DPI so
|
||||||
that the produced PDF documents are A4 sized.
|
that the produced PDF documents are A4 sized.
|
||||||
|
|
||||||
#### [`PAPERLESS_OCR_MAX_IMAGE_PIXELS=<num>`](#PAPERLESS_OCR_MAX_IMAGE_PIXELS) {#PAPERLESS_OCR_MAX_IMAGE_PIXELS}
|
#### [`PAPERLESS_OCR_MAX_IMAGE_PIXELS=<num>`](#PAPERLESS_OCR_MAX_IMAGE_PIXELS) {#PAPERLESS_OCR_MAX_IMAGE_PIXELS}
|
||||||
@@ -691,6 +772,8 @@ but could result in missing text content.
|
|||||||
If unset, will default to the value determined by
|
If unset, will default to the value determined by
|
||||||
[Pillow](https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.MAX_IMAGE_PIXELS).
|
[Pillow](https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.MAX_IMAGE_PIXELS).
|
||||||
|
|
||||||
|
Setting this value to 0 will entirely disable the limit. See the below warning.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
Increasing this limit could cause Paperless to consume additional
|
Increasing this limit could cause Paperless to consume additional
|
||||||
@@ -700,7 +783,7 @@ but could result in missing text content.
|
|||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
The limit is intended to prevent malicious files from consuming
|
The limit is intended to prevent malicious files from consuming
|
||||||
system resources and causing crashes and other errors. Only increase
|
system resources and causing crashes and other errors. Only change
|
||||||
this value if you are certain your documents are not malicious and
|
this value if you are certain your documents are not malicious and
|
||||||
you need the text which was not OCRed
|
you need the text which was not OCRed
|
||||||
|
|
||||||
@@ -733,7 +816,7 @@ they use underscores instead of dashes.
|
|||||||
Paperless has been tested to work with the OCR options provided
|
Paperless has been tested to work with the OCR options provided
|
||||||
above. There are many options that are incompatible with each other,
|
above. There are many options that are incompatible with each other,
|
||||||
so specifying invalid options may prevent paperless from consuming
|
so specifying invalid options may prevent paperless from consuming
|
||||||
any documents.
|
any documents. Use with caution!
|
||||||
|
|
||||||
Specify arguments as a JSON dictionary. Keep note of lower case
|
Specify arguments as a JSON dictionary. Keep note of lower case
|
||||||
booleans and double quoted parameter names and strings. Examples:
|
booleans and double quoted parameter names and strings. Examples:
|
||||||
@@ -884,6 +967,28 @@ documents.
|
|||||||
|
|
||||||
Default is none, which disables the temporary directory.
|
Default is none, which disables the temporary directory.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_APPS=<string>`](#PAPERLESS_APPS) {#PAPERLESS_APPS}
|
||||||
|
|
||||||
|
: A comma-separated list of Django apps to be included in Django's
|
||||||
|
[`INSTALLED_APPS`](https://docs.djangoproject.com/en/5.0/ref/applications/). This setting should
|
||||||
|
be used with caution!
|
||||||
|
|
||||||
|
Defaults to None, which does not add any additional apps.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_MAX_IMAGE_PIXELS=<number>`](#PAPERLESS_MAX_IMAGE_PIXELS) {#PAPERLESS_MAX_IMAGE_PIXELS}
|
||||||
|
|
||||||
|
: Configures the maximum size of an image PIL will allow to load without warning or error.
|
||||||
|
|
||||||
|
: If unset, will default to the value determined by
|
||||||
|
[Pillow](https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.MAX_IMAGE_PIXELS).
|
||||||
|
|
||||||
|
Defaults to None, which does change the limit
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
This limit is designed to prevent denial of service from malicious files.
|
||||||
|
It should only be raised or disabled in certain circumstances and with great care.
|
||||||
|
|
||||||
## Document Consumption {#consume_config}
|
## Document Consumption {#consume_config}
|
||||||
|
|
||||||
#### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES}
|
#### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES}
|
||||||
@@ -931,7 +1036,7 @@ or hidden folders some tools use to store data.
|
|||||||
`._foo.pdf` and `._bar/foo.pdf`
|
`._foo.pdf` and `._bar/foo.pdf`
|
||||||
|
|
||||||
Defaults to
|
Defaults to
|
||||||
`[".DS_STORE/*", "._*", ".stfolder/*", ".stversions/*", ".localized/*", "desktop.ini", "@eaDir/*"]`.
|
`[".DS_Store", ".DS_STORE", "._*", ".stfolder/*", ".stversions/*", ".localized/*", "desktop.ini", "@eaDir/*", "Thumbs.db"]`.
|
||||||
|
|
||||||
#### [`PAPERLESS_CONSUMER_BARCODE_SCANNER=<string>`](#PAPERLESS_CONSUMER_BARCODE_SCANNER) {#PAPERLESS_CONSUMER_BARCODE_SCANNER}
|
#### [`PAPERLESS_CONSUMER_BARCODE_SCANNER=<string>`](#PAPERLESS_CONSUMER_BARCODE_SCANNER) {#PAPERLESS_CONSUMER_BARCODE_SCANNER}
|
||||||
|
|
||||||
@@ -981,7 +1086,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
|
||||||
@@ -1006,11 +1111,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
|
||||||
|
|
||||||
@@ -1042,8 +1147,10 @@ system changes with `inotify`.
|
|||||||
|
|
||||||
#### [`PAPERLESS_CONSUMER_POLLING_RETRY_COUNT=<num>`](#PAPERLESS_CONSUMER_POLLING_RETRY_COUNT) {#PAPERLESS_CONSUMER_POLLING_RETRY_COUNT}
|
#### [`PAPERLESS_CONSUMER_POLLING_RETRY_COUNT=<num>`](#PAPERLESS_CONSUMER_POLLING_RETRY_COUNT) {#PAPERLESS_CONSUMER_POLLING_RETRY_COUNT}
|
||||||
|
|
||||||
: If consumer polling is enabled, sets the number of times paperless
|
: If consumer polling is enabled, sets the maximum number of times
|
||||||
will check for a file to remain unmodified.
|
paperless will check for a file to remain unmodified. If a file's
|
||||||
|
modification time and size are identical for two consecutive checks, it
|
||||||
|
will be consumed.
|
||||||
|
|
||||||
Defaults to 5.
|
Defaults to 5.
|
||||||
|
|
||||||
@@ -1135,7 +1242,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.
|
||||||
@@ -1152,14 +1259,62 @@ combination with PAPERLESS_CONSUMER_BARCODE_UPSCALE bigger than 1.0.
|
|||||||
|
|
||||||
Defaults to "300"
|
Defaults to "300"
|
||||||
|
|
||||||
|
#### [`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
|
||||||
|
assigns or creates tags if a properly formatted barcode is detected.
|
||||||
|
|
||||||
|
The barcode must match one of the (configurable) regular expressions.
|
||||||
|
If the barcode text contains ',' (comma), it is split into multiple
|
||||||
|
barcodes which are individually processed for tagging.
|
||||||
|
|
||||||
|
Matching is case insensitive.
|
||||||
|
|
||||||
|
Defaults to false.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING=<json dict>`](#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING) {#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING}
|
||||||
|
|
||||||
|
: Defines a dictionary of filter regex and substitute expressions.
|
||||||
|
|
||||||
|
Syntax: `{"<regex>": "<substitute>" [,...]]}`
|
||||||
|
|
||||||
|
A barcode is considered for tagging if the barcode text matches
|
||||||
|
at least one of the provided <regex> pattern.
|
||||||
|
|
||||||
|
If a match is found, the <substitute> rule is applied. This allows very
|
||||||
|
versatile reformatting and mapping of barcode pattern to tag values.
|
||||||
|
|
||||||
|
If a tag is not found it will be created.
|
||||||
|
|
||||||
|
Defaults to:
|
||||||
|
|
||||||
|
`{"TAG:(.*)": "\\g<1>"}` which defines
|
||||||
|
- a regex TAG:(.*) which includes barcodes beginning with TAG:
|
||||||
|
followed by any text that gets stored into match group #1 and
|
||||||
|
- a substitute `\\g<1>` that replaces the original barcode text
|
||||||
|
by the content in match group #1.
|
||||||
|
Consequently, the tag is the barcode text without its TAG: prefix.
|
||||||
|
|
||||||
|
More examples:
|
||||||
|
|
||||||
|
`{"ASN12.*": "JOHN", "ASN13.*": "SMITH"}` for example maps
|
||||||
|
- ASN12nnnn barcodes to the tag JOHN and
|
||||||
|
- ASN13nnnn barcodes to the tag SMITH.
|
||||||
|
|
||||||
|
`{"T-J": "JOHN", "T-S": "SMITH", "T-D": "DOE"}` directly maps
|
||||||
|
- T-J barcodes to the tag JOHN,
|
||||||
|
- T-S barcodes to the tag SMITH and
|
||||||
|
- T-D barcodes to the tag DOE.
|
||||||
|
|
||||||
|
Please refer to the Python regex documentation for more information.
|
||||||
|
|
||||||
## Audit Trail
|
## Audit Trail
|
||||||
|
|
||||||
#### [`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}
|
||||||
|
|
||||||
@@ -1298,7 +1453,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:
|
||||||
@@ -1309,6 +1464,10 @@ specified as "chi-tra".
|
|||||||
|
|
||||||
Defaults to none, which does not install any additional languages.
|
Defaults to none, which does not install any additional languages.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
This option must not be used in rootless containers.
|
||||||
|
|
||||||
#### [`PAPERLESS_ENABLE_FLOWER=<defined>`](#PAPERLESS_ENABLE_FLOWER) {#PAPERLESS_ENABLE_FLOWER}
|
#### [`PAPERLESS_ENABLE_FLOWER=<defined>`](#PAPERLESS_ENABLE_FLOWER) {#PAPERLESS_ENABLE_FLOWER}
|
||||||
|
|
||||||
: If this environment variable is defined, the Celery monitoring tool
|
: If this environment variable is defined, the Celery monitoring tool
|
||||||
@@ -1317,7 +1476,21 @@ started by the container.
|
|||||||
|
|
||||||
You can read more about this in the [advanced documentation](advanced_usage.md#celery-monitoring).
|
You can read more about this in the [advanced documentation](advanced_usage.md#celery-monitoring).
|
||||||
|
|
||||||
## Update Checking {#update-checking}
|
#### [`PAPERLESS_SUPERVISORD_WORKING_DIR=<defined>`](#PAPERLESS_SUPERVISORD_WORKING_DIR) {#PAPERLESS_SUPERVISORD_WORKING_DIR}
|
||||||
|
|
||||||
|
: If this environment variable is defined, the `supervisord.log` and `supervisord.pid` file will be created under the specified path in `PAPERLESS_SUPERVISORD_WORKING_DIR`. Setting `PAPERLESS_SUPERVISORD_WORKING_DIR=/tmp` and `PYTHONPYCACHEPREFIX=/tmp/pycache` would allow paperless to work on a read-only filesystem.
|
||||||
|
|
||||||
|
Please take note that the `PAPERLESS_DATA_DIR` and `PAPERLESS_MEDIA_ROOT` paths still have to be writable, just like the `PAPERLESS_SUPERVISORD_WORKING_DIR`. The can be archived by using bind or volume mounts. Only works in the container is run as user *paperless*
|
||||||
|
|
||||||
|
## Frontend Settings
|
||||||
|
|
||||||
|
#### [`PAPERLESS_APP_TITLE=<str>`](#PAPERLESS_APP_TITLE) {#PAPERLESS_APP_TITLE}
|
||||||
|
|
||||||
|
: If set, overrides the default name "Paperless-ngx"
|
||||||
|
|
||||||
|
#### [`PAPERLESS_APP_LOGO=<path>`](#PAPERLESS_APP_LOGO) {#PAPERLESS_APP_LOGO}
|
||||||
|
|
||||||
|
: Path to an image file in the /media/logo directory, must include 'logo', e.g. `/logo/Atari_logo.svg`
|
||||||
|
|
||||||
#### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK}
|
#### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK}
|
||||||
|
|
||||||
|
@@ -277,27 +277,17 @@ Adding new languages requires adding the translated files in the
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Add the language to the available options in
|
2. Add the language to the `LANGUAGE_OPTIONS` array in
|
||||||
`src-ui/src/app/services/settings.service.ts`:
|
`src-ui/src/app/services/settings.service.ts`:
|
||||||
|
|
||||||
```typescript
|
|
||||||
getLanguageOptions(): LanguageOption[] {
|
|
||||||
return [
|
|
||||||
{code: "en-us", name: $localize`English (US)`, englishName: "English (US)", dateInputFormat: "mm/dd/yyyy"},
|
|
||||||
{code: "en-gb", name: $localize`English (GB)`, englishName: "English (GB)", dateInputFormat: "dd/mm/yyyy"},
|
|
||||||
{code: "de", name: $localize`German`, englishName: "German", dateInputFormat: "dd.mm.yyyy"},
|
|
||||||
{code: "nl", name: $localize`Dutch`, englishName: "Dutch", dateInputFormat: "dd-mm-yyyy"},
|
|
||||||
{code: "fr", name: $localize`French`, englishName: "French", dateInputFormat: "dd/mm/yyyy"},
|
|
||||||
{code: "pt-br", name: $localize`Portuguese (Brazil)`, englishName: "Portuguese (Brazil)", dateInputFormat: "dd/mm/yyyy"}
|
|
||||||
// Add your new language here
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`dateInputFormat` is a special string that defines the behavior of
|
`dateInputFormat` is a special string that defines the behavior of
|
||||||
the date input fields and absolutely needs to contain "dd", "mm"
|
the date input fields and absolutely needs to contain "dd", "mm"
|
||||||
and "yyyy".
|
and "yyyy".
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
3. Import and register the Angular data for this locale in
|
3. Import and register the Angular data for this locale in
|
||||||
`src-ui/src/app/app.module.ts`:
|
`src-ui/src/app/app.module.ts`:
|
||||||
|
|
||||||
|
@@ -8,6 +8,13 @@ physical documents into a searchable online archive so you can keep, well, _less
|
|||||||
[Get started](setup.md){ .md-button .md-button--primary .index-callout }
|
[Get started](setup.md){ .md-button .md-button--primary .index-callout }
|
||||||
[Demo](https://demo.paperless-ngx.com){ .md-button .md-button--secondary target=\_blank }
|
[Demo](https://demo.paperless-ngx.com){ .md-button .md-button--secondary target=\_blank }
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: end; margin-top: -1.5rem;">
|
||||||
|
<a href="https://m.do.co/c/8d70b916d462" target="_blank">
|
||||||
|
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_white.svg#only-dark" class="no-lightbox" width="150px">
|
||||||
|
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_black.svg#only-light" class="no-lightbox" width="150px">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid-right" markdown>
|
<div class="grid-right" markdown>
|
||||||
{.index-screenshot}
|
{.index-screenshot}
|
||||||
@@ -18,6 +25,7 @@ physical documents into a searchable online archive so you can keep, well, _less
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Organize and index** your scanned documents with tags, correspondents, types, and more.
|
- **Organize and index** your scanned documents with tags, correspondents, types, and more.
|
||||||
|
- _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way.
|
||||||
- Performs **OCR** on your documents, adding searchable and selectable text, even to documents scanned with only images.
|
- Performs **OCR** on your documents, adding searchable and selectable text, even to documents scanned with only images.
|
||||||
- Utilizes the open-source Tesseract engine to recognize more than 100 languages.
|
- Utilizes the open-source Tesseract engine to recognize more than 100 languages.
|
||||||
- Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals.
|
- Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals.
|
||||||
@@ -41,7 +49,7 @@ physical documents into a searchable online archive so you can keep, well, _less
|
|||||||
- Configure multiple accounts and rules for each account.
|
- Configure multiple accounts and rules for each account.
|
||||||
- After processing, paperless can perform actions on the messages such as marking as read, deleting and more.
|
- After processing, paperless can perform actions on the messages such as marking as read, deleting and more.
|
||||||
- A built-in robust **multi-user permissions** system that supports 'global' permissions as well as per document or object.
|
- A built-in robust **multi-user permissions** system that supports 'global' permissions as well as per document or object.
|
||||||
- A powerful templating system that gives you more control over the consumption pipeline.
|
- A powerful workflow system that gives you even more control.
|
||||||
- **Optimized** for multi core systems: Paperless-ngx consumes multiple documents in parallel.
|
- **Optimized** for multi core systems: Paperless-ngx consumes multiple documents in parallel.
|
||||||
- The integrated sanity checker makes sure that your document archive is in good health.
|
- The integrated sanity checker makes sure that your document archive is in good health.
|
||||||
|
|
||||||
@@ -156,9 +164,9 @@ Tag, correspondent, document type and storage path editing.
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid-half-right" markdown>
|
<div class="grid-half-right" markdown>
|
||||||
Consumption templates provide finer control over the document pipeline.
|
Workflows provide finer control over the document pipeline and trigger actions.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="clear"></div>
|
<div class="clear"></div>
|
||||||
|
103
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
|
||||||
@@ -28,7 +29,8 @@ steps described in [Docker setup](#docker_hub) automatically.
|
|||||||
1. Make sure that Docker and Docker Compose are installed.
|
1. Make sure that Docker and Docker Compose are installed.
|
||||||
|
|
||||||
!!! tip
|
!!! tip
|
||||||
See the Docker installation instructions at https://docs.docker.com/engine/install/
|
|
||||||
|
See the Docker installation instructions at https://docs.docker.com/engine/install/
|
||||||
|
|
||||||
2. Download and run the installation script:
|
2. Download and run the installation script:
|
||||||
|
|
||||||
@@ -72,7 +74,7 @@ steps described in [Docker setup](#docker_hub) automatically.
|
|||||||
|
|
||||||
If you want to use the included `docker-compose.*.yml` file, you
|
If you want to use the included `docker-compose.*.yml` file, you
|
||||||
need to have at least Docker version **17.09.0** and Docker Compose
|
need to have at least Docker version **17.09.0** and Docker Compose
|
||||||
version **v2**. To check do: `docker compose -v` or `docker -v`
|
version **v2**. To check do: `docker compose version` or `docker -v`
|
||||||
|
|
||||||
See the [Docker installation guide](https://docs.docker.com/engine/install/) on how to install the current
|
See the [Docker installation guide](https://docs.docker.com/engine/install/) on how to install the current
|
||||||
version of Docker for your operating system or Linux distribution of
|
version of Docker for your operating system or Linux distribution of
|
||||||
@@ -95,7 +97,7 @@ steps described in [Docker setup](#docker_hub) automatically.
|
|||||||
- /home/jonaswinkler/paperless-inbox:/usr/src/paperless/consume
|
- /home/jonaswinkler/paperless-inbox:/usr/src/paperless/consume
|
||||||
```
|
```
|
||||||
|
|
||||||
Don't change the part after the colon or paperless wont find your
|
Don't change the part after the colon or paperless won't find your
|
||||||
documents.
|
documents.
|
||||||
|
|
||||||
You may also need to change the default port that the webserver will
|
You may also need to change the default port that the webserver will
|
||||||
@@ -120,6 +122,10 @@ steps described in [Docker setup](#docker_hub) automatically.
|
|||||||
|
|
||||||
**Rootless**
|
**Rootless**
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
It is currently not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
|
||||||
|
|
||||||
If you want to run Paperless as a rootless container, you will need
|
If you want to run Paperless as a rootless container, you will need
|
||||||
to do the following in your `docker-compose.yml`:
|
to do the following in your `docker-compose.yml`:
|
||||||
|
|
||||||
@@ -686,95 +692,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
|
||||||
|
|
||||||
|
@@ -138,7 +138,7 @@ command:
|
|||||||
You might encounter errors such as:
|
You might encounter errors such as:
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
The following error occured while consuming document.pdf: [Errno 13] Permission denied: '/usr/src/paperless/src/../consume/document.pdf'
|
The following error occurred while consuming document.pdf: [Errno 13] Permission denied: '/usr/src/paperless/src/../consume/document.pdf'
|
||||||
```
|
```
|
||||||
|
|
||||||
This happens when paperless does not have permission to delete files
|
This happens when paperless does not have permission to delete files
|
||||||
|
254
docs/usage.md
@@ -109,7 +109,7 @@ 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}
|
### IMAP (Email) {#usage-email}
|
||||||
@@ -149,7 +149,7 @@ different means. These are as follows:
|
|||||||
- **Flag:** Sets the 'important' flag on mails with consumed
|
- **Flag:** Sets the 'important' flag on mails with consumed
|
||||||
documents. Paperless will not consume flagged mails.
|
documents. Paperless will not consume flagged mails.
|
||||||
- **Move to folder:** Moves consumed mails out of the way so that
|
- **Move to folder:** Moves consumed mails out of the way so that
|
||||||
paperless wont consume them again.
|
paperless won't consume them again.
|
||||||
- **Add custom Tag:** Adds a custom tag to mails with consumed
|
- **Add custom Tag:** Adds a custom tag to mails with consumed
|
||||||
documents (the IMAP standard calls these "keywords"). Paperless
|
documents (the IMAP standard calls these "keywords"). Paperless
|
||||||
will not consume mails already tagged. Not all mail servers support
|
will not consume mails already tagged. Not all mail servers support
|
||||||
@@ -206,12 +206,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,87 +219,166 @@ 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 consumption templates.
|
[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).
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
|
||||||
|
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 the user 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 | Grants global permissions to add, edit, delete or view Correspondents. |
|
||||||
|
| CustomField | Grants global permissions to add, edit, delete or view Custom Fields. |
|
||||||
|
| Document | Grants global permissions to add, edit, delete or view Documents. |
|
||||||
|
| DocumentType | Grants global permissions to add, edit, delete or view Document Types. |
|
||||||
|
| Group | Grants global permissions to add, edit, delete or view Groups. |
|
||||||
|
| MailAccount | Grants global permissions to add, edit, delete or view Mail Accounts. |
|
||||||
|
| MailRule | Grants global permissions to add, edit, delete or view Mail Rules. |
|
||||||
|
| Note | Grants global permissions to add, edit, delete or view Notes. |
|
||||||
|
| PaperlessTask | Grants global permissions to view or dismiss (_Change_) File Tasks. |
|
||||||
|
| SavedView | Grants global permissions to add, edit, delete or view Saved Views. |
|
||||||
|
| ShareLink | Grants global permissions to add, delete or view Share Links. |
|
||||||
|
| StoragePath | Grants global permissions to add, edit, delete or view Storage Paths. |
|
||||||
|
| Tag | Grants global permissions to add, edit, delete or view Tags. |
|
||||||
|
| UISettings | Grants global permissions to add, edit, delete or view the UI settings that are used by the web app.<br/>Users expected to access the web UI should usually be granted at least _View_ permissions. |
|
||||||
|
| User | Grants global permissions to add, edit, delete or view Users. |
|
||||||
|
| Workflow | Grants global permissions to 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
|
||||||
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST)
|
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). If your installation does not have
|
||||||
|
[`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) set, the reset link included in emails will use the server host.
|
||||||
|
|
||||||
## Consumption templates
|
## Workflows
|
||||||
|
|
||||||
Consumption templates were introduced in v2.0 and allow for finer control over what metadata (tags, doc
|
!!! note
|
||||||
types) and permissions (owner, privileges) are assigned to documents during consumption. In general,
|
|
||||||
templates are applied sequentially (by sort order) but subsequent templates will never override an
|
|
||||||
assignment from a preceding template. The same is true for mail rules, e.g. if you set the correspondent
|
|
||||||
in a mail rule any subsequent consumption templates that are applied _will not_ overwrite this. The
|
|
||||||
exception to this is assignments that can be multiple e.g. tags and permissions, which will be merged.
|
|
||||||
|
|
||||||
Consumption templates allow you to filter by:
|
v2.3 added "Workflows" and existing "Consumption Templates" were converted automatically to the new more powerful format.
|
||||||
|
|
||||||
|
Workflows allow hooking into the Paperless-ngx document pipeline, for example to alter what metadata (tags, doc types) and
|
||||||
|
permissions (owner, privileges) are assigned to documents. Workflows can have multiple 'triggers' and 'actions'. Triggers
|
||||||
|
are events (with optional filtering rules) that will cause the workflow to be run and actions are the set of sequential
|
||||||
|
actions to apply.
|
||||||
|
|
||||||
|
In general, workflows and any actions they contain are applied sequentially by sort order. For "assignment" actions, subsequent
|
||||||
|
workflow actions will override previous assignments, except for assignments that accept multiple items e.g. tags, custom
|
||||||
|
fields and permissions, which will be merged.
|
||||||
|
|
||||||
|
### Workflow Triggers
|
||||||
|
|
||||||
|
Currently, there are three events that correspond to workflow trigger 'types':
|
||||||
|
|
||||||
|
1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption
|
||||||
|
folder or API), file path, file name, mail rule
|
||||||
|
2. **Document Added**: _after_ a document is added. At this time, file path and source information is no longer available,
|
||||||
|
but the document content has been extracted and metadata such as document type, tags, etc. have been set, so these can now
|
||||||
|
be used for filtering.
|
||||||
|
3. **Document Updated**: when a document is updated. Similar to 'added' events, triggers can include filtering by content matching,
|
||||||
|
tags, doc type, or correspondent.
|
||||||
|
|
||||||
|
The following flow diagram illustrates the three trigger types:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
consumption{"Matching
|
||||||
|
'Consumption'
|
||||||
|
trigger(s)"}
|
||||||
|
|
||||||
|
added{"Matching
|
||||||
|
'Added'
|
||||||
|
trigger(s)"}
|
||||||
|
|
||||||
|
updated{"Matching
|
||||||
|
'Updated'
|
||||||
|
trigger(s)"}
|
||||||
|
|
||||||
|
A[New Document] --> consumption
|
||||||
|
consumption --> |Yes| C[Workflow Actions Run]
|
||||||
|
consumption --> |No| D
|
||||||
|
C --> D[Document Added]
|
||||||
|
D -- Paperless-ngx 'matching' of tags, etc. --> added
|
||||||
|
added --> |Yes| F[Workflow Actions Run]
|
||||||
|
added --> |No| G
|
||||||
|
F --> G[Document Finalized]
|
||||||
|
H[Existing Document Changed] --> updated
|
||||||
|
updated --> |Yes| J[Workflow Actions Run]
|
||||||
|
updated --> |No| K
|
||||||
|
J --> K[Document Saved]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Filters {#workflow-trigger-filters}
|
||||||
|
|
||||||
|
Workflows allow you to filter by:
|
||||||
|
|
||||||
- Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch
|
- Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch
|
||||||
- File name, including wildcards e.g. \*.pdf will apply to all pdfs
|
- File name, including wildcards e.g. \*.pdf will apply to all pdfs
|
||||||
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
||||||
example, automatically assigning documents to different owners based on the upload directory.
|
example, automatically assigning documents to different owners based on the upload directory.
|
||||||
- Mail rule. Choosing this option will force 'mail fetch' to be the template source.
|
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
||||||
|
- Content matching (`Added` and `Updated` triggers only). Filter document content using the matching settings.
|
||||||
|
- Tags (`Added` and `Updated` triggers only). Filter for documents with any of the specified tags
|
||||||
|
- Document type (`Added` and `Updated` triggers only). Filter documents with this doc type
|
||||||
|
- Correspondent (`Added` and `Updated` triggers only). Filter documents with this correspondent
|
||||||
|
|
||||||
!!! note
|
### Workflow Actions
|
||||||
|
|
||||||
You must include a file name filter, a path filter or a mail rule filter. Use * for either to apply
|
There are currently two types of workflow actions, "Assignment", which can assign:
|
||||||
to all files.
|
|
||||||
|
|
||||||
Consumption templates can assign:
|
|
||||||
|
|
||||||
- Title, see [title placeholders](usage.md#title-placeholders) below
|
- Title, see [title placeholders](usage.md#title-placeholders) below
|
||||||
- Tags, correspondent, document types
|
- Tags, correspondent, document type and storage path
|
||||||
- Document owner
|
- Document owner
|
||||||
- View and / or edit permissions to users or groups
|
- View and / or edit permissions to users or groups
|
||||||
- Custom fields. Note that no value for the field will be set
|
- Custom fields. Note that no value for the field will be set
|
||||||
|
|
||||||
### Consumption template permissions
|
and "Removal" actions, which can remove either all of or specific sets of the following:
|
||||||
|
|
||||||
All users who have application permissions for editing consumption templates can see the same set
|
- Tags, correspondents, document types or storage paths
|
||||||
of templates. In other words, templates themselves intentionally do not have an owner or permissions.
|
- Document owner
|
||||||
|
- View and / or edit permissions
|
||||||
|
- Custom fields
|
||||||
|
|
||||||
Given their potentially far-reaching capabilities, you may want to restrict access to templates.
|
#### Title placeholders
|
||||||
|
|
||||||
Upon migration, existing installs will grant access to consumption templates to users who can add
|
Workflow titles can include placeholders but the available options differ depending on the type of
|
||||||
documents (and superusers who can always access all parts of the app).
|
workflow trigger. This is because at the time of consumption (when the title is to be set), no automatic tags etc. have been
|
||||||
|
applied. You can use the following placeholders with any trigger type:
|
||||||
### Title placeholders
|
|
||||||
|
|
||||||
Consumption template titles can include placeholders, _only for items that are assigned within the template_.
|
|
||||||
This is because at the time of consumption (when the title is to be set), no automatic tags etc. have been
|
|
||||||
applied. You can use the following placeholders:
|
|
||||||
|
|
||||||
- `{correspondent}`: assigned correspondent name
|
- `{correspondent}`: assigned correspondent name
|
||||||
- `{document_type}`: assigned document type name
|
- `{document_type}`: assigned document type name
|
||||||
@@ -311,6 +390,29 @@ applied. You can use the following placeholders:
|
|||||||
- `{added_month_name}`: added month name
|
- `{added_month_name}`: added month name
|
||||||
- `{added_month_name_short}`: added month short name
|
- `{added_month_name_short}`: added month short name
|
||||||
- `{added_day}`: added day
|
- `{added_day}`: added day
|
||||||
|
- `{added_time}`: added time in HH:MM format
|
||||||
|
- `{original_filename}`: original file name without extension
|
||||||
|
|
||||||
|
The following placeholders are only available for "added" or "updated" triggers
|
||||||
|
|
||||||
|
- `{created}`: created datetime
|
||||||
|
- `{created_year}`: created year
|
||||||
|
- `{created_year_short}`: created year
|
||||||
|
- `{created_month}`: created month
|
||||||
|
- `{created_month_name}`: created month name
|
||||||
|
- `{created_month_name_short}`: created month short name
|
||||||
|
- `{created_day}`: created day
|
||||||
|
- `{created_time}`: created time in HH:MM format
|
||||||
|
|
||||||
|
### Workflow permissions
|
||||||
|
|
||||||
|
All users who have application permissions for editing workflows can see the same set
|
||||||
|
of workflows. In other words, workflows themselves intentionally do not have an owner or permissions.
|
||||||
|
|
||||||
|
Given their potentially far-reaching capabilities, you may want to restrict access to workflows.
|
||||||
|
|
||||||
|
Upon migration, existing installs will grant access to workflows to users who can add
|
||||||
|
documents (and superusers who can always access all parts of the app).
|
||||||
|
|
||||||
## Custom Fields {#custom-fields}
|
## Custom Fields {#custom-fields}
|
||||||
|
|
||||||
@@ -342,12 +444,12 @@ The following custom field types are supported:
|
|||||||
- `URL`: a valid url
|
- `URL`: a valid url
|
||||||
- `Integer`: integer number e.g. 12
|
- `Integer`: integer number e.g. 12
|
||||||
- `Number`: float number e.g. 12.3456
|
- `Number`: float number e.g. 12.3456
|
||||||
- `Monetary`: float number with exactly two decimals, e.g. 12.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
|
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
|
||||||
|
|
||||||
## Share Links
|
## Share Links
|
||||||
|
|
||||||
Paperless-ngx added the abiltiy to create shareable links to files in version 2.0. You can find the button for this on the document detail screen.
|
Paperless-ngx added the ability to create shareable links to files in version 2.0. You can find the button for this on the document detail screen.
|
||||||
|
|
||||||
- Share links do not require a user to login and thus link directly to a file.
|
- Share links do not require a user to login and thus link directly to a file.
|
||||||
- Links are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`.
|
- Links are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`.
|
||||||
@@ -358,6 +460,24 @@ Paperless-ngx added the abiltiy 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 3 basic editing operations for PDFs (these operations 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
|
||||||
|
|
||||||
|
!!! important
|
||||||
|
|
||||||
|
Note that rotation alters 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".
|
||||||
|
|
||||||
## 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
|
||||||
@@ -430,6 +550,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
|
||||||
@@ -485,6 +615,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))
|
||||||
|
|
||||||
|
|
||||||
|
@@ -56,8 +56,8 @@ if ! command -v docker &> /dev/null ; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v docker compose &> /dev/null ; then
|
if ! docker compose &> /dev/null ; then
|
||||||
echo "docker compose executable not found. Is docker compose installed?"
|
echo "docker compose plugin not found. Is docker compose installed?"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -71,7 +71,17 @@ if ! docker stats --no-stream &> /dev/null ; then
|
|||||||
sleep 3
|
sleep 3
|
||||||
fi
|
fi
|
||||||
|
|
||||||
default_time_zone=$(timedatectl show -p Timezone --value)
|
# 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)
|
||||||
|
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
|
||||||
|
|
||||||
@@ -315,7 +325,7 @@ fi
|
|||||||
wget "https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docker/compose/docker-compose.$DOCKER_COMPOSE_VERSION.yml" -O docker-compose.yml
|
wget "https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docker/compose/docker-compose.$DOCKER_COMPOSE_VERSION.yml" -O docker-compose.yml
|
||||||
wget "https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docker/compose/.env" -O .env
|
wget "https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docker/compose/.env" -O .env
|
||||||
|
|
||||||
SECRET_KEY=$(LC_ALL=C tr -dc 'a-zA-Z0-9!"#$%&'\''()*+,-./:;<=>?@[\]^_`{|}~' < /dev/urandom | head --bytes 64)
|
SECRET_KEY=$(LC_ALL=C tr -dc 'a-zA-Z0-9!#$%&()*+,-./:;<=>?@[\]^_`{|}~' < /dev/urandom | dd bs=1 count=64 2>/dev/null)
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_LANGUAGES=("deu eng fra ita spa")
|
DEFAULT_LANGUAGES=("deu eng fra ita spa")
|
||||||
@@ -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
|
||||||
|
@@ -44,6 +44,11 @@ markdown_extensions:
|
|||||||
- pymdownx.inlinehilite
|
- pymdownx.inlinehilite
|
||||||
- pymdownx.snippets
|
- pymdownx.snippets
|
||||||
- footnotes
|
- footnotes
|
||||||
|
- pymdownx.superfences:
|
||||||
|
custom_fences:
|
||||||
|
- name: mermaid
|
||||||
|
class: mermaid
|
||||||
|
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||||
strict: true
|
strict: true
|
||||||
nav:
|
nav:
|
||||||
- index.md
|
- index.md
|
||||||
@@ -68,4 +73,6 @@ extra:
|
|||||||
link: https://matrix.to/#/#paperless:matrix.org
|
link: https://matrix.to/#/#paperless:matrix.org
|
||||||
plugins:
|
plugins:
|
||||||
- search
|
- search
|
||||||
- glightbox
|
- glightbox:
|
||||||
|
skip_classes:
|
||||||
|
- no-lightbox
|
||||||
|
36
paperless-ngx.code-workspace
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -68,6 +68,8 @@
|
|||||||
#PAPERLESS_CONSUMER_BARCODE_STRING=PATCHT
|
#PAPERLESS_CONSUMER_BARCODE_STRING=PATCHT
|
||||||
#PAPERLESS_CONSUMER_BARCODE_UPSCALE=0.0
|
#PAPERLESS_CONSUMER_BARCODE_UPSCALE=0.0
|
||||||
#PAPERLESS_CONSUMER_BARCODE_DPI=300
|
#PAPERLESS_CONSUMER_BARCODE_DPI=300
|
||||||
|
#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE=false
|
||||||
|
#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING={"TAG:(.*)": "\\g<1>"}
|
||||||
#PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED=false
|
#PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED=false
|
||||||
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_SUBDIR_NAME=double-sided
|
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_SUBDIR_NAME=double-sided
|
||||||
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT=false
|
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT=false
|
||||||
|
@@ -31,6 +31,7 @@
|
|||||||
"fr-FR": "src/locale/messages.fr_FR.xlf",
|
"fr-FR": "src/locale/messages.fr_FR.xlf",
|
||||||
"hu-HU": "src/locale/messages.hu_HU.xlf",
|
"hu-HU": "src/locale/messages.hu_HU.xlf",
|
||||||
"it-IT": "src/locale/messages.it_IT.xlf",
|
"it-IT": "src/locale/messages.it_IT.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",
|
||||||
"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",
|
||||||
@@ -76,7 +77,9 @@
|
|||||||
"scripts": [],
|
"scripts": [],
|
||||||
"allowedCommonJsDependencies": [
|
"allowedCommonJsDependencies": [
|
||||||
"pdfjs-dist",
|
"pdfjs-dist",
|
||||||
"pdfjs-dist/web/pdf_viewer"
|
"pdfjs-dist/web/pdf_viewer",
|
||||||
|
"filesize",
|
||||||
|
"file-saver"
|
||||||
],
|
],
|
||||||
"vendorChunk": true,
|
"vendorChunk": true,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
@@ -125,18 +128,18 @@
|
|||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-devkit/build-angular:dev-server",
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
"options": {
|
"options": {
|
||||||
"browserTarget": "paperless-ui:build:en-US"
|
"buildTarget": "paperless-ui:build:en-US"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"browserTarget": "paperless-ui:build:production"
|
"buildTarget": "paperless-ui:build:production"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extract-i18n": {
|
"extract-i18n": {
|
||||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||||
"options": {
|
"options": {
|
||||||
"browserTarget": "paperless-ui:build"
|
"buildTarget": "paperless-ui:build"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
|
@@ -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,
|
||||||
|
@@ -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,
|
||||||
|
@@ -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,
|
||||||
|
@@ -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,
|
||||||
|
@@ -12,13 +12,9 @@ test('should activate / deactivate save button when changes are saved', async ({
|
|||||||
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
|
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
|
||||||
/\w+/
|
/\w+/
|
||||||
)
|
)
|
||||||
await expect(
|
await expect(page.getByRole('button', { name: 'Save' }).nth(1)).toBeDisabled()
|
||||||
page.getByRole('button', { name: 'Save', exact: true })
|
|
||||||
).toBeDisabled()
|
|
||||||
await page.getByTitle('Storage path').getByTitle('Clear all').click()
|
await page.getByTitle('Storage path').getByTitle('Clear all').click()
|
||||||
await expect(
|
await expect(page.getByRole('button', { name: 'Save' }).nth(1)).toBeEnabled()
|
||||||
page.getByRole('button', { name: 'Save', exact: true })
|
|
||||||
).toBeEnabled()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should warn on unsaved changes', async ({ page }) => {
|
test('should warn on unsaved changes', async ({ page }) => {
|
||||||
@@ -27,16 +23,12 @@ test('should warn on unsaved changes', async ({ page }) => {
|
|||||||
await expect(page.getByTitle('Correspondent', { exact: true })).toHaveText(
|
await expect(page.getByTitle('Correspondent', { exact: true })).toHaveText(
|
||||||
/\w+/
|
/\w+/
|
||||||
)
|
)
|
||||||
await expect(
|
await expect(page.getByRole('button', { name: 'Save' }).nth(1)).toBeDisabled()
|
||||||
page.getByRole('button', { name: 'Save', exact: true })
|
|
||||||
).toBeDisabled()
|
|
||||||
await page
|
await page
|
||||||
.getByTitle('Storage path', { exact: true })
|
.getByTitle('Storage path', { exact: true })
|
||||||
.getByTitle('Clear all')
|
.getByTitle('Clear all')
|
||||||
.click()
|
.click()
|
||||||
await expect(
|
await expect(page.getByRole('button', { name: 'Save' }).nth(1)).toBeEnabled()
|
||||||
page.getByRole('button', { name: 'Save', exact: true })
|
|
||||||
).toBeEnabled()
|
|
||||||
await page.getByRole('button', { name: 'Close', exact: true }).click()
|
await page.getByRole('button', { name: 'Close', exact: true }).click()
|
||||||
await expect(page.getByRole('dialog')).toHaveText(/unsaved changes/)
|
await expect(page.getByRole('dialog')).toHaveText(/unsaved changes/)
|
||||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||||
|
@@ -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,14 +81,15 @@ 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('button', { name: 'Dates Clear selected' }).click()
|
||||||
await page.getByRole('button', { name: 'Created' }).click()
|
await page.getByRole('button', { name: 'Dates' }).click()
|
||||||
await page
|
await page
|
||||||
.getByRole('menuitem', { name: 'After mm/dd/yyyy' })
|
.getByRole('menuitem', { name: 'After mm/dd/yyyy' })
|
||||||
.getByRole('button')
|
.getByRole('button')
|
||||||
|
.first()
|
||||||
.click()
|
.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')
|
||||||
@@ -131,18 +132,18 @@ test('sorting', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Notes' }).click()
|
await page.getByRole('button', { name: 'Notes' }).click()
|
||||||
await expect(page).toHaveURL(/sort=num_notes/)
|
await expect(page).toHaveURL(/sort=num_notes/)
|
||||||
await page.getByRole('button', { name: 'Sort' }).click()
|
await page.getByRole('button', { name: 'Sort' }).click()
|
||||||
await page.locator('.w-100 > label > .toolbaricon').first().click()
|
await page.locator('.w-100 > label > i-bs').first().click()
|
||||||
await expect(page).not.toHaveURL(/reverse=1/)
|
await expect(page).not.toHaveURL(/reverse=1/)
|
||||||
})
|
})
|
||||||
|
|
||||||
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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
5055
src-ui/messages.xlf
7101
src-ui/package-lock.json
generated
@@ -11,56 +11,58 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^16.2.11",
|
"@angular/cdk": "^17.3.6",
|
||||||
"@angular/common": "~16.2.11",
|
"@angular/common": "~17.3.7",
|
||||||
"@angular/compiler": "~16.2.11",
|
"@angular/compiler": "~17.3.7",
|
||||||
"@angular/core": "~16.2.11",
|
"@angular/core": "~17.3.7",
|
||||||
"@angular/forms": "~16.2.11",
|
"@angular/forms": "~17.3.7",
|
||||||
"@angular/localize": "~16.2.11",
|
"@angular/localize": "~17.3.7",
|
||||||
"@angular/platform-browser": "~16.2.11",
|
"@angular/platform-browser": "~17.3.7",
|
||||||
"@angular/platform-browser-dynamic": "~16.2.11",
|
"@angular/platform-browser-dynamic": "~17.3.7",
|
||||||
"@angular/router": "~16.2.11",
|
"@angular/router": "~17.3.7",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^15.1.2",
|
"@ng-bootstrap/ng-bootstrap": "^16.0.0",
|
||||||
"@ng-select/ng-select": "^11.2.0",
|
"@ng-select/ng-select": "^12.0.7",
|
||||||
"@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.2",
|
"bootstrap": "^5.3.3",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
|
"ngx-bootstrap-icons": "^1.9.3",
|
||||||
"ngx-color": "^9.0.0",
|
"ngx-color": "^9.0.0",
|
||||||
"ngx-cookie-service": "^16.0.1",
|
"ngx-cookie-service": "^17.1.0",
|
||||||
"ngx-file-drop": "^16.0.0",
|
"ngx-file-drop": "^16.0.0",
|
||||||
"ngx-ui-tour-ng-bootstrap": "^13.0.6",
|
"ngx-filesize": "^3.0.3",
|
||||||
|
"ngx-ui-tour-ng-bootstrap": "^14.0.2",
|
||||||
"pdfjs-dist": "^3.11.174",
|
"pdfjs-dist": "^3.11.174",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"zone.js": "^0.13.3"
|
"zone.js": "^0.14.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/jest": "16.0.1",
|
"@angular-builders/jest": "17.0.3",
|
||||||
"@angular-devkit/build-angular": "~16.2.9",
|
"@angular-devkit/build-angular": "~17.3.6",
|
||||||
"@angular-eslint/builder": "16.2.0",
|
"@angular-eslint/builder": "17.3.0",
|
||||||
"@angular-eslint/eslint-plugin": "16.2.0",
|
"@angular-eslint/eslint-plugin": "17.3.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "16.2.0",
|
"@angular-eslint/eslint-plugin-template": "17.3.0",
|
||||||
"@angular-eslint/schematics": "16.2.0",
|
"@angular-eslint/schematics": "17.3.0",
|
||||||
"@angular-eslint/template-parser": "16.2.0",
|
"@angular-eslint/template-parser": "17.3.0",
|
||||||
"@angular/cli": "~16.2.9",
|
"@angular/cli": "~17.3.6",
|
||||||
"@angular/compiler-cli": "~16.2.3",
|
"@angular/compiler-cli": "~17.3.2",
|
||||||
"@playwright/test": "^1.40.1",
|
"@playwright/test": "^1.42.1",
|
||||||
"@types/jest": "^29.5.10",
|
"@types/jest": "^29.5.12",
|
||||||
"@types/node": "^20.10.2",
|
"@types/node": "^20.12.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.13.1",
|
"@typescript-eslint/eslint-plugin": "^7.4.0",
|
||||||
"@typescript-eslint/parser": "^6.13.1",
|
"@typescript-eslint/parser": "^7.4.0",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"eslint": "^8.55.0",
|
"eslint": "^8.57.0",
|
||||||
"jest": "29.7.0",
|
"jest": "29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"jest-preset-angular": "^13.1.4",
|
"jest-preset-angular": "^14.0.0",
|
||||||
"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.1.6",
|
"typescript": "^5.3.3",
|
||||||
"wait-on": "^7.2.0"
|
"wait-on": "^7.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -23,6 +23,7 @@ import localeFi from '@angular/common/locales/fi'
|
|||||||
import localeFr from '@angular/common/locales/fr'
|
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 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'
|
||||||
@@ -53,6 +54,7 @@ registerLocaleData(localeFi)
|
|||||||
registerLocaleData(localeFr)
|
registerLocaleData(localeFr)
|
||||||
registerLocaleData(localeHu)
|
registerLocaleData(localeHu)
|
||||||
registerLocaleData(localeIt)
|
registerLocaleData(localeIt)
|
||||||
|
registerLocaleData(localeJa)
|
||||||
registerLocaleData(localeLb)
|
registerLocaleData(localeLb)
|
||||||
registerLocaleData(localeNl)
|
registerLocaleData(localeNl)
|
||||||
registerLocaleData(localeNo)
|
registerLocaleData(localeNo)
|
||||||
@@ -74,12 +76,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', {
|
||||||
@@ -92,6 +98,10 @@ Object.defineProperty(navigator, 'clipboard', {
|
|||||||
})
|
})
|
||||||
Object.defineProperty(navigator, 'canShare', { value: () => true })
|
Object.defineProperty(navigator, 'canShare', { value: () => true })
|
||||||
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
|
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
value: { reload: jest.fn() },
|
||||||
|
})
|
||||||
|
|
||||||
HTMLCanvasElement.prototype.getContext = <
|
HTMLCanvasElement.prototype.getContext = <
|
||||||
typeof HTMLCanvasElement.prototype.getContext
|
typeof HTMLCanvasElement.prototype.getContext
|
||||||
|
@@ -21,10 +21,11 @@ import {
|
|||||||
PermissionAction,
|
PermissionAction,
|
||||||
PermissionType,
|
PermissionType,
|
||||||
} from './services/permissions.service'
|
} from './services/permissions.service'
|
||||||
import { ConsumptionTemplatesComponent } from './components/manage/consumption-templates/consumption-templates.component'
|
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||||
import { MailComponent } from './components/manage/mail/mail.component'
|
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'
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||||
@@ -140,10 +141,7 @@ export const routes: Routes = [
|
|||||||
component: LogsComponent,
|
component: LogsComponent,
|
||||||
canActivate: [PermissionsGuard],
|
canActivate: [PermissionsGuard],
|
||||||
data: {
|
data: {
|
||||||
requiredPermission: {
|
requireAdmin: true,
|
||||||
action: PermissionAction.View,
|
|
||||||
type: PermissionType.Admin,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// redirect old paths
|
// redirect old paths
|
||||||
@@ -162,7 +160,7 @@ export const routes: Routes = [
|
|||||||
canActivate: [PermissionsGuard],
|
canActivate: [PermissionsGuard],
|
||||||
data: {
|
data: {
|
||||||
requiredPermission: {
|
requiredPermission: {
|
||||||
action: PermissionAction.View,
|
action: PermissionAction.Change,
|
||||||
type: PermissionType.UISettings,
|
type: PermissionType.UISettings,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -179,6 +177,17 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'config',
|
||||||
|
component: ConfigComponent,
|
||||||
|
canActivate: [PermissionsGuard],
|
||||||
|
data: {
|
||||||
|
requiredPermission: {
|
||||||
|
action: PermissionAction.Change,
|
||||||
|
type: PermissionType.AppConfig,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'tasks',
|
path: 'tasks',
|
||||||
component: TasksComponent,
|
component: TasksComponent,
|
||||||
@@ -202,13 +211,13 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'templates',
|
path: 'workflows',
|
||||||
component: ConsumptionTemplatesComponent,
|
component: WorkflowsComponent,
|
||||||
canActivate: [PermissionsGuard],
|
canActivate: [PermissionsGuard],
|
||||||
data: {
|
data: {
|
||||||
requiredPermission: {
|
requiredPermission: {
|
||||||
action: PermissionAction.View,
|
action: PermissionAction.View,
|
||||||
type: PermissionType.ConsumptionTemplate,
|
type: PermissionType.Workflow,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@@ -1,32 +1,36 @@
|
|||||||
<pngx-toasts></pngx-toasts>
|
<pngx-toasts></pngx-toasts>
|
||||||
|
|
||||||
<pngx-file-drop>
|
<pngx-file-drop>
|
||||||
<ng-container content>
|
<ng-container content>
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</pngx-file-drop>
|
</pngx-file-drop>
|
||||||
|
|
||||||
<tour-step-template>
|
<tour-step-template>
|
||||||
<ng-template #tourStep let-step="step">
|
<ng-template #tourStep let-step="step">
|
||||||
<p class="tour-step-content" [innerHTML]="step?.content"></p>
|
<p class="tour-step-content" [innerHTML]="step?.content"></p>
|
||||||
<hr/>
|
<hr/>
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<span class="badge bg-light text-dark">{{ tourService.steps?.indexOf(step) + 1 }} / {{ tourService.steps?.length }}</span>
|
<span class="badge bg-light text-dark">{{ tourService.steps?.indexOf(step) + 1 }} / {{ tourService.steps?.length }}</span>
|
||||||
<div class="tour-step-navigation btn-toolbar" role="toolbar" aria-label="Controls">
|
<div class="tour-step-navigation btn-toolbar" role="toolbar" aria-label="Controls">
|
||||||
<div class="btn-group btn-group-sm me-2" role="group" aria-label="Dismiss">
|
<div class="btn-group btn-group-sm me-2" role="group" aria-label="Dismiss">
|
||||||
<button class="btn btn-outline-danger" (click)="tourService.end()">
|
<button class="btn btn-outline-danger" (click)="tourService.end()">
|
||||||
{{ step?.endBtnTitle }}
|
{{ step?.endBtnTitle }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
<div class="btn-group btn-group-sm align-self-end" role="group" aria-label="Previous / Next">
|
|
||||||
<button *ngIf="tourService.hasPrev(step)" class="btn btn-outline-primary" (click)="tourService.prev()">
|
|
||||||
« {{ step?.prevBtnTitle }}
|
|
||||||
</button>
|
|
||||||
<button *ngIf="tourService.hasNext(step)" class="btn btn-outline-primary" (click)="tourService.next()">
|
|
||||||
{{ step?.nextBtnTitle }} »
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
<div class="btn-group btn-group-sm align-self-end" role="group" aria-label="Previous / Next">
|
||||||
|
@if (tourService.hasPrev(step)) {
|
||||||
|
<button class="btn btn-outline-primary" (click)="tourService.prev()">
|
||||||
|
« {{ step?.prevBtnTitle }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (tourService.hasNext(step)) {
|
||||||
|
<button class="btn btn-outline-primary" (click)="tourService.next()">
|
||||||
|
{{ step?.nextBtnTitle }} »
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
</tour-step-template>
|
</tour-step-template>
|
||||||
|
@@ -5,8 +5,7 @@ import {
|
|||||||
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,10 @@ 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'
|
||||||
|
|
||||||
describe('AppComponent', () => {
|
describe('AppComponent', () => {
|
||||||
let component: AppComponent
|
let component: AppComponent
|
||||||
@@ -31,16 +34,18 @@ 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: [],
|
providers: [PermissionsGuard, DirtySavedViewGuard],
|
||||||
imports: [
|
imports: [
|
||||||
HttpClientTestingModule,
|
HttpClientTestingModule,
|
||||||
TourNgBootstrapModule,
|
TourNgBootstrapModule,
|
||||||
RouterTestingModule.withRoutes(routes),
|
RouterModule.forRoot(routes),
|
||||||
NgxFileDropModule,
|
NgxFileDropModule,
|
||||||
|
NgbModalModule,
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
@@ -50,6 +55,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 +145,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'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { SettingsService } from './services/settings.service'
|
import { SettingsService } from './services/settings.service'
|
||||||
import { SETTINGS_KEYS } from './data/paperless-uisettings'
|
import { SETTINGS_KEYS } from './data/ui-settings'
|
||||||
import { Component, OnDestroy, OnInit, Renderer2 } from '@angular/core'
|
import { Component, OnDestroy, OnInit, Renderer2 } from '@angular/core'
|
||||||
import { Router } from '@angular/router'
|
import { Router } from '@angular/router'
|
||||||
import { Subscription, first } from 'rxjs'
|
import { Subscription, first } from 'rxjs'
|
||||||
@@ -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,7 +32,8 @@ 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
|
||||||
) {
|
) {
|
||||||
this.settings.updateAppearanceSettings()
|
this.settings.updateAppearanceSettings()
|
||||||
}
|
}
|
||||||
@@ -123,6 +125,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`
|
||||||
@@ -176,9 +208,9 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
anchorId: 'tour.consumption-templates',
|
anchorId: 'tour.workflows',
|
||||||
content: $localize`Consumption templates give you finer control over the document ingestion process.`,
|
content: $localize`Workflows give you more control over the document pipeline.`,
|
||||||
route: '/templates',
|
route: '/workflows',
|
||||||
backdropConfig: {
|
backdropConfig: {
|
||||||
offset: 0,
|
offset: 0,
|
||||||
},
|
},
|
||||||
|
@@ -31,7 +31,7 @@ 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'
|
||||||
@@ -95,8 +95,8 @@ import { UsernamePipe } from './pipes/username.pipe'
|
|||||||
import { LogoComponent } from './components/common/logo/logo.component'
|
import { LogoComponent } from './components/common/logo/logo.component'
|
||||||
import { IsNumberPipe } from './pipes/is-number.pipe'
|
import { IsNumberPipe } from './pipes/is-number.pipe'
|
||||||
import { ShareLinksDropdownComponent } from './components/common/share-links-dropdown/share-links-dropdown.component'
|
import { ShareLinksDropdownComponent } from './components/common/share-links-dropdown/share-links-dropdown.component'
|
||||||
import { ConsumptionTemplatesComponent } from './components/manage/consumption-templates/consumption-templates.component'
|
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||||
import { ConsumptionTemplateEditDialogComponent } from './components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component'
|
import { WorkflowEditDialogComponent } from './components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
|
||||||
import { MailComponent } from './components/manage/mail/mail.component'
|
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 { DragDropModule } from '@angular/cdk/drag-drop'
|
import { DragDropModule } from '@angular/cdk/drag-drop'
|
||||||
@@ -107,6 +107,220 @@ import { CustomFieldsDropdownComponent } from './components/common/custom-fields
|
|||||||
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 { PdfViewerComponent } from './components/common/pdf-viewer/pdf-viewer.component'
|
||||||
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 { SwitchComponent } from './components/common/input/switch/switch.component'
|
||||||
|
import { ConfigComponent } from './components/admin/config/config.component'
|
||||||
|
import { FileComponent } from './components/common/input/file/file.component'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { ConfirmButtonComponent } from './components/common/confirm-button/confirm-button.component'
|
||||||
|
import { MonetaryComponent } from './components/common/input/monetary/monetary.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 {
|
||||||
|
airplane,
|
||||||
|
archive,
|
||||||
|
arrowClockwise,
|
||||||
|
arrowCounterclockwise,
|
||||||
|
arrowDown,
|
||||||
|
arrowLeft,
|
||||||
|
arrowRepeat,
|
||||||
|
arrowRight,
|
||||||
|
arrowRightShort,
|
||||||
|
arrowUpRight,
|
||||||
|
asterisk,
|
||||||
|
bodyText,
|
||||||
|
boxArrowUp,
|
||||||
|
boxArrowUpRight,
|
||||||
|
boxes,
|
||||||
|
calendar,
|
||||||
|
calendarEvent,
|
||||||
|
calendarEventFill,
|
||||||
|
cardChecklist,
|
||||||
|
cardHeading,
|
||||||
|
caretDown,
|
||||||
|
caretUp,
|
||||||
|
chatLeftText,
|
||||||
|
check,
|
||||||
|
check2All,
|
||||||
|
checkAll,
|
||||||
|
checkCircleFill,
|
||||||
|
checkLg,
|
||||||
|
chevronDoubleLeft,
|
||||||
|
chevronDoubleRight,
|
||||||
|
clipboard,
|
||||||
|
clipboardCheck,
|
||||||
|
clipboardCheckFill,
|
||||||
|
clipboardFill,
|
||||||
|
dash,
|
||||||
|
diagram3,
|
||||||
|
dice5,
|
||||||
|
doorOpen,
|
||||||
|
download,
|
||||||
|
envelope,
|
||||||
|
envelopeAt,
|
||||||
|
exclamationCircleFill,
|
||||||
|
exclamationTriangle,
|
||||||
|
exclamationTriangleFill,
|
||||||
|
eye,
|
||||||
|
fileEarmark,
|
||||||
|
fileEarmarkCheck,
|
||||||
|
fileEarmarkFill,
|
||||||
|
fileEarmarkLock,
|
||||||
|
files,
|
||||||
|
fileText,
|
||||||
|
filter,
|
||||||
|
folder,
|
||||||
|
folderFill,
|
||||||
|
funnel,
|
||||||
|
gear,
|
||||||
|
grid,
|
||||||
|
gripVertical,
|
||||||
|
hash,
|
||||||
|
hddStack,
|
||||||
|
house,
|
||||||
|
infoCircle,
|
||||||
|
journals,
|
||||||
|
link,
|
||||||
|
listTask,
|
||||||
|
listUl,
|
||||||
|
pencil,
|
||||||
|
people,
|
||||||
|
peopleFill,
|
||||||
|
person,
|
||||||
|
personCircle,
|
||||||
|
personFill,
|
||||||
|
personFillLock,
|
||||||
|
personLock,
|
||||||
|
personSquare,
|
||||||
|
plus,
|
||||||
|
plusCircle,
|
||||||
|
questionCircle,
|
||||||
|
scissors,
|
||||||
|
search,
|
||||||
|
slashCircle,
|
||||||
|
sliders2Vertical,
|
||||||
|
sortAlphaDown,
|
||||||
|
sortAlphaUpAlt,
|
||||||
|
tagFill,
|
||||||
|
tag,
|
||||||
|
tags,
|
||||||
|
textIndentLeft,
|
||||||
|
textLeft,
|
||||||
|
threeDots,
|
||||||
|
threeDotsVertical,
|
||||||
|
trash,
|
||||||
|
uiRadios,
|
||||||
|
upcScan,
|
||||||
|
x,
|
||||||
|
xLg,
|
||||||
|
} from 'ngx-bootstrap-icons'
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
airplane,
|
||||||
|
archive,
|
||||||
|
arrowClockwise,
|
||||||
|
arrowCounterclockwise,
|
||||||
|
arrowDown,
|
||||||
|
arrowLeft,
|
||||||
|
arrowRepeat,
|
||||||
|
arrowRight,
|
||||||
|
arrowRightShort,
|
||||||
|
arrowUpRight,
|
||||||
|
asterisk,
|
||||||
|
bodyText,
|
||||||
|
boxArrowUp,
|
||||||
|
boxArrowUpRight,
|
||||||
|
boxes,
|
||||||
|
calendar,
|
||||||
|
calendarEvent,
|
||||||
|
calendarEventFill,
|
||||||
|
cardChecklist,
|
||||||
|
cardHeading,
|
||||||
|
caretDown,
|
||||||
|
caretUp,
|
||||||
|
chatLeftText,
|
||||||
|
check,
|
||||||
|
check2All,
|
||||||
|
checkAll,
|
||||||
|
checkCircleFill,
|
||||||
|
checkLg,
|
||||||
|
chevronDoubleLeft,
|
||||||
|
chevronDoubleRight,
|
||||||
|
clipboard,
|
||||||
|
clipboardCheck,
|
||||||
|
clipboardCheckFill,
|
||||||
|
clipboardFill,
|
||||||
|
dash,
|
||||||
|
diagram3,
|
||||||
|
dice5,
|
||||||
|
doorOpen,
|
||||||
|
download,
|
||||||
|
envelope,
|
||||||
|
envelopeAt,
|
||||||
|
exclamationCircleFill,
|
||||||
|
exclamationTriangle,
|
||||||
|
exclamationTriangleFill,
|
||||||
|
eye,
|
||||||
|
fileEarmark,
|
||||||
|
fileEarmarkCheck,
|
||||||
|
fileEarmarkFill,
|
||||||
|
fileEarmarkLock,
|
||||||
|
files,
|
||||||
|
fileText,
|
||||||
|
filter,
|
||||||
|
folder,
|
||||||
|
folderFill,
|
||||||
|
funnel,
|
||||||
|
gear,
|
||||||
|
grid,
|
||||||
|
gripVertical,
|
||||||
|
hash,
|
||||||
|
hddStack,
|
||||||
|
house,
|
||||||
|
infoCircle,
|
||||||
|
journals,
|
||||||
|
link,
|
||||||
|
listTask,
|
||||||
|
listUl,
|
||||||
|
pencil,
|
||||||
|
people,
|
||||||
|
peopleFill,
|
||||||
|
person,
|
||||||
|
personCircle,
|
||||||
|
personFill,
|
||||||
|
personFillLock,
|
||||||
|
personLock,
|
||||||
|
personSquare,
|
||||||
|
plus,
|
||||||
|
plusCircle,
|
||||||
|
questionCircle,
|
||||||
|
scissors,
|
||||||
|
search,
|
||||||
|
slashCircle,
|
||||||
|
sliders2Vertical,
|
||||||
|
sortAlphaDown,
|
||||||
|
sortAlphaUpAlt,
|
||||||
|
tagFill,
|
||||||
|
tag,
|
||||||
|
tags,
|
||||||
|
textIndentLeft,
|
||||||
|
textLeft,
|
||||||
|
threeDots,
|
||||||
|
threeDotsVertical,
|
||||||
|
trash,
|
||||||
|
uiRadios,
|
||||||
|
upcScan,
|
||||||
|
x,
|
||||||
|
xLg,
|
||||||
|
}
|
||||||
|
|
||||||
import localeAf from '@angular/common/locales/af'
|
import localeAf from '@angular/common/locales/af'
|
||||||
import localeAr from '@angular/common/locales/ar'
|
import localeAr from '@angular/common/locales/ar'
|
||||||
@@ -123,6 +337,7 @@ import localeFi from '@angular/common/locales/fi'
|
|||||||
import localeFr from '@angular/common/locales/fr'
|
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 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'
|
||||||
@@ -153,6 +368,7 @@ registerLocaleData(localeFi)
|
|||||||
registerLocaleData(localeFr)
|
registerLocaleData(localeFr)
|
||||||
registerLocaleData(localeHu)
|
registerLocaleData(localeHu)
|
||||||
registerLocaleData(localeIt)
|
registerLocaleData(localeIt)
|
||||||
|
registerLocaleData(localeJa)
|
||||||
registerLocaleData(localeLb)
|
registerLocaleData(localeLb)
|
||||||
registerLocaleData(localeNl)
|
registerLocaleData(localeNl)
|
||||||
registerLocaleData(localeNo)
|
registerLocaleData(localeNo)
|
||||||
@@ -201,7 +417,7 @@ function initializeApp(settings: SettingsService) {
|
|||||||
FilterEditorComponent,
|
FilterEditorComponent,
|
||||||
FilterableDropdownComponent,
|
FilterableDropdownComponent,
|
||||||
ToggleableDropdownButtonComponent,
|
ToggleableDropdownButtonComponent,
|
||||||
DateDropdownComponent,
|
DatesDropdownComponent,
|
||||||
DocumentCardLargeComponent,
|
DocumentCardLargeComponent,
|
||||||
DocumentCardSmallComponent,
|
DocumentCardSmallComponent,
|
||||||
BulkEditorComponent,
|
BulkEditorComponent,
|
||||||
@@ -250,8 +466,8 @@ function initializeApp(settings: SettingsService) {
|
|||||||
LogoComponent,
|
LogoComponent,
|
||||||
IsNumberPipe,
|
IsNumberPipe,
|
||||||
ShareLinksDropdownComponent,
|
ShareLinksDropdownComponent,
|
||||||
ConsumptionTemplatesComponent,
|
WorkflowsComponent,
|
||||||
ConsumptionTemplateEditDialogComponent,
|
WorkflowEditDialogComponent,
|
||||||
MailComponent,
|
MailComponent,
|
||||||
UsersAndGroupsComponent,
|
UsersAndGroupsComponent,
|
||||||
FileDropComponent,
|
FileDropComponent,
|
||||||
@@ -261,6 +477,21 @@ function initializeApp(settings: SettingsService) {
|
|||||||
ProfileEditDialogComponent,
|
ProfileEditDialogComponent,
|
||||||
PdfViewerComponent,
|
PdfViewerComponent,
|
||||||
DocumentLinkComponent,
|
DocumentLinkComponent,
|
||||||
|
PreviewPopupComponent,
|
||||||
|
SwitchComponent,
|
||||||
|
ConfigComponent,
|
||||||
|
FileComponent,
|
||||||
|
ConfirmButtonComponent,
|
||||||
|
MonetaryComponent,
|
||||||
|
SystemStatusDialogComponent,
|
||||||
|
RotateConfirmDialogComponent,
|
||||||
|
MergeConfirmDialogComponent,
|
||||||
|
SplitConfirmDialogComponent,
|
||||||
|
DocumentHistoryComponent,
|
||||||
|
DragDropSelectComponent,
|
||||||
|
CustomFieldDisplayComponent,
|
||||||
|
GlobalSearchComponent,
|
||||||
|
HotkeyDialogComponent,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
@@ -274,6 +505,8 @@ function initializeApp(settings: SettingsService) {
|
|||||||
ColorSliderModule,
|
ColorSliderModule,
|
||||||
TourNgBootstrapModule,
|
TourNgBootstrapModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
|
NgxBootstrapIconsModule.pick(icons),
|
||||||
|
NgxFilesizeModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
59
src-ui/src/app/components/admin/config/config.component.html
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<pngx-page-header
|
||||||
|
title="Application Configuration"
|
||||||
|
i18n-title
|
||||||
|
info="Global app configuration options which apply to <strong>every</strong> user of this install of Paperless-ngx. Options can also be set using environment variables or the configuration file but the value here will always take precedence."
|
||||||
|
i18n-info
|
||||||
|
infoLink="configuration">
|
||||||
|
</pngx-page-header>
|
||||||
|
|
||||||
|
<form [formGroup]="configForm" (ngSubmit)="saveConfig()" class="pb-4">
|
||||||
|
|
||||||
|
<ul ngbNav #nav="ngbNav" class="nav-tabs">
|
||||||
|
@for (category of optionCategories; track category) {
|
||||||
|
<li [ngbNavItem]="category">
|
||||||
|
<a ngbNavLink i18n>{{category}}</a>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
||||||
|
@for (option of getCategoryOptions(category); track option.key) {
|
||||||
|
<div class="col">
|
||||||
|
<div class="card bg-light">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-title">
|
||||||
|
<h6>
|
||||||
|
{{option.title}}
|
||||||
|
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
||||||
|
<i-bs name="info-circle"></i-bs>
|
||||||
|
</a>
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="mb-n3">
|
||||||
|
@switch (option.type) {
|
||||||
|
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
|
||||||
|
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
|
||||||
|
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [showUnsetNote]="true" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
|
||||||
|
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||||
|
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||||
|
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
|
||||||
|
<div class="btn-toolbar" role="toolbar">
|
||||||
|
<div class="btn-group me-2">
|
||||||
|
<button type="button" (click)="discardChanges()" class="btn btn-secondary" [disabled]="loading || (isDirty$ | async) === false" i18n>Discard</button>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="submit" class="btn btn-primary" [disabled]="loading || !configForm.valid || (isDirty$ | async) === false" i18n>Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
145
src-ui/src/app/components/admin/config/config.component.spec.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
|
||||||
|
import { ConfigComponent } from './config.component'
|
||||||
|
import { ConfigService } from 'src/app/services/config.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { of, throwError } from 'rxjs'
|
||||||
|
import { OutputTypeConfig } from 'src/app/data/paperless-config'
|
||||||
|
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||||
|
import { BrowserModule } from '@angular/platform-browser'
|
||||||
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
|
import { TextComponent } from '../../common/input/text/text.component'
|
||||||
|
import { NumberComponent } from '../../common/input/number/number.component'
|
||||||
|
import { SwitchComponent } from '../../common/input/switch/switch.component'
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
|
import { SelectComponent } from '../../common/input/select/select.component'
|
||||||
|
import { FileComponent } from '../../common/input/file/file.component'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
|
||||||
|
describe('ConfigComponent', () => {
|
||||||
|
let component: ConfigComponent
|
||||||
|
let fixture: ComponentFixture<ConfigComponent>
|
||||||
|
let configService: ConfigService
|
||||||
|
let toastService: ToastService
|
||||||
|
let settingService: SettingsService
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [
|
||||||
|
ConfigComponent,
|
||||||
|
TextComponent,
|
||||||
|
SelectComponent,
|
||||||
|
NumberComponent,
|
||||||
|
SwitchComponent,
|
||||||
|
FileComponent,
|
||||||
|
PageHeaderComponent,
|
||||||
|
],
|
||||||
|
imports: [
|
||||||
|
HttpClientTestingModule,
|
||||||
|
BrowserModule,
|
||||||
|
NgbModule,
|
||||||
|
NgSelectModule,
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
configService = TestBed.inject(ConfigService)
|
||||||
|
toastService = TestBed.inject(ToastService)
|
||||||
|
settingService = TestBed.inject(SettingsService)
|
||||||
|
fixture = TestBed.createComponent(ConfigComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should load config on init, show error if necessary', () => {
|
||||||
|
const getSpy = jest.spyOn(configService, 'getConfig')
|
||||||
|
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
getSpy.mockReturnValueOnce(
|
||||||
|
throwError(() => new Error('Error getting config'))
|
||||||
|
)
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(getSpy).toHaveBeenCalled()
|
||||||
|
expect(errorSpy).toHaveBeenCalled()
|
||||||
|
getSpy.mockReturnValueOnce(
|
||||||
|
of({ output_type: OutputTypeConfig.PDF_A } as any)
|
||||||
|
)
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(component.initialConfig).toEqual({
|
||||||
|
output_type: OutputTypeConfig.PDF_A,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should save config, show error if necessary', () => {
|
||||||
|
const saveSpy = jest.spyOn(configService, 'saveConfig')
|
||||||
|
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
saveSpy.mockReturnValueOnce(
|
||||||
|
throwError(() => new Error('Error saving config'))
|
||||||
|
)
|
||||||
|
component.saveConfig()
|
||||||
|
expect(saveSpy).toHaveBeenCalled()
|
||||||
|
expect(errorSpy).toHaveBeenCalled()
|
||||||
|
saveSpy.mockReturnValueOnce(
|
||||||
|
of({ output_type: OutputTypeConfig.PDF_A } as any)
|
||||||
|
)
|
||||||
|
component.saveConfig()
|
||||||
|
expect(component.initialConfig).toEqual({
|
||||||
|
output_type: OutputTypeConfig.PDF_A,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support discard changes', () => {
|
||||||
|
component.initialConfig = { output_type: OutputTypeConfig.PDF_A2 } as any
|
||||||
|
component.configForm.patchValue({ output_type: OutputTypeConfig.PDF_A })
|
||||||
|
component.discardChanges()
|
||||||
|
expect(component.configForm.get('output_type').value).toEqual(
|
||||||
|
OutputTypeConfig.PDF_A2
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support JSON validation for e.g. user_args', () => {
|
||||||
|
component.configForm.patchValue({ user_args: '{ foo bar }' })
|
||||||
|
expect(component.errors).toEqual({ user_args: 'Invalid JSON' })
|
||||||
|
component.configForm.patchValue({ user_args: '{ "foo": "bar" }' })
|
||||||
|
expect(component.errors).toEqual({ user_args: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should upload file, show error if necessary', () => {
|
||||||
|
const uploadSpy = jest.spyOn(configService, 'uploadFile')
|
||||||
|
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
uploadSpy.mockReturnValueOnce(
|
||||||
|
throwError(() => new Error('Error uploading file'))
|
||||||
|
)
|
||||||
|
component.uploadFile(new File([], 'test.png'), 'app_logo')
|
||||||
|
expect(uploadSpy).toHaveBeenCalled()
|
||||||
|
expect(errorSpy).toHaveBeenCalled()
|
||||||
|
uploadSpy.mockReturnValueOnce(
|
||||||
|
of({ app_logo: 'https://example.com/logo/test.png' } as any)
|
||||||
|
)
|
||||||
|
component.uploadFile(new File([], 'test.png'), 'app_logo')
|
||||||
|
expect(component.initialConfig).toEqual({
|
||||||
|
app_logo: 'https://example.com/logo/test.png',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should refresh ui settings after save or upload', () => {
|
||||||
|
const saveSpy = jest.spyOn(configService, 'saveConfig')
|
||||||
|
const initSpy = jest.spyOn(settingService, 'initializeSettings')
|
||||||
|
saveSpy.mockReturnValueOnce(
|
||||||
|
of({ output_type: OutputTypeConfig.PDF_A } as any)
|
||||||
|
)
|
||||||
|
component.saveConfig()
|
||||||
|
expect(initSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
const uploadSpy = jest.spyOn(configService, 'uploadFile')
|
||||||
|
uploadSpy.mockReturnValueOnce(
|
||||||
|
of({ app_logo: 'https://example.com/logo/test.png' } as any)
|
||||||
|
)
|
||||||
|
component.uploadFile(new File([], 'test.png'), 'app_logo')
|
||||||
|
expect(initSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
189
src-ui/src/app/components/admin/config/config.component.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { Component, OnDestroy, OnInit } from '@angular/core'
|
||||||
|
import { AbstractControl, FormControl, FormGroup } from '@angular/forms'
|
||||||
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
|
Observable,
|
||||||
|
Subject,
|
||||||
|
Subscription,
|
||||||
|
first,
|
||||||
|
takeUntil,
|
||||||
|
} from 'rxjs'
|
||||||
|
import {
|
||||||
|
PaperlessConfigOptions,
|
||||||
|
ConfigCategory,
|
||||||
|
ConfigOption,
|
||||||
|
ConfigOptionType,
|
||||||
|
PaperlessConfig,
|
||||||
|
} from 'src/app/data/paperless-config'
|
||||||
|
import { ConfigService } from 'src/app/services/config.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
|
import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-config',
|
||||||
|
templateUrl: './config.component.html',
|
||||||
|
styleUrl: './config.component.scss',
|
||||||
|
})
|
||||||
|
export class ConfigComponent
|
||||||
|
extends ComponentWithPermissions
|
||||||
|
implements OnInit, OnDestroy, DirtyComponent
|
||||||
|
{
|
||||||
|
public readonly ConfigOptionType = ConfigOptionType
|
||||||
|
|
||||||
|
// generated dynamically
|
||||||
|
public configForm = new FormGroup({})
|
||||||
|
|
||||||
|
public errors = {}
|
||||||
|
|
||||||
|
get optionCategories(): string[] {
|
||||||
|
return Object.values(ConfigCategory)
|
||||||
|
}
|
||||||
|
|
||||||
|
getCategoryOptions(category: string): ConfigOption[] {
|
||||||
|
return PaperlessConfigOptions.filter((o) => o.category === category)
|
||||||
|
}
|
||||||
|
|
||||||
|
public loading: boolean = false
|
||||||
|
|
||||||
|
initialConfig: PaperlessConfig
|
||||||
|
store: BehaviorSubject<any>
|
||||||
|
storeSub: Subscription
|
||||||
|
isDirty$: Observable<boolean>
|
||||||
|
|
||||||
|
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private toastService: ToastService,
|
||||||
|
private settingsService: SettingsService
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
this.configForm.addControl('id', new FormControl())
|
||||||
|
PaperlessConfigOptions.forEach((option) => {
|
||||||
|
this.configForm.addControl(option.key, new FormControl())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loading = true
|
||||||
|
this.configService
|
||||||
|
.getConfig()
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe({
|
||||||
|
next: (config) => {
|
||||||
|
this.loading = false
|
||||||
|
this.initialize(config)
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.loading = false
|
||||||
|
this.toastService.showError($localize`Error retrieving config`, e)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// validate JSON inputs
|
||||||
|
PaperlessConfigOptions.filter(
|
||||||
|
(o) => o.type === ConfigOptionType.JSON
|
||||||
|
).forEach((option) => {
|
||||||
|
this.configForm
|
||||||
|
.get(option.key)
|
||||||
|
.addValidators((control: AbstractControl) => {
|
||||||
|
if (!control.value || control.value.toString().length === 0)
|
||||||
|
return null
|
||||||
|
try {
|
||||||
|
JSON.parse(control.value)
|
||||||
|
} catch (e) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
user_args: e,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
this.configForm.get(option.key).statusChanges.subscribe((status) => {
|
||||||
|
this.errors[option.key] =
|
||||||
|
status === 'INVALID' ? $localize`Invalid JSON` : null
|
||||||
|
})
|
||||||
|
this.configForm.get(option.key).updateValueAndValidity()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.unsubscribeNotifier.next(true)
|
||||||
|
this.unsubscribeNotifier.complete()
|
||||||
|
}
|
||||||
|
|
||||||
|
private initialize(config: PaperlessConfig) {
|
||||||
|
if (!this.store) {
|
||||||
|
this.store = new BehaviorSubject(config)
|
||||||
|
|
||||||
|
this.store
|
||||||
|
.asObservable()
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((state) => {
|
||||||
|
this.configForm.patchValue(state, { emitEvent: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable())
|
||||||
|
}
|
||||||
|
this.configForm.patchValue(config)
|
||||||
|
|
||||||
|
this.initialConfig = config
|
||||||
|
}
|
||||||
|
|
||||||
|
getDocsUrl(key: string) {
|
||||||
|
return `https://docs.paperless-ngx.com/configuration/#${key}`
|
||||||
|
}
|
||||||
|
|
||||||
|
public saveConfig() {
|
||||||
|
this.loading = true
|
||||||
|
this.configService
|
||||||
|
.saveConfig(this.configForm.value as PaperlessConfig)
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier), first())
|
||||||
|
.subscribe({
|
||||||
|
next: (config) => {
|
||||||
|
this.loading = false
|
||||||
|
this.initialize(config)
|
||||||
|
this.store.next(config)
|
||||||
|
this.settingsService.initializeSettings().subscribe()
|
||||||
|
this.toastService.showInfo($localize`Configuration updated`)
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.loading = false
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`An error occurred updating configuration`,
|
||||||
|
e
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public discardChanges() {
|
||||||
|
this.configForm.reset(this.initialConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
public uploadFile(file: File, key: string) {
|
||||||
|
this.loading = true
|
||||||
|
this.configService
|
||||||
|
.uploadFile(file, this.configForm.value['id'], key)
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier), first())
|
||||||
|
.subscribe({
|
||||||
|
next: (config) => {
|
||||||
|
this.loading = false
|
||||||
|
this.initialize(config)
|
||||||
|
this.store.next(config)
|
||||||
|
this.settingsService.initializeSettings().subscribe()
|
||||||
|
this.toastService.showInfo($localize`File successfully updated`)
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.loading = false
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`An error occurred uploading file`,
|
||||||
|
e
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@@ -1,30 +1,44 @@
|
|||||||
<pngx-page-header title="Logs" i18n-title>
|
<pngx-page-header
|
||||||
<div class="form-check form-switch" (click)="toggleAutoRefresh()">
|
title="Logs"
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="autoRefreshSwitch" [attr.checked]="autoRefreshInterval">
|
i18n-title
|
||||||
|
info="Review the log files for the application and for email checking."
|
||||||
|
i18n-info>
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="autoRefreshSwitch" (click)="toggleAutoRefresh()" [attr.checked]="autoRefreshInterval">
|
||||||
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
||||||
</div>
|
</div>
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeLog" (activeIdChange)="reloadLogs()" class="nav-tabs">
|
<ul ngbNav #nav="ngbNav" [(activeId)]="activeLog" (activeIdChange)="reloadLogs()" class="nav-tabs">
|
||||||
<li *ngFor="let logFile of logFiles" [ngbNavItem]="logFile">
|
@for (logFile of logFiles; track logFile) {
|
||||||
<a ngbNavLink>
|
<li [ngbNavItem]="logFile">
|
||||||
{{logFile}}.log
|
<a ngbNavLink>
|
||||||
</a>
|
{{logFile}}.log
|
||||||
</li>
|
</a>
|
||||||
<div *ngIf="isLoading || !logFiles.length" class="ps-2 d-flex align-items-center">
|
</li>
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
}
|
||||||
<ng-container *ngIf="!logFiles.length" i18n>Loading...</ng-container>
|
@if (isLoading || !logFiles.length) {
|
||||||
</div>
|
<div class="ps-2 d-flex align-items-center">
|
||||||
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
|
@if (!logFiles.length) {
|
||||||
|
<ng-container i18n>Loading...</ng-container>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div [ngbNavOutlet]="nav" class="mt-2"></div>
|
<div [ngbNavOutlet]="nav" class="mt-2"></div>
|
||||||
|
|
||||||
<div class="bg-dark p-3 text-light font-monospace log-container" #logContainer>
|
<div class="bg-dark p-3 text-light font-monospace log-container" #logContainer>
|
||||||
<div *ngIf="isLoading && logFiles.length">
|
@if (isLoading && logFiles.length) {
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
<div>
|
||||||
<ng-container i18n>Loading...</ng-container>
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
</div>
|
<ng-container i18n>Loading...</ng-container>
|
||||||
<p
|
</div>
|
||||||
class="m-0 p-0 log-entry-{{getLogLevel(log)}}"
|
}
|
||||||
*ngFor="let log of logs">{{log}}</p>
|
@for (log of logs; track log) {
|
||||||
|
<p
|
||||||
|
class="m-0 p-0 log-entry-{{getLogLevel(log)}}"
|
||||||
|
>{{log}}</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@@ -11,6 +11,7 @@ import { of, throwError } from 'rxjs'
|
|||||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
import { HttpClientTestingModule } 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'
|
||||||
|
|
||||||
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,7 +38,12 @@ describe('LogsComponent', () => {
|
|||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
declarations: [LogsComponent, PageHeaderComponent],
|
declarations: [LogsComponent, PageHeaderComponent],
|
||||||
providers: [],
|
providers: [],
|
||||||
imports: [HttpClientTestingModule, BrowserModule, NgbModule],
|
imports: [
|
||||||
|
HttpClientTestingModule,
|
||||||
|
BrowserModule,
|
||||||
|
NgbModule,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
logService = TestBed.inject(LogService)
|
logService = TestBed.inject(LogService)
|
||||||
|
@@ -2,9 +2,9 @@ import {
|
|||||||
Component,
|
Component,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
OnInit,
|
OnInit,
|
||||||
AfterViewChecked,
|
|
||||||
ViewChild,
|
ViewChild,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
|
ChangeDetectorRef,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { Subject, takeUntil } from 'rxjs'
|
import { Subject, takeUntil } from 'rxjs'
|
||||||
import { LogService } from 'src/app/services/rest/log.service'
|
import { LogService } from 'src/app/services/rest/log.service'
|
||||||
@@ -14,8 +14,11 @@ import { LogService } from 'src/app/services/rest/log.service'
|
|||||||
templateUrl: './logs.component.html',
|
templateUrl: './logs.component.html',
|
||||||
styleUrls: ['./logs.component.scss'],
|
styleUrls: ['./logs.component.scss'],
|
||||||
})
|
})
|
||||||
export class LogsComponent implements OnInit, AfterViewChecked, OnDestroy {
|
export class LogsComponent implements OnInit, OnDestroy {
|
||||||
constructor(private logService: LogService) {}
|
constructor(
|
||||||
|
private logService: LogService,
|
||||||
|
private changedetectorRef: ChangeDetectorRef
|
||||||
|
) {}
|
||||||
|
|
||||||
public logs: string[] = []
|
public logs: string[] = []
|
||||||
|
|
||||||
@@ -47,13 +50,10 @@ export class LogsComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
ngAfterViewChecked() {
|
|
||||||
this.scrollToBottom()
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.unsubscribeNotifier.next(true)
|
this.unsubscribeNotifier.next(true)
|
||||||
this.unsubscribeNotifier.complete()
|
this.unsubscribeNotifier.complete()
|
||||||
|
clearInterval(this.autoRefreshInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
reloadLogs() {
|
reloadLogs() {
|
||||||
@@ -65,6 +65,7 @@ export class LogsComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|||||||
next: (result) => {
|
next: (result) => {
|
||||||
this.logs = result
|
this.logs = result
|
||||||
this.isLoading = false
|
this.isLoading = false
|
||||||
|
this.scrollToBottom()
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.logs = []
|
this.logs = []
|
||||||
@@ -88,6 +89,7 @@ export class LogsComponent implements OnInit, AfterViewChecked, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
scrollToBottom(): void {
|
scrollToBottom(): void {
|
||||||
|
this.changedetectorRef.detectChanges()
|
||||||
this.logContainer?.nativeElement.scroll({
|
this.logContainer?.nativeElement.scroll({
|
||||||
top: this.logContainer.nativeElement.scrollHeight,
|
top: this.logContainer.nativeElement.scrollHeight,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
@@ -1,11 +1,36 @@
|
|||||||
<pngx-page-header title="Settings" i18n-title>
|
<pngx-page-header
|
||||||
<button class="btn btn-sm btn-outline-primary" (click)="tourService.start()"><ng-container i18n>Start tour</ng-container></button>
|
title="Settings"
|
||||||
<a *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }" class="btn btn-sm btn-primary ms-3" href="admin/" target="_blank">
|
i18n-title
|
||||||
|
info="Options to customize appearance, notifications, saved views and more. Settings apply to the <strong>current user only</strong>."
|
||||||
|
i18n-info
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
@if (permissionsService.isAdmin()) {
|
||||||
|
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
|
||||||
|
[disabled]="!systemStatus">
|
||||||
|
@if (!systemStatus) {
|
||||||
|
<div class="spinner-border spinner-border-sm me-1 h-75" role="status"></div>
|
||||||
|
} @else {
|
||||||
|
<i-bs class="me-2" name="card-checklist"></i-bs>
|
||||||
|
@if (systemStatusHasErrors) {
|
||||||
|
<span class="badge bg-body position-absolute top-0 start-100 translate-middle rounded-pill p-0">
|
||||||
|
<i-bs name="exclamation-circle-fill" class="text-danger" width="1.75em" height="1.75em"></i-bs>
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<span class="badge bg-body position-absolute top-0 start-100 translate-middle rounded-pill p-0">
|
||||||
|
<i-bs name="check-circle-fill" class="text-primary" width="1.75em" height="1.75em"></i-bs>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<ng-container i18n>System Status</ng-container>
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
<svg class="sidebaricon ms-1" fill="currentColor">
|
<i-bs name="arrow-up-right"></i-bs>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#arrow-up-right"/>
|
</a>
|
||||||
</svg>
|
}
|
||||||
</a>
|
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
|
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
|
||||||
@@ -24,10 +49,16 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
|
|
||||||
<select class="form-select" formControlName="displayLanguage">
|
<select class="form-select" formControlName="displayLanguage">
|
||||||
<option *ngFor="let lang of displayLanguageOptions" [ngValue]="lang.code">{{lang.name}}<span *ngIf="lang.code && currentLocale !== 'en-US'"> - {{lang.englishName}}</span></option>
|
@for (lang of displayLanguageOptions; track lang) {
|
||||||
|
<option [ngValue]="lang.code">{{lang.name}}@if (lang.code && currentLocale !== 'en-US') {
|
||||||
|
<span> - {{lang.englishName}}</span>
|
||||||
|
}</option>
|
||||||
|
}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<small *ngIf="displayLanguageIsDirty" class="form-text text-primary" i18n>You need to reload the page after applying a new language.</small>
|
@if (displayLanguageIsDirty) {
|
||||||
|
<small class="form-text text-primary" i18n>You need to reload the page after applying a new language.</small>
|
||||||
|
}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,7 +70,11 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
|
|
||||||
<select class="form-select" formControlName="dateLocale">
|
<select class="form-select" formControlName="dateLocale">
|
||||||
<option *ngFor="let lang of dateLocaleOptions" [ngValue]="lang.code">{{lang.name}}<span *ngIf="lang.code"> - {{today | customDate:'shortDate':null:lang.code}}</span></option>
|
@for (lang of dateLocaleOptions; track lang) {
|
||||||
|
<option [ngValue]="lang.code">{{lang.name}}@if (lang.code) {
|
||||||
|
<span> - {{today | customDate:'shortDate':null:lang.code}}</span>
|
||||||
|
}</option>
|
||||||
|
}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -125,9 +160,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-2">
|
<div class="col-2">
|
||||||
<button class="btn btn-link btn-sm pt-2 ps-0" [disabled]="!this.settingsForm.get('themeColor').value" (click)="clearThemeColor()">
|
<button class="btn btn-link btn-sm pt-2 ps-0" [disabled]="!this.settingsForm.get('themeColor').value" (click)="clearThemeColor()">
|
||||||
<svg fill="currentColor" class="buttonicon-sm me-1">
|
<i-bs width="1em" height="1em" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
|
||||||
</svg><ng-container i18n>Reset</ng-container>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,16 +170,24 @@
|
|||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="offset-md-3 col">
|
<div class="offset-md-3 col">
|
||||||
<p i18n>
|
<p i18n>
|
||||||
Update checking works by pinging the public <a href="https://api.github.com/repos/paperless-ngx/paperless-ngx/releases/latest" target="_blank" rel="noopener noreferrer">GitHub API</a> for the latest release to determine whether a new version is available.<br/>
|
Update checking works by pinging the public <a href="https://api.github.com/repos/paperless-ngx/paperless-ngx/releases/latest" target="_blank" rel="noopener noreferrer">GitHub API</a> for the latest release to determine whether a new version is available.<br/>
|
||||||
Actual updating of the app must still be performed manually.
|
Actual updating of the app must still be performed manually.
|
||||||
</p>
|
</p>
|
||||||
<p i18n>
|
<p i18n>
|
||||||
<em>No tracking data is collected by the app in any way.</em>
|
<em>No tracking data is collected by the app in any way.</em>
|
||||||
</p>
|
</p>
|
||||||
<pngx-input-check i18n-title title="Enable update checking" formControlName="updateCheckingEnabled"></pngx-input-check>
|
<pngx-input-check i18n-title title="Enable update checking" formControlName="updateCheckingEnabled"></pngx-input-check>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h4 class="mt-4" i18n>Document editing</h4>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="offset-md-3 col">
|
||||||
|
<pngx-input-check i18n-title title="Automatically remove inbox tag(s) on save" formControlName="documentEditingRemoveInboxTags"></pngx-input-check>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h4 class="mt-4" i18n>Bulk editing</h4>
|
<h4 class="mt-4" i18n>Bulk editing</h4>
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
@@ -156,6 +197,14 @@
|
|||||||
</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="Search database only (do not include advanced search results)" formControlName="searchDbOnly"></pngx-input-check>
|
||||||
|
</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">
|
||||||
@@ -176,8 +225,8 @@
|
|||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="offset-md-3 col">
|
<div class="offset-md-3 col">
|
||||||
<p i18n>
|
<p i18n>
|
||||||
Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI
|
Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
@@ -279,40 +328,71 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4 i18n>Views</h4>
|
<h4 i18n>Views</h4>
|
||||||
<div formGroupName="savedViews">
|
<ul class="list-group" formGroupName="savedViews">
|
||||||
|
|
||||||
<div *ngFor="let view of savedViews" [formGroupName]="view.id" class="row">
|
@for (view of savedViews; track view) {
|
||||||
<div class="mb-3 col">
|
<li class="list-group-item py-3">
|
||||||
<label class="form-label" for="name_{{view.id}}" i18n>Name</label>
|
<div [formGroupName]="view.id" class="row">
|
||||||
<input type="text" class="form-control" formControlName="name" id="name_{{view.id}}">
|
<div class="row">
|
||||||
</div>
|
<div class="col">
|
||||||
|
<pngx-input-text title="Name" formControlName="name"></pngx-input-text>
|
||||||
<div class="mb-2 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">
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check form-switch">
|
<div class="col">
|
||||||
<input type="checkbox" class="form-check-input" id="show_in_sidebar_{{view.id}}" formControlName="show_in_sidebar">
|
<div class="form-check form-switch mt-3">
|
||||||
<label class="form-check-label" for="show_in_sidebar_{{view.id}}" i18n>Show in sidebar</label>
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input type="checkbox" class="form-check-input" id="show_in_sidebar_{{view.id}}" formControlName="show_in_sidebar">
|
||||||
|
<label class="form-check-label" for="show_in_sidebar_{{view.id}}" i18n>Show in sidebar</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
|
||||||
|
<pngx-confirm-button
|
||||||
|
label="Delete"
|
||||||
|
i18n-label
|
||||||
|
(confirm)="deleteSavedView(view)"
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }"
|
||||||
|
buttonClasses="btn-sm btn-outline-danger form-control"
|
||||||
|
iconName="trash">
|
||||||
|
</pngx-confirm-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
<div class="mb-2 col-auto">
|
<div class="col">
|
||||||
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
|
<pngx-input-number i18n-title title="Documents page size" [showAdd]="false" formControlName="page_size"></pngx-input-number>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger form-control" (click)="deleteSavedView(view)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }" i18n>Delete</button>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
<div *ngIf="savedViews && savedViews.length === 0" i18n>No saved views defined.</div>
|
@if (savedViews && savedViews.length === 0) {
|
||||||
|
<li class="list-group-item">
|
||||||
|
<div i18n>No saved views defined.</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
<div *ngIf="!savedViews">
|
@if (!savedViews) {
|
||||||
|
<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>
|
||||||
@@ -320,5 +400,6 @@
|
|||||||
|
|
||||||
<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" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" [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>
|
||||||
|
@@ -9,12 +9,14 @@ import {
|
|||||||
NgbModule,
|
NgbModule,
|
||||||
NgbAlertModule,
|
NgbAlertModule,
|
||||||
NgbNavLink,
|
NgbNavLink,
|
||||||
|
NgbModal,
|
||||||
|
NgbModalModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
@@ -37,6 +39,17 @@ import { TextComponent } from '../../common/input/text/text.component'
|
|||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { SettingsComponent } from './settings.component'
|
import { SettingsComponent } from './settings.component'
|
||||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
||||||
|
import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
|
||||||
|
import { SystemStatusService } from 'src/app/services/system-status.service'
|
||||||
|
import {
|
||||||
|
SystemStatus,
|
||||||
|
InstallType,
|
||||||
|
SystemStatusItemStatus,
|
||||||
|
} 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'
|
||||||
|
|
||||||
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 },
|
||||||
@@ -63,6 +76,8 @@ describe('SettingsComponent', () => {
|
|||||||
let userService: UserService
|
let userService: UserService
|
||||||
let permissionsService: PermissionsService
|
let permissionsService: PermissionsService
|
||||||
let groupService: GroupService
|
let groupService: GroupService
|
||||||
|
let modalService: NgbModal
|
||||||
|
let systemStatusService: SystemStatusService
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -82,6 +97,8 @@ describe('SettingsComponent', () => {
|
|||||||
PermissionsUserComponent,
|
PermissionsUserComponent,
|
||||||
PermissionsGroupComponent,
|
PermissionsGroupComponent,
|
||||||
IfOwnerDirective,
|
IfOwnerDirective,
|
||||||
|
ConfirmButtonComponent,
|
||||||
|
DragDropSelectComponent,
|
||||||
],
|
],
|
||||||
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
|
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
|
||||||
imports: [
|
imports: [
|
||||||
@@ -92,6 +109,9 @@ describe('SettingsComponent', () => {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgbAlertModule,
|
NgbAlertModule,
|
||||||
NgSelectModule,
|
NgSelectModule,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
NgbModalModule,
|
||||||
|
DragDropModule,
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
@@ -103,6 +123,8 @@ describe('SettingsComponent', () => {
|
|||||||
settingsService.currentUser = users[0]
|
settingsService.currentUser = users[0]
|
||||||
userService = TestBed.inject(UserService)
|
userService = TestBed.inject(UserService)
|
||||||
permissionsService = TestBed.inject(PermissionsService)
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
|
modalService = TestBed.inject(NgbModal)
|
||||||
|
systemStatusService = TestBed.inject(SystemStatusService)
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
jest
|
jest
|
||||||
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||||
@@ -138,7 +160,7 @@ describe('SettingsComponent', () => {
|
|||||||
of({
|
of({
|
||||||
all: savedViews.map((v) => v.id),
|
all: savedViews.map((v) => v.id),
|
||||||
count: savedViews.length,
|
count: savedViews.length,
|
||||||
results: (savedViews as PaperlessSavedView[]).concat([]),
|
results: (savedViews as SavedView[]).concat([]),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -226,9 +248,7 @@ describe('SettingsComponent', () => {
|
|||||||
savedViewPatchSpy.mockClear()
|
savedViewPatchSpy.mockClear()
|
||||||
|
|
||||||
// succeed saved views
|
// succeed saved views
|
||||||
savedViewPatchSpy.mockReturnValueOnce(
|
savedViewPatchSpy.mockReturnValueOnce(of(savedViews as SavedView[]))
|
||||||
of(savedViews as PaperlessSavedView[])
|
|
||||||
)
|
|
||||||
component.saveSettings()
|
component.saveSettings()
|
||||||
expect(toastErrorSpy).not.toHaveBeenCalled()
|
expect(toastErrorSpy).not.toHaveBeenCalled()
|
||||||
expect(savedViewPatchSpy).toHaveBeenCalled()
|
expect(savedViewPatchSpy).toHaveBeenCalled()
|
||||||
@@ -289,7 +309,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(24)
|
expect(setSpy).toHaveBeenCalledTimes(26)
|
||||||
|
|
||||||
// succeed
|
// succeed
|
||||||
storeSpy.mockReturnValueOnce(of(true))
|
storeSpy.mockReturnValueOnce(of(true))
|
||||||
@@ -307,10 +327,15 @@ describe('SettingsComponent', () => {
|
|||||||
component.store.getValue()['displayLanguage'] = 'en-US'
|
component.store.getValue()['displayLanguage'] = 'en-US'
|
||||||
component.store.getValue()['updateCheckingEnabled'] = false
|
component.store.getValue()['updateCheckingEnabled'] = false
|
||||||
component.settingsForm.value.displayLanguage = 'en-GB'
|
component.settingsForm.value.displayLanguage = 'en-GB'
|
||||||
component.settingsForm.value.updateCheckingEnabled = true
|
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValueOnce(of(true))
|
|
||||||
component.saveSettings()
|
component.saveSettings()
|
||||||
expect(toast.actionName).toEqual('Reload now')
|
expect(toast.actionName).toEqual('Reload now')
|
||||||
|
|
||||||
|
component.settingsForm.value.updateCheckingEnabled = true
|
||||||
|
component.saveSettings()
|
||||||
|
|
||||||
|
expect(toast.actionName).toEqual('Reload now')
|
||||||
|
toast.action()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should allow setting theme color, visually apply change immediately but not save', () => {
|
it('should allow setting theme color, visually apply change immediately but not save', () => {
|
||||||
@@ -335,7 +360,7 @@ describe('SettingsComponent', () => {
|
|||||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
const deleteSpy = jest.spyOn(savedViewService, 'delete')
|
const deleteSpy = jest.spyOn(savedViewService, 'delete')
|
||||||
deleteSpy.mockReturnValue(of(true))
|
deleteSpy.mockReturnValue(of(true))
|
||||||
component.deleteSavedView(savedViews[0] as PaperlessSavedView)
|
component.deleteSavedView(savedViews[0] as SavedView)
|
||||||
expect(deleteSpy).toHaveBeenCalled()
|
expect(deleteSpy).toHaveBeenCalled()
|
||||||
expect(toastSpy).toHaveBeenCalledWith(
|
expect(toastSpy).toHaveBeenCalledWith(
|
||||||
`Saved view "${savedViews[0].name}" deleted.`
|
`Saved view "${savedViews[0].name}" deleted.`
|
||||||
@@ -365,4 +390,62 @@ describe('SettingsComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(toastErrorSpy).toBeCalled()
|
expect(toastErrorSpy).toBeCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should load system status on initialize, show errors if needed', () => {
|
||||||
|
const status: SystemStatus = {
|
||||||
|
pngx_version: '2.4.3',
|
||||||
|
server_os: 'macOS-14.1.1-arm64-arm-64bit',
|
||||||
|
install_type: InstallType.BareMetal,
|
||||||
|
storage: { total: 494384795648, available: 13573525504 },
|
||||||
|
database: {
|
||||||
|
type: 'sqlite',
|
||||||
|
url: '/paperless-ngx/data/db.sqlite3',
|
||||||
|
status: SystemStatusItemStatus.ERROR,
|
||||||
|
error: null,
|
||||||
|
migration_status: {
|
||||||
|
latest_migration: 'socialaccount.0006_alter_socialaccount_extra_data',
|
||||||
|
unapplied_migrations: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
redis_url: 'redis://localhost:6379',
|
||||||
|
redis_status: SystemStatusItemStatus.ERROR,
|
||||||
|
redis_error:
|
||||||
|
'Error 61 connecting to localhost:6379. Connection refused.',
|
||||||
|
celery_status: SystemStatusItemStatus.ERROR,
|
||||||
|
index_status: SystemStatusItemStatus.OK,
|
||||||
|
index_last_modified: new Date().toISOString(),
|
||||||
|
index_error: null,
|
||||||
|
classifier_status: SystemStatusItemStatus.OK,
|
||||||
|
classifier_last_trained: new Date().toISOString(),
|
||||||
|
classifier_error: null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
|
||||||
|
jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true)
|
||||||
|
completeSetup()
|
||||||
|
expect(component['systemStatus']).toEqual(status) // private
|
||||||
|
expect(component.systemStatusHasErrors).toBeTruthy()
|
||||||
|
// coverage
|
||||||
|
component['systemStatus'].database.status = SystemStatusItemStatus.OK
|
||||||
|
component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK
|
||||||
|
component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK
|
||||||
|
expect(component.systemStatusHasErrors).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should open system status dialog', () => {
|
||||||
|
const modalOpenSpy = jest.spyOn(modalService, 'open')
|
||||||
|
completeSetup()
|
||||||
|
component.showSystemStatus()
|
||||||
|
expect(modalOpenSpy).toHaveBeenCalledWith(SystemStatusDialogComponent, {
|
||||||
|
size: 'xl',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support reset', () => {
|
||||||
|
completeSetup()
|
||||||
|
component.settingsForm.get('themeColor').setValue('#ff0000')
|
||||||
|
component.reset()
|
||||||
|
expect(component.settingsForm.get('themeColor').value).toEqual('')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@@ -9,7 +9,11 @@ import {
|
|||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormGroup, FormControl } from '@angular/forms'
|
import { FormGroup, FormControl } from '@angular/forms'
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
import { NgbNavChangeEvent } from '@ng-bootstrap/ng-bootstrap'
|
import {
|
||||||
|
NgbModal,
|
||||||
|
NgbModalRef,
|
||||||
|
NgbNavChangeEvent,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms'
|
import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms'
|
||||||
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import {
|
import {
|
||||||
@@ -21,10 +25,10 @@ import {
|
|||||||
takeUntil,
|
takeUntil,
|
||||||
tap,
|
tap,
|
||||||
} from 'rxjs'
|
} from 'rxjs'
|
||||||
import { PaperlessGroup } from 'src/app/data/paperless-group'
|
import { Group } from 'src/app/data/group'
|
||||||
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { PaperlessUser } from 'src/app/data/paperless-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 {
|
||||||
PermissionsService,
|
PermissionsService,
|
||||||
@@ -40,6 +44,13 @@ import {
|
|||||||
} from 'src/app/services/settings.service'
|
} from 'src/app/services/settings.service'
|
||||||
import { ToastService, Toast } from 'src/app/services/toast.service'
|
import { ToastService, Toast } from 'src/app/services/toast.service'
|
||||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
|
import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
|
||||||
|
import { SystemStatusService } from 'src/app/services/system-status.service'
|
||||||
|
import {
|
||||||
|
SystemStatusItemStatus,
|
||||||
|
SystemStatus,
|
||||||
|
} from 'src/app/data/system-status'
|
||||||
|
import { DisplayMode } from 'src/app/data/document'
|
||||||
|
|
||||||
enum SettingsNavIDs {
|
enum SettingsNavIDs {
|
||||||
General = 1,
|
General = 1,
|
||||||
@@ -48,6 +59,12 @@ enum SettingsNavIDs {
|
|||||||
SavedViews = 4,
|
SavedViews = 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const systemLanguage = { code: '', name: $localize`Use system language` }
|
||||||
|
const systemDateFormat = {
|
||||||
|
code: '',
|
||||||
|
name: $localize`Use date format of display language`,
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-settings',
|
selector: 'pngx-settings',
|
||||||
templateUrl: './settings.component.html',
|
templateUrl: './settings.component.html',
|
||||||
@@ -57,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({})
|
||||||
|
|
||||||
@@ -82,6 +99,8 @@ export class SettingsComponent
|
|||||||
defaultPermsViewGroups: new FormControl(null),
|
defaultPermsViewGroups: new FormControl(null),
|
||||||
defaultPermsEditUsers: new FormControl(null),
|
defaultPermsEditUsers: new FormControl(null),
|
||||||
defaultPermsEditGroups: new FormControl(null),
|
defaultPermsEditGroups: new FormControl(null),
|
||||||
|
documentEditingRemoveInboxTags: new FormControl(null),
|
||||||
|
searchDbOnly: new FormControl(null),
|
||||||
|
|
||||||
notificationsConsumerNewDocument: new FormControl(null),
|
notificationsConsumerNewDocument: new FormControl(null),
|
||||||
notificationsConsumerSuccess: new FormControl(null),
|
notificationsConsumerSuccess: new FormControl(null),
|
||||||
@@ -92,7 +111,11 @@ export class SettingsComponent
|
|||||||
savedViews: this.savedViewGroup,
|
savedViews: this.savedViewGroup,
|
||||||
})
|
})
|
||||||
|
|
||||||
savedViews: PaperlessSavedView[]
|
savedViews: SavedView[]
|
||||||
|
SettingsNavIDs = SettingsNavIDs
|
||||||
|
get displayFields() {
|
||||||
|
return this.settings.allDisplayFields
|
||||||
|
}
|
||||||
|
|
||||||
store: BehaviorSubject<any>
|
store: BehaviorSubject<any>
|
||||||
storeSub: Subscription
|
storeSub: Subscription
|
||||||
@@ -101,8 +124,20 @@ export class SettingsComponent
|
|||||||
unsubscribeNotifier: Subject<any> = new Subject()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
savePending: boolean = false
|
savePending: boolean = false
|
||||||
|
|
||||||
users: PaperlessUser[]
|
users: User[]
|
||||||
groups: PaperlessGroup[]
|
groups: Group[]
|
||||||
|
|
||||||
|
public systemStatus: SystemStatus
|
||||||
|
|
||||||
|
get systemStatusHasErrors(): boolean {
|
||||||
|
return (
|
||||||
|
this.systemStatus.database.status === SystemStatusItemStatus.ERROR ||
|
||||||
|
this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR ||
|
||||||
|
this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR ||
|
||||||
|
this.systemStatus.tasks.index_status === SystemStatusItemStatus.ERROR ||
|
||||||
|
this.systemStatus.tasks.classifier_status === SystemStatusItemStatus.ERROR
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
get computedDateLocale(): string {
|
get computedDateLocale(): string {
|
||||||
return (
|
return (
|
||||||
@@ -124,7 +159,9 @@ export class SettingsComponent
|
|||||||
private usersService: UserService,
|
private usersService: UserService,
|
||||||
private groupsService: GroupService,
|
private groupsService: GroupService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
public permissionsService: PermissionsService
|
public permissionsService: PermissionsService,
|
||||||
|
private modalService: NgbModal,
|
||||||
|
private systemStatusService: SystemStatusService
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
this.settings.settingsSaved.subscribe(() => {
|
this.settings.settingsSaved.subscribe(() => {
|
||||||
@@ -265,6 +302,10 @@ export class SettingsComponent
|
|||||||
defaultPermsEditGroups: this.settings.get(
|
defaultPermsEditGroups: this.settings.get(
|
||||||
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS
|
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS
|
||||||
),
|
),
|
||||||
|
documentEditingRemoveInboxTags: this.settings.get(
|
||||||
|
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS
|
||||||
|
),
|
||||||
|
searchDbOnly: this.settings.get(SETTINGS_KEYS.SEARCH_DB_ONLY),
|
||||||
savedViews: {},
|
savedViews: {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,6 +347,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(),
|
||||||
@@ -314,6 +358,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([]),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -350,6 +397,12 @@ export class SettingsComponent
|
|||||||
// prevents loss of unsaved changes
|
// prevents loss of unsaved changes
|
||||||
this.settingsForm.patchValue(currentFormValue)
|
this.settingsForm.patchValue(currentFormValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.permissionsService.isAdmin()) {
|
||||||
|
this.systemStatusService.get().subscribe((status) => {
|
||||||
|
this.systemStatus = status
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private emptyGroup(group: FormGroup) {
|
private emptyGroup(group: FormGroup) {
|
||||||
@@ -357,12 +410,12 @@ export class SettingsComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didnt save
|
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didn't save
|
||||||
this.storeSub && this.storeSub.unsubscribe()
|
this.storeSub && this.storeSub.unsubscribe()
|
||||||
this.settings.organizingSidebarSavedViews = false
|
this.settings.organizingSidebarSavedViews = false
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteSavedView(savedView: PaperlessSavedView) {
|
deleteSavedView(savedView: SavedView) {
|
||||||
this.savedViewService.delete(savedView).subscribe(() => {
|
this.savedViewService.delete(savedView).subscribe(() => {
|
||||||
this.savedViewGroup.removeControl(savedView.id.toString())
|
this.savedViewGroup.removeControl(savedView.id.toString())
|
||||||
this.savedViews.splice(this.savedViews.indexOf(savedView), 1)
|
this.savedViews.splice(this.savedViews.indexOf(savedView), 1)
|
||||||
@@ -478,6 +531,14 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS,
|
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS,
|
||||||
this.settingsForm.value.defaultPermsEditGroups
|
this.settingsForm.value.defaultPermsEditGroups
|
||||||
)
|
)
|
||||||
|
this.settings.set(
|
||||||
|
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS,
|
||||||
|
this.settingsForm.value.documentEditingRemoveInboxTags
|
||||||
|
)
|
||||||
|
this.settings.set(
|
||||||
|
SETTINGS_KEYS.SEARCH_DB_ONLY,
|
||||||
|
this.settingsForm.value.searchDbOnly
|
||||||
|
)
|
||||||
this.settings.setLanguage(this.settingsForm.value.displayLanguage)
|
this.settings.setLanguage(this.settingsForm.value.displayLanguage)
|
||||||
this.settings
|
this.settings
|
||||||
.storeSettings()
|
.storeSettings()
|
||||||
@@ -486,8 +547,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,
|
||||||
@@ -512,15 +573,11 @@ export class SettingsComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get displayLanguageOptions(): LanguageOption[] {
|
get displayLanguageOptions(): LanguageOption[] {
|
||||||
return [{ code: '', name: $localize`Use system language` }].concat(
|
return [systemLanguage].concat(this.settings.getLanguageOptions())
|
||||||
this.settings.getLanguageOptions()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get dateLocaleOptions(): LanguageOption[] {
|
get dateLocaleOptions(): LanguageOption[] {
|
||||||
return [
|
return [systemDateFormat].concat(this.settings.getDateLocaleOptions())
|
||||||
{ code: '', name: $localize`Use date format of display language` },
|
|
||||||
].concat(this.settings.getDateLocaleOptions())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get today() {
|
get today() {
|
||||||
@@ -529,7 +586,7 @@ export class SettingsComponent
|
|||||||
|
|
||||||
saveSettings() {
|
saveSettings() {
|
||||||
// only patch views that have actually changed
|
// only patch views that have actually changed
|
||||||
const changed: PaperlessSavedView[] = []
|
const changed: SavedView[] = []
|
||||||
Object.values(this.savedViewGroup.controls)
|
Object.values(this.savedViewGroup.controls)
|
||||||
.filter((g: FormGroup) => !g.pristine)
|
.filter((g: FormGroup) => !g.pristine)
|
||||||
.forEach((group: FormGroup) => {
|
.forEach((group: FormGroup) => {
|
||||||
@@ -552,7 +609,21 @@ export class SettingsComponent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.settingsForm.patchValue(this.store.getValue())
|
||||||
|
}
|
||||||
|
|
||||||
clearThemeColor() {
|
clearThemeColor() {
|
||||||
this.settingsForm.get('themeColor').patchValue('')
|
this.settingsForm.get('themeColor').patchValue('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showSystemStatus() {
|
||||||
|
const modal: NgbModalRef = this.modalService.open(
|
||||||
|
SystemStatusDialogComponent,
|
||||||
|
{
|
||||||
|
size: 'xl',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
modal.componentInstance.status = this.systemStatus
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,26 +1,27 @@
|
|||||||
<pngx-page-header title="File Tasks" i18n-title>
|
<pngx-page-header
|
||||||
|
title="File Tasks"
|
||||||
|
i18n-title
|
||||||
|
info="File Tasks shows you documents that have been consumed, are waiting to be, or may have failed during the process."
|
||||||
|
i18n-info
|
||||||
|
>
|
||||||
<div class="btn-toolbar col col-md-auto align-items-center">
|
<div class="btn-toolbar col col-md-auto align-items-center">
|
||||||
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0">
|
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0">
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<i-bs name="x"></i-bs> <ng-container i18n>Clear selection</ng-container>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
|
||||||
</svg> <ng-container i18n>Clear selection</ng-container>
|
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-primary me-4" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="tasksService.total === 0">
|
<button class="btn btn-sm btn-outline-primary me-4" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="tasksService.total === 0">
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<i-bs name="check2-all"></i-bs> {{dismissButtonText}}
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#check2-all"/>
|
|
||||||
</svg> <ng-container i18n>{{dismissButtonText}}</ng-container>
|
|
||||||
</button>
|
</button>
|
||||||
<div class="form-check form-switch mb-0" (click)="toggleAutoRefresh()">
|
<div class="form-check form-switch mb-0">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="autoRefreshSwitch" [attr.checked]="autoRefreshInterval">
|
<input class="form-check-input" type="checkbox" role="switch" id="autoRefreshSwitch" (click)="toggleAutoRefresh()" [attr.checked]="autoRefreshInterval">
|
||||||
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<ng-container *ngIf="!tasksService.completedFileTasks && tasksService.loading">
|
@if (!tasksService.completedFileTasks && tasksService.loading) {
|
||||||
<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>
|
||||||
</ng-container>
|
}
|
||||||
|
|
||||||
<ng-template let-tasks="tasks" #tasksTemplate>
|
<ng-template let-tasks="tasks" #tasksTemplate>
|
||||||
<table class="table table-striped align-middle border shadow-sm">
|
<table class="table table-striped align-middle border shadow-sm">
|
||||||
@@ -28,99 +29,124 @@
|
|||||||
<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>
|
||||||
<th scope="col" i18n>Name</th>
|
<th scope="col" i18n>Name</th>
|
||||||
<th scope="col" class="d-none d-lg-table-cell" i18n>Created</th>
|
<th scope="col" class="d-none d-lg-table-cell" i18n>Created</th>
|
||||||
<th scope="col" class="d-none d-lg-table-cell" *ngIf="activeTab !== 'started' && activeTab !== 'queued'" i18n>Results</th>
|
@if (activeTab !== 'started' && activeTab !== 'queued') {
|
||||||
|
<th scope="col" class="d-none d-lg-table-cell" i18n>Results</th>
|
||||||
|
}
|
||||||
<th scope="col" class="d-table-cell d-lg-none" i18n>Info</th>
|
<th scope="col" class="d-table-cell d-lg-none" i18n>Info</th>
|
||||||
<th scope="col" i18n>Actions</th>
|
<th scope="col" i18n>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<ng-container *ngFor="let task of tasks | slice: (page-1) * pageSize : page * pageSize">
|
@for (task of tasks | slice: (page-1) * pageSize : page * pageSize; track task) {
|
||||||
<tr (click)="toggleSelected(task, $event); $event.stopPropagation();">
|
<tr (click)="toggleSelected(task, $event); $event.stopPropagation();">
|
||||||
<td>
|
<td>
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input type="checkbox" class="form-check-input" id="task{{task.id}}" [checked]="selectedTasks.has(task.id)" (click)="toggleSelected(task, $event); $event.stopPropagation();">
|
<input type="checkbox" class="form-check-input" id="task{{task.id}}" [checked]="selectedTasks.has(task.id)" (click)="toggleSelected(task, $event); $event.stopPropagation();">
|
||||||
<label class="form-check-label" for="task{{task.id}}"></label>
|
<label class="form-check-label" for="task{{task.id}}"></label>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="overflow-auto name-col">{{ task.task_file_name }}</td>
|
<td class="overflow-auto name-col">{{ task.task_file_name }}</td>
|
||||||
<td class="d-none d-lg-table-cell">{{ task.date_created | customDate:'short' }}</td>
|
<td class="d-none d-lg-table-cell">{{ task.date_created | customDate:'short' }}</td>
|
||||||
<td class="d-none d-lg-table-cell" *ngIf="activeTab !== 'started' && activeTab !== 'queued'">
|
@if (activeTab !== 'started' && activeTab !== 'queued') {
|
||||||
<div *ngIf="task.result?.length > 50" class="result" (click)="expandTask(task); $event.stopPropagation();"
|
<td class="d-none d-lg-table-cell">
|
||||||
[ngbPopover]="resultPopover" popoverClass="shadow small mobile" triggers="mouseenter:mouseleave" container="body">
|
@if (task.result?.length > 50) {
|
||||||
<span class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result | slice:0:50 }}…</span>
|
<div class="result" (click)="expandTask(task); $event.stopPropagation();"
|
||||||
</div>
|
[ngbPopover]="resultPopover" popoverClass="shadow small mobile" triggers="mouseenter:mouseleave" container="body">
|
||||||
<span *ngIf="task.result?.length <= 50" class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result }}</span>
|
<span class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result | slice:0:50 }}…</span>
|
||||||
<ng-template #resultPopover>
|
</div>
|
||||||
<pre class="small mb-0">{{ task.result | slice:0:300 }}<ng-container *ngIf="task.result.length > 300">…</ng-container></pre>
|
}
|
||||||
<ng-container *ngIf="task.result?.length > 300"><br/><em>(<ng-container i18n>click for full output</ng-container>)</em></ng-container>
|
@if (task.result?.length <= 50) {
|
||||||
</ng-template>
|
<span class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result }}</span>
|
||||||
</td>
|
}
|
||||||
<td class="d-lg-none">
|
<ng-template #resultPopover>
|
||||||
<button class="btn btn-link" (click)="expandTask(task); $event.stopPropagation();">
|
<pre class="small mb-0">{{ task.result | slice:0:300 }}@if (task.result.length > 300) {
|
||||||
<svg fill="currentColor" class="" width="1.2em" height="1.2em" style="vertical-align: text-top;" viewBox="0 0 16 16">
|
…
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#info-circle" />
|
}</pre>
|
||||||
</svg>
|
@if (task.result?.length > 300) {
|
||||||
</button>
|
<br/><em>(<ng-container i18n>click for full output</ng-container>)</em>
|
||||||
</td>
|
}
|
||||||
<td scope="row">
|
</ng-template>
|
||||||
<div class="btn-group" role="group">
|
</td>
|
||||||
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
|
}
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<td class="d-lg-none">
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#check"/>
|
<button class="btn btn-link" (click)="expandTask(task); $event.stopPropagation();">
|
||||||
</svg> <ng-container i18n>Dismiss</ng-container>
|
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
</td>
|
||||||
<button *ngIf="task.related_document" class="btn btn-sm btn-outline-primary" (click)="dismissAndGo(task); $event.stopPropagation();">
|
<td scope="row">
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<div class="btn-group" role="group">
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#file-text"/>
|
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
|
||||||
</svg> <ng-container i18n>Open Document</ng-container>
|
<i-bs name="check"></i-bs> <ng-container i18n>Dismiss</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</ng-container>
|
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||||
</div>
|
@if (task.related_document) {
|
||||||
</td>
|
<button class="btn btn-sm btn-outline-primary" (click)="dismissAndGo(task); $event.stopPropagation();">
|
||||||
</tr>
|
<i-bs name="file-text"></i-bs> <ng-container i18n>Open Document</ng-container>
|
||||||
<tr>
|
</button>
|
||||||
<td class="p-0" [class.border-0]="expandedTask !== task.id" colspan="5">
|
}
|
||||||
<pre #collapse="ngbCollapse" [ngbCollapse]="expandedTask !== task.id" class="small mb-0"><div class="small p-1 p-lg-3 ms-lg-3">{{ task.result }}</div></pre>
|
</ng-container>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</td>
|
||||||
</ng-container>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="p-0" [class.border-0]="expandedTask !== task.id" colspan="5">
|
||||||
|
<pre #collapse="ngbCollapse" [ngbCollapse]="expandedTask !== task.id" class="small mb-0"><div class="small p-1 p-lg-3 ms-lg-3">{{ task.result }}</div></pre>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class="pb-3 d-sm-flex justify-content-between align-items-center">
|
<div class="pb-3 d-sm-flex justify-content-between align-items-center">
|
||||||
<div class="pb-2 pb-sm-0" i18n *ngIf="tasks.length > 0">{tasks.length, plural, =1 {One {{this.activeTabLocalized}} task} other {{{tasks.length || 0}} total {{this.activeTabLocalized}} tasks}}</div>
|
@if (tasks.length > 0) {
|
||||||
<ngb-pagination *ngIf="tasks.length > pageSize" [(page)]="page" [pageSize]="pageSize" [collectionSize]="tasks.length" maxSize="8" size="sm"></ngb-pagination>
|
<div class="pb-2 pb-sm-0">
|
||||||
|
<ng-container i18n>{tasks.length, plural, =1 {One {{this.activeTabLocalized}} task} other {{{tasks.length || 0}} total {{this.activeTabLocalized}} tasks}}</ng-container>
|
||||||
|
@if (selectedTasks.size > 0) {
|
||||||
|
<ng-container i18n> ({{selectedTasks.size}} selected)</ng-container>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (tasks.length > pageSize) {
|
||||||
|
<ngb-pagination [(page)]="page" [pageSize]="pageSize" [collectionSize]="tasks.length" maxSize="8" size="sm"></ngb-pagination>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-tabs" (hidden)="duringTabChange($event)">
|
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-tabs" (hidden)="duringTabChange($event)">
|
||||||
<li ngbNavItem="failed">
|
<li ngbNavItem="failed">
|
||||||
<a ngbNavLink i18n>Failed<span *ngIf="tasksService.failedFileTasks.length > 0" class="badge bg-danger ms-2">{{tasksService.failedFileTasks.length}}</span></a>
|
<a ngbNavLink i18n>Failed@if (tasksService.failedFileTasks.length > 0) {
|
||||||
|
<span class="badge bg-danger ms-2">{{tasksService.failedFileTasks.length}}</span>
|
||||||
|
}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.failedFileTasks}"></ng-container>
|
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.failedFileTasks}"></ng-container>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
<li ngbNavItem="completed">
|
<li ngbNavItem="completed">
|
||||||
<a ngbNavLink i18n>Complete<span *ngIf="tasksService.completedFileTasks.length > 0" class="badge bg-secondary ms-2">{{tasksService.completedFileTasks.length}}</span></a>
|
<a ngbNavLink i18n>Complete@if (tasksService.completedFileTasks.length > 0) {
|
||||||
|
<span class="badge bg-secondary ms-2">{{tasksService.completedFileTasks.length}}</span>
|
||||||
|
}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.completedFileTasks}"></ng-container>
|
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.completedFileTasks}"></ng-container>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
<li ngbNavItem="started">
|
<li ngbNavItem="started">
|
||||||
<a ngbNavLink i18n>Started<span *ngIf="tasksService.startedFileTasks.length > 0" class="badge bg-secondary ms-2">{{tasksService.startedFileTasks.length}}</span></a>
|
<a ngbNavLink i18n>Started@if (tasksService.startedFileTasks.length > 0) {
|
||||||
|
<span class="badge bg-secondary ms-2">{{tasksService.startedFileTasks.length}}</span>
|
||||||
|
}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.startedFileTasks}"></ng-container>
|
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.startedFileTasks}"></ng-container>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
<li ngbNavItem="queued">
|
<li ngbNavItem="queued">
|
||||||
<a ngbNavLink i18n>Queued<span *ngIf="tasksService.queuedFileTasks.length > 0" class="badge bg-secondary ms-2">{{tasksService.queuedFileTasks.length}}</span></a>
|
<a ngbNavLink i18n>Queued@if (tasksService.queuedFileTasks.length > 0) {
|
||||||
|
<span class="badge bg-secondary ms-2">{{tasksService.queuedFileTasks.length}}</span>
|
||||||
|
}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.queuedFileTasks}"></ng-container>
|
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.queuedFileTasks}"></ng-container>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@@ -28,6 +28,8 @@ import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dial
|
|||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
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 { FormsModule } from '@angular/forms'
|
||||||
|
|
||||||
const tasks: PaperlessTask[] = [
|
const tasks: PaperlessTask[] = [
|
||||||
{
|
{
|
||||||
@@ -138,6 +140,8 @@ describe('TasksComponent', () => {
|
|||||||
NgbModule,
|
NgbModule,
|
||||||
HttpClientTestingModule,
|
HttpClientTestingModule,
|
||||||
RouterTestingModule.withRoutes(routes),
|
RouterTestingModule.withRoutes(routes),
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
FormsModule,
|
||||||
],
|
],
|
||||||
}).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
|
||||||
@@ -46,6 +47,7 @@ export class TasksComponent
|
|||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.tasksService.cancelPending()
|
this.tasksService.cancelPending()
|
||||||
|
clearInterval(this.autoRefreshInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
dismissTask(task: PaperlessTask) {
|
dismissTask(task: PaperlessTask) {
|
||||||
@@ -119,6 +121,7 @@ export class TasksComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
|
this.togggleAll = false
|
||||||
this.selectedTasks.clear()
|
this.selectedTasks.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,97 +1,94 @@
|
|||||||
<pngx-page-header title="Users & Groups" i18n-title>
|
<pngx-page-header
|
||||||
|
title="Users & Groups"
|
||||||
|
i18n-title
|
||||||
|
info="Create, delete and edit users and groups."
|
||||||
|
i18n-info
|
||||||
|
infoLink="usage/#users-and-groups"
|
||||||
|
>
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<ng-container *ngIf="users">
|
@if (users) {
|
||||||
<h4 class="d-flex">
|
<h4 class="d-flex">
|
||||||
<ng-container i18n>Users</ng-container>
|
<ng-container i18n>Users</ng-container>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }">
|
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }">
|
||||||
<svg class="sidebaricon me-1" fill="currentColor">
|
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Add User</ng-container>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
|
</button>
|
||||||
</svg>
|
</h4>
|
||||||
<ng-container i18n>Add User</ng-container>
|
<ul class="list-group">
|
||||||
</button>
|
<li class="list-group-item">
|
||||||
</h4>
|
<div class="row">
|
||||||
<ul class="list-group">
|
<div class="col" i18n>Username</div>
|
||||||
<li class="list-group-item">
|
<div class="col" i18n>Name</div>
|
||||||
|
<div class="col" i18n>Groups</div>
|
||||||
|
<div class="col" i18n>Actions</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
@for (user of users; track user) {
|
||||||
|
<li class="list-group-item">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col" i18n>Username</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" i18n>Name</div>
|
<div class="col d-flex align-items-center">{{user.first_name}} {{user.last_name}}</div>
|
||||||
<div class="col" i18n>Groups</div>
|
<div class="col d-flex align-items-center">{{user.groups?.map(getGroupName, this).join(', ')}}</div>
|
||||||
<div class="col" i18n>Actions</div>
|
<div class="col">
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li *ngFor="let user of users" class="list-group-item">
|
|
||||||
<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">{{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">
|
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editUser(user)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.User }">
|
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editUser(user)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.User }">
|
||||||
<svg class="buttonicon-sm" fill="currentColor">
|
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#pencil" />
|
</button>
|
||||||
</svg> <ng-container i18n>Edit</ng-container>
|
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.User }">
|
||||||
</button>
|
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||||
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.User }">
|
</button>
|
||||||
<svg class="buttonicon-sm" fill="currentColor">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#trash" />
|
|
||||||
</svg> <ng-container i18n>Delete</ng-container>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
}
|
||||||
</ng-container>
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
<ng-container *ngIf="groups">
|
@if (groups) {
|
||||||
<h4 class="mt-4 d-flex">
|
<h4 class="mt-4 d-flex">
|
||||||
<ng-container i18n>Groups</ng-container>
|
<ng-container i18n>Groups</ng-container>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
|
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
|
||||||
<svg class="sidebaricon me-1" fill="currentColor">
|
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Add Group</ng-container>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
|
</button>
|
||||||
</svg>
|
</h4>
|
||||||
<ng-container i18n>Add Group</ng-container>
|
<ul class="list-group">
|
||||||
</button>
|
<li class="list-group-item">
|
||||||
</h4>
|
<div class="row">
|
||||||
<ul *ngIf="groups.length > 0" class="list-group">
|
<div class="col" i18n>Name</div>
|
||||||
<li class="list-group-item">
|
<div class="col"></div>
|
||||||
|
<div class="col"></div>
|
||||||
|
<div class="col" i18n>Actions</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
@for (group of groups; track group) {
|
||||||
|
<li class="list-group-item">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col" i18n>Name</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" i18n>Actions</div>
|
<div class="col">
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li *ngFor="let group of groups" class="list-group-item">
|
|
||||||
<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"></div>
|
|
||||||
<div class="col"></div>
|
|
||||||
<div class="col">
|
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Group }">
|
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Group }">
|
||||||
<svg class="buttonicon-sm" fill="currentColor">
|
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#pencil" />
|
</button>
|
||||||
</svg> <ng-container i18n>Edit</ng-container>
|
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Group }">
|
||||||
</button>
|
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||||
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Group }">
|
</button>
|
||||||
<svg class="buttonicon-sm" fill="currentColor">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#trash" />
|
|
||||||
</svg> <ng-container i18n>Delete</ng-container>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li *ngIf="groups.length === 0" class="list-group-item" i18n>No groups defined</li>
|
}
|
||||||
</ul>
|
@if (groups.length === 0) {
|
||||||
|
<li class="list-group-item" i18n>No groups defined</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
</ng-container>
|
@if (!users || !groups) {
|
||||||
|
<div>
|
||||||
<div *ngIf="!users || !groups">
|
|
||||||
<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>
|
</div>
|
||||||
|
}
|
||||||
|
@@ -41,8 +41,9 @@ import { TextComponent } from '../../common/input/text/text.component'
|
|||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { SettingsComponent } from '../settings/settings.component'
|
import { SettingsComponent } from '../settings/settings.component'
|
||||||
import { UsersAndGroupsComponent } from './users-groups.component'
|
import { UsersAndGroupsComponent } from './users-groups.component'
|
||||||
import { PaperlessUser } from 'src/app/data/paperless-user'
|
import { User } from 'src/app/data/user'
|
||||||
import { PaperlessGroup } from 'src/app/data/paperless-group'
|
import { Group } from 'src/app/data/group'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
|
||||||
const users = [
|
const users = [
|
||||||
{ id: 1, username: 'user1', is_superuser: false },
|
{ id: 1, username: 'user1', is_superuser: false },
|
||||||
@@ -92,6 +93,7 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgbAlertModule,
|
NgbAlertModule,
|
||||||
NgSelectModule,
|
NgSelectModule,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
fixture = TestBed.createComponent(UsersAndGroupsComponent)
|
fixture = TestBed.createComponent(UsersAndGroupsComponent)
|
||||||
@@ -119,7 +121,7 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
of({
|
of({
|
||||||
all: users.map((a) => a.id),
|
all: users.map((a) => a.id),
|
||||||
count: users.length,
|
count: users.length,
|
||||||
results: (users as PaperlessUser[]).concat([]),
|
results: (users as User[]).concat([]),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -128,7 +130,7 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
of({
|
of({
|
||||||
all: groups.map((r) => r.id),
|
all: groups.map((r) => r.id),
|
||||||
count: groups.length,
|
count: groups.length,
|
||||||
results: (groups as PaperlessGroup[]).concat([]),
|
results: (groups as Group[]).concat([]),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
import { Component, OnDestroy, OnInit } from '@angular/core'
|
import { Component, OnDestroy, OnInit } from '@angular/core'
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { Subject, first, takeUntil } from 'rxjs'
|
import { Subject, first, takeUntil } from 'rxjs'
|
||||||
import { PaperlessGroup } from 'src/app/data/paperless-group'
|
import { Group } from 'src/app/data/group'
|
||||||
import { PaperlessUser } from 'src/app/data/paperless-user'
|
import { User } from 'src/app/data/user'
|
||||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
import { GroupService } from 'src/app/services/rest/group.service'
|
import { GroupService } from 'src/app/services/rest/group.service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
@@ -23,8 +23,8 @@ export class UsersAndGroupsComponent
|
|||||||
extends ComponentWithPermissions
|
extends ComponentWithPermissions
|
||||||
implements OnInit, OnDestroy
|
implements OnInit, OnDestroy
|
||||||
{
|
{
|
||||||
users: PaperlessUser[]
|
users: User[]
|
||||||
groups: PaperlessGroup[]
|
groups: Group[]
|
||||||
|
|
||||||
unsubscribeNotifier: Subject<any> = new Subject()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ export class UsersAndGroupsComponent
|
|||||||
this.unsubscribeNotifier.next(true)
|
this.unsubscribeNotifier.next(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
editUser(user: PaperlessUser = null) {
|
editUser(user: User = null) {
|
||||||
var modal = this.modalService.open(UserEditDialogComponent, {
|
var modal = this.modalService.open(UserEditDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
size: 'xl',
|
size: 'xl',
|
||||||
@@ -80,7 +80,7 @@ export class UsersAndGroupsComponent
|
|||||||
modal.componentInstance.object = user
|
modal.componentInstance.object = user
|
||||||
modal.componentInstance.succeeded
|
modal.componentInstance.succeeded
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((newUser: PaperlessUser) => {
|
.subscribe((newUser: User) => {
|
||||||
if (
|
if (
|
||||||
newUser.id === this.settings.currentUser.id &&
|
newUser.id === this.settings.currentUser.id &&
|
||||||
(modal.componentInstance as UserEditDialogComponent).passwordIsSet
|
(modal.componentInstance as UserEditDialogComponent).passwordIsSet
|
||||||
@@ -107,7 +107,7 @@ export class UsersAndGroupsComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteUser(user: PaperlessUser) {
|
deleteUser(user: User) {
|
||||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
@@ -133,7 +133,7 @@ export class UsersAndGroupsComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
editGroup(group: PaperlessGroup = null) {
|
editGroup(group: Group = null) {
|
||||||
var modal = this.modalService.open(GroupEditDialogComponent, {
|
var modal = this.modalService.open(GroupEditDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
size: 'lg',
|
size: 'lg',
|
||||||
@@ -157,7 +157,7 @@ export class UsersAndGroupsComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteGroup(group: PaperlessGroup) {
|
deleteGroup(group: Group) {
|
||||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
|
@@ -4,25 +4,30 @@
|
|||||||
(click)="isMenuCollapsed = !isMenuCollapsed">
|
(click)="isMenuCollapsed = !isMenuCollapsed">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<a class="navbar-brand col-auto col-md-3 col-lg-2 me-0 px-3 py-3 order-sm-0" [ngClass]="slimSidebarEnabled ? 'slim' : 'col-auto col-md-3 col-lg-2'" routerLink="/dashboard" tourAnchor="tour.intro">
|
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.43 238.91" width="1em" class="me-2" fill="currentColor">
|
[ngClass]="{ 'slim': slimSidebarEnabled, 'col-auto col-md-3 col-lg-2 col-xxxl-1' : !slimSidebarEnabled, 'py-3' : !customAppTitle?.length || slimSidebarEnabled, 'py-2': customAppTitle?.length }"
|
||||||
<path d="M194.7,0C164.22,70.94,17.64,79.74,64.55,194.06c.58,1.47-10.85,17-18.47,29.9-1.76-6.45-3.81-13.48-3.52-14.07,38.11-45.14-27.26-70.65-30.78-107.58C-4.64,131.62-10.5,182.92,39,212.53c.3,0,2.64,11.14,3.81,16.71a58.55,58.55,0,0,0-2.93,6.45c-1.17,2.93,7.62,2.64,7.62,3.22.88-.29,21.7-36.93,22.28-37.23C187.67,174.72,208.48,68.6,194.7,0ZM134.61,74.75C79.5,124,70.12,160.64,71.88,178.53,53.41,134.85,107.64,86.77,134.61,74.75ZM28.2,145.11c10.55,9.67,28.14,39.28,13.19,56.57C44.91,193.77,46.08,175.89,28.2,145.11Z" transform="translate(0 0)"/>
|
routerLink="/dashboard"
|
||||||
|
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">
|
||||||
|
<path
|
||||||
|
d="M194.7,0C164.22,70.94,17.64,79.74,64.55,194.06c.58,1.47-10.85,17-18.47,29.9-1.76-6.45-3.81-13.48-3.52-14.07,38.11-45.14-27.26-70.65-30.78-107.58C-4.64,131.62-10.5,182.92,39,212.53c.3,0,2.64,11.14,3.81,16.71a58.55,58.55,0,0,0-2.93,6.45c-1.17,2.93,7.62,2.64,7.62,3.22.88-.29,21.7-36.93,22.28-37.23C187.67,174.72,208.48,68.6,194.7,0ZM134.61,74.75C79.5,124,70.12,160.64,71.88,178.53,53.41,134.85,107.64,86.77,134.61,74.75ZM28.2,145.11c10.55,9.67,28.14,39.28,13.19,56.57C44.91,193.77,46.08,175.89,28.2,145.11Z"
|
||||||
|
transform="translate(0 0)" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="ms-2" [class.visually-hidden]="slimSidebarEnabled" i18n="app title">Paperless-ngx</span>
|
<div class="ms-2 ms-md-3 d-inline-block" [class.d-md-none]="slimSidebarEnabled">
|
||||||
|
@if (customAppTitle?.length) {
|
||||||
|
<div class="d-flex flex-column align-items-start">
|
||||||
|
<span class="title">{{customAppTitle}}</span>
|
||||||
|
<span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
Paperless-ngx
|
||||||
|
}
|
||||||
|
</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" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
<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">
|
||||||
<form (ngSubmit)="search()" class="form-inline flex-grow-1">
|
<div class="col-12 col-md-7">
|
||||||
<svg width="1em" height="1em" fill="currentColor">
|
<pngx-global-search></pngx-global-search>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#search"/>
|
</div>
|
||||||
</svg>
|
|
||||||
<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>
|
|
||||||
<button type="button" *ngIf="!searchFieldEmpty" class="btn btn-link btn-sm px-0 position-absolute top-0 end-0" (click)="resetSearchField()">
|
|
||||||
<svg fill="currentColor" class="buttonicon-sm me-1">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
|
||||||
</svg>
|
|
||||||
</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">
|
||||||
@@ -30,9 +35,7 @@
|
|||||||
<span class="small me-2 d-none d-sm-inline">
|
<span class="small me-2 d-none d-sm-inline">
|
||||||
{{this.settingsService.displayName}}
|
{{this.settingsService.displayName}}
|
||||||
</span>
|
</span>
|
||||||
<svg width="1.3em" height="1.3em" fill="currentColor">
|
<i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#person-circle"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu class="dropdown-menu-end shadow me-2" aria-labelledby="userDropdown">
|
<div ngbDropdownMenu class="dropdown-menu-end shadow me-2" aria-labelledby="userDropdown">
|
||||||
<div class="d-sm-none">
|
<div class="d-sm-none">
|
||||||
@@ -40,25 +43,19 @@
|
|||||||
<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()">
|
||||||
<svg class="sidebaricon me-2" fill="currentColor">
|
<i-bs class="me-2" name="person"></i-bs> <ng-container i18n>My Profile</ng-container>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#person"/>
|
|
||||||
</svg><ng-container i18n>My Profile</ng-container>
|
|
||||||
</button>
|
</button>
|
||||||
<a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }">
|
<a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()"
|
||||||
<svg class="sidebaricon me-2" fill="currentColor">
|
*pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }">
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#gear"/>
|
<i-bs class="me-2" name="gear"></i-bs><ng-container i18n>Settings</ng-container>
|
||||||
</svg><ng-container i18n>Settings</ng-container>
|
|
||||||
</a>
|
</a>
|
||||||
<a ngbDropdownItem class="nav-link" href="accounts/logout/" (click)="onLogout()">
|
<a ngbDropdownItem class="nav-link d-flex" href="accounts/logout/" (click)="onLogout()">
|
||||||
<svg class="sidebaricon me-2" fill="currentColor">
|
<i-bs class="me-2" name="door-open"></i-bs><ng-container i18n>Logout</ng-container>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#door-open"/>
|
|
||||||
</svg><ng-container i18n>Logout</ng-container>
|
|
||||||
</a>
|
</a>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<a ngbDropdownItem class="nav-link" target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com">
|
<a ngbDropdownItem class="nav-link" target="_blank" rel="noopener noreferrer"
|
||||||
<svg class="sidebaricon me-2" fill="currentColor">
|
href="https://docs.paperless-ngx.com">
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#question-circle"/>
|
<i-bs class="me-2" name="question-circle"></i-bs><ng-container i18n>Documentation</ng-container>
|
||||||
</svg><ng-container i18n>Documentation</ng-container>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -67,229 +64,277 @@
|
|||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse" [ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating" [ngbCollapse]="isMenuCollapsed">
|
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
||||||
|
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating"
|
||||||
|
[ngbCollapse]="isMenuCollapsed">
|
||||||
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
|
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
|
||||||
<svg class="sidebaricon-sm" fill="currentColor">
|
@if (slimSidebarEnabled) {
|
||||||
<use *ngIf="slimSidebarEnabled" xlink:href="assets/bootstrap-icons.svg#chevron-double-right"/>
|
<i-bs width="0.9em" height="0.9em" name="chevron-double-right"></i-bs>
|
||||||
<use *ngIf="!slimSidebarEnabled" xlink:href="assets/bootstrap-icons.svg#chevron-double-left"/>
|
} @else {
|
||||||
</svg>
|
<i-bs width="0.9em" height="0.9em" name="chevron-double-left"></i-bs>
|
||||||
|
}
|
||||||
</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()" ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#house"/>
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
</svg><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()" ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
<a class="nav-link" routerLink="documents" routerLinkActive="active" (click)="closeMenu()"
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#files"/>
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
</svg><span> <ng-container i18n>Documents</ng-container></span>
|
<i-bs class="me-1" name="files"></i-bs><span> <ng-container i18n>Documents</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
|
||||||
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted" *ngIf='savedViewService.loading || savedViewService.sidebarViews?.length > 0'>
|
|
||||||
<span i18n>Saved views</span>
|
|
||||||
<div *ngIf="savedViewService.loading" class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
|
||||||
</h6>
|
|
||||||
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
|
||||||
<li class="nav-item w-100" *ngFor="let view of savedViewService.sidebarViews"
|
|
||||||
cdkDrag
|
|
||||||
[cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
|
|
||||||
cdkDragPreviewContainer="parent"
|
|
||||||
cdkDragPreviewClass="navItemDrag"
|
|
||||||
(cdkDragStarted)="onDragStart($event)"
|
|
||||||
(cdkDragEnded)="onDragEnd($event)">
|
|
||||||
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}" routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name" [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#funnel"/>
|
|
||||||
</svg><span> {{view.name}}</span>
|
|
||||||
</a>
|
|
||||||
<div *ngIf="settingsService.organizingSidebarSavedViews" class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
|
||||||
<svg class="sidebaricon text-muted" fill="currentColor">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#grip-vertical"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
<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" *ngIf='openDocuments.length > 0'>
|
@if (savedViewService.loading || savedViewService.sidebarViews?.length > 0) {
|
||||||
<span i18n>Open documents</span>
|
<h6 class="sidebar-heading px-3 text-muted">
|
||||||
|
<span i18n>Saved views</span>
|
||||||
|
@if (savedViewService.loading) {
|
||||||
|
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
||||||
|
}
|
||||||
|
</h6>
|
||||||
|
}
|
||||||
|
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||||
|
@for (view of savedViewService.sidebarViews; track view.id) {
|
||||||
|
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
|
||||||
|
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
||||||
|
(cdkDragEnded)="onDragEnd($event)">
|
||||||
|
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}"
|
||||||
|
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
||||||
|
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||||
|
popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="funnel"></i-bs><span> {{view.name}}</span>
|
||||||
|
</a>
|
||||||
|
@if (settingsService.organizingSidebarSavedViews) {
|
||||||
|
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
||||||
|
<i-bs name="grip-vertical"></i-bs>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-group mt-3 mb-1" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||||
|
@if (openDocuments.length > 0) {
|
||||||
|
<h6 class="sidebar-heading px-3 text-muted">
|
||||||
|
<span i18n>Open documents</span>
|
||||||
|
</h6>
|
||||||
|
}
|
||||||
|
<ul class="nav flex-column mb-2">
|
||||||
|
@for (d of openDocuments; track d) {
|
||||||
|
<li class="nav-item w-100 app-link">
|
||||||
|
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}"
|
||||||
|
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle"
|
||||||
|
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||||
|
popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="file-text"></i-bs><span> {{d.title | documentTitle}}</span>
|
||||||
|
<span class="close" (click)="closeDocument(d); $event.preventDefault()">
|
||||||
|
<i-bs name="x"></i-bs>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
@if (openDocuments.length >= 1) {
|
||||||
|
<li class="nav-item w-100 app-link">
|
||||||
|
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()"
|
||||||
|
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
|
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>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-group mt-3 mb-1">
|
||||||
|
<h6 class="sidebar-heading px-3 text-muted">
|
||||||
|
<span i18n>Manage</span>
|
||||||
</h6>
|
</h6>
|
||||||
<ul class="nav flex-column mb-2">
|
<ul class="nav flex-column mb-2">
|
||||||
<li class="nav-item w-100" *ngFor='let d of openDocuments'>
|
<li class="nav-item app-link"
|
||||||
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}" routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle" [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#file-text"/>
|
ngbPopover="Correspondents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
</svg><span> {{d.title | documentTitle}}</span>
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<span class="close" (click)="closeDocument(d); $event.preventDefault()">
|
<i-bs class="me-1" name="person"></i-bs><span> <ng-container i18n>Correspondents</ng-container></span>
|
||||||
<svg fill="currentColor" class="toolbaricon">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item w-100" *ngIf="openDocuments.length >= 1">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"
|
||||||
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()" ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
tourAnchor="tour.tags">
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Tags"
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
</svg><span> <ng-container i18n>Close all</ng-container></span>
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="tags"></i-bs><span> <ng-container i18n>Tags</ng-container></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item app-link"
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||||
|
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()"
|
||||||
|
ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="hash"></i-bs><span> <ng-container i18n>Document Types</ng-container></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
||||||
|
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()"
|
||||||
|
ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
|
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>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
||||||
|
<a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()"
|
||||||
|
ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
|
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>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item app-link"
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||||
|
tourAnchor="tour.workflows">
|
||||||
|
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||||
|
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="boxes"></i-bs><span> <ng-container i18n>Workflows</ng-container></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||||
|
tourAnchor="tour.mail">
|
||||||
|
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||||
|
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="envelope"></i-bs><span> <ng-container i18n>Mail</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</ng-container>
|
</div>
|
||||||
|
|
||||||
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted">
|
<div class="nav-group mt-auto mb-1">
|
||||||
<span i18n>Manage</span>
|
<h6 class="sidebar-heading px-3 pt-4 text-muted">
|
||||||
</h6>
|
<span i18n>Administration</span>
|
||||||
<ul class="nav flex-column mb-2">
|
</h6>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
<ul class="nav flex-column mb-2">
|
||||||
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Correspondents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
tourAnchor="tour.settings">
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#person"/>
|
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
||||||
</svg><span> <ng-container i18n>Correspondents</ng-container></span>
|
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
</a>
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
</li>
|
<i-bs class="me-1" name="gear"></i-bs><span> <ng-container i18n>Settings</ng-container></span>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }" tourAnchor="tour.tags">
|
</a>
|
||||||
<a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Tags" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
</li>
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#tags"/>
|
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
|
||||||
</svg><span> <ng-container i18n>Tags</ng-container></span>
|
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
</a>
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
</li>
|
<i-bs class="me-1" name="sliders2-vertical"></i-bs><span> <ng-container i18n>Configuration</ng-container></span>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
</a>
|
||||||
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
</li>
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#hash"/>
|
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
|
||||||
</svg><span> <ng-container i18n>Document Types</ng-container></span>
|
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
</a>
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
</li>
|
<i-bs class="me-1" name="people"></i-bs><span> <ng-container i18n>Users & Groups</ng-container></span>
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
</a>
|
||||||
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
</li>
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<li class="nav-item app-link"
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#folder"/>
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
|
||||||
</svg><span> <ng-container i18n>Storage Paths</ng-container></span>
|
tourAnchor="tour.file-tasks">
|
||||||
</a>
|
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
|
||||||
</li>
|
ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
<i-bs class="me-1" name="list-task"></i-bs><span> <ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) {
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span>
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#ui-radios"/>
|
}</span>
|
||||||
</svg><span> <ng-container i18n>Custom Fields</ng-container></span>
|
@if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) {
|
||||||
</a>
|
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.failedFileTasks.length}}</span>
|
||||||
</li>
|
}
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ConsumptionTemplate }" tourAnchor="tour.consumption-templates">
|
</a>
|
||||||
<a class="nav-link" routerLink="templates" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Consumption templates" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
</li>
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
@if (permissionsService.isAdmin()) {
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#file-earmark-ruled"/>
|
<li class="nav-item app-link">
|
||||||
</svg><span> <ng-container i18n>Templates</ng-container></span>
|
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
||||||
</a>
|
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
</li>
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }" tourAnchor="tour.mail">
|
<i-bs class="me-1" name="text-left"></i-bs><span> <ng-container i18n>Logs</ng-container></span>
|
||||||
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#envelope"/>
|
|
||||||
</svg><span> <ng-container i18n>Mail</ng-container></span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h6 class="sidebar-heading px-3 mt-auto pt-4 mb-1 text-muted">
|
|
||||||
<span i18n>Administration</span>
|
|
||||||
</h6>
|
|
||||||
<ul class="nav flex-column mb-2">
|
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }" tourAnchor="tour.settings">
|
|
||||||
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#gear"/>
|
|
||||||
</svg><span> <ng-container i18n>Settings</ng-container></span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
|
||||||
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#people"/>
|
|
||||||
</svg><span> <ng-container i18n>Users & Groups</ng-container></span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }" tourAnchor="tour.file-tasks">
|
|
||||||
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()" ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
|
||||||
<span *ngIf="tasksService.failedFileTasks.length > 0 && slimSidebarEnabled" class="badge bg-danger position-absolute top-0 end-0">{{tasksService.failedFileTasks.length}}</span>
|
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#list-task"/>
|
|
||||||
</svg><span> <ng-container i18n>File Tasks<span *ngIf="tasksService.failedFileTasks.length > 0"><span class="badge bg-danger ms-2">{{tasksService.failedFileTasks.length}}</span></span></ng-container></span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }">
|
|
||||||
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#text-left"/>
|
|
||||||
</svg><span> <ng-container i18n>Logs</ng-container></span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<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" target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
|
||||||
<svg class="sidebaricon" fill="currentColor">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#question-circle"/>
|
|
||||||
</svg><span class="ms-1"> <ng-container i18n>Documentation</ng-container></span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
|
||||||
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap">
|
|
||||||
<div class="me-3">
|
|
||||||
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer" href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
|
||||||
{{ versionString }}
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</li>
|
||||||
<div *ngIf="!settingsService.updateCheckingIsSet || appRemoteVersion" class="version-check">
|
}
|
||||||
<ng-template #updateAvailablePopContent>
|
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
||||||
<span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is available.</ng-container><br/><ng-container i18n>Click to view.</ng-container></span>
|
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
|
||||||
</ng-template>
|
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||||
<ng-template #updateCheckingNotEnabledPopContent>
|
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
<p class="small mb-2">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<ng-container i18n>Paperless-ngx can automatically check for updates</ng-container>
|
<i-bs class="d-flex" name="question-circle"></i-bs><span class="ms-1"> <ng-container i18n>Documentation</ng-container></span>
|
||||||
</p>
|
</a>
|
||||||
<div class="btn-group btn-group-xs flex-fill w-100">
|
</li>
|
||||||
<button class="btn btn-outline-primary" (click)="setUpdateChecking(true)">Enable</button>
|
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||||
<button class="btn btn-outline-secondary" (click)="setUpdateChecking(false)">Disable</button>
|
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap">
|
||||||
|
<div class="me-3">
|
||||||
|
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||||
|
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
|
||||||
|
[disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
{{ versionString }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@if (!settingsService.updateCheckingIsSet || appRemoteVersion) {
|
||||||
|
<div class="version-check">
|
||||||
|
<ng-template #updateAvailablePopContent>
|
||||||
|
<span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is
|
||||||
|
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #updateCheckingNotEnabledPopContent>
|
||||||
|
<p class="small mb-2">
|
||||||
|
<ng-container i18n>Paperless-ngx can automatically check for updates</ng-container>
|
||||||
|
</p>
|
||||||
|
<div class="btn-group btn-group-xs flex-fill w-100">
|
||||||
|
<button class="btn btn-outline-primary" (click)="setUpdateChecking(true)">Enable</button>
|
||||||
|
<button class="btn btn-outline-secondary" (click)="setUpdateChecking(false)">Disable</button>
|
||||||
|
</div>
|
||||||
|
<p class="small mb-0 mt-2">
|
||||||
|
<a class="small text-decoration-none fst-italic" routerLink="/settings" fragment="update-checking" i18n>
|
||||||
|
How does this work?
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</ng-template>
|
||||||
|
@if (settingsService.updateCheckingIsSet) {
|
||||||
|
@if (appRemoteVersion.update_available) {
|
||||||
|
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||||
|
href="https://github.com/paperless-ngx/paperless-ngx/releases"
|
||||||
|
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
|
||||||
|
container="body">
|
||||||
|
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||||
|
@if (appRemoteVersion?.update_available) {
|
||||||
|
<ng-container i18n>Update available</ng-container>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<a class="small text-decoration-none" routerLink="/settings" fragment="update-checking"
|
||||||
|
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
|
||||||
|
container="body">
|
||||||
|
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<p class="small mb-0 mt-2">
|
}
|
||||||
<a class="small text-decoration-none fst-italic" routerLink="/settings" fragment="update-checking" i18n>
|
|
||||||
How does this work?
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</ng-template>
|
|
||||||
<ng-container *ngIf="settingsService.updateCheckingIsSet; else updateCheckNotSet">
|
|
||||||
<a *ngIf="appRemoteVersion.update_available" class="small text-decoration-none" target="_blank" rel="noopener noreferrer" href="https://github.com/paperless-ngx/paperless-ngx/releases"
|
|
||||||
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave" container="body">
|
|
||||||
<svg fill="currentColor" class="me-1" width="1.2em" height="1.2em" style="vertical-align: text-top;" viewBox="0 0 16 16">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#info-circle" />
|
|
||||||
</svg>
|
|
||||||
<ng-container *ngIf="appRemoteVersion?.update_available" i18n>Update available</ng-container>
|
|
||||||
</a>
|
|
||||||
</ng-container>
|
|
||||||
<ng-template #updateCheckNotSet>
|
|
||||||
<a class="small text-decoration-none" routerLink="/settings" fragment="update-checking"
|
|
||||||
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter" container="body">
|
|
||||||
<svg fill="currentColor" class="me-1" width="1.2em" height="1.2em" style="vertical-align: text-top;" viewBox="0 0 16 16">
|
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#info-circle" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</ng-template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
</li>
|
</ul>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main role="main" class="ms-sm-auto px-md-4" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
|
<main role="main" class="ms-sm-auto px-md-4"
|
||||||
|
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -18,6 +18,10 @@
|
|||||||
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%;
|
max-width: 25%;
|
||||||
@@ -152,9 +156,9 @@ main {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebaricon {
|
i-bs {
|
||||||
margin-right: 4px;
|
position: relative;
|
||||||
color: inherit;
|
top: -1px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,11 +190,11 @@ main {
|
|||||||
width: 1.8rem;
|
width: 1.8rem;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
svg {
|
i-bs {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover svg {
|
&:hover i-bs {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,7 +209,7 @@ main {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
i-bs {
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,9 +221,16 @@ main {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
padding-top: 0.75rem;
|
|
||||||
padding-bottom: 0.75rem;
|
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
|
||||||
|
.flex-column {
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.byline {
|
||||||
|
font-size: 0.5rem;
|
||||||
|
letter-spacing: 0.1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 768px) {
|
@media screen and (min-width: 768px) {
|
||||||
@@ -241,58 +252,11 @@ main {
|
|||||||
.navbar .dropdown-menu {
|
.navbar .dropdown-menu {
|
||||||
font-size: 0.875rem; // body size
|
font-size: 0.875rem; // body size
|
||||||
|
|
||||||
a svg {
|
a i-bs {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar .search-form-container {
|
|
||||||
max-width: 550px;
|
|
||||||
|
|
||||||
form {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
> svg {
|
|
||||||
position: absolute;
|
|
||||||
left: 0.6rem;
|
|
||||||
top: 0.5rem;
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
&:focus-within {
|
|
||||||
form > svg {
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -316,6 +280,6 @@ main {
|
|||||||
cursor: move;
|
cursor: move;
|
||||||
}
|
}
|
||||||
|
|
||||||
::ng-deep .navItemDrag .position-absolute svg {
|
::ng-deep .navItemDrag .position-absolute i-bs {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
@@ -15,24 +15,28 @@ import { RouterTestingModule } from '@angular/router/testing'
|
|||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
|
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { Observable, of, tap, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import {
|
||||||
|
DjangoMessageLevel,
|
||||||
|
DjangoMessagesService,
|
||||||
|
} from 'src/app/services/django-messages.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
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 { PaperlessSavedView } from 'src/app/data/paperless-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 { GlobalSearchComponent } from './global-search/global-search.component'
|
||||||
|
|
||||||
const saved_views = [
|
const saved_views = [
|
||||||
{
|
{
|
||||||
@@ -82,16 +86,19 @@ describe('AppFrameComponent', () => {
|
|||||||
let permissionsService: PermissionsService
|
let permissionsService: PermissionsService
|
||||||
let remoteVersionService: RemoteVersionService
|
let remoteVersionService: RemoteVersionService
|
||||||
let toastService: ToastService
|
let toastService: ToastService
|
||||||
|
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,
|
HttpClientTestingModule,
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
@@ -101,6 +108,7 @@ describe('AppFrameComponent', () => {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
NgbModalModule,
|
NgbModalModule,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
SettingsService,
|
SettingsService,
|
||||||
@@ -121,6 +129,7 @@ describe('AppFrameComponent', () => {
|
|||||||
RemoteVersionService,
|
RemoteVersionService,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
ToastService,
|
ToastService,
|
||||||
|
DjangoMessagesService,
|
||||||
OpenDocumentsService,
|
OpenDocumentsService,
|
||||||
SearchService,
|
SearchService,
|
||||||
NgbModal,
|
NgbModal,
|
||||||
@@ -149,9 +158,8 @@ describe('AppFrameComponent', () => {
|
|||||||
permissionsService = TestBed.inject(PermissionsService)
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
remoteVersionService = TestBed.inject(RemoteVersionService)
|
remoteVersionService = TestBed.inject(RemoteVersionService)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -248,7 +256,7 @@ describe('AppFrameComponent', () => {
|
|||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support collapsable menu', () => {
|
it('should support collapsible menu', () => {
|
||||||
const button: HTMLButtonElement = (
|
const button: HTMLButtonElement = (
|
||||||
fixture.nativeElement as HTMLDivElement
|
fixture.nativeElement as HTMLDivElement
|
||||||
).querySelector('button[data-toggle=collapse]')
|
).querySelector('button[data-toggle=collapse]')
|
||||||
@@ -287,62 +295,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)
|
||||||
@@ -356,7 +308,7 @@ describe('AppFrameComponent', () => {
|
|||||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||||
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
|
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
|
||||||
PaperlessSavedView[]
|
SavedView[]
|
||||||
>)
|
>)
|
||||||
expect(settingsSpy).toHaveBeenCalledWith([
|
expect(settingsSpy).toHaveBeenCalledWith([
|
||||||
saved_views[2],
|
saved_views[2],
|
||||||
@@ -379,7 +331,7 @@ describe('AppFrameComponent', () => {
|
|||||||
.spyOn(settingsService, 'storeSettings')
|
.spyOn(settingsService, 'storeSettings')
|
||||||
.mockReturnValue(throwError(() => new Error('unable to save')))
|
.mockReturnValue(throwError(() => new Error('unable to save')))
|
||||||
component.onDrop({ previousIndex: 0, currentIndex: 2 } as CdkDragDrop<
|
component.onDrop({ previousIndex: 0, currentIndex: 2 } as CdkDragDrop<
|
||||||
PaperlessSavedView[]
|
SavedView[]
|
||||||
>)
|
>)
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -391,4 +343,19 @@ describe('AppFrameComponent', () => {
|
|||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should show toasts for django messages', () => {
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
jest.spyOn(messagesService, 'get').mockReturnValue([
|
||||||
|
{ level: DjangoMessageLevel.WARNING, message: 'Test warning' },
|
||||||
|
{ level: DjangoMessageLevel.ERROR, message: 'Test error' },
|
||||||
|
{ level: DjangoMessageLevel.SUCCESS, message: 'Test success' },
|
||||||
|
{ level: DjangoMessageLevel.INFO, message: 'Test info' },
|
||||||
|
{ level: DjangoMessageLevel.DEBUG, message: 'Test debug' },
|
||||||
|
])
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalledTimes(2)
|
||||||
|
expect(toastInfoSpy).toHaveBeenCalledTimes(3)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@@ -1,23 +1,16 @@
|
|||||||
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,
|
import { Document } from 'src/app/data/document'
|
||||||
distinctUntilChanged,
|
|
||||||
map,
|
|
||||||
switchMap,
|
|
||||||
first,
|
|
||||||
catchError,
|
|
||||||
} from 'rxjs/operators'
|
|
||||||
import { PaperlessDocument } from 'src/app/data/paperless-document'
|
|
||||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||||
|
import {
|
||||||
|
DjangoMessageLevel,
|
||||||
|
DjangoMessagesService,
|
||||||
|
} 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,
|
||||||
@@ -25,7 +18,7 @@ import {
|
|||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||||
import {
|
import {
|
||||||
@@ -33,7 +26,7 @@ import {
|
|||||||
PermissionsService,
|
PermissionsService,
|
||||||
PermissionType,
|
PermissionType,
|
||||||
} from 'src/app/services/permissions.service'
|
} from 'src/app/services/permissions.service'
|
||||||
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import {
|
import {
|
||||||
CdkDragStart,
|
CdkDragStart,
|
||||||
CdkDragEnd,
|
CdkDragEnd,
|
||||||
@@ -42,6 +35,7 @@ import {
|
|||||||
} from '@angular/cdk/drag-drop'
|
} from '@angular/cdk/drag-drop'
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
|
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-app-frame',
|
selector: 'pngx-app-frame',
|
||||||
@@ -59,21 +53,18 @@ 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,
|
||||||
private modalService: NgbModal,
|
private modalService: NgbModal,
|
||||||
permissionsService: PermissionsService
|
public permissionsService: PermissionsService,
|
||||||
|
private djangoMessagesService: DjangoMessagesService
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
|
|
||||||
@@ -92,6 +83,20 @@ export class AppFrameComponent
|
|||||||
this.checkForUpdates()
|
this.checkForUpdates()
|
||||||
}
|
}
|
||||||
this.tasksService.reload()
|
this.tasksService.reload()
|
||||||
|
|
||||||
|
this.djangoMessagesService.get().forEach((message) => {
|
||||||
|
switch (message.level) {
|
||||||
|
case DjangoMessageLevel.ERROR:
|
||||||
|
case DjangoMessageLevel.WARNING:
|
||||||
|
this.toastService.showError(message.message)
|
||||||
|
break
|
||||||
|
case DjangoMessageLevel.SUCCESS:
|
||||||
|
case DjangoMessageLevel.INFO:
|
||||||
|
case DjangoMessageLevel.DEBUG:
|
||||||
|
this.toastService.showInfo(message.message)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSlimSidebar(): void {
|
toggleSlimSidebar(): void {
|
||||||
@@ -102,6 +107,10 @@ export class AppFrameComponent
|
|||||||
}, 200) // slightly longer than css animation for slim sidebar
|
}, 200) // slightly longer than css animation for slim sidebar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get customAppTitle(): string {
|
||||||
|
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
|
||||||
|
}
|
||||||
|
|
||||||
get slimSidebarEnabled(): boolean {
|
get slimSidebarEnabled(): boolean {
|
||||||
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
||||||
}
|
}
|
||||||
@@ -132,7 +141,7 @@ export class AppFrameComponent
|
|||||||
this.closeMenu()
|
this.closeMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
get openDocuments(): PaperlessDocument[] {
|
get openDocuments(): Document[] {
|
||||||
return this.openDocumentsService.getOpenDocuments()
|
return this.openDocumentsService.getOpenDocuments()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,66 +150,7 @@ export class AppFrameComponent
|
|||||||
return !this.openDocumentsService.hasDirty()
|
return !this.openDocumentsService.hasDirty()
|
||||||
}
|
}
|
||||||
|
|
||||||
get searchFieldEmpty(): boolean {
|
closeDocument(d: Document) {
|
||||||
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: PaperlessDocument) {
|
|
||||||
this.openDocumentsService
|
this.openDocumentsService
|
||||||
.closeDocument(d)
|
.closeDocument(d)
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
@@ -250,7 +200,7 @@ export class AppFrameComponent
|
|||||||
this.settingsService.globalDropzoneEnabled = true
|
this.settingsService.globalDropzoneEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
onDrop(event: CdkDragDrop<PaperlessSavedView[]>) {
|
onDrop(event: CdkDragDrop<SavedView[]>) {
|
||||||
const sidebarViews = this.savedViewService.sidebarViews.concat([])
|
const sidebarViews = this.savedViewService.sidebarViews.concat([])
|
||||||
moveItemInArray(sidebarViews, event.previousIndex, event.currentIndex)
|
moveItemInArray(sidebarViews, event.previousIndex, event.currentIndex)
|
||||||
|
|
||||||
|
@@ -0,0 +1,170 @@
|
|||||||
|
|
||||||
|
<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)="runAdvanedSearch()">
|
||||||
|
<ng-container i18n>Advanced 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="pencil"></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>Edit</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="pencil"></i-bs>
|
||||||
|
<span> <ng-container i18n>Edit</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,101 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,531 @@
|
|||||||
|
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 { HttpClientTestingModule } 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,
|
||||||
|
} 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'
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [GlobalSearchComponent],
|
||||||
|
imports: [
|
||||||
|
HttpClientTestingModule,
|
||||||
|
NgbModalModule,
|
||||||
|
NgbDropdownModule,
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
],
|
||||||
|
}).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)
|
||||||
|
|
||||||
|
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, 'runAdvanedSearch')
|
||||||
|
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 explicit advanced search', () => {
|
||||||
|
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||||
|
component.query = 'test'
|
||||||
|
component.runAdvanedSearch()
|
||||||
|
expect(qfSpy).toHaveBeenCalledWith([
|
||||||
|
{ rule_type: FILTER_FULLTEXT_QUERY, value: 'test' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
})
|
@@ -0,0 +1,402 @@
|
|||||||
|
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,
|
||||||
|
} 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'
|
||||||
|
|
||||||
|
@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>
|
||||||
|
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
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.runAdvanedSearch()
|
||||||
|
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 runAdvanedSearch() {
|
||||||
|
this.documentListViewService.quickFilter([
|
||||||
|
{ rule_type: FILTER_FULLTEXT_QUERY, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,9 +1,11 @@
|
|||||||
<button *ngIf="active" class="position-absolute top-0 start-100 translate-middle badge bg-secondary border border-light rounded-pill p-1" title="Clear" i18n-title (click)="onClick($event)">
|
@if (active) {
|
||||||
<svg *ngIf="!isNumbered && selected" width="1em" height="1em" class="check m-0 p-0 opacity-75" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
<button class="position-absolute top-0 start-100 translate-middle badge bg-secondary border border-light rounded-pill p-1" title="Clear" i18n-title (click)="onClick($event)">
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#check-lg"/>
|
@if (!isNumbered && selected) {
|
||||||
</svg>
|
<i-bs class="check" width="1em" height="1em" name="check-lg"></i-bs>
|
||||||
<div *ngIf="isNumbered" class="number">{{number}}<span class="visually-hidden">selected</span></div>
|
}
|
||||||
<svg width=".9em" height="1em" class="x m-0 p-0 opacity-75" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
@if (isNumbered) {
|
||||||
<use xlink:href="assets/bootstrap-icons.svg#x-lg"/>
|
<div class="number">{{number}}<span class="visually-hidden">selected</span></div>
|
||||||
</svg>
|
}
|
||||||
</button>
|
<i-bs class="x" width=".9em" height="1em" name="x-lg"></i-bs>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@@ -22,7 +22,7 @@ button:hover {
|
|||||||
.x {
|
.x {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 5px;
|
top: .4em;
|
||||||
left: calc(50% - 4px);
|
left: calc(50% - .4em);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { ClearableBadgeComponent } from './clearable-badge.component'
|
import { ClearableBadgeComponent } from './clearable-badge.component'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
|
||||||
describe('ClearableBadgeComponent', () => {
|
describe('ClearableBadgeComponent', () => {
|
||||||
let component: ClearableBadgeComponent
|
let component: ClearableBadgeComponent
|
||||||
@@ -8,6 +9,7 @@ describe('ClearableBadgeComponent', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
declarations: [ClearableBadgeComponent],
|
declarations: [ClearableBadgeComponent],
|
||||||
|
imports: [NgxBootstrapIconsModule.pick(allIcons)],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
fixture = TestBed.createComponent(ClearableBadgeComponent)
|
fixture = TestBed.createComponent(ClearableBadgeComponent)
|
||||||
|
@@ -0,0 +1,22 @@
|
|||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn {{buttonClasses}}"
|
||||||
|
(click)="onClick($event)"
|
||||||
|
[disabled]="disabled"
|
||||||
|
[ngbPopover]="popoverContent"
|
||||||
|
[autoClose]="true"
|
||||||
|
(hidden)="confirming = false"
|
||||||
|
#popover="ngbPopover"
|
||||||
|
popoverClass="popover-slim"
|
||||||
|
>
|
||||||
|
@if (iconName) {
|
||||||
|
<i-bs [class.me-1]="label" name="{{iconName}}"></i-bs>
|
||||||
|
}
|
||||||
|
<ng-container>{{label}}</ng-container>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ng-template #popoverContent>
|
||||||
|
<div>
|
||||||
|
{{confirmMessage}} <button class="btn btn-link btn-sm text-danger p-0 m-0 lh-1" type="button" (click)="onConfirm($event)">Yes</button>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
@@ -0,0 +1,12 @@
|
|||||||
|
// Taken from bootstrap rules, obv
|
||||||
|
::ng-deep .input-group > pngx-confirm-button:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) > button,
|
||||||
|
::ng-deep .btn-group > pngx-confirm-button:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) > button {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .input-group:not(.has-validation) > pngx-confirm-button:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating) > button,
|
||||||
|
::ng-deep .btn-group:not(.has-validation) > pngx-confirm-button:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating) > button {
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
@@ -0,0 +1,37 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
|
||||||
|
import { ConfirmButtonComponent } from './confirm-button.component'
|
||||||
|
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
|
||||||
|
describe('ConfirmButtonComponent', () => {
|
||||||
|
let component: ConfirmButtonComponent
|
||||||
|
let fixture: ComponentFixture<ConfirmButtonComponent>
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ConfirmButtonComponent],
|
||||||
|
imports: [NgbPopoverModule, NgxBootstrapIconsModule.pick(allIcons)],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ConfirmButtonComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show confirm on click', () => {
|
||||||
|
expect(component.popover.isOpen()).toBeFalsy()
|
||||||
|
expect(component.confirming).toBeFalsy()
|
||||||
|
component.onClick(new MouseEvent('click'))
|
||||||
|
expect(component.popover.isOpen()).toBeTruthy()
|
||||||
|
expect(component.confirming).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should emit confirm on confirm', () => {
|
||||||
|
const confirmSpy = jest.spyOn(component.confirm, 'emit')
|
||||||
|
component.onConfirm(new MouseEvent('click'))
|
||||||
|
expect(confirmSpy).toHaveBeenCalled()
|
||||||
|
expect(component.popover.isOpen()).toBeFalsy()
|
||||||
|
expect(component.confirming).toBeFalsy()
|
||||||
|
})
|
||||||
|
})
|
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-confirm-button',
|
||||||
|
templateUrl: './confirm-button.component.html',
|
||||||
|
styleUrl: './confirm-button.component.scss',
|
||||||
|
})
|
||||||
|
export class ConfirmButtonComponent {
|
||||||
|
@Input()
|
||||||
|
label: string
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
confirmMessage: string = $localize`Are you sure?`
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
buttonClasses: string = 'btn-primary'
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
iconName: string
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
disabled: boolean = false
|
||||||
|
|
||||||
|
@Output()
|
||||||
|
confirm: EventEmitter<void> = new EventEmitter<void>()
|
||||||
|
|
||||||
|
@ViewChild('popover') popover: NgbPopover
|
||||||
|
|
||||||
|
public confirming: boolean = false
|
||||||
|
|
||||||
|
public onClick(event: MouseEvent) {
|
||||||
|
if (!this.confirming) {
|
||||||
|
this.confirming = true
|
||||||
|
this.popover.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
public onConfirm(event: MouseEvent) {
|
||||||
|
this.confirm.emit()
|
||||||
|
this.confirming = false
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
}
|
||||||
|
}
|
@@ -1,24 +1,32 @@
|
|||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (messageBold) {
|
||||||
|
<p><b>{{messageBold}}</b></p>
|
||||||
|
}
|
||||||
|
@if (message) {
|
||||||
|
<p class="mb-0" [innerHTML]="message | safeHtml"></p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||||
|
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
||||||
<div class="modal-body">
|
<span>
|
||||||
<p *ngIf="messageBold"><b>{{messageBold}}</b></p>
|
{{btnCaption}}
|
||||||
<p class="mb-0" *ngIf="message" [innerHTML]="message | safeHtml"></p>
|
<span class="visually-hidden">{{ seconds | number: '1.0-0' }} seconds</span>
|
||||||
</div>
|
</span>
|
||||||
<div class="modal-footer">
|
@if (!confirmButtonEnabled) {
|
||||||
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" [disabled]="!buttonsEnabled" i18n>
|
<ngb-progressbar style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar>
|
||||||
<span class="d-inline-block" style="padding-bottom: 1px;" >Cancel</span>
|
}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
@if (alternativeBtnCaption) {
|
||||||
<span>
|
<button type="button" class="btn" [class]="alternativeBtnClass" (click)="alternative()" [disabled]="!alternativeButtonEnabled || !buttonsEnabled">
|
||||||
{{btnCaption}}
|
{{alternativeBtnCaption}}
|
||||||
<span class="visually-hidden">{{ seconds | number: '1.0-0' }} seconds</span>
|
</button>
|
||||||
</span>
|
}
|
||||||
<ngb-progressbar *ngIf="!confirmButtonEnabled" style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar>
|
</div>
|
||||||
</button>
|
|
||||||
<button *ngIf="alternativeBtnCaption" type="button" class="btn" [class]="alternativeBtnClass" (click)="alternative()" [disabled]="!alternativeButtonEnabled || !buttonsEnabled">
|
|
||||||
{{alternativeBtnCaption}}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
@@ -37,6 +37,12 @@ export class ConfirmDialogComponent {
|
|||||||
@Input()
|
@Input()
|
||||||
alternativeBtnCaption
|
alternativeBtnCaption
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
cancelBtnClass = 'btn-outline-secondary'
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
cancelBtnCaption = $localize`Cancel`
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
buttonsEnabled = true
|
buttonsEnabled = true
|
||||||
|
|
||||||
|
@@ -0,0 +1,39 @@
|
|||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>{{message}}</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="metadataDocumentID" i18n>Documents:</label>
|
||||||
|
<ul class="list-group"
|
||||||
|
cdkDropList
|
||||||
|
(cdkDropListDropped)="onDrop($event)">
|
||||||
|
@for (documentID of documentIDs; track documentID) {
|
||||||
|
<li class="list-group-item" cdkDrag>
|
||||||
|
<i-bs name="grip-vertical" class="me-2"></i-bs>
|
||||||
|
{{getDocument(documentID)?.title}}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mt-4">
|
||||||
|
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
|
||||||
|
<select class="form-select" [(ngModel)]="metadataDocumentID">
|
||||||
|
<option [ngValue]="-1" i18n>Regenerate all metadata</option>
|
||||||
|
@for (document of documents; track document.id) {
|
||||||
|
<option [ngValue]="document.id">{{document.title}}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||||
|
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
||||||
|
{{btnCaption}}
|
||||||
|
</button>
|
||||||
|
</div>
|
@@ -0,0 +1,3 @@
|
|||||||
|
.list-group-item {
|
||||||
|
cursor: move;
|
||||||
|
}
|